diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5e047f909..b952a98bc 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index 43b781b41..2dd0090a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "filmcraft" -version = "0.9.0" +version = "0.10.0" dependencies = [ "arboard", "chrono", @@ -1712,7 +1712,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1730,7 +1730,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1740,14 +1740,14 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-automation" -version = "0.9.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "filmcraft-engine", @@ -1763,7 +1763,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1774,21 +1774,21 @@ dependencies = [ [[package]] name = "filmcraft-avi" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-bitstream" -version = "0.9.0" +version = "0.10.0" dependencies = [ "proptest", ] [[package]] name = "filmcraft-captions" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "filmcraft-project", @@ -1801,11 +1801,11 @@ dependencies = [ [[package]] name = "filmcraft-cfb" -version = "0.9.0" +version = "0.10.0" [[package]] name = "filmcraft-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-automation", "filmcraft-codecs", @@ -1827,7 +1827,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1865,7 +1865,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.9.0" +version = "0.10.0" dependencies = [ "proptest", "serde", @@ -1873,7 +1873,7 @@ dependencies = [ [[package]] name = "filmcraft-dng" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-dng", "rayon", @@ -1882,7 +1882,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1892,7 +1892,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1905,7 +1905,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.9.0" +version = "0.10.0" dependencies = [ "craft-docpath", "filmcraft-audio-dsp", @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1971,7 +1971,7 @@ dependencies = [ [[package]] name = "filmcraft-flac" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1980,7 +1980,7 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-media", "filmcraft-project", @@ -1993,7 +1993,7 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2003,7 +2003,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.9.0" +version = "0.10.0" dependencies = [ "proptest", "serde", @@ -2011,7 +2011,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2028,7 +2028,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "filmcraft-export", @@ -2047,7 +2047,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2057,7 +2057,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2067,7 +2067,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2077,7 +2077,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2094,7 +2094,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2113,7 +2113,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "filmcraft-dng", @@ -2129,7 +2129,7 @@ dependencies = [ [[package]] name = "filmcraft-mpa" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2147,7 +2147,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2155,28 +2155,28 @@ dependencies = [ [[package]] name = "filmcraft-mxf" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-ogg" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-opus" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-platform" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-codecs", @@ -2202,7 +2202,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2226,7 +2226,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2246,7 +2246,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "serde", @@ -2255,7 +2255,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.9.0" +version = "0.10.0" dependencies = [ "candle-core", "candle-nn", @@ -2273,14 +2273,14 @@ dependencies = [ [[package]] name = "filmcraft-testkit" -version = "0.9.0" +version = "0.10.0" dependencies = [ "png", ] [[package]] name = "filmcraft-text" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-color", "harfrust", @@ -2292,7 +2292,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.9.0" +version = "0.10.0" dependencies = [ "proptest", "serde", @@ -2300,7 +2300,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.9.0" +version = "0.10.0" dependencies = [ "candle-core", "candle-nn", @@ -2314,7 +2314,7 @@ dependencies = [ [[package]] name = "filmcraft-tts-text" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", "thiserror 2.0.21", @@ -2322,7 +2322,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.9.0" +version = "0.10.0" dependencies = [ "cpu-time", "craft-ui", @@ -2361,14 +2361,14 @@ dependencies = [ [[package]] name = "filmcraft-vp8" -version = "0.9.0" +version = "0.10.0" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-vp9" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2377,7 +2377,7 @@ dependencies = [ [[package]] name = "filmcraft-web" -version = "0.9.0" +version = "0.10.0" dependencies = [ "eframe", "egui", @@ -7402,7 +7402,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.0" +version = "0.10.0" dependencies = [ "filmcraft-testkit", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 625558e91..46716a680 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index daff93b38..176ea82e9 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,22 @@

FilmCraft

- Video editing, color and sound; an open-source, clean-room reimplementation of Adobe Premiere Pro, rebuilt in pure Rust. + Video editing, color and sound; an open-source, professional non-linear video editor, built in pure Rust.

- An open-source, clean-room take on the Adobe Premiere Pro workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.
+ An open-source, modern non-linear video editing workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.
By the ArtCraft team.

- License: MIT OR Apache-2.0 - Written in pure Rust - Runs on macOS, Windows and Linux - Status: young and moving fast + License: MIT OR Apache-2.0 + Written in pure Rust + Runs on macOS, Windows and Linux + Status: young and moving fast +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -39,7 +42,7 @@

- 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 + 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

Apollo 11 - Tranquility: 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.

@@ -66,7 +69,7 @@
-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.
@@ -84,7 +87,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.

@@ -103,7 +106,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,

The Color workspace on Charade (1963), with live scopes beside the Program monitor.

-**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 +118,11 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels, @@ -130,14 +133,14 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels, ## Effects and motion

- 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 + 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

NASA's Earth Views from the ISS with a look applied, and the Effects browser open on the dissolves.

-- **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.

@@ -179,7 +182,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 +203,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran

Export mode, set to write the trailer as H.264 MP4.

- **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 +213,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 +226,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 +280,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 +458,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. -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. -

diff --git a/ROADMAP.md b/ROADMAP.md index fd0af0037..31ec2d752 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-19 · **Change:** minor (language table: Czech, #1156; previously: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (language table: Czech, #1156; previously: 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), @@ -88,7 +88,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% | | Czech | partial (whole interface; native review needed) | 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). @@ -168,6 +168,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| | 2026-10-19 | minor | Language table: Czech (#1156, 100%) | +| 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 | diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 0aebbc31d..fad4e4192 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -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 { 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}"); + } } diff --git a/crates/color/src/lut.rs b/crates/color/src/lut.rs index 67590eb12..73879a5b0 100644 --- a/crates/color/src/lut.rs +++ b/crates/color/src/lut.rs @@ -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::>().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]); + } } diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 262b1ec80..940c00af5 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -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"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index f7737f571..162fb18ca 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2860,10 +2860,17 @@ fn build() -> Vec { "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 { 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 { _ => 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, diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index f5053756b..00b66c72f 100644 --- a/crates/engine/src/graphic_templates.rs +++ b/crates/engine/src/graphic_templates.rs @@ -775,7 +775,12 @@ fn set_char_style(s: &mut Session, p: &Value) -> Result { }; 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 { "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, ), diff --git a/crates/engine/src/graphic_templates_tests.rs b/crates/engine/src/graphic_templates_tests.rs index 98a55a588..e49c0ec8f 100644 --- a/crates/engine/src/graphic_templates_tests.rs +++ b/crates/engine/src/graphic_templates_tests.rs @@ -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"); +} diff --git a/crates/engine/src/multicam.rs b/crates/engine/src/multicam.rs index 66e36308f..6145bbbd3 100644 --- a/crates/engine/src/multicam.rs +++ b/crates/engine/src/multicam.rs @@ -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 { 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 { + 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::from_params(p).map_err(|e| bad(cmd, e)) } diff --git a/crates/engine/src/multicam_tests.rs b/crates/engine/src/multicam_tests.rs index 1a8c3528b..c9221726f 100644 --- a/crates/engine/src/multicam_tests.rs +++ b/crates/engine/src/multicam_tests.rs @@ -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 = ["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 = 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 = 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) { let dir = tmp_dir("mc-edit"); let files: Vec = ["a.mov", "b.mov", "c.mov"].iter().map(|f| dir.join(f)).collect(); diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 5b75c02f4..859ba1bb9 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -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 { + 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 { + 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 { 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 { 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); diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index dfc6b7a48..95c6289c2 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -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); +} diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 4ae4ec645..e4e2de1cf 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -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 { diff --git a/crates/engine/src/track_params_tests.rs b/crates/engine/src/track_params_tests.rs index 6c243dfa5..707ad38cc 100644 --- a/crates/engine/src/track_params_tests.rs +++ b/crates/engine/src/track_params_tests.rs @@ -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")); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fb55786db..5d95c5b80 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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

` 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, + snapshots: Option, +} + +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 { + 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")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 259b9d042..428c3bddd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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. - **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md). ## G15. Interchange never validated in Avid / Pro Tools; slow XML import diff --git a/docs/graphics.md b/docs/graphics.md index cacf61732..61030d146 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -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..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 diff --git a/docs/images/filmcraft-lumetri.png b/docs/images/filmcraft-color-panel.png similarity index 100% rename from docs/images/filmcraft-lumetri.png rename to docs/images/filmcraft-color-panel.png diff --git a/docs/images/filmcraft-lumetri.png.attribution b/docs/images/filmcraft-color-panel.png.attribution similarity index 91% rename from docs/images/filmcraft-lumetri.png.attribution rename to docs/images/filmcraft-color-panel.png.attribution index 4e6f6ceb4..2cbab37de 100644 --- a/docs/images/filmcraft-lumetri.png.attribution +++ b/docs/images/filmcraft-color-panel.png.attribution @@ -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). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 211ab579e..ed8520ee6 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Korean covers the whole interface, #508; Czech interface catalog) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (German covers the whole interface; Czech interface catalog) · **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 | | Czech | cs | 3,591 (all Spanish source keys, plus the playback-resolution label `1/2`) | dialogs, Settings, effects, shortcuts, status; engine errors English | Latin (bundled fonts) | not reviewed | partial | native 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/.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). @@ -89,5 +94,6 @@ creative effect names, and the shared `Subtitle` key (caption format and graphic | Date | Change | Summary | |---|---|---| | 2026-10-19 | minor | Added Czech (`cs`) interface catalog and locale detection; rewrote all catalog rows with a binding glossary; AI-assisted, not yet proofread by a native speaker | +| 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 |
- 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
- The Lumetri Color panel. Basic Correction, Color Wheels and every other section, each with its own on/off switch. + 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
+ The color grading panel. Basic Correction, Color Wheels and every other section, each with its own on/off switch.
- Lumetri Scopes: a green luma waveform and a vectorscope of a Charade (1963) frame
+ The video scopes: a green luma waveform and a vectorscope of a Charade (1963) frame
Scopes that tell the truth. The waveform and vectorscope are computed from the graded frame. All grading happens in linear light, in 32-bit float.