Merge upstream/main into feature/turkish-localization

This commit is contained in:
MOzcelik14
2026-10-20 11:16:48 +03:00
56 changed files with 4825 additions and 98 deletions
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1 -1
View File
@@ -29,7 +29,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `docs/images/filmcraft-keyframes.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-export.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frame from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-assembly.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Carnival of Souls (1962), Night of the Living Dead (1968), thumbnails of all demo media | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-lumetri.png` | FilmCraft contributors | Original work: FilmCraft UI crop (Lumetri Color panel) | MIT OR Apache-2.0 |
| `docs/images/filmcraft-color-panel.png` | FilmCraft contributors | Original work: FilmCraft UI crop (color grading panel) | MIT OR Apache-2.0 |
| `docs/images/filmcraft-scopes.png` | FilmCraft contributors | Original work: FilmCraft UI crop (scopes of a Charade (1963) frame, public domain) | MIT OR Apache-2.0 |
| `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain |
| `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain |
+24 -26
View File
@@ -11,11 +11,11 @@
<h1 align="center">FilmCraft</h1>
<p align="center">
<b>Video editing, color and sound; an open-source, clean-room reimplementation of Adobe Premiere Pro, rebuilt in pure Rust.</b>
<b>Video editing, color and sound; an open-source, professional non-linear video editor, built in pure Rust.</b>
</p>
<p align="center">
An open-source, clean-room take on the Adobe Premiere Pro workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
An open-source, modern non-linear video editing workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
By the ArtCraft team.
</p>
@@ -39,7 +39,7 @@
<br>
<p align="center">
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with Lumetri and Scale keyframes on the shot, the Lumetri Color panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with color grading and Scale keyframes on the shot, the color grading panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
</p>
<p align="center"><sub><i>Apollo 11 - Tranquility</i>: a three-minute documentary edit of NASA's 1969 launch, landing and moonwalk film, with subtitles from the mission transcript. Every frame in these screenshots comes from public-domain footage, decoded, composited and graded by FilmCraft's own code.</sub></p>
@@ -66,7 +66,7 @@
<br>
FilmCraft is a non-linear editor for people who know Premiere: the same panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
FilmCraft is a professional non-linear editor: familiar panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
<br>
@@ -84,7 +84,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft and standard NLE key layouts.
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
<p align="center">
@@ -103,7 +103,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
<p align="center"><sub>The Color workspace on <i>Charade</i> (1963), with live scopes beside the Program monitor.</sub></p>
**A complete Lumetri-style grading panel**, in the order a colourist works:
**A complete color grading panel**, in the order a colourist works:
- **Basic Correction:** temperature and tint, exposure, contrast, highlights, shadows, whites, blacks, saturation.
- **Creative:** eight built-in looks with an intensity control, plus faded film, sharpen and vibrance. The looks are colour transforms written in code, not LUT files.
@@ -115,11 +115,11 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
<table>
<tr>
<td width="34%" valign="top" align="center">
<img src="docs/images/filmcraft-lumetri.png" alt="The Lumetri Color panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
<sub><b>The Lumetri Color panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
<img src="docs/images/filmcraft-color-panel.png" alt="The color grading panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
<sub><b>The color grading panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
</td>
<td width="66%" valign="top" align="center">
<img src="docs/images/filmcraft-scopes.png" alt="Lumetri Scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
<img src="docs/images/filmcraft-scopes.png" alt="The video scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
<sub><b>Scopes that tell the truth.</b> The waveform and vectorscope are computed from the graded frame. All grading happens in linear light, in 32-bit float.</sub>
</td>
</tr>
@@ -130,14 +130,14 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
## Effects and motion
<p align="center">
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and Lumetri Color, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and a colour grade, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
</p>
<p align="center"><sub>NASA's <i>Earth Views from the ISS</i> with a look applied, and the Effects browser open on the dissolves.</sub></p>
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
- **A comprehensive Video Effects bin (over 90 effects across 16 categories), legacy effects, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
- **Advanced keyframing:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
<p align="center">
@@ -179,7 +179,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
| **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 |
| **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 |
| **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska |
| **Avid DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
| **DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
| **MJPEG, PCM** | ✓ | ✓ | |
| **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own |
| **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks |
@@ -200,7 +200,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on.
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and standard OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
- **Background jobs** with progress and cancel, so you keep editing while it renders.
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
@@ -210,11 +210,11 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
Move timelines between FilmCraft and every other editor:
- **Final Cut Pro 7 XML (xmeml):** the format Premiere and DaVinci Resolve exchange. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
- **FCP 7 XML (xmeml):** industry-standard timeline interchange format. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
- **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips.
- **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`.
- **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track.
- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
- **AAF (Edit Protocol):** standard timeline and audio interchange format. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
- **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside.
Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk.
@@ -223,7 +223,7 @@ Import merges the document's bins, media and sequences into your project as one
## Built for agents
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with more commands continuously added. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
```jsonc
// over the control channel (JSON lines on TCP) or as MCP tool calls
@@ -277,22 +277,22 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log |
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus |
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment, the ranked list of shortfalls, milestones and current focus |
| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension |
## Status
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2):
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10):
- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
- **Feature breadth: ~86%.** Professional NLE menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to handling full production work on real projects.
The biggest gaps today:
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but color grading, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Native `.prproj` project import.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
@@ -455,15 +455,13 @@ with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
FilmCraft is an independent implementation. It contains no proprietary code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. FilmCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<br>
<p align="center">
+3 -2
View File
@@ -5,7 +5,7 @@
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (language table: German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
@@ -87,7 +87,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| Italian | partial (whole interface; native review welcome) | ~100% |
| Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | menus only (native-reviewed) | 10.6% |
| German | partial (whole interface; menus native-reviewed, the rest welcome) | ~100% |
| Vietnamese | none | 0% |
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
@@ -166,6 +166,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Language table: German covers the whole interface (~100%) |
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
+83 -1
View File
@@ -278,9 +278,22 @@ impl FilmcraftMcp {
}
/// The project tree plus the active sequence (null when there is none).
///
/// `project.inspect` and `sequence.inspect` are two separate calls (two lock scopes in
/// headless mode, two round trips to the app in bridge mode), so the active sequence can
/// change between them if another client, or the app's own UI, switches sequence tabs in
/// that window (#1477). Pinning `sequence.inspect` to the `activeSequence` id this
/// `project.inspect` just reported keeps the pair consistent: the returned `sequence`, when
/// not null, always describes the sequence the returned `project.activeSequence` names.
async fn document(&self) -> Result<Value, AutomationError> {
let project = self.run("project.inspect", json!({})).await?;
let sequence = self.run("sequence.inspect", json!({})).await.ok();
// `ItemId` is a u64 (`item_p` parses the `item` param with `u64_p`): read it as one too,
// not `as_i64`, so an id above `i64::MAX` is still looked up instead of silently going null.
let active = project.get("activeSequence").and_then(Value::as_u64);
let sequence = match active {
Some(id) => self.run("sequence.inspect", json!({"item": id})).await.ok(),
None => None,
};
Ok(json!({"project": project, "sequence": sequence}))
}
@@ -998,4 +1011,73 @@ mod tests {
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
/// `doc_inspect` composes `project.inspect` and `sequence.inspect` as two separate round
/// trips to the app. If the active sequence changes between them (another client, or the
/// app's own UI, switching tabs in that window), an unscoped `sequence.inspect` answers for
/// whichever sequence is active *now*, not the one `project.inspect` just reported, so the
/// pair comes back inconsistent (#1477, reproduced with a real bridge client and a flipping
/// `sequence.open` in `race_test.py`: ~300/300 calls mismatched). Pinning the second call to
/// the id the first one reported keeps `sequence`, when not null, always describing the
/// sequence `project.activeSequence` names.
#[tokio::test]
async fn doc_inspect_pins_the_sequence_to_the_reported_active_id() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let (r, mut w) = s.into_split();
let mut lines = tokio::io::BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let result = match req["params"]["command"].as_str().unwrap_or_default() {
"project.inspect" => json!({"activeSequence": 21}),
// The app's own active sequence has since moved on to 54; only an explicit
// `item` still gets 21's data (an unscoped call would wrongly answer for 54).
"sequence.inspect" => json!({"id": req["params"]["params"]["item"].as_i64().unwrap_or(54)}),
_ => Value::Null,
};
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
w.write_all(line.as_bytes()).await.unwrap();
}
});
let m = FilmcraftMcp::bridge(&addr).unwrap();
let v = m.document().await.unwrap();
assert_eq!(v["project"]["activeSequence"], json!(21), "{v}");
assert_eq!(v["sequence"]["id"], json!(21), "sequence must describe the reported active id, not whatever is active now: {v}");
}
/// `activeSequence` is a `u64` (`ItemId`'s representation; `item_p` parses the `item` param
/// with `u64_p`), so an id above `i64::MAX` must still round-trip through `document()`:
/// reading it with `as_i64` would silently return `None` and make `sequence` go null instead
/// of inspecting the (valid) id project.inspect reported.
#[tokio::test]
async fn doc_inspect_handles_an_active_sequence_id_above_i64_max() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
const HUGE: u64 = i64::MAX as u64 + 1;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let (r, mut w) = s.into_split();
let mut lines = tokio::io::BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let result = match req["params"]["command"].as_str().unwrap_or_default() {
"project.inspect" => json!({"activeSequence": HUGE}),
// Echoes back whatever `item` it was actually asked for, so the test can
// check that `document()` asked for `HUGE` rather than nothing at all.
"sequence.inspect" => json!({"id": req["params"]["params"]["item"]}),
_ => Value::Null,
};
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
w.write_all(line.as_bytes()).await.unwrap();
}
});
let m = FilmcraftMcp::bridge(&addr).unwrap();
let v = m.document().await.unwrap();
assert_eq!(v["project"]["activeSequence"].as_u64(), Some(HUGE), "{v}");
assert_eq!(v["sequence"]["id"].as_u64(), Some(HUGE), "an id above i64::MAX must still be inspected, not dropped to a null sequence: {v}");
}
}
+17 -1
View File
@@ -308,7 +308,11 @@ impl Lut {
if let Some(sh) = &self.shaper {
let _ = writeln!(s, "LUT_1D_SIZE {}", sh.size());
if both {
if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] {
let uniform = sh.domain_min.iter().all(|v| *v == sh.domain_min[0]) && sh.domain_max.iter().all(|v| *v == sh.domain_max[0]);
if !uniform {
// with both tables, DOMAIN_MIN/DOMAIN_MAX belong to the shaper
write_domain(&mut s, sh.domain_min, sh.domain_max);
} else if sh.domain_min != [0.0; 3] || sh.domain_max != [1.0; 3] {
let _ = writeln!(s, "LUT_1D_INPUT_RANGE {} {}", sh.domain_min[0], sh.domain_max[0]);
}
} else {
@@ -650,4 +654,16 @@ mod tests {
let mesh257 = (0..257).map(|i| i.to_string()).collect::<Vec<_>>().join(" ") + "\n";
assert!(Lut::parse_3dl(&mesh257).is_err());
}
#[test]
fn cube_shaper_per_channel_domain_round_trips() {
let mut text = String::from("LUT_1D_SIZE 2\nLUT_3D_SIZE 2\nDOMAIN_MAX 1 2 3\n");
for _ in 0..10 {
text.push_str("0 0 0\n");
}
let a = Lut::parse_cube(&text).unwrap();
assert_eq!(a.shaper.as_ref().unwrap().domain_max, [1.0, 2.0, 3.0]);
let b = Lut::parse_cube(&a.to_cube()).unwrap();
assert_eq!(b.shaper.unwrap().domain_max, [1.0, 2.0, 3.0]);
}
}
+23
View File
@@ -1059,3 +1059,26 @@ fn linked_rate_stretch_keeps_one_speed_when_a_neighbour_blocks_the_sound() {
assert!((pic.speed - sound.speed).abs() < 1e-12);
assert!((r["speed"].as_f64().unwrap() - 60.0 / 70.0).abs() < 1e-9);
}
/// #2358: Toggle Animation on a parameter an older effect instance lacks (it was added to the
/// effect later) creates it from its default and animates it, instead of "no such param".
#[test]
fn toggle_animation_creates_a_parameter_missing_from_an_older_instance() {
let mut s = crate::Session::default();
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
let seq = s.state.active_sequence.unwrap();
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
// an older project: Motion without its Rotation entry
{
let p = std::sync::Arc::make_mut(&mut s.project);
let q = p.sequence_mut(seq).unwrap();
let (_, it) = q.find_item_mut(clip).unwrap();
let m = it.effects.iter_mut().find(|e| e.effect == "motion").unwrap();
m.params.remove("rotation");
}
// `"mask": null` is no mask, as in effects.setParam
s.execute("effects.toggleAnimation", serde_json::json!({"clip": clip.0, "effect": "motion", "param": "rotation", "mask": null})).unwrap();
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
let rot = it.effect("motion").unwrap().param("rotation").unwrap();
assert!(rot.is_animated(), "the stopwatch is on");
}
+20 -1
View File
@@ -2860,10 +2860,17 @@ fn build() -> Vec<CommandSpec> {
"Track Settings",
[],
None,
r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?}"#,
r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?,"label":"Violet|Iris|…"|""|null?}"#,
has_seq,
|s, p| {
let tr = track_p(s, p, "track", "timeline.setTrack")?.ok_or_else(|| bad("timeline.setTrack", "need `track`"))?;
// colour label: a label name, or "" / "None" / null for no colour
let label = match p.get("label") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(n)) if n.is_empty() || n.eq_ignore_ascii_case("none") => Some(None),
Some(v) => Some(Some(v.as_str().and_then(Label::from_name).ok_or_else(|| bad("timeline.setTrack", format!("unknown label {v}")))?)),
};
let p = p.clone();
s.edit_sequence("Track Settings", |q, _, _| {
let t = q.track_mut(tr).ok_or(filmcraft_edit::EditError::NoTrack(tr))?;
@@ -2891,6 +2898,9 @@ fn build() -> Vec<CommandSpec> {
if let Some(v) = f64_p(&p, "pan") {
t.pan = v.clamp(-100.0, 100.0);
}
if let Some(l) = label {
t.label = l;
}
Ok(())
})?;
Ok(Value::Null)
@@ -3111,6 +3121,14 @@ fn build() -> Vec<CommandSpec> {
_ => None,
}
.ok_or_else(|| bad("effects.toggleAnimation", "no such effect"))?;
// a parameter an older instance lacks (added to the effect later) starts from its
// default, as the UI shows it, without touching an existing one
if p.get("mask").is_none_or(Value::is_null)
&& !e.params.contains_key(&pid)
&& let Some(d) = e.def().and_then(|d| d.param(&pid))
{
e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone()));
}
crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("effects.toggleAnimation", "no such param"))?.toggle_animation(mt);
Ok(())
})?;
@@ -4259,6 +4277,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
let tr = |t: &filmcraft_project::Track| {
json!({
"id": t.id.0, "name": t.name, "locked": t.locked, "syncLock": t.sync_lock, "enabled": t.enabled, "muted": t.muted, "solo": t.solo,
"label": t.label.map(|l| l.name()),
"items": t.items.iter().map(|i| json!({
"clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0,
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse,
+7 -2
View File
@@ -775,7 +775,12 @@ fn set_char_style(s: &mut Session, p: &Value) -> Result<Value> {
};
let tl = s.playhead();
let (start, end) = (u64_p(p, "start").map(|v| v as usize), u64_p(p, "end").map(|v| v as usize));
let runs = s.edit_sequence("Character Style", move |q, _, _| {
// A pointer gesture shares one undo step only within the same clip, layer and range.
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("graphics.setCharStyle:{}:{ei}:{start:?}:{end:?}", clip.0));
if bool_p(p, "begin").unwrap_or(false) {
s.history.merge_key = None;
}
let runs = s.edit_sequence_as("Character Style", merge.as_deref(), move |q, _, _| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
let e = &mut it.effects[ei];
@@ -1217,7 +1222,7 @@ pub fn commands() -> Vec<CommandSpec> {
"graphics.setCharStyle",
"Character Style",
&[],
r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range)}"##,
r##"{"clip":id?,"layer":n|name?,"start":char=0,"end":char=len,"style":{"font":str,"fontStyle":str,"size":px,"color":"#rrggbb","bold":bool,"italic":bool,"underline":bool,"tracking":n,"baselineShift":px,"caps":"normal|all caps|small caps"},"clear":bool? (remove styles from the range),"merge":bool?,"begin":bool? (start a new undo gesture)}"##,
has_graphic,
set_char_style,
),
@@ -380,3 +380,52 @@ fn copy_paste_duplicates_a_graphic_clip() {
assert!((pasted[0].start.seconds() - 620.0).abs() < 0.05, "at the playhead: {:?}", pasted[0].start);
assert_eq!(texts(&s, pasted[0].id), vec!["Lyric".to_string()]);
}
#[test]
fn character_style_drag_merges_only_its_target_and_range() {
let mut s = demo("gt-char-drag");
let c = clip_of(&s.execute("graphics.newText", json!({"text":"Alpha Bravo"})).unwrap());
let before = s.project.clone();
let n = s.history.undo.len();
for i in 0..8 {
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":105+i*5},"merge":true,"begin":i==0})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 1, "one drag, one undo step");
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":150},"merge":true,"begin":true})).unwrap();
assert_eq!(s.history.undo.len(), n + 2, "a new gesture on the same range starts a new step");
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":6,"end":11,"style":{"size":160},"merge":true})).unwrap();
assert_eq!(s.history.undo.len(), n + 3, "another character range does not merge");
let before = s.project.clone();
assert!(s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":3,"end":3,"style":{"size":170},"merge":true})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.history.undo.len(), n + 3, "an invalid edit adds no undo step");
}
#[test]
fn character_style_merge_preserves_default_calls_and_separate_clips() {
let mut s = demo("gt-char-merge-targets");
let a = clip_of(&s.execute("graphics.newText", json!({"text":"First", "newClip":true})).unwrap());
let b = clip_of(&s.execute("graphics.newText", json!({"text":"Other", "newClip":true})).unwrap());
assert_ne!(a, b);
let n = s.history.undo.len();
for c in [a, b] {
s.execute("graphics.setCharStyle", json!({"clip":c.0,"start":0,"end":5,"style":{"size":120},"merge":true})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 2, "distinct clips do not merge");
for size in [130, 140] {
s.execute("graphics.setCharStyle", json!({"clip":b.0,"style":{"size":size}})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 4, "default calls retain separate undo steps");
let added = s.execute("graphics.newText", json!({"clip":b.0,"text":"Layer"})).unwrap();
assert_eq!(clip_of(&added), b);
let n = s.history.undo.len();
for layer in [0, 1] {
s.execute("graphics.setCharStyle", json!({"clip":b.0,"layer":layer,"start":0,"end":5,"style":{"size":150},"merge":true})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 2, "distinct layers do not merge");
}
+27 -5
View File
@@ -243,17 +243,19 @@ fn can_synchronize(s: &Session) -> std::result::Result<(), String> {
if sel.len() < 2 { Err("select at least two clips to synchronize".into()) } else { Ok(()) }
}
fn has_items(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) {
if selected_items(s).iter().any(|i| sync::project_clip(&s.project, *i).is_some()) {
Ok(())
} else {
Err("select clips in the Project panel".into())
Err("select clips in the Project panel or the Timeline".into())
}
}
/// Two clips or more: Merge Clips then opens its dialog, which says what is wrong with a
/// selection it can't merge (two video clips, no audio clip) instead of a greyed-out menu item.
fn has_two_items(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 {
if selected_items(s).iter().filter(|i| sync::project_clip(&s.project, **i).is_some()).count() >= 2 {
Ok(())
} else {
Err("select a video clip and audio clips in the Project panel".into())
Err("select a video clip and audio clips in the Project panel or the Timeline".into())
}
}
@@ -262,10 +264,30 @@ fn has_two_items(s: &Session) -> std::result::Result<(), String> {
fn items_p(s: &Session, p: &Value) -> Vec<ItemId> {
match p.get("items").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(),
None => s.state.project_selection.clone(),
None => selected_items(s),
}
}
/// The items Merge Clips and Create Multi-Camera Source Sequence work on without `items`: the
/// clips selected in the Project panel, or, with none there, the media of the clips selected in
/// the Timeline (with their linked partners), once each in selection order (#1362).
pub fn selected_items(s: &Session) -> Vec<ItemId> {
if s.state.project_selection.iter().any(|i| sync::project_clip(&s.project, *i).is_some()) {
return s.state.project_selection.clone();
}
let Some(q) = s.active_sequence() else { return Vec::new() };
let mut out = Vec::new();
for c in with_links(s, &s.state.selection) {
if let Some((_, it)) = q.find_item(c)
&& sync::project_clip(&s.project, it.item).is_some()
&& !out.contains(&it.item)
{
out.push(it.item);
}
}
out
}
fn method_p(p: &Value, cmd: &str) -> Result<Method> {
Method::from_params(p).map_err(|e| bad(cmd, e))
}
+53
View File
@@ -328,6 +328,59 @@ const H: u32 = 90;
/// Three 4-second cameras (different scenes, own audio), a multi-camera source sequence by In
/// points, and a sequence holding one multi-camera clip on V1/A1. Returns (session, camera items,
/// source sequence, edit sequence).
/// #1362: with nothing selected in the Project panel, Create Multi-Camera Source Sequence and Merge
/// Clips take the media of the clips selected in the Timeline.
#[test]
fn multicam_from_clips_selected_in_the_timeline() {
let dir = tmp_dir("mc-timeline");
let files: Vec<PathBuf> = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect();
for (f, sc) in files.iter().zip([DemoScene::OceanSunset, DemoScene::CityNight, DemoScene::Aurora]) {
make_movie(f, sc, W, H, 48);
}
let mut s = Session::default();
let refs: Vec<&Path> = files.iter().map(|p| p.as_path()).collect();
let items = import(&mut s, &refs);
let mut p = (*s.project).clone();
let edit =
p.new_sequence("Edit", filmcraft_project::SequenceSettings { width: W, height: H, frame_rate: FrameRate::FPS_24, ..Default::default() }, 1, 1, None);
s.project = std::sync::Arc::new(p);
s.state.active_sequence = Some(edit);
for (k, item) in items.iter().enumerate() {
s.set_playhead(FrameRate::FPS_24.tick_of(k as i64 * 48));
s.execute("source.open", json!({"item": item.0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
}
s.execute("project.select", json!({"items": []})).unwrap();
s.execute("timeline.select", json!({"clips": []})).unwrap();
assert!(!s.is_enabled("clip.createMulticam"), "nothing selected anywhere");
let clips: Vec<u64> = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect();
assert_eq!(clips.len(), 3);
s.execute("timeline.select", json!({"clips": clips})).unwrap();
assert!(s.is_enabled("clip.createMulticam") && s.is_enabled("clip.mergeClips"));
// Merge Clips opens on them, and says why it can't merge three video clips
assert!(s.execute("clip.mergeClips", json!({"method": "in"})).unwrap_err().to_string().contains("one video clip"));
let r = s.execute("clip.createMulticam", json!({"method": "in"})).unwrap();
let src = ItemId(r["sequence"].as_u64().unwrap());
let cameras: Vec<ItemId> = s.project.sequence(src).unwrap().video_tracks.iter().filter_map(|t| t.items.first().map(|c| c.item)).collect();
assert_eq!(cameras, items, "one camera per selected clip's media, in selection order");
// one video clip and an audio-only one, selected in the Timeline: Merge Clips takes them
s.state.active_sequence = Some(edit);
let wav = dir.join("room.wav");
std::fs::write(&wav, crate::voiceover::write_wav_f32(&vec![0.0; 48_000 * 2], 48_000)).unwrap();
let audio = import(&mut s, &[wav.as_path()])[0];
s.set_playhead(FrameRate::FPS_24.tick_of(200));
s.execute("source.open", json!({"item": audio.0})).unwrap();
s.execute("source.overwrite", json!({})).unwrap();
let q = s.active_sequence().unwrap();
let a = q.audio_tracks.iter().flat_map(|t| &t.items).find(|c| c.item == audio).unwrap().id.0;
s.execute("project.select", json!({"items": []})).unwrap();
s.execute("timeline.select", json!({"clips": [clips[0], a], "linked": false})).unwrap();
assert!(s.is_enabled("clip.mergeClips"));
let r = s.execute("clip.mergeClips", json!({"method": "in"})).unwrap();
assert!(r["item"].as_u64().is_some(), "{r}");
}
fn mc_session(audio: &str) -> (Session, Vec<ItemId>, ItemId, ItemId) {
let dir = tmp_dir("mc-edit");
let files: Vec<PathBuf> = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect();
+16 -5
View File
@@ -535,8 +535,7 @@ impl PreviewStore {
}
}
let path = self.dir()?.join(name);
let bytes = std::fs::read(&path).ok()?;
let src = pool.open_bytes(name, bytes.into()).ok()?;
let src = open_preview_file(pool, name, &path)?;
let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner());
g.push((name.to_string(), src.clone()));
if g.len() > 6 {
@@ -546,6 +545,21 @@ impl PreviewStore {
}
}
/// Open a rendered preview file. Native builds read it in place (the container's index now,
/// frames on demand): previews are full-resolution ProRes, gigabytes a minute, and reading each
/// one whole filled memory during render and playback (#2185).
#[cfg(any(unix, windows))]
pub(crate) fn open_preview_file(_pool: &MediaPool, name: &str, path: &std::path::Path) -> Option<SharedSource> {
let reader = filmcraft_media::reader::FileReader::open(path).ok()?;
filmcraft_media::reader::open_reader_within(name, std::sync::Arc::new(reader), &filmcraft_codecs::reader_openers(), &[], 0).ok()
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn open_preview_file(pool: &MediaPool, name: &str, path: &std::path::Path) -> Option<SharedSource> {
let bytes = std::fs::read(path).ok()?;
pool.open_bytes(name, bytes.into()).ok()
}
fn need_state(seg: &Segment) -> BarState {
match seg.need {
Need::None => BarState::None,
@@ -639,7 +653,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
let (prog, res) = (job.progress.clone(), job.result.clone());
let nseg = todo.len();
let pool = s.media.clone();
let run = move || {
let t0 = web_time::Instant::now();
let mut bytes = 0u64;
@@ -679,8 +692,6 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
break;
}
store.add_video(&name);
// open it now so the first playback doesn't wait for the file read
let _ = store.open(&pool, &name);
}
Err(e) => {
let _ = std::fs::remove_file(&part);
+26
View File
@@ -282,3 +282,29 @@ fn float_wav_round_trip() {
fn bar_state_serializes_lowercase() {
assert_eq!(serde_json::to_value(BarState::Green).unwrap(), json!("green"));
}
/// #2185: preview files are read in place, never whole. Rendered previews still play from their
/// files, and a file only a whole-file reader could open is refused rather than loaded.
#[test]
fn preview_files_are_streamed_not_read_whole() {
let (mut s, _) = session("stream");
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
job_ok(&s, &v);
let seq = s.state.active_sequence.unwrap();
assert!(s.previews.frame(&s.media, &s.project, seq, 5, 1.0).is_some(), "a rendered frame plays from its file");
// a still image opens only from its whole bytes: as a preview file it is not opened
let dir = s.previews.dir().unwrap();
let png = dir.join("still.png");
// a 1×1 PNG
const PNG: &[u8] = &[
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84,
120, 218, 99, 100, 96, 248, 95, 15, 0, 2, 135, 1, 128, 235, 71, 186, 146, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
std::fs::write(&png, PNG).unwrap();
assert!(s.media.open_bytes("still.png", PNG.into()).is_ok(), "the image opens from its bytes");
// (the web, without a file reader, still reads preview files whole)
if cfg!(any(unix, windows)) {
assert!(crate::previews::open_preview_file(&s.media, "still.png", &png).is_none());
}
let _ = std::fs::remove_dir_all(dir);
}
+5
View File
@@ -109,6 +109,9 @@ pub struct AppearancePrefs {
/// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed
/// percentage of [`UI_SCALES`] (`100` = one point per physical pixel).
pub ui_scale: String,
/// "Animate zoom, scrolling and menus" (#2325): off, the Timeline zooms and scrolls in one
/// step and menus, collapsing sections and tooltips appear without a fade.
pub animations: bool,
}
impl Default for AppearancePrefs {
@@ -122,6 +125,7 @@ impl Default for AppearancePrefs {
accessible_contrast: false,
highlight_color: DEFAULT_HIGHLIGHT.into(),
ui_scale: "auto".into(),
animations: true,
}
}
}
@@ -1054,6 +1058,7 @@ static CATEGORIES: &[Category] = &[
b("appearance.accessibleContrast", "Accessible color contrast", true),
f("appearance.highlightColor", "Highlight Color", Kind::Color, true),
f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
b("appearance.animations", "Animate zoom, scrolling and menus", true),
],
},
Category {
+35
View File
@@ -114,3 +114,38 @@ fn place_on_an_audio_track_places_the_sound_there_and_nothing_else() {
s.execute("timeline.place", json!({"item": with_picture.0, "seconds": 600.0})).unwrap();
assert_eq!(counts(&s), [2, 1, 2, 2]);
}
/// Track colour labels (#1186): set, shown by `sequence.inspect`, saved with the project,
/// cleared with "" / null, one undo step each; unknown names are refused.
#[test]
fn a_track_carries_a_colour_label() {
let mut s = demo();
let label = |s: &mut Session, kind: &str| s.execute("sequence.inspect", json!({})).unwrap()[kind][0]["label"].clone();
assert_eq!(label(&mut s, "video"), json!(null));
s.execute("timeline.setTrack", json!({"track": "V1", "label": "Forest"})).unwrap();
s.execute("timeline.setTrack", json!({"track": "A1", "label": "Mango"})).unwrap();
assert_eq!(label(&mut s, "video"), json!("Forest"));
assert_eq!(label(&mut s, "audio"), json!("Mango"));
// saved with the project, and a project from before the field still loads
let text = serde_json::to_string(&*s.project).unwrap();
let back: filmcraft_project::Project = serde_json::from_str(&text).unwrap();
let q = back.sequence(s.state.active_sequence.unwrap()).unwrap();
assert_eq!(q.video_tracks[0].label, Some(filmcraft_project::Label::Forest));
let mut old = serde_json::to_value(&q.video_tracks[0]).unwrap();
old.as_object_mut().unwrap().remove("label");
let old: filmcraft_project::Track = serde_json::from_value(old).unwrap();
assert_eq!(old.label, None);
// a plain track does not write the key at all
assert!(serde_json::to_value(&q.video_tracks[1]).unwrap().get("label").is_none());
// clear, undo, refuse
s.execute("timeline.setTrack", json!({"track": "V1", "label": ""})).unwrap();
assert_eq!(label(&mut s, "video"), json!(null));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(label(&mut s, "video"), json!("Forest"));
s.execute("timeline.setTrack", json!({"track": "V1", "label": null})).unwrap();
assert_eq!(label(&mut s, "video"), json!(null));
assert!(matches!(s.execute("timeline.setTrack", json!({"track": "V1", "label": "Chartreuse"})), Err(EngineError::BadParams { .. })));
// other settings leave the label alone
s.execute("timeline.setTrack", json!({"track": "A1", "muted": true})).unwrap();
assert_eq!(label(&mut s, "audio"), json!("Mango"));
}
+4
View File
@@ -760,6 +760,9 @@ pub struct Track {
/// Audio Track Mixer state: automation mode and lanes, sends, output, record arm.
#[serde(default)]
pub mixer: MixerStrip,
/// Colour label tinting the track's header and lane background (`None` = no colour).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<Label>,
}
impl Track {
@@ -780,6 +783,7 @@ impl Track {
pan: 0.0,
effects: Vec::new(),
mixer: MixerStrip::default(),
label: None,
}
}
pub fn end(&self) -> Tick {
+29 -4
View File
@@ -416,11 +416,18 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
}
TimeDisplay::AudioSamples => {
let secs = t.0.div_euclid(TICKS_PER_SECOND);
let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate);
format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60)
let neg = t.0 < 0;
let a = t.0.unsigned_abs();
let tps = TICKS_PER_SECOND.unsigned_abs();
let secs = a / tps;
let rem = Tick((a % tps) as i64).to_units_floor(sample_rate);
format!("{}{:02}:{:02}:{:02}:{rem:05}", if neg { "-" } else { "" }, secs / 3600, (secs / 60) % 60, secs % 60)
}
TimeDisplay::Seconds => {
let s = format!("{:.3}", t.seconds());
// A tiny negative time rounds to zero but keeps its sign: drop it.
if s == "-0.000" { "0.000".to_string() } else { s }
}
TimeDisplay::Seconds => format!("{:.3}", t.seconds()),
}
}
@@ -507,6 +514,24 @@ mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn seconds_display_has_no_negative_zero() {
let f = |t: i64| format_time(Tick(t), FrameRate::default(), false, TimeDisplay::Seconds, 48000);
assert_eq!(f(-1), "0.000");
assert_eq!(f(-2), "0.000");
assert_eq!(f(-(TICKS_PER_SECOND / 2500)), "0.000");
assert_eq!(f(-TICKS_PER_SECOND), "-1.000");
assert_eq!(f(-TICKS_PER_SECOND / 1000), "-0.001");
}
#[test]
fn audio_samples_negative_time_has_leading_minus() {
let r = FrameRate::new(30000, 1001);
let f = |t| format_time(Tick(t), r, false, TimeDisplay::AudioSamples, 48000);
assert_eq!(f(-TICKS_PER_SECOND), "-00:00:01:00000");
assert_eq!(f(-(TICKS_PER_SECOND + TICKS_PER_SECOND / 2)), "-00:00:01:24000");
}
/// Damaged files and projects can carry zero / negative rates and timebases, and users or
/// agents can type huge timecodes: these used to panic (division by zero, overflow,
/// crossed `clamp` bounds).
+4 -4
View File
@@ -18,7 +18,8 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho
## Localisation
Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified
Chinese, German, Russian, French, Indonesian, Korean, Italian and Turkish (which cover the whole interface). The language is stored in the engine's `general.interfaceLanguage` preference,
Chinese, German, Russian, French, Indonesian, Korean, Italian and Turkish (all but Ukrainian and Russian cover
the whole interface; those two cover the core menus). The language is stored in the engine's `general.interfaceLanguage` preference,
restored on startup, and also available in Settings > General.
Its default, System Language (`system`), follows the operating system: the first of the user's
preferred languages that the interface has (the host supplies them through
@@ -32,11 +33,10 @@ fills translated templates, and `i18n::t` translates names from registries. Plac
(including user filenames containing braces) are inserted literally. Catalog translations are
original work using ordinary language, without proprietary localisation resources.
Spanish, Japanese, Simplified Chinese, French and Brazilian Portuguese cover menus, panels, dialogs, settings and registry labels. Searches accept both the
Spanish, Japanese, Simplified Chinese, French, Brazilian Portuguese and German cover menus, panels, dialogs, settings and registry labels. Searches accept both the
translated label and its English source, including Unicode capitals. Project content, command ids
and preference values retain their original values. Engine errors, CLI and MCP messages remain
English. Ukrainian and Russian currently cover the core menus, German every menu; they fall back
to English elsewhere.
English. Ukrainian and Russian currently cover the core menus; they fall back to English elsewhere.
Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders,
literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual
+11
View File
@@ -97,6 +97,11 @@ impl FilmcraftApp {
Action::Activate { panel } => Some(*panel),
_ => None,
};
// a panel closed while floating remembers where; closed while docked, it forgets (#1985)
let closing = match &action {
Action::Close { panel } => Some((*panel, self.ui.floating.iter().find(|g| g.panels.contains(panel)).map(|g| g.rect))),
_ => None,
};
self.commit_dock(|shared| {
crate::dock_shared::check_last_docked(shared, &action)?;
shared.apply(action).map_err(|error| error.to_string())
@@ -104,6 +109,12 @@ impl FilmcraftApp {
if let Some(panel) = reveal {
self.pending_panel_raise = Some(panel);
}
if let Some((panel, rect)) = closing {
self.ui.closed_floating.retain(|(p, _)| *p != panel);
if let Some(rect) = rect {
self.ui.closed_floating.push((panel, rect));
}
}
if relocating
|| self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel)))
{
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1266,6 +1266,7 @@
59.94 fps Non Drop-Frame 59,94 fps sin eliminación de fotogramas
ASC CDL ASC CDL
Accessible color contrast Contraste de color accesible
Animate zoom, scrolling and menus Animar el zoom, el desplazamiento y los menús
Action-Safe Margin Margen seguro de acción
Active Monitor Monitor activo
Add Añadir
@@ -3610,3 +3611,9 @@
Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft.
Couldn't open your web browser No se pudo abrir el navegador web
Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Restablecer zoom de la interfaz
Track Color Color de pista
Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos
No text graphics in this sequence. Add text with the Type tool. Esta secuencia no tiene gráficos con texto. Añade texto con la herramienta Texto.
No matching text. Ningún texto coincide.
Can't render this file because it contains an unexpected character in line 3 and column 95.
+7
View File
@@ -1266,6 +1266,7 @@
59.94 fps Non Drop-Frame 59,94 fps non-drop frame
ASC CDL ASC CDL
Accessible color contrast Contraste des couleurs accessible
Animate zoom, scrolling and menus Animer le zoom, le défilement et les menus
Action-Safe Margin Marge de sécurité de l'action
Active Monitor Moniteur actif
Add Ajouter
@@ -3577,3 +3578,9 @@
Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini…
Show the presets folder Afficher le dossier des paramètres prédéfinis
Delete this preset Supprimer ce paramètre prédéfini
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Réinitialiser le zoom de l'interface
Track Color Couleur de la piste
Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques
No text graphics in this sequence. Add text with the Type tool. Aucun graphique avec du texte dans cette séquence. Ajoutez du texte avec l'outil Texte.
No matching text. Aucun texte ne correspond.
Can't render this file because it contains an unexpected character in line 5 and column 83.
+7
View File
@@ -1271,6 +1271,7 @@
59.94 fps Non Drop-Frame 59,94 fps Non Drop-Frame
ASC CDL ASC CDL
Accessible color contrast Kontras warna yang aksesibel
Animate zoom, scrolling and menus Animasikan zoom, gulir, dan menu
Action-Safe Margin Margin Aman Aksi
Active Monitor Monitor Aktif
Add Tambah
@@ -3575,3 +3576,9 @@
Appearance Settings… Pengaturan Tampilan…
Add to favorites Tambahkan ke favorit
Remove from favorites Hapus dari favorit
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Atur Ulang Zoom Antarmuka
Track Color Warna Trek
Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya
No text graphics in this sequence. Add text with the Type tool. Tidak ada grafis teks di sekuens ini. Tambahkan teks dengan alat Teks.
No matching text. Tidak ada teks yang cocok.
Can't render this file because it contains an unexpected character in line 6 and column 14.
+7
View File
@@ -1263,6 +1263,7 @@
59.94 fps Non Drop-Frame 59,94 fps non drop-frame
ASC CDL ASC CDL
Accessible color contrast Contrasto dei colori accessibile
Animate zoom, scrolling and menus Anima zoom, scorrimento e menu
Action-Safe Margin Margine di sicurezza azione
Active Monitor Monitor attivo
Add Aggiungi
@@ -3601,3 +3602,9 @@
Shows the version, community links and credits in a FilmCraft window. Mostra la versione, i link della community e i riconoscimenti in una finestra di FilmCraft.
Couldn't open your web browser Impossibile aprire il browser web
Open this address in your browser yourself: Apri tu questo indirizzo nel browser:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Reimposta zoom dell'interfaccia
Track Color Colore traccia
Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche
No text graphics in this sequence. Add text with the Type tool. Nessuna grafica con testo in questa sequenza. Aggiungi testo con lo strumento Testo.
No matching text. Nessun testo corrispondente.
Can't render this file because it contains an unexpected character in line 3 and column 95.
+7
View File
@@ -1260,6 +1260,7 @@
59.94 fps Non Drop-Frame 59.94 fps ノンドロップフレーム
ASC CDL ASC CDL
Accessible color contrast アクセシビリティに配慮したカラーコントラスト
Animate zoom, scrolling and menus ズーム、スクロール、メニューをアニメーション表示
Action-Safe Margin アクションセーフの余白
Active Monitor アクティブなモニター
Add 加算
@@ -3609,3 +3610,9 @@
Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。
Couldn't open your web browser ウェブブラウザーを開けませんでした
Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom インターフェイスのズームをリセット
Track Color トラックカラー
Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます
No text graphics in this sequence. Add text with the Type tool. このシーケンスにはテキストのグラフィックスがありません。文字ツールでテキストを追加してください。
No matching text. 一致するテキストはありません。
Can't render this file because it contains an unexpected character in line 11 and column 59.
+7
View File
@@ -76,6 +76,7 @@
About FilmCraft FilmCraft 정보
Accent Color 강조 색상
Accessible color contrast 접근성 색상 대비
Animate zoom, scrolling and menus 확대/축소, 스크롤 및 메뉴 애니메이션
Action and Title Safe Areas 액션 및 제목 안전 영역
Action Safe Area 액션 안전 영역
Action-Safe Margin 액션 세이프 여백
@@ -3596,3 +3597,9 @@
Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다.
Couldn't open your web browser 웹 브라우저를 열 수 없습니다
Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom 인터페이스 확대/축소 재설정
Track Color 트랙 색상
Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요
No text graphics in this sequence. Add text with the Type tool. 이 시퀀스에는 텍스트 그래픽이 없습니다. 문자 도구로 텍스트를 추가하세요.
No matching text. 일치하는 텍스트가 없습니다.
Can't render this file because it contains an unexpected character in line 2815 and column 7.
+35 -9
View File
@@ -1189,16 +1189,17 @@ mod tests {
}
}
/// German covers at least the core menus (`core_menu_labels`, the top-level menus and the native
/// macOS app menu); every entry must still be a menu label.
/// German covers the whole interface (every entry of the Spanish catalog, the native macOS app
/// menu included), but, like French, the tests require only the core menus and the native app
/// menu, so strings added later show in English until they are translated. Every entry must
/// still have a Spanish counterpart: a renamed or removed source would otherwise leave a dead
/// German entry.
#[test]
fn german_entries_cover_the_original_menu_catalog() {
let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
let items = crate::menus::menu_items(&app);
let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text));
let (entries, _) = catalog::parse_entries(include_str!("de.tsv"));
for (_, en, _) in entries {
assert!(known(&en) || crate::menus::NATIVE_APP_MENU.contains(&en.as_str()), "not a menu label: {en}");
fn german_covers_the_menus_and_has_no_dead_entries() {
let (entries, errors) = catalog::parse_entries(include_str!("de.tsv"));
assert!(errors.is_empty(), "{errors:?}");
for (ctx, en, _) in &entries {
assert!(ctx.is_empty() && Language::Es.has(en), "German entry without a Spanish one: {ctx:?} {en:?}");
}
for en in core_menu_labels() {
assert!(Language::De.has(&en), "missing German menu label: {en}");
@@ -1209,6 +1210,21 @@ mod tests {
}
assert_eq!(Language::De.tr("Settings"), "Voreinstellungen");
assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden");
// beyond the menus: panels, effects and their parameters, settings, status messages
for (en, de) in [
("Gaussian Blur", "Gaußscher Weichzeichner"),
("Audio Track Mixer", "Audiospur-Mischpult"),
("Sequence", "Sequenz"),
("Playhead", "Abspielposition"),
("Keyboard Shortcuts", "Tastenkürzel"),
("{n} min ago", "vor {n} Min."),
] {
assert_eq!(Language::De.tr(en), de);
}
set_current(Language::De);
assert!(matches_query("Gaussian Blur", "weichzeichner"));
assert!(matches_query("Gaussian Blur", "gaussian"));
set_current(Language::En);
}
/// French covers the whole interface (the Spanish catalog's entries and the native macOS
@@ -1450,6 +1466,16 @@ mod tests {
assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters));
}
#[test]
fn german_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("de.tsv"));
// every German letter and quotation mark, even ones no entry uses yet, and every letter,
// dash and ellipsis the entries use (symbols such as ✔ come from the English sources)
let alphabet = "ÄÖÜäöüß„“‚‘";
let letters = entries.iter().flat_map(|(_, _, de)| de.chars()).filter(|ch| ch.is_alphabetic() || "„“‚‘–—…".contains(*ch));
assert_bundled_fonts_cover(alphabet.chars().chain(Language::De.name().chars()).chain(letters));
}
#[test]
fn indonesian_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("id.tsv"));
+7
View File
@@ -1268,6 +1268,7 @@
59.94 fps Non Drop-Frame 59,94 fps sem drop-frame
ASC CDL ASC CDL
Accessible color contrast Contraste de cores acessível
Animate zoom, scrolling and menus Animar zoom, rolagem e menus
Action-Safe Margin Margem de segurança de ação
Active Monitor Monitor ativo
Add Adicionar
@@ -3578,3 +3579,9 @@
# The native macOS application menu (Settings…).
Settings Preferências
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom Redefinir zoom da interface
Track Color Cor da trilha
Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela
No text graphics in this sequence. Add text with the Type tool. Esta sequência não tem gráficos com texto. Adicione texto com a ferramenta Texto.
No matching text. Nenhum texto corresponde.
Can't render this file because it contains an unexpected character in line 6 and column 4.
+7
View File
@@ -1254,6 +1254,7 @@
59.94 fps Non Drop-Frame 59.94 fps 非掉帧
ASC CDL ASC CDL
Accessible color contrast 无障碍颜色对比度
Animate zoom, scrolling and menus 缩放、滚动和菜单使用动画
Action-Safe Margin 动作安全边距
Active Monitor 活动监视器
Add 添加
@@ -3585,3 +3586,9 @@
Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。
Couldn't open your web browser 无法打开网页浏览器
Open this address in your browser yourself: 请自行在浏览器中打开此地址:
# View ▸ Reset Interface Zoom (#2253).
Reset Interface Zoom 重置界面缩放
Track Color 轨道颜色
Open a sequence to search its graphics 打开序列以搜索其中的图形
No text graphics in this sequence. Add text with the Type tool. 此序列中没有文本图形。请用文字工具添加文本。
No matching text. 没有匹配的文本。
Can't render this file because it contains an unexpected character in line 3 and column 95.
+68 -1
View File
@@ -39,6 +39,9 @@ pub mod credits;
pub mod dock;
mod dock_shared;
mod dock_ui;
/// Thumbnail textures kept before the least recently used are freed (each is up to 320 px wide).
const MAX_THUMB_TEXTURES: usize = 512;
pub mod frames;
pub mod graphics_notice;
pub mod header;
@@ -289,6 +292,9 @@ pub struct FilmcraftApp {
pub auto: automation::Registry,
/// Named textures (monitors, thumbnails) with the key they show.
textures: HashMap<String, (FrameKey, TextureHandle)>,
/// The UI pass each thumbnail texture was last used in: thumbnails beyond
/// [`MAX_THUMB_TEXTURES`] are freed, least recently used first (#1682).
thumb_used: HashMap<String, u64>,
control_rx: Option<Receiver<ControlRequest>>,
/// One explicit panel reveal; consumed by the custom renderer, never repeated per frame.
pub(crate) pending_panel_raise: Option<PanelKind>,
@@ -553,6 +559,7 @@ impl FilmcraftApp {
native_dialog: None,
auto: Default::default(),
textures: HashMap::new(),
thumb_used: HashMap::new(),
control_rx: None,
pending_panel_raise: None,
control_input_only: false,
@@ -633,6 +640,7 @@ impl FilmcraftApp {
FrameServer::default_workers(),
));
self.textures.clear();
self.thumb_used.clear();
self.tl.reset_media_caches();
// the new frame server needs the Memory settings
self.applied_prefs = None;
@@ -685,6 +693,16 @@ impl FilmcraftApp {
}
}
/// View ▸ Reset Interface Zoom: the scale Settings ▸ Appearance ▸ UI Scale asks for (the
/// system's with Auto), undoing any session zoom (#2253).
/// Returns the zoom factor set (egui reports it from the next pass on).
pub(crate) fn reset_ui_zoom(&mut self, ctx: &egui::Context) -> f32 {
let native = ctx.native_pixels_per_point().filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
let zoom = self.session.prefs.appearance.ui_scale_factor().map_or(1.0, |scale| scale / native);
ctx.set_zoom_factor(zoom);
zoom
}
/// Make the UI follow the settings after they change (theme, tooltips, frame cache budget,
/// play after rendering, audio device).
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
@@ -697,6 +715,11 @@ impl FilmcraftApp {
self.set_theme(ctx, k);
}
}
// checked every frame: a theme or font change installs the style again
let animation = if self.session.prefs.appearance.animations { theme::ANIMATION_TIME } else { 0.0 };
if ctx.global_style().animation_time != animation {
ctx.global_style_mut(|s| s.animation_time = animation);
}
if self.applied_prefs.as_ref() == Some(&self.session.prefs) {
return;
}
@@ -849,6 +872,13 @@ impl FilmcraftApp {
}
pub fn show_panel(&mut self, p: PanelKind) {
// closed while floating: it floats again where it was (#1985); taken before the Timeline's
// own restore below docks it
let open = self.ui.dock.contains(p) || self.ui.floating.iter().any(|group| group.panels.contains(&p));
let refloat = match self.ui.closed_floating.iter().position(|(k, _)| *k == p) {
Some(i) if !open => Some(self.ui.closed_floating.remove(i).1),
_ => None,
};
if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) {
self.ui.dock.restore_timeline();
}
@@ -873,6 +903,15 @@ impl FilmcraftApp {
};
self.ui.dock.open_near(p, near);
}
if let Some(rect) = refloat.filter(|r| r.iter().all(|v| v.is_finite())) {
let rect = [rect[0], rect[1], rect[2].max(160.0), rect[3].max(120.0)];
if let Err(e) = self.apply_dock(craft_ui::docking::Action::Float { panel: p, rect }) {
// refused (it is the only docked panel left): it stays docked, and its floating
// place is kept rather than lost
log::warn!("could not float {} again: {e}", p.id());
self.ui.closed_floating.push((p, rect));
}
}
let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p });
self.ui.focused = p;
}
@@ -1251,12 +1290,40 @@ impl FilmcraftApp {
let name = format!("thumb-{}-{}-{}", item.0, frame, width);
if let Some(img) = self.frames.get(&key) {
let id = self.texture_for(ctx, &name, key, &img);
self.thumb_touched(ctx, &name);
return Some((id, egui::vec2(img.w as f32, img.h as f32)));
}
let scale = width as f32 / src_w.max(1) as f32;
let project = self.session.project.clone();
self.frames.request(key, rate.tick_of(frame), scale, &project, 50);
self.texture_existing(&name)
let shown = self.texture_existing(&name);
if shown.is_some() {
self.thumb_touched(ctx, &name);
}
shown
}
/// Note that thumbnail texture `name` is used in this pass, and free the least recently used
/// thumbnails once there are more than [`MAX_THUMB_TEXTURES`]: every frame shown in a timeline
/// tile or a hover scrub got a texture of its own that was never freed (#1682).
fn thumb_touched(&mut self, ctx: &egui::Context, name: &str) {
let pass = ctx.cumulative_pass_nr();
if self.thumb_used.insert(name.to_string(), pass).is_some() || self.thumb_used.len() <= MAX_THUMB_TEXTURES {
return;
}
// down to three quarters, never a thumbnail already drawn in this pass
let mut old: Vec<(u64, String)> = self.thumb_used.iter().filter(|(_, p)| **p < pass).map(|(n, p)| (*p, n.clone())).collect();
old.sort_unstable();
let excess = self.thumb_used.len().saturating_sub(MAX_THUMB_TEXTURES * 3 / 4);
for (_, n) in old.into_iter().take(excess) {
self.thumb_used.remove(&n);
self.textures.remove(&n);
}
}
/// The number of thumbnail textures held (tests, diagnostics).
pub fn thumbnail_textures(&self) -> usize {
self.textures.keys().filter(|n| n.starts_with("thumb-")).count()
}
// ---------------------------------------------------------------- files
+17
View File
@@ -54,6 +54,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.zoomIn", "Zoom In", ["View"], Some("=")),
uic!("view.zoomOut", "Zoom Out", ["View"], Some("-")),
uic!("view.zoomToSequence", "Zoom to Sequence", ["View"], Some("\\")),
uic!("view.resetInterfaceZoom", "Reset Interface Zoom", ["View"], None),
uic!("view.playbackRes.full", "Full", ["View", "Playback Resolution"], None),
uic!("view.playbackRes.half", "1/2", ["View", "Playback Resolution"], None),
uic!("view.playbackRes.quarter", "1/4", ["View", "Playback Resolution"], None),
@@ -420,6 +421,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
app.ui.timeline.fit_pending = true;
return Ok(Value::Null);
}
// the whole interface back to Settings ▸ Appearance ▸ UI Scale, after Ctrl+Plus or a mouse
// mapped to page zoom scaled it (#2253)
"view.resetInterfaceZoom" => {
let zoom = app.reset_ui_zoom(ctx);
return Ok(json!({"zoom": zoom}));
}
"mode.import" => {
app.ui.mode = Mode::Import;
return Ok(Value::Null);
@@ -846,6 +853,16 @@ pub fn bindings(app: &FilmcraftApp) -> Vec<KeyBinding> {
let shifted: Vec<KeyBinding> =
v.iter().filter(|b| b.0.shift).filter_map(|(m, k, id, p)| shifted_key(*k).map(|k2| (*m, k2, id.clone(), p.clone()))).collect();
v.extend(shifted);
// `+` is the `=` key with Shift on US layouts and its own key on others and the keypad: a chord
// on `=` takes it too, unless something is bound to `+` itself. Otherwise Ctrl+Plus fell
// through to egui's interface zoom while Ctrl+- and Ctrl+0 were taken by FilmCraft
// shortcuts, so the interface grew and could not shrink back (#2253).
let plus: Vec<KeyBinding> = v
.iter()
.filter(|(m, k, _, p)| *k == egui::Key::Equals && !v.iter().any(|(m2, k2, _, p2)| *k2 == egui::Key::Plus && m2 == m && p2 == p))
.map(|(m, _, id, p)| (*m, egui::Key::Plus, id.clone(), p.clone()))
.collect();
v.extend(plus);
v.sort_by_key(|(m, ..)| std::cmp::Reverse(specificity(*m)));
v
}
@@ -197,9 +197,18 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first.
let mut order: Vec<usize> = (0..it.effects.len()).collect();
order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic));
let mut after_fixed = false;
for idx in order {
let e = &it.effects[idx];
let Some(def) = e.def() else { continue };
// the first effect added from the library after the fixed ones gets a line on top
// (#2328); drawn on its edge, so no row moves
let first_added = if def.intrinsic {
after_fixed = true;
false
} else {
std::mem::take(&mut after_fixed)
};
let key = format!("{}:{}", clip.0, idx);
let open = !app.ui.collapsed_fx.contains(&key);
let selected = app.session.state.selected_effect.as_ref().is_some_and(|s| s.clip == clip && s.index(&it.effects) == Some(idx));
@@ -210,6 +219,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
bui.painter().rect_filled(crate::panels::effect_controls::row_fill(r), 0.0, t.hover);
}
row_line(bui, r, &lane, &t);
if first_added {
let line = Rect::from_min_max(pos2(r.min.x, r.min.y - 1.0), pos2(r.max.x, r.min.y + 1.0));
bui.painter().rect_filled(line, 0.0, t.accent.gamma_multiply(0.8));
let which = if *kind == TrackKind::Video { "video" } else { "audio" };
app.auto.add(&format!("effectControls.addedEffects.{which}"), line, "Added effects");
}
// only the arrow twirls the effect open or shut; the rest of the header selects it
let tw = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0));
icons::paint(bui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
+13 -4
View File
@@ -87,8 +87,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 46.0;
let icon_r = Rect::from_center_size(pos2(x - 10.0, r.center().y), vec2(13.0, 13.0));
// inside the top folder, or inside its sub-folder
let x = r.min.x + tree_x(if s.is_empty() { 1 } else { 2 }) + 30.0;
let icon_r = Rect::from_center_size(pos2(x - 12.0, r.center().y), vec2(13.0, 13.0));
let is_tr = matches!(d.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition);
ui.painter().rect_stroke(icon_r, 2.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside);
if is_tr {
@@ -139,6 +140,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// Where a tree row at `depth` starts (its chevron): folders at 0 (top) and 1 (sub-folders), and
/// what a folder holds one level deeper than the folder (#2329), so an effect is never level with,
/// or left of, the folder it is in. A row's icon is centred 18 points in, its label 30.
pub(crate) fn tree_x(depth: usize) -> f32 {
8.0 + depth as f32 * 16.0
}
fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize, filter: &str) -> bool {
let t = app.tokens;
let open = !filter.is_empty() || app.ui.expanded_fx.iter().any(|k| k == key);
@@ -146,7 +154,7 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 8.0 + depth as f32 * 16.0;
let x = r.min.x + tree_x(depth);
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x + 4.0, r.center().y), vec2(10.0, 10.0)),
@@ -240,7 +248,8 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti
ui.painter().rect_filled(r, 0.0, t.hover);
}
let tex = preset_texture(app, ui.ctx(), p, 64);
let tr = Rect::from_min_size(pos2(r.min.x + 40.0, r.min.y + 3.0), vec2(32.0, 18.0));
// inside a Lumetri Presets sub-folder: one level deeper than it
let tr = Rect::from_min_size(pos2(r.min.x + tree_x(2) + 12.0, r.min.y + 3.0), vec2(32.0, 18.0));
ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(p.name), Tokens::ui(12.0), t.text);
app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name);
+48 -7
View File
@@ -831,7 +831,20 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
let p = resp.interact_pointer_pos().unwrap_or(pic.center());
let in_edit = edit_view.is_some_and(|v| v.hit(p));
match tool {
_ if in_edit => {}
// a click in the text being edited puts the caret there (Shift extends the selection),
// as a press that becomes a drag already does (#2430)
_ if in_edit => {
if let (Some(v), Some(mut ed)) = (edit_view, editing.clone())
&& let Some(tp) = text_of(&v.spec)
&& let Some((x, y)) = v.to_local(p)
{
ed.caret = text_layout(tp).hit(x, y);
if !ui.input(|i| i.modifiers.shift) {
ed.anchor = ed.caret;
}
app.ui.gfx_edit = Some(ed);
}
}
Tool::Type | Tool::VerticalType => {
if let Some(v) = views.iter().find(|v| v.is_text() && v.hit(p)) {
start_edit_at(app, ui, v, p, &mut actions);
@@ -910,8 +923,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE));
}
let (x, base, li) = l.caret(caret);
let line = &l.lines[li.min(l.lines.len() - 1)];
let (a, b) = (sp(&v.to_screen, x, base - line.ascent), sp(&v.to_screen, x, base + line.descent));
let (ascent, descent) = l.lines.get(li.min(l.lines.len().saturating_sub(1))).map_or((0.0, 0.0), |line| (line.ascent, line.descent));
let (a, b) = (sp(&v.to_screen, x, base - ascent), sp(&v.to_screen, x, base + descent));
painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE));
painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK));
let er = Rect::from_points(&v.quad()).union(Rect::from_two_pos(a, b));
@@ -1198,6 +1211,8 @@ fn color32(c: [f32; 4]) -> Color32 {
struct Ctx<'a> {
clip: ClipId,
layer: usize,
/// The layer's index in the clip's effect list (what `effects.toggleAnimation` addresses).
eidx: usize,
e: &'a EffectInstance,
mt: Tick,
t: Tokens,
@@ -1245,12 +1260,37 @@ impl Ctx<'_> {
);
(r, v)
}
/// The stopwatch in front of an animatable property's label (#2358): turns keyframing on or
/// off at the playhead, like the one in Effect Controls. While a property animates, changing
/// it here adds or updates a keyframe at the playhead instead of changing it everywhere. A
/// Type-tool selection edits the selected characters, which have no keyframes: no stopwatch.
fn stopwatch(&mut self, ui: &egui::Ui, row: Rect, id: &str) {
let animatable = self.e.def().and_then(|d| d.param(id)).is_some_and(|d| d.animatable);
if !animatable || (self.range.is_some() && char_style_key(id).is_some()) {
return;
}
let animated = self.e.params.get(id).is_some_and(|p| p.is_animated());
let sw = Rect::from_center_size(pos2(row.min.x + 8.0, row.center().y), vec2(12.0, 12.0));
let resp = ui.interact(sw.expand(2.0), egui::Id::new(("gfxsw", self.clip.0, self.layer, id)), Sense::click()).on_hover_text(tl!("Toggle animation"));
let tint = if animated {
self.t.accent
} else if resp.hovered() {
self.t.text
} else {
self.t.text_faint
};
icons::paint(ui.painter(), sw, Icon::Stopwatch, tint);
self.autos.push((format!("graphics.prop.{id}.stopwatch"), sw, "Toggle animation".to_string()));
if resp.clicked() {
self.actions.push(("effects.toggleAnimation".into(), json!({"clip": self.clip.0, "effect": self.eidx, "param": id})));
}
}
fn number(&mut self, ui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) {
let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0);
let (r, mut vui) = self.row(ui, label);
let (resp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t);
self.auto(id, resp.rect, label);
let _ = r;
self.stopwatch(ui, r, id);
if let Some(nv) = nv {
self.set(id, json!(nv));
}
@@ -1265,7 +1305,8 @@ impl Ctx<'_> {
}
fn point(&mut self, ui: &mut egui::Ui, label: &str, id: &str) {
let v = pv(self.e, id, self.mt).as_vec2().unwrap_or_default();
let (_, mut vui) = self.row(ui, label);
let (r, mut vui) = self.row(ui, label);
self.stopwatch(ui, r, id);
let (rx, nx) = crate::widgets::hot_number(
&mut vui,
egui::Id::new(("gfxp", self.clip.0, self.layer, id, "x")),
@@ -1565,7 +1606,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ch = |b: usize| text.get(..b.min(text.len())).map_or(0, |s| s.chars().count());
Some((ch(ed.caret.min(ed.anchor)), ch(ed.caret.max(ed.anchor))))
});
let mut cx = Ctx { clip, layer: l, e, mt, t, actions: Vec::new(), autos: Vec::new(), range };
let mut cx = Ctx { clip, layer: l, eidx: idx[l], e, mt, t, actions: Vec::new(), autos: Vec::new(), range };
if section(ui, app, "Responsive Design - Position", &t) {
crate::panels::graphics_templates::responsive_position(app, ui, clip, &it, l, &mut cx.autos, &mut cx.actions);
}
@@ -1810,7 +1851,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (cmd, mut p) in actions {
// a drag of a property is one undo step: the first change of each press begins a new one,
// the rest fold into it (like Effect Controls, #201 / #887)
if cmd == "graphics.set" && down {
if matches!(cmd.as_str(), "graphics.set" | "graphics.setCharStyle") && down {
p["merge"] = json!(true);
p["begin"] = json!(begun != press);
wrote = true;
+4 -2
View File
@@ -70,8 +70,10 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<
if let Err(e) = filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session)) {
return Some(Err(e));
}
let items: Vec<u64> = app.session.state.project_selection.iter().map(|i| i.0).collect();
let first = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default();
// the Project panel's selection, or the media of the clips selected in the Timeline (#1362)
let selected = filmcraft_engine::multicam::selected_items(&app.session);
let items: Vec<u64> = selected.iter().map(|i| i.0).collect();
let first = selected.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default();
let name = match kind {
"merge" => tlf!("{first} - Merged", first),
"multicam" => tlf!("{first} Multicam", first),
+2 -1
View File
@@ -26,7 +26,8 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
let x = r.min.x + 30.0;
// inside the Presets folder: icon and label one level deeper than it (#2329)
let x = r.min.x + crate::panels::effects::tree_x(1) + 18.0;
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x, r.center().y), vec2(13.0, 13.0)),
+161 -2
View File
@@ -1,4 +1,6 @@
//! The Text panel: Transcript / Captions / Graphics tabs. The Captions tab lists the caption
//! The Text panel: Transcript / Captions / Graphics tabs. The Graphics tab lists the text of every
//! text layer in the sequence's graphics (#1487), with a search; clicking an entry goes to its clip
//! and selects the layer, and the text edits in place (`graphics.set`). The Captions tab lists the caption
//! segments of a caption track with editable in/out timecodes and text, a toolbar (add, split,
//! merge, delete), track choice and the track style. Every edit dispatches a `captions.*` engine
//! command; every widget registers an automation id (`text.*`). The Transcript tab shows the
@@ -57,7 +59,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
match app.ui.text_tab.as_str() {
"Captions" => captions(app, ui, body),
"Transcript" => transcript(app, ui, body),
_ => crate::dock::placeholder(ui, body, &t, tl!("Graphics text search arrives with M10.1–M10.2")),
_ => graphics(app, ui, body),
}
}
@@ -282,6 +284,163 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
run(app, ui, actions);
}
/// A text layer of a graphic clip in the active sequence, for the Graphics tab.
#[derive(Clone, Debug, PartialEq)]
pub struct GraphicText {
pub clip: filmcraft_project::ClipId,
/// The layer's number among the clip's layers (what `graphics.set` / `graphics.selectLayer` take).
pub layer: usize,
pub start: filmcraft_time::Tick,
/// The track's label (`V2`).
pub track: String,
/// The layer's own name, when it has one.
pub name: Option<String>,
pub text: String,
/// The timeline time the text was read at (the playhead, or the clip's start when the
/// playhead is outside the clip): an edit is written there, so keyframed text changes the
/// value that is shown.
pub at: filmcraft_time::Tick,
}
/// Every text layer of every graphic clip in `seq`, in timeline order (then lower tracks first,
/// then paint order), with its text at `at` when that falls in the clip (Source Text keyframes
/// hold), else at the clip's first frame.
pub fn graphic_texts(seq: &filmcraft_project::Sequence, at: filmcraft_time::Tick) -> Vec<GraphicText> {
use filmcraft_project::graphic::{TEXT_LAYER, layer_indices};
let mut out = Vec::new();
for (ti, track) in seq.video_tracks.iter().enumerate() {
for it in &track.items {
let t = if it.start <= at && at < it.end() { at } else { it.start };
let mt = it.effect_time_at(t);
for (layer, ei) in layer_indices(&it.effects).into_iter().enumerate() {
let Some(e) = it.effects.get(ei).filter(|e| e.effect == TEXT_LAYER) else { continue };
let text = match e.param("text").map(|p| p.value_at(mt)) {
Some(filmcraft_project::ParamValue::Text(s)) => s,
_ => String::new(),
};
let name = match e.param("name").map(|p| p.value.clone()) {
Some(filmcraft_project::ParamValue::Text(n)) if !n.trim().is_empty() => Some(n),
_ => None,
};
out.push((ti, GraphicText { clip: it.id, layer, start: it.start, track: format!("V{}", ti + 1), name, text, at: t }));
}
}
}
out.sort_by(|(ta, a), (tb, b)| a.start.cmp(&b.start).then(ta.cmp(tb)).then(a.layer.cmp(&b.layer)));
out.into_iter().map(|(_, g)| g).collect()
}
/// The Graphics tab (#1487): the text of every text layer in the sequence, searchable. The
/// timecode goes to the clip and selects the layer; the text edits in place.
fn graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let project = app.session.project.clone();
let Some(seq) = app.session.state.active_sequence.and_then(|id| project.sequence(id)) else {
crate::dock::placeholder(ui, rect, &t, tl!("Open a sequence to search its graphics"));
return;
};
let (rate, df) = (seq.settings.frame_rate, seq.settings.drop_frame);
let tc = |x: filmcraft_time::Tick| format_time(x, rate, df, TimeDisplay::Timecode, 48_000);
let ph = app.session.playhead();
let entries = graphic_texts(seq, ph);
let mut actions: Vec<(String, Value)> = Vec::new();
// search
let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0));
let skey = egui::Id::new("text-gfx-search");
let mut search = ui.data(|d| d.get_temp::<String>(skey)).unwrap_or_default();
let w = 220.0f32.min(bar.width());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(w, 24.0))));
let sresp = crate::widgets::search_field(&mut child, &mut search, tl!("Search"), w, &t);
app.auto.add("text.graphics.search", sresp.rect, "Search graphics text");
ui.data_mut(|d| d.insert_temp(skey, search.clone()));
let q = search.to_lowercase();
// list
let list = Rect::from_min_max(pos2(rect.min.x + 6.0, bar.max.y + 8.0), pos2(rect.max.x - 6.0, rect.max.y - 4.0));
ui.painter().rect_filled(list, 3.0, t.app_bg);
let sel_clips = app.session.state.selection.clone();
let sel_layers = app.session.state.graphic_layers.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink(4.0)).id_salt("graphics-text-list"));
egui::ScrollArea::vertical().auto_shrink([false, false]).id_salt("graphics-text-scroll").show(&mut child, |ui| {
ui.set_width(list.width() - 12.0);
let mut shown = 0;
for (n, g) in entries.iter().enumerate() {
if !q.is_empty() && !g.text.to_lowercase().contains(&q) && !g.name.as_deref().unwrap_or("").to_lowercase().contains(&q) {
continue;
}
shown += 1;
let key = format!("{}.{}", g.clip.0, g.layer);
let selected = sel_clips.contains(&g.clip) && sel_layers.contains(&g.layer);
let fill = if selected {
t.row_selected
} else if n % 2 == 1 {
t.row_alt
} else {
Color32::TRANSPARENT
};
let select = |actions: &mut Vec<(String, Value)>| {
actions.push(("timeline.select".into(), json!({"clips": [g.clip.0]})));
actions.push(("graphics.selectLayer".into(), json!({"clip": g.clip.0, "layers": [g.layer]})));
};
let frame = egui::Frame::NONE.fill(fill).inner_margin(egui::Margin::symmetric(6, 4)).corner_radius(3.0);
let fr = frame.show(ui, |ui| {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(92.0);
let goto = ui.add(egui::Button::new(egui::RichText::new(tc(g.start)).font(Tokens::mono(11.0)).color(t.hot_text)).frame(false));
app.auto.add(&format!("text.graphics.{key}.goto"), goto.rect, "Go to graphic");
if goto.clicked() {
actions.push(("playhead.set".into(), json!({"time": g.start.0})));
select(&mut actions);
}
let label = match &g.name {
Some(name) => format!("{} · {name}", g.track),
None => g.track.clone(),
};
ui.label(egui::RichText::new(label).size(11.0).color(t.text_dim));
});
ui.vertical(|ui| {
let tkey = egui::Id::new(("gfx-text", g.clip.0, g.layer));
let mut buf = ui.data(|d| d.get_temp::<String>(tkey)).unwrap_or_else(|| g.text.clone());
let resp = ui.add(
egui::TextEdit::multiline(&mut buf)
.id(tkey.with("te"))
.desired_rows(1)
.desired_width(ui.available_width())
.font(Tokens::ui(12.5))
.frame(egui::Frame::NONE),
);
app.auto.add(&format!("text.graphics.{key}.text"), resp.rect, "Graphic text");
if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(tkey, buf.clone()));
if !selected {
select(&mut actions);
}
} else {
ui.data_mut(|d| d.remove::<String>(tkey));
}
if resp.lost_focus() && buf != g.text {
actions.push(("graphics.set".into(), json!({"clip": g.clip.0, "layer": g.layer, "props": {"text": buf}, "time": g.at.0})));
}
});
});
});
app.auto.add(&format!("text.graphics.{key}.row"), fr.response.rect, &g.text);
ui.add_space(2.0);
}
if shown == 0 {
ui.label(
egui::RichText::new(if entries.is_empty() {
tl!("No text graphics in this sequence. Add text with the Type tool.")
} else {
tl!("No matching text.")
})
.color(t.text_faint),
);
}
});
run(app, ui, actions);
}
/// Whether a caption is selected, so the Properties panel shows [`caption_properties`].
pub fn caption_selected(app: &FilmcraftApp) -> bool {
app.session.active_sequence().is_some_and(|q| app.session.state.caption_selection.iter().any(|c| q.find_caption(*c).is_some()))
+40 -1
View File
@@ -294,6 +294,10 @@ pub fn zoom_about(v: &mut TimelineView, factor: f64, anchor_secs: f64, width: f3
v.target_scroll = (anchor_secs - x as f64 / new).max(0.0);
}
/// How strongly a track's colour label tints its header and its lane.
const TRACK_HEADER_TINT: f32 = 0.18;
const TRACK_LANE_TINT: f32 = 0.10;
fn label_color(app: &FilmcraftApp, l: filmcraft_project::Label) -> Color32 {
crate::panels::settings::label_color(app, l)
}
@@ -340,7 +344,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
v.fit_pending = false;
v.fit_empty = empty.then_some(v.target_pps);
}
let k = 1.0 - (-dt * 20.0).exp();
// eased, or in one step with Settings ▸ Appearance ▸ animations off (#2325)
let k = if app.session.prefs.appearance.animations { 1.0 - (-dt * 20.0).exp() } else { 1.0 };
let zooming = (v.pps - v.target_pps).abs() / v.target_pps > 0.001;
// video thumbnails keep the layout of the zoom a gesture or animation started at (#1094)
if zooming || matches!(app.tl.drag, Some(Drag::ZoomBar { mode: 1 | 2, .. })) {
@@ -455,6 +460,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let bg = if r.index % 2 == 0 { t.tl_track_bg } else { t.tl_track_bg_alt };
painter.rect_filled(row, 0.0, bg);
// a track's colour label tints its lane (#1186)
if let Some(l) = seq.track(r.track).and_then(|tr| tr.label) {
painter.rect_filled(row, 0.0, label_color(app, l).gamma_multiply(TRACK_LANE_TINT));
}
painter.line_segment([pos2(row.min.x, r.rect.max.y - 0.5), pos2(row.max.x, r.rect.max.y - 0.5)], Stroke::new(1.0, t.separator));
// the top video track also gets a line above it
if r.kind == TrackKind::Video && r.index + 1 == nv {
@@ -1258,6 +1267,36 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
}
let p = ui.painter().with_clip_rect(visible);
p.rect_filled(hrect, 0.0, t.tl_header_bg);
// 0. colour label (#1186): a tint over the header, and the strip at its left edge, which
// opens the colour menu
let strip = Rect::from_min_max(hrect.min, pos2(hrect.min.x + 9.0, hrect.max.y));
if let Some(l) = tr.label {
let c = label_color(app, l);
p.rect_filled(hrect, 0.0, c.gamma_multiply(TRACK_HEADER_TINT));
p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, c);
}
let cresp = ui.interact(strip.intersect(visible), egui::Id::new(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color"));
app.auto.add(&format!("timeline.track.{}{}.color", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1), strip, "Track Color");
if cresp.hovered() && tr.label.is_none() {
p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, t.hover);
}
egui::Popup::menu(&cresp).show(|ui| {
let prefix = format!("timeline.track.{}{}.color", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1);
let none = ui.selectable_label(tr.label.is_none(), tl!("None"));
app.auto.add(&format!("{prefix}.None"), none.rect, "None");
if none.clicked() {
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "label": ""})));
ui.close();
}
// the user's label names and colours, as in Edit ▸ Label
let labels = app.session.prefs.labels.clone();
for l in filmcraft_project::Label::ALL {
if crate::menus::label_choice(ui, &mut app.auto, &labels, l, &prefix).clicked() {
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "label": l.name()})));
ui.close();
}
}
});
p.line_segment([pos2(hrect.min.x, hrect.max.y - 0.5), pos2(hrect.max.x, hrect.max.y - 0.5)], Stroke::new(1.0, t.separator));
if r.kind == TrackKind::Video && r.index + 1 == seq.video_tracks.len() {
p.line_segment([pos2(hrect.min.x, hrect.min.y + 0.5), pos2(hrect.max.x, hrect.min.y + 0.5)], Stroke::new(1.0, t.separator));
+5
View File
@@ -436,6 +436,10 @@ pub struct UiState {
pub dock: DockNode,
#[serde(default)]
pub floating: Vec<crate::dock::Floating>,
/// Where each panel closed while floating was ([x, y, width, height]): opening it again
/// brings it back there, floating, instead of docked (#1985).
#[serde(default)]
pub closed_floating: Vec<(PanelKind, [f32; 4])>,
/// Focused panel (blue outline, receives shortcuts).
pub focused: PanelKind,
pub timeline: TimelineView,
@@ -960,6 +964,7 @@ impl Default for UiState {
mode: Mode::Edit,
workspace: "Editing".into(),
floating: vec![],
closed_floating: vec![],
dock: crate::dock::workspace("Editing"),
focused: PanelKind::Timeline,
timeline: TimelineView::default(),
+5 -1
View File
@@ -507,10 +507,14 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional));
s.text_styles.insert(TextStyle::Heading, FontId::new(15.0, FontFamily::Name("semibold".into())));
s.text_styles.insert(TextStyle::Monospace, FontId::new(12.0, FontFamily::Monospace));
s.animation_time = 0.12;
s.animation_time = ANIMATION_TIME;
});
}
/// How long egui's fades and slides take (menus, collapsing sections, tooltips); 0 when
/// Settings ▸ Appearance ▸ "Animate zoom, scrolling and menus" is off (#2325).
pub const ANIMATION_TIME: f32 = 0.12;
#[cfg(test)]
mod tests {
use super::*;
@@ -357,6 +357,26 @@ fn text_committed_by_an_input_method_is_typed_on_the_monitor() {
assert_eq!(list["layers"][0]["text"], "A你好");
}
/// #2430: a click (no drag) in the text being edited on the monitor puts the caret there; before,
/// only a press that moved a little did, and typing went on at the end.
#[test]
fn a_click_in_the_edited_text_moves_the_caret() {
let mut d = Driver::new();
d.ok("ui.set", json!({"tool": "Type"}));
d.frames(3);
let pic = d.rect("program.picture");
d.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.3, "y": pic[1] + pic[3] * 0.2}));
d.ok("ui.type", json!({"text": "Title"}));
d.frames(2);
let text = d.rect("program.textEdit");
// just inside the first letter: the caret goes before it
d.ok("ui.click", json!({"x": text[0] + 1.0, "y": text[1] + text[3] / 2.0}));
d.frames(2);
d.ok("ui.type", json!({"text": "X"}));
d.frames(2);
assert_eq!(d.exec("graphics.list", json!({}))["layers"][0]["text"], "XTitle");
}
#[test]
fn text_properties_switches_between_point_and_paragraph_text() {
let mut d = Driver::new();
+168
View File
@@ -230,3 +230,171 @@ fn shadow_color_drag_is_one_undo_step() {
d.exec("edit.undo", json!({}));
assert_eq!(color(&mut d), before, "one undo restores the original color");
}
/// Character ranges use setCharStyle, but must share the same one-gesture undo rule as a layer.
#[test]
fn selected_character_size_drag_is_one_undo_step() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace":"Captions and Graphics","tool":"Type"}));
d.exec("playhead.set", json!({"seconds":2}));
d.frames(3);
let p = d.rect("program.picture");
d.ok("ui.click", json!({"x":p[0]+p[2]*0.2,"y":p[1]+p[3]*0.35}));
d.ok("ui.type", json!({"text":"Undo drag test"}));
d.ok("ui.key", json!({"key":"Cmd+A"}));
d.frames(3);
let before = d.harness.state().session.project.clone();
let n = d.harness.state().session.history.undo.len();
let r = d.rect("graphics.prop.size");
d.ok("ui.drag", json!({"from":{"x":r[0]+r[2]/2.0,"y":r[1]+r[3]/2.0},"to":{"x":r[0]+r[2]/2.0+80.0,"y":r[1]+r[3]/2.0},"steps":8}));
d.frames(3);
assert_eq!(d.harness.state().session.history.undo.len(), n + 1);
let after = d.harness.state().session.project.clone();
assert_ne!(*after, *before);
d.exec("edit.undo", json!({}));
assert_eq!(*d.harness.state().session.project, *before);
d.exec("edit.redo", json!({}));
assert_eq!(*d.harness.state().session.project, *after);
}
/// The Properties panel's stopwatches animate a layer property (#2358): Tracking (letter
/// spacing) gets keyframes from the Essential Graphics panel itself, a change at another time
/// adds a keyframe there instead of changing the value everywhere, and the stopwatch turns
/// keyframing off again.
#[test]
fn the_tracking_stopwatch_animates_letter_spacing() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
d.exec("playhead.set", json!({"seconds": 0.0}));
let r = d.exec("graphics.newText", json!({"text": "Title", "seconds": 4}));
let clip = r["clip"].as_u64().unwrap();
d.exec("graphics.selectLayer", json!({"clip": clip, "layers": [0]}));
d.frames(4);
for _ in 0..20 {
if d.rect("graphics.prop.tracking")[1] < 800.0 {
break;
}
d.ok("ui.scroll", json!({"id": "graphics.prop.scale", "dy": -200.0}));
d.frames(2);
}
let keys = |d: &mut Driver| -> Vec<(i64, f64)> {
let s = &d.harness.state().session;
let (_, it) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap();
let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap();
e.params.get("tracking").map(|p| p.keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect()).unwrap_or_default()
};
assert!(keys(&mut d).is_empty());
d.ok("ui.click", json!({"id": "graphics.prop.tracking.stopwatch"}));
d.frames(2);
assert_eq!(keys(&mut d).len(), 1, "the stopwatch sets a keyframe at the playhead");
// two seconds later, drag the Tracking number: a second keyframe, the first stays
d.exec("playhead.set", json!({"seconds": 2.0}));
d.frames(2);
let n = d.rect("graphics.prop.tracking");
let (cx, cy) = (n[0] + n[2] / 2.0, n[1] + n[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 60.0, "y": cy}, "steps": 6}));
d.frames(4);
let k = keys(&mut d);
assert_eq!(k.len(), 2, "{k:?}");
// the clip starts at 0, where the first keyframe was set, so the second one sits at the playhead
let playhead = d.harness.state().session.playhead().0;
assert_eq!((k[0].0, k[1].0), (0, playhead), "the keyframes are where the playhead was: {k:?}");
assert_eq!(k[0].1, 0.0, "the first keyframe keeps its value: {k:?}");
assert!(k[1].1 > 10.0, "the new keyframe has the dragged value: {k:?}");
// letter spacing now animates: halfway between the keyframes the value is in between
let mid = {
let s = &d.harness.state().session;
let (_, it) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap();
let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap();
e.param("tracking").unwrap().value_at(filmcraft_time::Tick((k[0].0 + k[1].0) / 2)).as_f64().unwrap()
};
assert!(mid > 0.0 && mid < k[1].1, "{mid} between 0 and {}", k[1].1);
d.ok("ui.click", json!({"id": "graphics.prop.tracking.stopwatch"}));
d.frames(2);
assert!(keys(&mut d).is_empty(), "the stopwatch turns keyframing off");
d.shot("tracking-stopwatch");
}
/// Text panel ▸ Graphics (#1487): the text of every text layer in the sequence, in timeline
/// order, searchable; the timecode goes to the clip and selects the layer, and the text edits in
/// place as one undo step.
#[test]
fn the_text_panels_graphics_tab_lists_and_edits_graphic_text() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
let mut clips = Vec::new();
for (sec, text) in [(6.0, "Closing credits"), (1.0, "Opening title")] {
d.exec("playhead.set", json!({"seconds": sec}));
let r = d.exec("graphics.newText", json!({"text": text, "seconds": 2}));
clips.push(r["clip"].as_u64().unwrap());
}
let (credits, title) = (clips[0], clips[1]);
d.exec("playhead.set", json!({"seconds": 10.0}));
d.ok("ui.click", json!({"id": "text.tab.Graphics"}));
d.frames(3);
let rows = |d: &mut Driver| -> Vec<String> {
let v = d.ok("ui.elements", json!({"prefix": "text.graphics."}));
v.as_array().unwrap().iter().filter(|e| e["id"].as_str().unwrap().ends_with(".row")).map(|e| e["label"].as_str().unwrap_or("").to_string()).collect()
};
assert_eq!(rows(&mut d), ["Opening title", "Closing credits"], "in timeline order");
// search
d.ok("ui.click", json!({"id": "text.graphics.search"}));
d.ok("ui.type", json!({"text": "CRED"}));
d.frames(3);
assert_eq!(rows(&mut d), ["Closing credits"]);
d.ok("ui.key", json!({"key": "A", "command": true}));
d.ok("ui.key", json!({"key": "Backspace"}));
d.frames(3);
assert_eq!(rows(&mut d).len(), 2);
// the timecode goes to the clip and selects its layer
d.ok("ui.click", json!({"id": format!("text.graphics.{credits}.0.goto")}));
d.frames(3);
let start = d.harness.state().session.active_sequence().unwrap().find_item(filmcraft_project::ClipId(credits)).unwrap().1.start;
assert_eq!(d.harness.state().session.playhead(), start);
assert_eq!(d.harness.state().session.state.selection, vec![filmcraft_project::ClipId(credits)]);
assert_eq!(d.harness.state().session.state.graphic_layers, vec![0]);
// edit the title's text in place
let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
d.ok("ui.click", json!({"id": format!("text.graphics.{title}.0.text")}));
d.ok("ui.key", json!({"key": "A", "command": true}));
d.ok("ui.type", json!({"text": "Night Drive"}));
d.ok("ui.click", json!({"id": "text.graphics.search"}));
d.frames(3);
let text = text_param(&mut d, title, "text");
assert_eq!(text, Some(filmcraft_project::ParamValue::Text("Night Drive".into())));
assert_eq!(d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(), undo0 + 1, "one undo step");
assert_eq!(rows(&mut d), ["Night Drive", "Closing credits"]);
d.shot("text-panel-graphics");
}
/// Keyframed text: the Graphics tab shows a clip's text at the clip's start when the playhead is
/// past the clip, and an edit is written there (not at the clip's last frame), so the value shown
/// is the one that changes.
#[test]
fn a_graphics_tab_edit_writes_keyframed_text_where_it_is_shown() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
d.exec("playhead.set", json!({"seconds": 1.0}));
let clip = d.exec("graphics.newText", json!({"text": "Start", "seconds": 2}))["clip"].as_u64().unwrap();
let it = d.harness.state().session.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.clone();
// animate the text: "Start" at the clip's start, "End" near its end
d.exec("effects.toggleAnimation", json!({"clip": clip, "effect": "graphic_text", "param": "text"}));
let late = it.start.0 + (it.duration.0 * 9) / 10;
d.exec("graphics.set", json!({"clip": clip, "layer": 0, "props": {"text": "End"}, "time": late}));
// the playhead past the clip: the row shows the start's text
d.exec("playhead.set", json!({"seconds": 10.0}));
d.ok("ui.click", json!({"id": "text.tab.Graphics"}));
d.frames(3);
d.ok("ui.click", json!({"id": format!("text.graphics.{clip}.0.text")}));
d.ok("ui.key", json!({"key": "A", "command": true}));
d.ok("ui.type", json!({"text": "Edited"}));
d.ok("ui.click", json!({"id": "text.graphics.search"}));
d.frames(3);
let at = |d: &mut Driver, t: i64| {
let it = d.harness.state().session.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.clone();
let mt = it.effect_time_at(filmcraft_time::Tick(t));
it.effects.iter().find(|e| e.effect == "graphic_text").unwrap().param("text").unwrap().value_at(mt)
};
assert_eq!(at(&mut d, it.start.0), filmcraft_project::ParamValue::Text("Edited".into()), "the shown (start) value changed");
assert_eq!(at(&mut d, late), filmcraft_project::ParamValue::Text("End".into()), "the end keeps its own");
}
+96
View File
@@ -0,0 +1,96 @@
//! Headless UI tests of the interface zoom (#2253).
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
return v["result"].clone();
}
}
panic!("no reply to {method} {params}");
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
/// One key press with `modifiers`, as the window reports it.
fn press(&mut self, key: egui::Key, modifiers: egui::Modifiers) {
let events = &mut self.harness.input_mut().events;
events.push(egui::Event::ModifiersChanged(modifiers));
for pressed in [true, false] {
events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
}
events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
self.frames(3);
}
}
/// #2253: with the Timeline focused, Ctrl+= and Ctrl+- are its track-height keys, but Ctrl+Plus
/// (the `+` key, or Shift+=) fell through to egui and scaled the whole interface, which the taken
/// Ctrl+- and Ctrl+0 could not undo. `+` now goes where `=` goes, and View ▸ Reset Interface Zoom
/// gives the configured scale back.
#[test]
fn ctrl_plus_does_what_ctrl_equals_does_and_the_interface_zoom_can_be_reset() {
let mut d = Driver::demo();
let clip = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].id.0;
d.click(&format!("timeline.clip.{clip}"));
assert_eq!(d.app().ui.focused, filmcraft_ui_egui::dock::PanelKind::Timeline);
let zoom = d.harness.ctx.zoom_factor();
let height = d.app().ui.timeline.video_track_h;
d.press(egui::Key::Plus, egui::Modifiers::COMMAND);
assert_eq!(d.harness.ctx.zoom_factor(), zoom, "the interface keeps its scale");
assert!(d.app().ui.timeline.video_track_h > height, "Ctrl+Plus makes the video tracks taller, as Ctrl+= does");
// however it got scaled, View ▸ Reset Interface Zoom brings it back
d.harness.ctx.set_zoom_factor(1.75);
d.frames(2);
assert_eq!(d.harness.ctx.zoom_factor(), 1.75);
let r = d.ok("ui.menu.invoke", json!({"id": "view.resetInterfaceZoom"}));
assert_eq!(r["zoom"], json!(1.0), "the reply says the zoom it set: {r}");
d.frames(2);
assert_eq!(d.harness.ctx.zoom_factor(), 1.0);
}
+19
View File
@@ -708,3 +708,22 @@ fn mask_keyframe_menus_preserve_the_effect_and_other_masks() {
assert_eq!(effect(&d), before);
}
}
/// #2328: a line separates the fixed effects (Motion, Opacity, Time Remapping) from the effects
/// added from the library; without added effects there is none.
#[test]
fn a_line_separates_added_effects_from_the_fixed_ones() {
let (mut d, clip) = Driver::demo();
assert!(d.find("effectControls.addedEffects.video").is_none(), "only fixed effects: no line");
d.exec("effects.apply", json!({"clips": [clip.id], "effect": "gaussian_blur"}));
d.exec("effects.apply", json!({"clips": [clip.id], "effect": "tint"}));
let line = d.rect("effectControls.addedEffects.video");
let opacity = d.rect("effectControls.effect.opacity");
let blur = d.rect("effectControls.effect.gaussian_blur");
let tint = d.rect("effectControls.effect.tint");
assert!(
opacity[1] < line[1] && (line[1] + line[3] / 2.0 - blur[1]).abs() <= 0.5,
"on the edge between the fixed and the added effects: {opacity:?} {line:?} {blur:?}"
);
assert!(tint[1] > blur[1], "one line, above all the added effects");
}
+25
View File
@@ -389,3 +389,28 @@ fn multicam_view_pages_beyond_sixteen_angles_and_edit_cameras() {
assert!(!mc.cameras[1].enabled);
assert_eq!(d.exec("multicam.grid", json!({}))["angles"], 19);
}
/// #1362: with nothing selected in the Project panel, Create Multi-Camera Source Sequence works on
/// the clips selected in the Timeline.
#[test]
fn create_multicam_from_clips_selected_in_the_timeline() {
let (s, items) = session();
let mut d = Driver::new(s);
for (k, item) in items.iter().enumerate() {
d.exec("source.open", json!({"item": item}));
d.exec("playhead.set", json!({"frame": k as i64 * 96}));
d.exec("source.overwrite", json!({}));
}
let clips: Vec<u64> = d.app().session.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect();
assert_eq!(clips.len(), 4);
d.exec("project.select", json!({"items": []}));
d.exec("timeline.select", json!({"clips": clips}));
d.ok("ui.menu.invoke", json!({"id": "clip.createMulticam"}));
d.frames(3);
assert_eq!(d.app().ui.sync_dialog.as_ref().map(|x| x.items.clone()), Some(items.clone()), "the dialog takes the Timeline clips' media");
d.click("mcam.method.in");
d.click("mcam.ok");
assert!(d.app().ui.sync_dialog.is_none(), "dialog closed: {}", d.app().ui.status);
let src = d.app().session.state.project_selection[0];
assert_eq!(d.app().session.project.sequence(src).unwrap().multicam.clone().unwrap().cameras.len(), 4);
}
+37
View File
@@ -744,3 +744,40 @@ fn middle_closing_an_inactive_project_tab_keeps_the_inspector_target() {
let source = d.app().ui.panels.inspector_source;
assert_eq!(d.label("info.item"), name, "closing a tab must not select its stale item; focused={focused:?}, source={source:?}");
}
/// #2329: in the Effects panel what a folder holds is indented under it: an effect's label starts
/// right of its sub-folder's, a sub-folder's right of its top folder's, and the same for presets
/// and Lumetri presets.
#[test]
fn effects_are_indented_under_their_folders() {
fn text_x(d: &mut Driver, text: &str) -> f32 {
fn find(shapes: &[egui::Shape], text: &str) -> Option<f32> {
shapes.iter().find_map(|s| match s {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos.x),
egui::Shape::Vec(v) => find(v, text),
_ => None,
})
}
let shapes: Vec<egui::Shape> = d.harness.output().shapes.iter().map(|c| c.shape.clone()).collect();
find(&shapes, text).unwrap_or_else(|| panic!("`{text}` is not drawn"))
}
let mut d = Driver::demo();
d.ok("ui.panel.show", json!({"panel": "Effects"}));
d.frames(3);
for folder in ["Video Effects", "Video Effects/Blur & Sharpen", "Presets", "Lumetri Presets", "Lumetri Presets/Monochrome"] {
d.click(&format!("effects.folder.{folder}"));
}
d.frames(2);
let preset = d.app().session.presets.all()[0].name.clone();
let lumetri = filmcraft_render::lumetri_presets::presets().into_iter().find(|p| p.folder == "Monochrome").unwrap().name;
for (outer, inner) in [
("Video Effects", "Blur & Sharpen"),
("Blur & Sharpen", "Gaussian Blur"),
("Presets", preset.as_str()),
("Lumetri Presets", "Monochrome"),
("Monochrome", lumetri),
] {
let (a, b) = (text_x(&mut d, outer), text_x(&mut d, inner));
assert!(b >= a + 12.0, "`{inner}` ({b}) is not indented under `{outer}` ({a})");
}
}
+29
View File
@@ -190,3 +190,32 @@ fn a_newer_revision_keeps_the_frame_being_rendered() {
d.ready(key(9002));
d.ready(key(9001));
}
/// #1682: thumbnail textures (timeline tiles, hover scrubs) are capped instead of one being kept
/// for every frame ever shown.
#[test]
fn thumbnail_textures_are_capped() {
let mut d = Driver::demo();
let ctx = d.harness.ctx.clone();
let item = {
let p = &d.harness.state().session.project;
p.items.values().find(|i| i.as_media().is_some_and(|m| m.info.video.is_some())).expect("a video item").id
};
let rate = d.harness.state().session.project.item(item).unwrap().frame_rate();
// 800 distinct frames, 100 per UI pass: request, wait until rendered, then make the textures
for chunk in 0..8 {
let frames: Vec<i64> = (chunk * 100..chunk * 100 + 100).collect();
for &f in &frames {
d.harness.state_mut().thumbnail(&ctx, item, rate.tick_of(f), 32);
}
for _ in 0..2000 {
if frames.iter().all(|&f| d.harness.state_mut().thumbnail(&ctx, item, rate.tick_of(f), 32).is_some()) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
d.frames(1);
}
let n = d.harness.state().thumbnail_textures();
assert!((100..=512).contains(&n), "{n} thumbnail textures");
}
+51
View File
@@ -559,3 +559,54 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro
assert_eq!(h.state().ui.tts, draft);
assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project));
}
/// #1985: a panel closed while floating came back docked when opened again from the Window menu;
/// it now floats again where it was. Closed while docked, it opens docked as before.
#[test]
fn a_panel_closed_while_floating_reopens_floating_where_it_was() {
let mut app = FilmcraftApp::new(Session::default());
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap();
let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).unwrap().rect;
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap();
assert!(!app.ui.dock.contains(PanelKind::Info) && !app.ui.floating.iter().any(|g| g.panels.contains(&PanelKind::Info)));
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap();
let back = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).expect("Info floats again");
assert_eq!(back.rect, rect, "where it was");
assert!(!app.ui.dock.contains(PanelKind::Info));
// docked, then closed: it opens docked
menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "anchor": "Project"})).unwrap();
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap();
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap();
assert!(app.ui.dock.contains(PanelKind::Info));
assert!(!app.ui.floating.iter().any(|g| g.panels.contains(&PanelKind::Info)));
// the Timeline too, though reopening it first puts it back in its own place in the dock
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Timeline", "rect": [60, 80, 700, 300]})).unwrap();
let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Timeline)).unwrap().rect;
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Timeline"})).unwrap();
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Timeline), json!({})).unwrap();
let back = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Timeline)).expect("the Timeline floats again");
assert_eq!(back.rect, rect);
assert!(!app.ui.dock.contains(PanelKind::Timeline));
// with nothing else docked, a reopened panel has to stay docked (the workspace keeps one
// docked panel); its floating place is kept, not lost (#2482 review)
let mut app = FilmcraftApp::new(Session::default());
menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap();
let rect = app.ui.floating.iter().find(|g| g.panels.contains(&PanelKind::Info)).unwrap().rect;
for _ in 0..64 {
let Some(p) = PanelKind::ALL.iter().copied().find(|p| app.ui.dock.contains(*p)) else { break };
if menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": p.id()})).is_err() {
break;
}
}
let docked = PanelKind::ALL.iter().filter(|p| app.ui.dock.contains(**p)).count();
if docked > 0 {
eprintln!("SKIPPED: the dock cannot be emptied ({docked} panels stay)");
return;
}
menus::invoke(&mut app, &ctx, "window.closePanel", json!({"panel": "Info"})).unwrap();
menus::invoke(&mut app, &ctx, &menus::panel_command_id(PanelKind::Info), json!({})).unwrap();
assert!(app.ui.dock.contains(PanelKind::Info), "the only panel stays docked");
assert!(app.ui.closed_floating.iter().any(|(p, r)| *p == PanelKind::Info && *r == rect), "{:?}", app.ui.closed_floating);
}
@@ -342,3 +342,33 @@ fn repeated_home_and_end_keys_reveal_a_playhead_already_at_the_endpoint() {
fn repeated_start_and_end_commands_reveal_a_playhead_already_at_the_endpoint() {
repeated_endpoint_navigation_reveals_the_same_playhead(false);
}
/// #2325: Settings ▸ Appearance ▸ "Animate zoom, scrolling and menus" off makes a zoom and a
/// scroll land in one frame, and egui's fades (menus, collapsing sections, tooltips) instant.
#[test]
fn turning_animations_off_zooms_and_scrolls_in_one_step() {
let mut d = Driver::tall();
let animation_time = |d: &mut Driver| d.harness.ctx.global_style().animation_time;
assert!(animation_time(&mut d) > 0.0, "animated by default");
// animated: one frame later the zoom is on its way, not there
let v = &mut d.app().ui.timeline;
v.target_pps = 200.0;
v.target_scroll = 8.0;
d.frames(1);
let v = &d.app().ui.timeline;
assert!(v.pps > 50.0 && v.pps < 200.0 && v.scroll < 8.0, "eased: {} {}", v.pps, v.scroll);
assert_eq!(d.view(), (8.0, 200.0, 0.0, 0.0));
d.exec("prefs.set", json!({"key": "appearance.animations", "value": false}));
d.frames(2);
assert_eq!(animation_time(&mut d), 0.0);
let v = &mut d.app().ui.timeline;
v.target_pps = 50.0;
v.target_scroll = 3.0;
d.frames(1);
let v = &d.app().ui.timeline;
assert_eq!((v.pps, v.scroll), (50.0, 3.0), "in one step");
// and back on
d.exec("prefs.set", json!({"key": "appearance.animations", "value": true}));
d.frames(2);
assert!(animation_time(&mut d) > 0.0);
}
+161
View File
@@ -0,0 +1,161 @@
//! Headless UI test of track colour labels (#1186): the strip at the left edge of a track header
//! opens the colour menu; the label tints the header and the lane and is an undoable track setting.
//!
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render offscreen with wgpu and write `track-color-*.png`.
#![allow(dead_code)]
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
snapshots: Option<std::path::PathBuf>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
if snapshots.is_some() {
b = b.wgpu();
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, snapshots };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let v = self.call(method, params.clone());
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
v["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn element(&mut self, id: &str) -> Option<[f32; 4]> {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array()?.iter().find(|e| e["id"] == id).map(|e| {
let r = &e["rect"];
[r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32]
})
}
fn ids(&mut self, prefix: &str) -> Vec<String> {
let v = self.ok("ui.elements", json!({"prefix": prefix}));
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
}
/// Offscreen render of the window (or the bounding box of the elements under `prefix`).
fn snapshot(&mut self, name: &str, prefix: Option<&str>) {
let Some(dir) = self.snapshots.clone() else { return };
let crop = prefix.map(|p| {
let v = self.ok("ui.elements", json!({"prefix": p}));
let mut bb = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
for e in v.as_array().unwrap() {
let r = &e["rect"];
let (x, y, w, h) = (r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x + w), bb[3].max(y + h)];
}
bb
});
self.frames(2);
let img = match self.harness.render() {
Ok(i) => i,
Err(e) => {
eprintln!("snapshot {name} skipped: {e}");
return;
}
};
let ppp = img.width() as f32 / 1600.0;
let img = match crop {
Some(bb) => {
let x0 = ((bb[0] - 24.0) * ppp).max(0.0) as u32;
let y0 = ((bb[1] - 30.0) * ppp).max(0.0) as u32;
let x1 = (((bb[2] + 16.0) * ppp) as u32).min(img.width());
let y1 = (((bb[3] + 46.0) * ppp) as u32).min(img.height());
image::imageops::crop_imm(&img, x0, y0, x1 - x0, y1 - y0).to_image()
}
None => img,
};
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name}.png"));
img.save(&path).unwrap();
eprintln!("snapshot: {}", path.display());
}
}
fn label(d: &mut Driver, kind: &str, index: usize) -> Value {
d.exec("sequence.inspect", json!({}))[kind][index]["label"].clone()
}
#[test]
fn the_track_header_strip_sets_a_colour_label() {
let mut d = Driver::demo();
assert_eq!(label(&mut d, "video", 0), json!(null));
let ids = d.ids("timeline.track.");
for id in ["timeline.track.V1.color", "timeline.track.A1.color"] {
assert!(ids.iter().any(|i| i == id), "{id} missing from {ids:?}");
}
// the strip sits left of the source patch, inside the header
let strip = d.element("timeline.track.V1.color").unwrap();
let patch = d.element("timeline.track.V1.sourcePatch").unwrap();
assert!(strip[0] + strip[2] <= patch[0], "strip {strip:?} overlaps the source patch {patch:?}");
d.click("timeline.track.V1.color");
let menu = d.ids("timeline.track.V1.color.");
assert!(menu.iter().any(|i| i == "timeline.track.V1.color.Forest") && menu.iter().any(|i| i == "timeline.track.V1.color.None"), "{menu:?}");
d.click("timeline.track.V1.color.Forest");
assert_eq!(label(&mut d, "video", 0), json!("Forest"));
assert!(!d.ids("timeline.track.V1.color.").iter().any(|i| i == "timeline.track.V1.color.Forest"), "the menu closes");
// audio tracks too
d.click("timeline.track.A1.color");
d.click("timeline.track.A1.color.Mango");
assert_eq!(label(&mut d, "audio", 0), json!("Mango"));
d.snapshot("track-color-labels", None);
// None clears; undo brings it back
d.click("timeline.track.V1.color");
d.click("timeline.track.V1.color.None");
assert_eq!(label(&mut d, "video", 0), json!(null));
d.exec("edit.undo", json!({}));
assert_eq!(label(&mut d, "video", 0), json!("Forest"));
}
+8 -4
View File
@@ -27,7 +27,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
| G11 | No plugin hosting | ecosystem | no | 200–400 h |
| G12 | AI features | AI | no | 150–300 h |
| G13 | Delivery formats | file format | no | 60–110 h |
| G14 | Localization: 8 of the 12 key languages missing | localization | no | 150–230 h |
| G14 | Localization: 3 of the 12 key languages missing | localization | no | 150–230 h |
| G15 | Interchange never validated in Avid / Pro Tools; slow XML import | file format | partly | 20–40 h |
| G16 | Performance reports on 0.5.0 | performance | partly | 30–60 h |
| G17 | Audio device and playback reliability | stability | partly | 20–40 h |
@@ -231,22 +231,26 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
(audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).
- **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md).
## G14. Localization: 8 of the 12 key languages missing
## G14. Localization: 3 of the 12 key languages missing (Hindi, Arabic, Vietnamese)
- **Have:** Spanish, Japanese, Simplified Chinese, French (#498) and Portuguese (Brazil) (#859)
catalogs cover ~99.6–100% of the ~3,500 cataloged strings; Ukrainian ~10% (menus).
- **Missing:** Hindi, Arabic (no RTL interface layout or catalog; the interface has Arabic and Hebrew
fallback fonts and shows captions in reading order, but other names and fields keep egui's
left-to-right run order; Arabic titles reported broken, #395),
German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts),
Korean (#508; needs Hangul fonts in craft-fonts),
Vietnamese; Premiere also ships Russian. Engine errors,
CLI and MCP stay English. No language has a recorded native-speaker review.
CLI and MCP stay English. Apart from the German menus (#630), no language has a recorded
native-speaker review.
- **Done:** Indonesian (#867): `id.tsv` has every entry of the Spanish catalog (~100%), in Edit ▸
Language, Settings ▸ General and the native macOS menu; System Language picks it for `id-*` (and
legacy `in-*`) locales. Native-speaker review welcome.
- **Done:** Italian: `it.tsv` has every entry of the Spanish catalog (~100%), in Edit ▸ Language,
Settings ▸ General and the native macOS menu; System Language picks it for `it-*` locales.
Native-speaker review welcome.
- **Done:** German: `de.tsv` has every entry of the Spanish catalog (~100%), beyond the menus of
#630 (panels, dialogs, effects and their parameters, settings, status messages). The menus are
native-reviewed; review of the rest welcome.
- **Done:** Turkish: `tr.tsv` has every entry of the Spanish catalog (~100%), in Edit ▸ Language,
Settings ▸ General and the native macOS menu; System Language picks it for `tr-*` locales.
Native-speaker review welcome.
+4 -1
View File
@@ -13,7 +13,10 @@ kerning and ligatures, bidi, line breaking, all drawn as vectors in linear light
- Its **layers** are effect instances `graphic_text` / `graphic_shape` in the clip's effect list,
in paint order (first = back). They are hidden from the Effects panel. Because they are effect
instances they save with the project, take keyframes on every animatable property (Source Text
keyframes hold), appear in Effect Controls and copy with the clip. Standard effects on a graphic
keyframes hold), appear in Effect Controls and copy with the clip. The Essential Graphics
panel's number and point properties (Tracking, Leading, Font Size, Position, Opacity…) carry
the same stopwatch as Effect Controls (`graphics.prop.<id>.stopwatch`, #2358): once a property
animates, changing it in the panel adds or updates a keyframe at the playhead. Standard effects on a graphic
clip apply to the composed graphic; Motion and Opacity apply last.
- `filmcraft_project::graphic::eval_layer` evaluates a layer at a time into a `LayerSpec`.
- Projects with graphics need no schema migration: everything is additive (new item kind, new

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

@@ -1,11 +1,11 @@
asset: filmcraft-lumetri.png
title: README: Lumetri Color panel
asset: filmcraft-color-panel.png
title: README: color grading panel
author: FilmCraft contributors (screenshot); film/footage/music credits below
source: original work (screenshot of FilmCraft captured with its ui.screenshot control command)
license: MIT OR Apache-2.0 (screenshot); embedded film frames are public domain, music CC0-1.0
license-file:
added: 2026-09-30 by Claude (agent) for Brandon Thomas
notes: Crop of the Lumetri Color panel (Basic Correction, Color Wheels) while grading Charade (1963); shows panel controls only. All UI icons are drawn in code; fonts are Inter and JetBrains Mono (OFL-1.1). Contains no Adobe artwork.
notes: Crop of the color grading panel (Basic Correction, Color Wheels) while grading Charade (1963); shows panel controls only. All UI icons are drawn in code; fonts are Inter and JetBrains Mono (OFL-1.1). Contains no Adobe artwork.
Media visible in the screenshot (none of it is committed to the repository):
- Night of the Living Dead (1968), dir. George A. Romero, Image Ten. US public domain (released without a copyright notice). Source: https://archive.org/details/night-of-the-living-dead-1968-english (Public Domain Mark).
- Carnival of Souls (1962), dir. Herk Harvey, Harcourt Productions. US public domain. Source: https://archive.org/details/CarnivalOfSoulsVideoQualityUpgrade (Public Domain Mark).
+13 -7
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Korean covers the whole interface, #508) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
Per-language status of FilmCraft's interface. Premiere 26.5.2 ships 10 interface languages (the
installed bundle's `.lproj` folders): German, English, Spanish, French, Italian, Japanese, Korean,
@@ -17,8 +17,8 @@ are reachable over the control channel. One catalog per language,
commands, panels, workspaces, presets) are translated where they are drawn. Searches match the
displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a
registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu
label; `ja.tsv`, `fr.tsv` and `pt-br.tsv` have the same entries but are not yet required by tests
(French must cover the core menus, Brazilian Portuguese every menu label, and neither may keep an
label; `ja.tsv`, `fr.tsv`, `pt-br.tsv` and `de.tsv` have the same entries but are not yet required by tests
(French and German must cover the core menus, Brazilian Portuguese every menu label, and neither may keep an
entry without a Spanish one). Engine error messages, the
control channel, the CLI and MCP stay English.
@@ -50,18 +50,23 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests.
| Portuguese (Brazil) | pt-br | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#859) | review |
| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review |
| Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review |
| German | de | 371 (10.6%) | menus only | Latin | yes (#630) | menus only | 5–9 h + review |
| German | de | 3,590 (same entries as Spanish); must cover the core menus and the macOS app menu, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | menus yes (#630); the rest not recorded, welcome | partial | review |
| Korean | ko | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | installed system Hangul font (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…); refused without one | not recorded; welcome | partial (#508) | review; Hangul in craft-fonts for the web |
| Vietnamese | vi | 0 (0%) | — | Latin with stacked diacritics (font coverage to check) | — | none | 6–12 h + review |
| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review |
| *Other shipped:* Turkish | tr | 3,590 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review |
| *Other shipped:* Ukrainian | uk | 351 (10.0%) | menus only | Cyrillic (bundled) | not recorded | menus only | 5–9 h |
UI strings are catalog entries (the non-comment lines of `src/i18n/<code>.tsv`; English counts the
source strings in the code), so the rows were measured at different times: at this change the
Spanish and German catalogs both have 3,590 entries, German every Spanish one. Strings added
later may stay English in German until translated; only the core menus are required.
Premiere also ships Italian, which we now cover like Spanish, and Russian (6–10 h).
**Localization dimension:** 6 of the 12 key languages near-complete (English, Spanish, Japanese,
Simplified Chinese, French, Portuguese (Brazil)), 1 menus-only, 5 none; against Premiere's 10 languages
we cover 6 well and 1 partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script
**Localization dimension:** 9 of the 12 key languages near-complete (English, Spanish, Japanese,
Simplified Chinese, French, Portuguese (Brazil), Indonesian, Korean, German), 3 none (Hindi, Arabic,
Vietnamese); against Premiere's 10 languages we cover 9 well and Russian partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script
work (RTL and complex shaping in the egui interface) is the largest and least parallel part.
Every language needs native-speaker review (human).
@@ -69,5 +74,6 @@ Every language needs native-speaker review (human).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | German (de) covers the whole interface: every entry of the Spanish catalog (3,590); the tests require the core menus and the macOS app menu and reject entries without a Spanish one |
| 2026-10-11 | minor | German (de) ships the menus (#630): 371 of 3,498 strings (10.6%), native-reviewed; dimension now 2 menus-only, 5 none |
| 2026-10-10 | major | Created from ROADMAP.md's "Interface localization" section; catalog coverage measured (es 3,496, ja 3,487, zh-cn 3,485, pt-br 348, uk 351 of 3,498); the 12-language table and script-support gaps added |