Merge main into the playback branch (mixer recordStart moves to the end of preroll; GopCache keeps main's random-access key walk-back)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 08:30:04 -04:00
co-authored by Claude Opus 5.5
191 changed files with 26062 additions and 911 deletions
+24
View File
@@ -12,6 +12,9 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `docs/images/filmcraft-hero.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from public-domain films (Night of the Living Dead 1968, Carnival of Souls 1962), CC0 Chopin (Musopen) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-color.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Charade (1963, public domain) | MIT OR Apache-2.0; film frames public domain |
@@ -23,6 +26,26 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `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 |
## Golden test images
Reference frames for the renderer golden tests (`crates/golden`), rendered by FilmCraft itself from
procedural demo footage (`crates/media/src/generators.rs`); no external media. Regenerate with
`FILMCRAFT_BLESS=1 cargo test -p filmcraft-golden`.
| Asset | Author | Source | Licence |
|---|---|---|---|
| `crates/golden/goldens/blend_modes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `blend_modes`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/crop.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `crop`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/gaussian_blur.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `gaussian_blur`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/graphic_shapes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_shapes`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 |
| `crates/golden/goldens/graphic_title.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_title`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 |
| `crates/golden/goldens/lumetri_basic.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_basic`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/text_burnin.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `text_burnin`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/transform_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transform_opacity`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/transition_cross_dissolve.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_cross_dissolve`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/transition_dip_to_black.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_dip_to_black`) from procedurally generated media | MIT OR Apache-2.0 |
| `crates/golden/goldens/transition_wipe.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_wipe`) from procedurally generated media | MIT OR Apache-2.0 |
## Third-party media shown in screenshots
The README screenshots show frames from these works. The media files themselves are not in the
@@ -44,6 +67,7 @@ is traced or derived from Adobe artwork.
| Asset | Where |
|---|---|
| UI icons (tools, transport, panels, header) | `crates/ui-egui/src/icons.rs`: vector paths on a 16×16 grid |
| Align / distribute / paragraph-alignment glyphs in the graphics panel | `crates/ui-egui/src/panels/graphics.rs` (`align_glyph`): bars and lines drawn with the egui painter |
| Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` |
| Lumetri "Look" presets (Teal & Orange, Warm Film, …) | `crates/render/src/effects.rs` (`apply_look`): procedural colour transforms, not LUT files |
| Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork |
Generated
+129 -4
View File
@@ -1103,6 +1103,24 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"pollster 1.0.1",
"serde",
"toml 1.1.6+spec-1.1.0",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -1306,6 +1324,7 @@ name = "filmcraft-aac"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@@ -1313,6 +1332,9 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.1.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
@@ -1343,9 +1365,9 @@ version = "0.1.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
"filmcraft-text",
"filmcraft-time",
"proptest",
"skrifa",
"thiserror 2.0.21",
]
@@ -1378,6 +1400,7 @@ dependencies = [
"filmcraft-opus",
"filmcraft-prores",
"filmcraft-time",
"filmcraft-vp9",
"image",
"log",
"symphonia",
@@ -1419,6 +1442,7 @@ dependencies = [
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
"filmcraft-text",
"filmcraft-time",
"log",
"serde",
@@ -1439,6 +1463,7 @@ dependencies = [
"filmcraft-project",
"filmcraft-prores",
"filmcraft-render",
"filmcraft-testkit",
"filmcraft-time",
"image",
"rayon",
@@ -1477,6 +1502,21 @@ dependencies = [
"serde",
]
[[package]]
name = "filmcraft-golden"
version = "0.1.0"
dependencies = [
"filmcraft-geom",
"filmcraft-gpu",
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
"filmcraft-testkit",
"filmcraft-time",
"pollster 0.4.0",
"wgpu",
]
[[package]]
name = "filmcraft-gpu"
version = "0.1.0"
@@ -1498,6 +1538,7 @@ name = "filmcraft-h264"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@@ -1507,6 +1548,7 @@ name = "filmcraft-h264enc"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@@ -1516,6 +1558,7 @@ name = "filmcraft-hevc"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@@ -1541,6 +1584,7 @@ name = "filmcraft-isobmff"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"proptest",
"serde_json",
"thiserror 2.0.21",
@@ -1550,6 +1594,7 @@ dependencies = [
name = "filmcraft-matroska"
version = "0.1.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
]
@@ -1569,6 +1614,9 @@ dependencies = [
[[package]]
name = "filmcraft-opus"
version = "0.1.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-project"
@@ -1589,6 +1637,7 @@ name = "filmcraft-prores"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@@ -1604,6 +1653,7 @@ dependencies = [
"filmcraft-geom",
"filmcraft-media",
"filmcraft-project",
"filmcraft-text",
"filmcraft-time",
"image",
"rayon",
@@ -1611,6 +1661,24 @@ dependencies = [
"serde_json",
]
[[package]]
name = "filmcraft-testkit"
version = "0.1.0"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.1.0"
dependencies = [
"filmcraft-color",
"harfrust",
"skrifa",
"unicode-bidi",
"unicode-linebreak",
]
[[package]]
name = "filmcraft-time"
version = "0.1.0"
@@ -1625,6 +1693,7 @@ version = "0.1.0"
dependencies = [
"eframe",
"egui",
"egui_kittest",
"filmcraft-audio-dsp",
"filmcraft-codecs",
"filmcraft-color",
@@ -1636,7 +1705,9 @@ dependencies = [
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
"filmcraft-text",
"filmcraft-time",
"image",
"log",
"pollster 0.4.0",
"rayon",
@@ -1645,6 +1716,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "filmcraft-vp9"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -2458,6 +2538,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -3955,6 +4045,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -4332,7 +4431,7 @@ dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml",
"toml 0.8.2",
"version-compare",
]
@@ -4465,11 +4564,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
dependencies = [
"serde",
"serde_spanned",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
"toml_edit 0.20.2",
]
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
@@ -4507,7 +4619,7 @@ checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
@@ -4591,6 +4703,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@@ -4603,6 +4721,12 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -5643,6 +5767,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
name = "xtask"
version = "0.1.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
]
+7 -1
View File
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/*", "xtask"]
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/filmcraft"
[workspace.dependencies]
@@ -20,6 +20,7 @@ filmcraft-isobmff = { path = "crates/isobmff" }
filmcraft-matroska = { path = "crates/matroska" }
filmcraft-h264 = { path = "crates/h264" }
filmcraft-hevc = { path = "crates/hevc" }
filmcraft-vp9 = { path = "crates/vp9" }
filmcraft-prores = { path = "crates/prores" }
filmcraft-aac = { path = "crates/aac" }
filmcraft-opus = { path = "crates/opus" }
@@ -31,6 +32,7 @@ filmcraft-codecs = { path = "crates/codecs" }
filmcraft-project = { path = "crates/project" }
filmcraft-edit = { path = "crates/edit" }
filmcraft-interchange = { path = "crates/interchange" }
filmcraft-text = { path = "crates/text" }
filmcraft-captions = { path = "crates/captions" }
filmcraft-render = { path = "crates/render" }
filmcraft-gpu = { path = "crates/gpu" }
@@ -39,6 +41,7 @@ filmcraft-format = { path = "crates/format" }
filmcraft-engine = { path = "crates/engine" }
filmcraft-ui-egui = { path = "crates/ui-egui" }
filmcraft-automation = { path = "crates/automation" }
filmcraft-testkit = { path = "crates/testkit" }
# foundation
thiserror = "2"
@@ -90,6 +93,9 @@ opt-level = 3
[profile.dev.package.filmcraft-opus]
opt-level = 3
[profile.dev.package.filmcraft-vp9]
opt-level = 3
[profile.release]
lto = "thin"
codegen-units = 1
+11 -7
View File
@@ -2,7 +2,7 @@
Progress toward feature parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
**Last updated:** 2026-09-30 (midday) · **Overall parity:** ~32% · **Code:** ~71k lines of Rust, 400+ tests
**Last updated:** 2026-10-01 · **Overall parity:** ~45% · **Code:** ~125k lines of Rust in 31 crates, 800+ tests
## Estimate to parity
@@ -31,24 +31,28 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
| M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — |
| M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 |
| M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — |
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers | Trim mode + dynamic trimming, multicam, subclips, keyboard shortcut editor | 10–15 |
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets | Multicam, subclip polish, ~220 Premiere default shortcuts whose commands don't exist yet | 6–10 |
| M4 | Playback | 🟡 | Audio-clock master, prefetch, J/K/L, dropped-frame stats, playback resolution | Render previews, render bar, 4K/8K tuning | 8–12 |
| M5 | Effects, keyframes, GPU | 🟡 | ~60 CPU effects, 30 transitions, keyframes + value/velocity graphs, wgpu compositor | Full ~150-effect catalogue, WGSL parity for all, masks + tracking, adjustment layers, presets, Warp Stabilizer, Morph Cut | 30–45 |
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 + own AAC; ProRes, MJPEG, PNG, GIF, WAV; background jobs | Preset library, queue UI, smart render | 6–8 |
| M7 | Audio | 🟡 | Mixer basics, peak meters, BS.1770 loudness meters (M/S/I LUFS, true peak; matches ffmpeg), DSP crate, 12 clip audio effects running on it (EQ, filters, dynamics, limiter, delay, reverb, DeNoise, DeHum, pitch) with keyframes | Effects UI polish (EQ curve), remaining effects (multiband, convolution reverb), track effects/mixers with automation, Essential Sound, audio transitions, 5.1 | 6–10 |
| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 12 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade | Essential Sound, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 |
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette; basic scopes | LUT import UI, colour match, colour management + HDR (PQ/HLG, log, tone mapping) | 8–12 |
| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), Matroska/WebM import (H.264/HEVC/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4) | VP9 (in progress), Ogg Opus files, AV1 (rav1d), DNxHR, MXF, hardware decode | 12–20 |
| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export | Text engine with shaping, Type tool, Essential Graphics, rolls/crawls, MCC/STL/TTML, 608/708 embedding, speech-to-text | 18–27 |
| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4) | VP9 frame threading, Ogg Opus files, AV1 (rav1d), DNxHR, MXF, hardware decode | 12–20 |
| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute | Responsive design pins, rolls/crawls, per-character styles, motion graphics templates, MCC/STL/TTML, 608/708 embedding, speech-to-text | 12–18 |
| M11 | Interchange & project management | 🟡 | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO | Relink/offline, project manager, proxies | 8 |
| M12–M16 | Web (WASM), platform, long tail | 🟡 | L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), ~850 remaining commands and dialogs, performance hardening | 40–65 |
## Running now
- VP9 decoder (`crates/vp9`), from the public VP9 bitstream spec
- Interchange (`crates/interchange`): CMX 3600 EDL, FCP7 XML, FCPXML, OTIO import and export
- Playback: fix a UI hang after preview renders, then a load-robust playback benchmark and profiling (target: 3 × 1080p streams with 0 dropped frames)
## Log
- **2026-10-01 (later):** text engine (shaping, bidi, line breaking) + Type/Shape/Pen tools + graphic clips + graphics panel; Trim Monitor + dynamic trimming; Keyboard Shortcuts editor; audio mixer with automation. Fixed: MP4 muxer wrote unreadable all-empty sample tables; system font scan race.
- **2026-10-01:** Audio mixing (M7.2/M7.5/M7.6 basics): mixer graph with submixes, sends, inserts and latency compensation; Audio Track / Clip Mixer panels; Latch/Touch/Write automation recorded live; timeline track keyframes; Audio Gain dialog.
- **2026-10-01:** recovered from a machine crash with no lost work. Merged: VP9 decoder (profiles 0–3, bit-exact, WebM/MKV/MP4), Opus decoder (RFC 8251 range-exact; WebM/MKV/MP4), captions (SRT/VTT/SCC, burn-in), render bar + render previews, project schema versioning + atomic saves + auto-save + crash-recovery journal, test infrastructure (golden images, loudness oracle vs ffmpeg, headless scripted UI tests; fixed a GPU stale-texture bug), public contributor docs and licence files.
- **2026-09-30 (evening):** Opus decoder (RFC 6716/8251, range-exact on every conformance vector, ~80–110× realtime 48 kHz stereo) wired into WebM/MKV and MP4 import.
- **2026-09-30 (afternoon):** Matroska/WebM import; LUFS meters; clip audio effects on the DSP crate.
- **2026-09-30 (midday):** HEVC decoder, Matroska/WebM demuxer and audio DSP merged; HEVC import wired; asset rules (AGENTS.md, ATTRIBUTION.md, `cargo xtask assets`); README with hero screenshot and the Craft family.
+5 -1
View File
@@ -111,12 +111,16 @@ fn main() -> eframe::Result {
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_project =
Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
#[cfg(target_os = "macos")]
{
let rx = native_menu::install(&app, cc.egui_ctx.clone());
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
app.command_inbox = Some(rx);
app.hooks.shortcuts_changed = Some(update);
app.ui.show_menu_bar = false;
}
if let Some(port) = control_port {
+14 -2
View File
@@ -19,8 +19,12 @@ fn accel(s: &str) -> Option<Accelerator> {
mapped.parse().ok()
}
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver<String> {
/// Updates native key equivalents when the active keyboard shortcuts change.
pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
let items = menu_items(app);
let mut native: Vec<(String, MenuItem)> = Vec::new();
let bar = Menu::new();
let app_menu = Submenu::new("FilmCraft", true);
let _ = app_menu.append_items(&[
@@ -40,6 +44,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver<String> {
let mut subs: Vec<(String, Submenu)> = Vec::new();
for it in mine {
let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel));
native.push((it.id.clone(), mi.clone()));
if let Some(name) = it.path.get(1) {
if let Some((_, s)) = subs.iter().find(|(n, _)| n == name) {
let _ = s.append(&mi);
@@ -69,5 +74,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver<String> {
ctx.request_repaint();
}
});
rx
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
for (id, mi) in &native {
if let Some(it) = items.iter().find(|i| &i.id == id) {
let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel));
}
}
});
(rx, update)
}
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
asset: Inter-Bold.ttf
title: Inter Bold (text engine / graphics)
author: The Inter Project Authors (Rasmus Andersson et al.)
source: https://github.com/rsms/inter (release v4.1, extras/ttf)
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins.
Binary file not shown.
@@ -0,0 +1,8 @@
asset: Inter-Italic.ttf
title: Inter Italic (text engine / graphics)
author: The Inter Project Authors (Rasmus Andersson et al.)
source: https://github.com/rsms/inter (release v4.1, extras/ttf)
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins.
+1 -1
View File
@@ -5,4 +5,4 @@ source: https://github.com/rsms/inter
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-09-30 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs).
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
+1 -1
View File
@@ -5,4 +5,4 @@ source: https://github.com/rsms/inter
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-09-30 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs).
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
+1 -1
View File
@@ -5,4 +5,4 @@ source: https://github.com/rsms/inter
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-09-30 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, embedded via include_bytes! in the UI (crates/ui-egui/src/theme.rs) and as the caption burn-in font (crates/captions/src/font.rs).
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs; caption burn-in, graphics), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
@@ -5,4 +5,4 @@ source: https://github.com/JetBrains/JetBrainsMono
license: OFL-1.1
license-file: assets/fonts/OFL-JetBrainsMono.txt
added: 2026-09-30 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs).
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
Binary file not shown.
@@ -0,0 +1,8 @@
asset: NotoSerif-Regular.ttf
title: Noto Serif Regular (serif face of the text engine / graphics)
author: The Noto Project Authors
source: https://github.com/notofonts/notofonts.github.io (fonts/NotoSerif/unhinted/ttf)
license: OFL-1.1
license-file: assets/fonts/OFL-NotoSerif.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+3
View File
@@ -17,3 +17,6 @@ rayon = ["dep:rayon"]
filmcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+3 -9
View File
@@ -5,19 +5,13 @@ use std::process::Command;
use filmcraft_aac::{Decoder, Encoder, EncoderConfig, EncoderStats, split_adts};
/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`.
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
filmcraft_testkit::ffmpeg_or_skip("aac oracle")
}
pub fn fixtures() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/aac");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("aac")
}
pub struct Rng(pub u64);
+3
View File
@@ -11,3 +11,6 @@ workspace = true
# Intentionally dependency-free (layer L1): planar `&mut [f32]` slices in, planar slices out.
[dependencies]
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+3
View File
@@ -24,6 +24,9 @@ Householder FDN, phase vocoder).
- Gated integrated loudness (−70 LUFS / −10 LU) and LRA (−70 / −20 LU, 10th–95th percentile) use a
0.01 LU histogram with exact energy sums: fixed memory for programmes of any length.
- True peak: 4× (2× at 88.2/96 kHz) polyphase Kaiser-sinc interpolation.
- Oracle-tested against ffmpeg's `ebur128` filter (`tests/loudness_oracle.rs`, skipped without ffmpeg):
M/S within 0.0005 LU, I within 0.007 LU, LRA within 0.04 LU and true peak within 0.045 dB (0.053 dB of the analytic value on an fs/4 tone) on
sine, pink-noise, speech-like and gated stereo signals at 44.1/48/96 kHz.
- `normalize_gain_db(measured, target)` and `normalize_gain_db_peak_limited(...)` for Normalize /
Essential Sound loudness auto-match.
+285
View File
@@ -0,0 +1,285 @@
//! Oracle test: BS.1770-4 / EBU R128 loudness against ffmpeg's `ebur128` filter.
//!
//! Each signal is generated here, written as a 32-bit float WAV and measured twice: by
//! `LoudnessMeter` and by `ffmpeg -af ebur128=peak=true:metadata=1,ametadata=print` (three decimals
//! per 100 ms frame). ffmpeg is an external oracle only (never linked).
//!
//! Tolerances (EBU Tech 3341 allows ±0.1 LU for M/S/I and +0.2/−0.4 dB for true peak; Tech 3342
//! allows ±1 LU for LRA):
//! - momentary (every 100 ms once 400 ms have been seen) and short-term (once 3 s have been seen):
//! ±0.1 LU wherever ffmpeg reads above −70 LUFS;
//! - integrated: ±0.1 LU;
//! - loudness range: ±0.5 LU;
//! - true peak: ±0.2 dB (both are 4× oversampling estimates with different interpolators).
use filmcraft_audio_dsp::loudness::LoudnessMeter;
use std::f64::consts::PI;
use std::path::Path;
use std::process::Command;
const TOL_MS: f64 = 0.1;
const TOL_I: f64 = 0.1;
const TOL_LRA: f64 = 0.5;
const TOL_TP_DB: f64 = 0.2;
/// ffmpeg's per-100 ms frame values.
#[derive(Clone, Copy, Debug, Default)]
struct Frame {
m: f64,
s: f64,
i: f64,
lra: f64,
true_peak: f64,
}
fn ffmpeg_ebur128(ff: &Path, wav: &Path) -> Vec<Frame> {
let out = Command::new(ff)
.args(["-hide_banner", "-nostats", "-v", "error", "-i"])
.arg(wav)
.args(["-af", "ebur128=peak=true:metadata=1,ametadata=mode=print:file=-", "-f", "null", "-"])
.output()
.expect("run ffmpeg");
assert!(out.status.success(), "ffmpeg ebur128 failed: {}", String::from_utf8_lossy(&out.stderr));
let mut frames = Vec::new();
let mut cur: Option<Frame> = None;
for line in String::from_utf8_lossy(&out.stdout).lines() {
if line.starts_with("frame:") {
frames.extend(cur.take());
cur = Some(Frame::default());
continue;
}
let (Some(f), Some((k, v))) = (cur.as_mut(), line.strip_prefix("lavfi.r128.").and_then(|l| l.split_once('='))) else { continue };
let v: f64 = v.trim().parse().unwrap_or(f64::NAN);
match k {
"M" => f.m = v,
"S" => f.s = v,
"I" => f.i = v,
"LRA" => f.lra = v,
"true_peak" => f.true_peak = v,
_ => {}
}
}
frames.extend(cur);
frames
}
/// Our measurements after each 100 ms block: (M, S) per block, plus the final meter.
fn ours(chans: &[Vec<f32>], sr: u32) -> (Vec<(f64, f64)>, LoudnessMeter) {
let mut m = LoudnessMeter::new(sr as f64, chans.len());
let block = sr as usize / 10;
let n = chans[0].len();
let mut per = Vec::new();
let mut pos = 0;
while pos < n {
let end = (pos + block).min(n);
let slices: Vec<&[f32]> = chans.iter().map(|c| &c[pos..end]).collect();
m.process(&slices);
per.push((m.momentary(), m.short_term()));
pos = end;
}
(per, m)
}
struct Report {
max_dm: f64,
max_ds: f64,
i: (f64, f64),
lra: (f64, f64),
tp: (f64, f64),
/// True-peak reference used (analytic value when known, else ffmpeg).
tp_ref: f64,
}
/// Measure `chans` with both meters and check the tolerances. `tp_truth`: the analytic true peak
/// (dBTP) when known; it then replaces ffmpeg as the true-peak reference.
fn compare(ff: &Path, name: &'static str, chans: &[Vec<f32>], sr: u32, tp_truth: Option<f64>) -> Report {
let dir = filmcraft_testkit::fixtures_dir("audio-dsp/loudness");
let wav = dir.join(format!("{name}_{sr}.wav"));
let tmp = filmcraft_testkit::temp_path(&wav);
let refs: Vec<&[f32]> = chans.iter().map(|c| c.as_slice()).collect();
filmcraft_testkit::wav::write_f32(&tmp, &refs, sr).unwrap();
let frames = ffmpeg_ebur128(ff, &tmp);
let _ = std::fs::remove_file(&tmp);
let (per, meter) = ours(chans, sr);
assert!(frames.len() + 1 >= per.len() && !frames.is_empty(), "{name}: ffmpeg gave {} frames, we have {} blocks", frames.len(), per.len());
let (mut max_dm, mut max_ds, mut n_m, mut n_s) = (0f64, 0f64, 0, 0);
for (k, (f, (m, s))) in frames.iter().zip(&per).enumerate() {
let full_blocks = k + 1;
if full_blocks >= 4 && f.m > -70.0 {
let d = (f.m - m).abs();
assert!(d <= TOL_MS, "{name}: momentary at {:.1} s: ffmpeg {:.3}, ours {m:.3}", full_blocks as f64 / 10.0, f.m);
max_dm = max_dm.max(d);
n_m += 1;
}
if full_blocks >= 30 && f.s > -70.0 {
let d = (f.s - s).abs();
assert!(d <= TOL_MS, "{name}: short-term at {:.1} s: ffmpeg {:.3}, ours {s:.3}", full_blocks as f64 / 10.0, f.s);
max_ds = max_ds.max(d);
n_s += 1;
}
}
let last = frames.last().unwrap();
let ff_tp_db = 20.0 * last.true_peak.log10();
let r = Report {
tp_ref: tp_truth.unwrap_or(ff_tp_db),
max_dm,
max_ds,
i: (meter.integrated(), last.i),
lra: (meter.loudness_range(), last.lra),
tp: (meter.true_peak_dbtp(), ff_tp_db),
};
eprintln!(
"{name} @ {sr} Hz: I ours {:.3} / ffmpeg {:.3} LUFS; LRA {:.2} / {:.2} LU; TP {:.3} / {:.3} dBTP; max |ΔM| {:.4} over {n_m}, max |ΔS| {:.4} over {n_s}",
r.i.0, r.i.1, r.lra.0, r.lra.1, r.tp.0, r.tp.1, r.max_dm, r.max_ds
);
assert!(n_m > 0, "{name}: no momentary values compared");
assert!((r.i.0 - r.i.1).abs() <= TOL_I, "{name}: integrated ours {:.3} vs ffmpeg {:.3}", r.i.0, r.i.1);
assert!((r.lra.0 - r.lra.1).abs() <= TOL_LRA, "{name}: LRA ours {:.3} vs ffmpeg {:.3}", r.lra.0, r.lra.1);
match tp_truth {
Some(t) => {
eprintln!("{name}: analytic true peak {t:.3} dBTP: ours off by {:+.3} dB, ffmpeg off by {:+.3} dB", r.tp.0 - t, r.tp.1 - t);
assert!((r.tp.0 - t).abs() <= TOL_TP_DB, "{name}: true peak ours {:.3} vs analytic {t:.3} dBTP", r.tp.0);
}
None => assert!((r.tp.0 - r.tp.1).abs() <= TOL_TP_DB, "{name}: true peak ours {:.3} vs ffmpeg {:.3} dBTP", r.tp.0, r.tp.1),
}
r
}
// ---- signals ----
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
}
fn db(x: f64) -> f64 {
10f64.powf(x / 20.0)
}
fn sine(freq: f64, amp: f64, sr: u32, secs: f64, phase: f64) -> Vec<f32> {
(0..(secs * sr as f64) as usize).map(|i| (amp * (2.0 * PI * freq * i as f64 / sr as f64 + phase).sin()) as f32).collect()
}
/// Pink noise (Paul Kellet's refined filter on white noise), scaled to `amp` RMS-ish.
fn pink(sr: u32, secs: f64, amp: f64, seed: u64) -> Vec<f32> {
let mut r = Rng(seed);
let (mut b0, mut b1, mut b2, mut b3, mut b4, mut b5, mut b6) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
(0..(secs * sr as f64) as usize)
.map(|_| {
let w = r.next();
b0 = 0.99886 * b0 + w * 0.0555179;
b1 = 0.99332 * b1 + w * 0.0750759;
b2 = 0.96900 * b2 + w * 0.1538520;
b3 = 0.86650 * b3 + w * 0.3104856;
b4 = 0.55000 * b4 + w * 0.5329522;
b5 = -0.7616 * b5 - w * 0.0168980;
let y = b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362;
b6 = w * 0.115926;
(y * 0.11 * amp) as f32
})
.collect()
}
/// Speech-like: syllable bursts (80–300 ms) of a pitched, formant-ish signal with pauses
/// (50–600 ms) and phrase-level level changes, so gating and the M/S windows are exercised.
fn speech(sr: u32, secs: f64, seed: u64) -> Vec<f32> {
let mut r = Rng(seed);
let n = (secs * sr as f64) as usize;
let mut out = vec![0f32; n];
let mut pos = 0usize;
let mut phrase_gain = 1.0;
while pos < n {
if r.next() > 0.7 {
phrase_gain = db(-12.0 * (r.next() + 1.0) / 2.0);
}
let syl = ((0.08 + 0.22 * (r.next() + 1.0) / 2.0) * sr as f64) as usize;
let f0 = 110.0 + 60.0 * (r.next() + 1.0) / 2.0;
let (f1, f2) = (500.0 + 400.0 * r.next(), 1500.0 + 700.0 * r.next());
for i in 0..syl.min(n - pos) {
let t = i as f64 / sr as f64;
let env = (PI * i as f64 / syl as f64).sin().powi(2);
let mut v = 0.0;
for h in 1..=20 {
let f = f0 * h as f64;
if f > sr as f64 / 2.2 {
break;
}
let formant = (-((f - f1) / 200.0).powi(2)).exp() + 0.5 * (-((f - f2) / 300.0).powi(2)).exp() + 0.05;
v += formant * (2.0 * PI * f * t).sin() / h as f64;
}
out[pos + i] = (0.3 * phrase_gain * env * v + 0.003 * r.next()) as f32;
}
pos += syl;
let gap = ((0.05 + 0.55 * (r.next() + 1.0) / 2.0) * sr as f64) as usize;
pos += gap;
}
out
}
fn concat(parts: &[Vec<f32>]) -> Vec<f32> {
parts.concat()
}
#[test]
fn loudness_matches_ffmpeg_ebur128() {
let ff = filmcraft_testkit::require_ffmpeg!();
let sr = 48_000;
let mut reports = Vec::new();
// 1. Steady 997 Hz sine, −20 dBFS, both channels (dual mono adds +3 dB).
let s = sine(997.0, db(-20.0), sr, 10.0, 0.0);
reports.push(compare(&ff, "sine_997", &[s.clone(), s], sr, Some(-20.0)));
// 2. Independent pink noise per channel.
reports.push(compare(&ff, "pink_noise", &[pink(sr, 15.0, 0.5, 1), pink(sr, 15.0, 0.5, 2)], sr, None));
// 3. Speech-like bursts and pauses (exercises the relative gate and LRA).
let sp = speech(sr, 25.0, 7);
let sp2: Vec<f32> = sp.iter().map(|v| v * 0.7).collect();
reports.push(compare(&ff, "speech_like", &[sp, sp2], sr, None));
// 4. Stereo with silence and quiet passages: loud tone, digital silence, a −30 dBFS tone on L
// only, a −50 dBFS passage (below the relative gate) and a −80 dBFS passage (below the
// absolute gate).
let l = concat(&[
sine(440.0, db(-18.0), sr, 5.0, 0.0),
vec![0.0; 3 * sr as usize],
sine(1000.0, db(-30.0), sr, 5.0, 0.0),
pink(sr, 4.0, db(-50.0), 3),
pink(sr, 4.0, db(-80.0), 4),
]);
let r = concat(&[
sine(440.0, db(-18.0), sr, 5.0, 0.3),
vec![0.0; 3 * sr as usize],
vec![0.0; 5 * sr as usize],
pink(sr, 4.0, db(-50.0), 5),
pink(sr, 4.0, db(-80.0), 6),
]);
reports.push(compare(&ff, "gated_stereo", &[l, r], sr, None));
// 5. Inter-sample peaks: fs/4 at 45° phase, 0 dBFS true peak but −3 dBFS sample peak.
let x = sine(sr as f64 / 4.0, 0.9, sr, 5.0, PI / 4.0);
reports.push(compare(&ff, "intersample_peak", &[x.clone(), x], sr, Some(20.0 * 0.9f64.log10())));
// 6. Mono speech-like at 44.1 kHz (K-weighting derived for another rate; true peak at 4×).
let sr2 = 44_100;
reports.push(compare(&ff, "speech_mono_44k", &[speech(sr2, 20.0, 11)], sr2, None));
// 7. Pink noise at 96 kHz (2× true-peak oversampling).
reports.push(compare(&ff, "pink_noise_96k", &[pink(96_000, 8.0, 0.4, 21), pink(96_000, 8.0, 0.4, 22)], 96_000, None));
let worst = |f: fn(&Report) -> f64| reports.iter().map(f).fold(0f64, f64::max);
eprintln!(
"worst: |ΔM| {:.3}, |ΔS| {:.3}, |ΔI| {:.3}, |ΔLRA| {:.3}, |ΔTP| {:.3} dB",
worst(|r| r.max_dm),
worst(|r| r.max_ds),
worst(|r| (r.i.0 - r.i.1).abs()),
worst(|r| (r.lra.0 - r.lra.1).abs()),
worst(|r| (r.tp.0 - r.tp_ref).abs()),
);
}
+1 -2
View File
@@ -13,9 +13,8 @@ workspace = true
filmcraft-time = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-text = { workspace = true }
thiserror = { workspace = true }
# Font tables, glyph outlines and metrics (MIT OR Apache-2.0). Rasterisation is our own.
skrifa = { version = "0.44", default-features = false, features = ["std"] }
[dev-dependencies]
proptest = { workspace = true }
+4 -7
View File
@@ -1,7 +1,7 @@
# filmcraft-captions
Caption files and caption burn-in for FilmCraft (layer L2: depends on `filmcraft-time`,
`filmcraft-color`, `filmcraft-project` and `skrifa`).
`filmcraft-color`, `filmcraft-project` and `filmcraft-text`).
The caption *model* (caption tracks in a sequence, captions, track style) lives in
`filmcraft-project` (`caption.rs`) because it is part of the `.fcproj` document. This crate reads
@@ -40,16 +40,13 @@ rasterises it to a small premultiplied linear-light RGBA overlay. `filmcraft-ren
overlays of visible caption tracks over the finished frame (Program monitor and export burn-in).
Text is set in **Inter SemiBold** (`assets/fonts/Inter-SemiBold.ttf`, SIL OFL 1.1, attributed in
`ATTRIBUTION.md`). `skrifa` (MIT OR Apache-2.0) provides glyph outlines and metrics; the
rasteriser (`raster.rs`, non-zero winding, 5 sub-scanlines with exact horizontal coverage) is our
own. Glyph masks are cached per character, size and quarter-pixel offset. There is no shaping or
kerning yet; that arrives with the M10.1 text engine.
`ATTRIBUTION.md`) by the `filmcraft-text` engine: shaped with kerning and ligatures, bidi-ordered,
and drawn from its sub-pixel positioned glyph cache.
## Tests
- Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608
tables, PAC round trips, known SCC pop-on and roll-up streams, rasteriser coverage, glyphs,
layout).
tables, PAC round trips, known SCC pop-on and roll-up streams, layout).
- `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers,
settings, STYLE blocks) and SCC (drop-frame and non-drop, gaps and back-to-back captions)
round-trip exactly; readers never panic on random bytes; SRT → VTT → SCC keeps text and frames.
+18 -26
View File
@@ -10,7 +10,7 @@ use filmcraft_color::srgb_to_linear;
use filmcraft_project::{Caption, CaptionAlign, CaptionAnchor, CaptionStyle, CaptionTrack, Sequence};
use filmcraft_time::Tick;
use crate::font;
use filmcraft_text::{ParagraphStyle, TextStyle, layout, render};
/// A rendered caption: premultiplied linear-light RGBA f32 pixels at `(x, y)` in the frame.
#[derive(Clone, Debug, PartialEq)]
@@ -49,6 +49,15 @@ impl Overlay {
}
}
/// The caption face: Inter SemiBold at `px`.
fn caption_style(px: f32) -> TextStyle {
TextStyle { style: "SemiBold".into(), size: px, ..Default::default() }
}
fn measure(text: &str, px: f32) -> f32 {
filmcraft_text::measure(text, &caption_style(px))
}
fn lin(c: [u8; 4]) -> [f32; 4] {
let a = c[3] as f32 / 255.0;
[srgb_to_linear(c[0] as f32 / 255.0) * a, srgb_to_linear(c[1] as f32 / 255.0) * a, srgb_to_linear(c[2] as f32 / 255.0) * a, a]
@@ -60,7 +69,7 @@ fn wrap(line: &str, px: f32, max_w: f32) -> Vec<String> {
let mut cur = String::new();
for word in line.split(' ') {
let cand = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
if !cur.is_empty() && font::measure(&cand, px) > max_w {
if !cur.is_empty() && measure(&cand, px) > max_w {
out.push(std::mem::take(&mut cur));
cur = word.to_string();
} else {
@@ -115,7 +124,8 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
if lines.is_empty() {
return None;
}
let (asc, desc) = font::metrics(px);
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve("Inter", "SemiBold").face).metrics(px);
let (asc, desc) = (vm.ascent, vm.descent);
let lh = px * style.line_spacing.max(0.8);
let pad_x = (px * 0.3).round();
let block_h = lh * lines.len() as f32;
@@ -131,7 +141,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
.max(0.0)
.round();
let side = w as f32 * 0.05;
let widths: Vec<f32> = lines.iter().map(|l| font::measure(l, px)).collect();
let widths: Vec<f32> = lines.iter().map(|l| measure(l, px)).collect();
let xs: Vec<f32> = widths
.iter()
.map(|&lw| match align {
@@ -166,28 +176,10 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
}
}
let baseline = lt + (lh - (asc + desc)) / 2.0 + asc;
let mut pen = xs[i];
for ch in line.chars() {
if let Some(g) = font::glyph(ch, px, pen.fract()) {
let gx = pen.floor() as i32 + g.left - ox;
let gy = baseline.round() as i32 + g.top - oy;
for y in 0..g.mask.h {
let ty = gy + y as i32;
if ty < 0 || ty >= oh as i32 {
continue;
}
for x in 0..g.mask.w {
let tx = gx + x as i32;
if tx < 0 || tx >= ow as i32 {
continue;
}
let j = ty as usize * ow + tx as usize;
cover[j] = (cover[j] + g.mask.get(x, y)).min(1.0);
}
}
}
pen += font::advance(ch, px);
}
let l = layout(line, &caption_style(px), &ParagraphStyle::default());
let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) };
render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy));
cover = m.a;
}
let stroke = if outline > 0.0 { dilate(&cover, ow, oh, outline) } else { Vec::new() };
let (tc, bc, oc) = (lin(style.color), lin(style.background_color), lin(style.outline_color));
-137
View File
@@ -1,137 +0,0 @@
//! The caption font: Inter SemiBold (SIL OFL 1.1, `assets/fonts/Inter-SemiBold.ttf`, attributed in
//! ATTRIBUTION.md), read with `skrifa` and rasterised by [`crate::raster`]. Glyph masks are
//! cached by (char, size, sub-pixel offset).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{FontRef, MetadataProvider};
use crate::raster::{Mask, Path, fill};
static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
fn font() -> Option<FontRef<'static>> {
FontRef::new(INTER_SEMIBOLD).ok()
}
/// Vertical metrics at a pixel size: (ascent, descent), both positive.
pub fn metrics(px: f32) -> (f32, f32) {
match font() {
Some(f) => {
let m = f.metrics(Size::new(px), LocationRef::default());
(m.ascent, -m.descent)
}
None => (px * 0.8, px * 0.2),
}
}
/// Horizontal advance of a character at a pixel size.
pub fn advance(c: char, px: f32) -> f32 {
let Some(f) = font() else { return px * 0.5 };
let gid = f.charmap().map(c).or_else(|| f.charmap().map('?'));
gid.and_then(|g| f.glyph_metrics(Size::new(px), LocationRef::default()).advance_width(g)).unwrap_or(px * 0.5)
}
/// Width of a single line of text.
pub fn measure(text: &str, px: f32) -> f32 {
text.chars().map(|c| advance(c, px)).sum()
}
struct Pen {
p: Path,
}
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
self.p.move_to(x, -y);
}
fn line_to(&mut self, x: f32, y: f32) {
self.p.line_to(x, -y);
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.p.quad_to(cx0, -cy0, x, -y);
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.p.cubic_to(cx0, -cy0, cx1, -cy1, x, -y);
}
fn close(&mut self) {
self.p.close();
}
}
/// A rasterised glyph: coverage mask placed with its top-left at `(left, top)` relative to the
/// pen position on the baseline.
#[derive(Debug)]
pub struct Glyph {
pub mask: Mask,
pub left: i32,
pub top: i32,
}
type Key = (char, u32, u8);
fn cache() -> &'static Mutex<HashMap<Key, Option<Arc<Glyph>>>> {
static C: OnceLock<Mutex<HashMap<Key, Option<Arc<Glyph>>>>> = OnceLock::new();
C.get_or_init(Default::default)
}
/// Rasterise `c` at `px` with the pen at fractional x offset `frac` (0..1, quantised to ¼ px).
pub fn glyph(c: char, px: f32, frac: f32) -> Option<Arc<Glyph>> {
let size_q = (px * 4.0).round().max(1.0) as u32;
let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4;
let key = (c, size_q, frac_q);
if let Some(g) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return g.clone();
}
let g = render_glyph(c, size_q as f32 / 4.0, frac_q as f32 / 4.0).map(Arc::new);
let mut m = cache().lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 8192 {
m.clear();
}
m.insert(key, g.clone());
g
}
fn render_glyph(c: char, px: f32, frac: f32) -> Option<Glyph> {
if c.is_whitespace() {
return None;
}
let f = font()?;
let gid = f.charmap().map(c).or_else(|| f.charmap().map('?'))?;
let outline = f.outline_glyphs().get(gid)?;
let mut pen = Pen { p: Path::default() };
outline.draw(DrawSettings::unhinted(Size::new(px), LocationRef::default()), &mut pen).ok()?;
pen.p.close();
let (x0, y0, x1, y1) = pen.p.bounds()?;
let left = (x0 + frac).floor() as i32;
let top = y0.floor() as i32;
let w = ((x1 + frac).ceil() as i32 - left).max(1) as usize;
let h = (y1.ceil() as i32 - top).max(1) as usize;
let mask = fill(&pen.p, w, h, frac - left as f32, -top as f32);
Some(Glyph { mask, left, top })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inter_loads_and_draws() {
let (a, d) = metrics(100.0);
assert!(a > 80.0 && a < 110.0, "ascent {a}");
assert!(d > 10.0 && d < 40.0, "descent {d}");
assert!(advance('W', 100.0) > advance('i', 100.0));
let g = glyph('H', 40.0, 0.0).unwrap();
// the H has two stems: the middle row has ink on both sides and a gap between them
let y = g.mask.h / 4;
let row: Vec<f32> = (0..g.mask.w).map(|x| g.mask.get(x, y)).collect();
assert!(row[1] > 0.9 && row[g.mask.w - 2] > 0.9, "{row:?}");
assert!(row[g.mask.w / 2] < 0.1, "{row:?}");
assert!(g.top < 0, "glyph rises above the baseline");
assert!(glyph(' ', 40.0, 0.0).is_none());
assert!(measure("Hello", 30.0) > 50.0);
}
}
+2 -4
View File
@@ -10,13 +10,11 @@
//! (drop-frame or not) convert exactly; [`Document::snap_to_frames`] snaps to a sequence's frames.
//! - **Model:** [`track_from_document`] / [`document_from_track`] convert to and from the project's
//! [`CaptionTrack`].
//! - **Burn-in:** [`burn`] lays a caption out with the track style and rasterises it with the
//! bundled Inter font (outlines via `skrifa`, coverage rasteriser in [`raster`]).
//! - **Burn-in:** [`burn`] lays a caption out with the track style and draws it with the
//! `filmcraft-text` engine (Inter SemiBold, shaped and kerned).
pub mod burn;
pub mod cea608;
pub mod font;
pub mod raster;
pub mod scc;
pub mod srt;
pub mod vtt;
-228
View File
@@ -1,228 +0,0 @@
//! A small anti-aliased path rasteriser for glyph outlines.
//!
//! Curves are flattened to line segments; each pixel row is sampled at [`SUB`] sub-scanlines, and
//! on each sub-scanline the non-zero winding spans between edge crossings are accumulated with
//! exact horizontal coverage at the span ends. Original implementation for FilmCraft.
/// Vertical sub-samples per pixel row.
pub const SUB: usize = 5;
/// A flattened path: line segments `(x0, y0, x1, y1)` in pixel space, y down.
#[derive(Clone, Debug, Default)]
pub struct Path {
pub segs: Vec<[f32; 4]>,
start: (f32, f32),
cur: (f32, f32),
open: bool,
}
impl Path {
pub fn move_to(&mut self, x: f32, y: f32) {
self.close();
self.start = (x, y);
self.cur = (x, y);
self.open = true;
}
pub fn line_to(&mut self, x: f32, y: f32) {
if (x, y) != self.cur {
self.segs.push([self.cur.0, self.cur.1, x, y]);
}
self.cur = (x, y);
}
pub fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let (x0, y0) = self.cur;
let dd = ((x0 - 2.0 * cx + x).abs() + (y0 - 2.0 * cy + y).abs()).max(0.01);
let n = ((dd * 2.0).sqrt().ceil() as usize).clamp(1, 32);
for i in 1..=n {
let t = i as f32 / n as f32;
let u = 1.0 - t;
self.line_to(u * u * x0 + 2.0 * u * t * cx + t * t * x, u * u * y0 + 2.0 * u * t * cy + t * t * y);
}
}
pub fn cubic_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) {
let (x0, y0) = self.cur;
let dd = (x0 - 2.0 * c1x + c2x).abs() + (y0 - 2.0 * c1y + c2y).abs() + (c1x - 2.0 * c2x + x).abs() + (c1y - 2.0 * c2y + y).abs();
let n = (((dd.max(0.01)) * 3.0).sqrt().ceil() as usize).clamp(1, 48);
for i in 1..=n {
let t = i as f32 / n as f32;
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
self.line_to(a * x0 + b * c1x + c * c2x + d * x, a * y0 + b * c1y + c * c2y + d * y);
}
}
pub fn close(&mut self) {
if self.open && self.cur != self.start {
self.segs.push([self.cur.0, self.cur.1, self.start.0, self.start.1]);
}
self.cur = self.start;
self.open = false;
}
/// Bounding box `(x0, y0, x1, y1)` of all segments.
pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> {
let mut it = self.segs.iter();
let f = it.next()?;
let mut b = (f[0].min(f[2]), f[1].min(f[3]), f[0].max(f[2]), f[1].max(f[3]));
for s in it {
b = (b.0.min(s[0]).min(s[2]), b.1.min(s[1]).min(s[3]), b.2.max(s[0]).max(s[2]), b.3.max(s[1]).max(s[3]));
}
Some(b)
}
}
/// Coverage mask (0..1), row-major.
#[derive(Clone, Debug, PartialEq)]
pub struct Mask {
pub w: usize,
pub h: usize,
pub a: Vec<f32>,
}
impl Mask {
pub fn new(w: usize, h: usize) -> Self {
Self { w, h, a: vec![0.0; w * h] }
}
pub fn get(&self, x: usize, y: usize) -> f32 {
self.a[y * self.w + x]
}
}
/// Fill `path` (non-zero winding) into a `w`×`h` mask, offsetting the path by `(dx, dy)`.
pub fn fill(path: &Path, w: usize, h: usize, dx: f32, dy: f32) -> Mask {
let mut m = Mask::new(w, h);
if w == 0 || h == 0 {
return m;
}
// edges: (ytop, ybot, x at ytop, dx/dy, winding)
let mut edges: Vec<(f32, f32, f32, f32, i32)> = path
.segs
.iter()
.filter(|s| s[1] != s[3])
.map(|s| {
let (x0, y0, x1, y1) = (s[0] + dx, s[1] + dy, s[2] + dx, s[3] + dy);
let (dir, xa, ya, xb, yb) = if y0 < y1 { (1, x0, y0, x1, y1) } else { (-1, x1, y1, x0, y0) };
(ya, yb, xa, (xb - xa) / (yb - ya), dir)
})
.collect();
edges.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut xs: Vec<(f32, i32)> = Vec::new();
let mut row = vec![0.0f32; w + 1];
let weight = 1.0 / SUB as f32;
for y in 0..h {
row.iter_mut().for_each(|v| *v = 0.0);
let mut any = false;
for k in 0..SUB {
let sy = y as f32 + (k as f32 + 0.5) / SUB as f32;
xs.clear();
for e in &edges {
if e.0 > sy {
break; // sorted by top
}
if sy < e.1 {
xs.push((e.2 + (sy - e.0) * e.3, e.4));
}
}
if xs.len() < 2 {
continue;
}
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut wind = 0;
let mut span_start = 0.0f32;
for &(x, d) in &xs {
let before = wind;
wind += d;
if before == 0 && wind != 0 {
span_start = x;
} else if before != 0 && wind == 0 {
add_span(&mut row, span_start, x, weight, w);
any = true;
}
}
}
if any {
let out = &mut m.a[y * w..(y + 1) * w];
for (o, v) in out.iter_mut().zip(&row) {
*o = v.min(1.0);
}
}
}
m
}
/// Add coverage `weight` over `[x0, x1)` with fractional ends.
fn add_span(row: &mut [f32], x0: f32, x1: f32, weight: f32, w: usize) {
let x0 = x0.clamp(0.0, w as f32);
let x1 = x1.clamp(0.0, w as f32);
if x1 <= x0 {
return;
}
let i0 = x0.floor() as usize;
let i1 = x1.floor() as usize;
if i0 == i1 {
row[i0] += (x1 - x0) * weight;
return;
}
row[i0] += (i0 as f32 + 1.0 - x0) * weight;
for v in &mut row[i0 + 1..i1] {
*v += weight;
}
if i1 < w {
row[i1] += (x1 - i1 as f32) * weight;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Path {
let mut p = Path::default();
p.move_to(x0, y0);
p.line_to(x1, y0);
p.line_to(x1, y1);
p.line_to(x0, y1);
p.close();
p
}
#[test]
fn rectangle_coverage() {
let m = fill(&rect(1.5, 2.0, 4.0, 5.0), 6, 6, 0.0, 0.0);
assert!((m.get(1, 3) - 0.5).abs() < 1e-5);
assert!((m.get(2, 3) - 1.0).abs() < 1e-5);
assert_eq!(m.get(4, 3), 0.0);
assert_eq!(m.get(2, 1), 0.0);
assert_eq!(m.get(2, 5), 0.0);
let total: f32 = m.a.iter().sum();
assert!((total - 2.5 * 3.0).abs() < 1e-3, "{total}");
}
#[test]
fn hole_with_opposite_winding() {
let mut p = rect(0.0, 0.0, 10.0, 10.0);
// inner square wound the other way
p.move_to(3.0, 3.0);
p.line_to(3.0, 7.0);
p.line_to(7.0, 7.0);
p.line_to(7.0, 3.0);
p.close();
let m = fill(&p, 10, 10, 0.0, 0.0);
assert_eq!(m.get(5, 5), 0.0);
assert!((m.get(1, 5) - 1.0).abs() < 1e-5);
}
#[test]
fn circle_area() {
let mut p = Path::default();
let (cx, cy, r) = (10.0f32, 10.0f32, 6.0f32);
p.move_to(cx + r, cy);
for i in 1..=64 {
let a = i as f32 / 64.0 * std::f32::consts::TAU;
p.line_to(cx + r * a.cos(), cy + r * a.sin());
}
p.close();
let m = fill(&p, 20, 20, 0.0, 0.0);
let total: f32 = m.a.iter().sum();
let want = std::f32::consts::PI * r * r;
assert!((total - want).abs() / want < 0.02, "{total} vs {want}");
}
}
+1
View File
@@ -19,6 +19,7 @@ filmcraft-matroska = { workspace = true }
filmcraft-bitstream = { workspace = true }
filmcraft-h264 = { workspace = true }
filmcraft-hevc = { workspace = true }
filmcraft-vp9 = { workspace = true }
filmcraft-prores = { workspace = true }
filmcraft-aac = { workspace = true }
filmcraft-opus = { workspace = true }
+10 -1
View File
@@ -180,7 +180,7 @@ impl GopCache {
if st.intra {
return self.intra_frame(st, s, i, want_pts);
}
let key = s.sync_before(i);
let mut key = s.sync_before(i);
// Continue the running decoder when it has passed the wanted sample's sync sample and
// either has not reached the sample yet or has been fed it without outputting it yet
// (a frame-threaded decoder holds many pictures in flight). Otherwise the frame was
@@ -192,6 +192,15 @@ impl GopCache {
let continuing = running && (st.next > key || near) && (i >= st.next || (st.start <= i && want_pts > st.out_max));
if !continuing {
SEEKS.fetch_add(1, Ordering::Relaxed);
// The container's sync flags may be wrong for the codec (an MP4 without `stss` marks
// every sample): step back to a sample the decoder can start from.
while key > 0 {
let data = s.read(key)?;
if st.decoder.as_ref().expect("decoder").is_random_access(&data) != Some(false) {
break;
}
key = s.sync_before(key - 1);
}
if let Some(d) = st.decoder.as_mut() {
d.reset();
}
+2 -2
View File
@@ -1,6 +1,6 @@
//! The container + codec hub.
//!
//! - [`VideoDecoder`]: the trait every video codec implements (our own H.264/ProRes/…, MJPEG, and
//! - [`VideoDecoder`]: the trait every video codec implements (our own H.264/HEVC/VP9/ProRes/…, MJPEG, and
//! OS hardware decoders registered by the platform layer). Factories are tried in registration
//! order, so a hardware decoder can take precedence over the pure-Rust one.
//! - [`Mp4Source`]: a [`MediaSource`](filmcraft_media::MediaSource) over MP4/MOV using
@@ -51,7 +51,7 @@ impl From<CodecError> for filmcraft_media::MediaError {
fn factories() -> &'static RwLock<Vec<VideoDecoderFactory>> {
static F: std::sync::OnceLock<RwLock<Vec<VideoDecoderFactory>>> = std::sync::OnceLock::new();
F.get_or_init(|| RwLock::new(vec![video::h264_factory, video::hevc_factory, video::prores_factory, video::mjpeg_factory]))
F.get_or_init(|| RwLock::new(vec![video::h264_factory, video::hevc_factory, video::vp9_factory, video::prores_factory, video::mjpeg_factory]))
}
/// Register a video decoder factory (tried before previously registered ones).
+38 -4
View File
@@ -1,7 +1,7 @@
//! Matroska/WebM media source (`filmcraft-matroska` demux, GOP-aware video via [`crate::gop`]).
//!
//! Video codecs are mapped onto ISO-BMFF sample entries so the same decoder factories serve both
//! containers (H.264, HEVC, ProRes, MJPEG; VP9/AV1 once their decoders land). Audio: AAC and Opus
//! containers (H.264, HEVC, VP9, ProRes, MJPEG; AV1 once its decoder lands). Audio: AAC and Opus
//! via our decoders, PCM directly, MP3/FLAC/Vorbis via the bootstrap decoders.
//!
//! Opus (`A_OPUS`): `CodecPrivate` is the `OpusHead`; output is always 48 kHz. The demuxer
@@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex};
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_isobmff::{AvcConfig, FourCc, HevcConfig, PcmConfig, SampleEntry};
use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig};
use filmcraft_matroska::{Codec, MkvFile, TrackKind};
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
use filmcraft_time::{FrameRate, Tick};
@@ -105,8 +105,42 @@ fn from_tick(t: &filmcraft_matroska::Track, time: Tick) -> i64 {
time.to_rational_floor(n, d)
}
fn sample_entry(c: &Codec, w: u16, h: u16) -> Option<SampleEntry> {
/// VP9 configuration from the Matroska `CodecPrivate` feature list (ID / length / value triples:
/// 1 profile, 2 level, 3 bit depth, 4 chroma subsampling) and the track's `Colour`.
fn vp9_config(private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>) -> VpcConfig {
let mut c = VpcConfig { bit_depth: 8, colour_primaries: 2, transfer_characteristics: 2, matrix_coefficients: 2, ..Default::default() };
let mut p = 0;
while p + 2 <= private.len() {
let (id, len) = (private[p], private[p + 1] as usize);
let Some(val) = private.get(p + 2..p + 2 + len) else { break };
let x = val.first().copied().unwrap_or(0);
match id {
1 => c.profile = x,
2 => c.level = x,
3 => c.bit_depth = x,
4 => c.chroma_subsampling = x,
_ => {}
}
p += 2 + len;
}
if let Some(col) = v.and_then(|v| v.colour.as_ref()) {
if let Some(t) = col.transfer_characteristics {
c.transfer_characteristics = t as u8;
}
if let Some(pr) = col.primaries {
c.colour_primaries = pr as u8;
}
if let Some(m) = col.matrix_coefficients {
c.matrix_coefficients = m as u8;
}
c.full_range = col.full_range();
}
c
}
fn sample_entry(c: &Codec, v: Option<&filmcraft_matroska::VideoInfo>, w: u16, h: u16) -> Option<SampleEntry> {
Some(match c {
Codec::Vp9 { private } => SampleEntry::video(FourCc(*b"vp09"), CodecConfig::Vp9(vp9_config(private, v)), w, h),
Codec::Avc { avcc } => SampleEntry::avc(AvcConfig::parse(avcc).ok()?, w, h),
Codec::Hevc { hvcc } => SampleEntry::hevc(HevcConfig::parse(hvcc).ok()?, w, h),
Codec::ProRes { fourcc } => SampleEntry::prores(FourCc(fourcc.unwrap_or(*b"apcn")), w, h),
@@ -190,7 +224,7 @@ impl MkvSource {
if explicit {
explicit_color = Some(color);
}
ventry = sample_entry(&t.codec, w as u16, h as u16);
ventry = sample_entry(&t.codec, t.video.as_ref(), w as u16, h as u16);
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
VideoStreamInfo {
+20 -3
View File
@@ -48,9 +48,17 @@ pub fn sniff(b: &[u8]) -> bool {
fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo {
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
if let Some(v) = &entry.video
&& let Some(col) = &v.color
{
// VP9 carries its colour description in vpcC (used when there is no colr box).
let vpc = match &entry.codec {
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
primaries: v.colour_primaries as u16,
transfer: v.transfer_characteristics as u16,
matrix: v.matrix_coefficients as u16,
full_range: v.full_range,
}),
_ => None,
};
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
let (p, t, m, full) = match col {
filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range),
filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false),
@@ -369,6 +377,7 @@ fn codec_label(c: &CodecConfig) -> String {
}
),
CodecConfig::Hevc(_) => "HEVC".into(),
CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile),
CodecConfig::ProRes { fourcc } => match &fourcc.0 {
b"apco" => "Apple ProRes 422 Proxy".into(),
b"apcs" => "Apple ProRes 422 LT".into(),
@@ -399,6 +408,14 @@ fn pixfmt_label(c: &CodecConfig) -> String {
244 => "YUV 4:4:4".into(),
_ => "YUV 4:2:0 8-bit".into(),
},
CodecConfig::Vp9(c) => {
let sub = match c.chroma_subsampling {
2 => "4:2:2",
3 => "4:4:4",
_ => "4:2:0",
};
format!("YUV {sub} {}-bit", c.bit_depth.max(8))
}
CodecConfig::ProRes { fourcc } if fourcc.0[2] == b'4' => "YUVA 4:4:4 12-bit".into(),
CodecConfig::ProRes { .. } => "YUV 4:2:2 10-bit".into(),
CodecConfig::Jpeg { .. } => "YUV 4:2:x 8-bit".into(),
+163
View File
@@ -179,6 +179,169 @@ fn matroska_prores_pcm() {
check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]);
}
/// Path of a fixture under `target/fixtures/codecs/`, generated with ffmpeg when missing
/// (`None` when ffmpeg or the encoder is unavailable).
fn fixture_path(name: &str, args: &[&str]) -> Option<PathBuf> {
fixture(name, args)?;
Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs").join(name))
}
/// ffmpeg's decode of `src` as raw planar video (the oracle).
fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option<Vec<u8>> {
let name = format!("{}.{pix_fmt}.yuv", src.file_name()?.to_str()?);
let path = src.to_str()?;
let b = fixture(&name, &["-i", path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt])?;
Some(b.to_vec())
}
/// Planes of a decoded YUV frame as little-endian bytes (ffmpeg rawvideo layout).
fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec<u8> {
match &f.data {
filmcraft_frame::PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(),
filmcraft_frame::PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(),
_ => panic!("not a YUV frame: {}", f.format_label()),
}
}
/// Random access into a VP9 file: frames requested out of order (seeks back and forth across key
/// frames, then sequential playback) are sample-exact against ffmpeg's decode.
fn check_vp9_seeks(name: &str, container: &str, pix_fmt: &str, extra: &[&str], frames: usize) {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs");
let log = dir.join(format!("{name}.passlog")).to_string_lossy().into_owned();
let mut args = vec!["-f", "lavfi", "-i", "testsrc2=s=352x288:r=25,noise=alls=8:allf=t", "-frames:v"];
let n = frames.to_string();
args.push(&n);
args.extend_from_slice(&["-c:v", "libvpx-vp9", "-pix_fmt", pix_fmt, "-g", "25", "-b:v", "600k"]);
args.extend_from_slice(extra);
if extra.contains(&"-auto-alt-ref") {
// libvpx only uses alternate reference frames in two-pass mode: run the first pass.
let Some(ff) = ffmpeg() else { return };
if !dir.join(name).exists() {
let _ = std::fs::create_dir_all(&dir);
let ok = Command::new(ff).args(["-y", "-v", "error"]).args(&args).args(["-pass", "1", "-passlogfile"]).arg(&log).args(["-f", "null", "-"]).status();
if !ok.is_ok_and(|s| s.success()) {
eprintln!("first pass for {name} failed; skipping");
return;
}
}
args.extend_from_slice(&["-pass", "2", "-passlogfile"]);
args.push(&log);
}
let Some(path) = fixture_path(name, &args) else { return };
let Some(reference) = reference_yuv(&path, pix_fmt) else { return };
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
let src = crate::open_bytes(name, bytes).unwrap();
let info = src.info().clone();
assert_eq!(info.container, container);
let v = info.video.as_ref().unwrap();
assert!(v.codec.contains("VP9"), "{}", v.codec);
assert_eq!((v.width, v.height), (352, 288));
let frame_len = reference.len() / frames;
let rate = info.frame_rate();
let order: Vec<usize> = [frames - 3, 3, 40, 26, 24, frames - 1, 0, 12].into_iter().chain(30..45).filter(|&k| k < frames).collect();
for k in order {
let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap();
assert_eq!((f.width, f.height), (352, 288));
let got = yuv_bytes(&f);
assert!(got == reference[k * frame_len..(k + 1) * frame_len], "{name}: frame {k} differs from ffmpeg");
}
}
#[test]
fn webm_vp9_seeks_bit_exact() {
check_vp9_seeks("noise_vp9.webm", "WebM", "yuv420p", &["-deadline", "realtime", "-speed", "8"], 60);
}
#[test]
fn webm_vp9_altref_superframes_seek_bit_exact() {
// Good-quality encode with alternate reference frames: hidden frames packed into superframes.
check_vp9_seeks("noise_vp9_altref.webm", "WebM", "yuv420p", &["-speed", "4", "-auto-alt-ref", "1", "-lag-in-frames", "16"], 60);
}
#[test]
fn mp4_vp9_10bit_seeks_bit_exact() {
check_vp9_seeks("noise_vp9_10bit.mp4", "MPEG-4", "yuv420p10le", &["-profile:v", "2", "-deadline", "realtime", "-speed", "8"], 50);
}
#[test]
fn mkv_vp9_444_12bit_seeks_bit_exact() {
check_vp9_seeks("noise_vp9_444_12bit.mkv", "Matroska", "yuv444p12le", &["-profile:v", "3", "-deadline", "realtime", "-speed", "8"], 30);
}
#[test]
fn mkv_vp9_422_seeks_bit_exact() {
check_vp9_seeks("noise_vp9_422.mkv", "Matroska", "yuv422p", &["-profile:v", "1", "-deadline", "realtime", "-speed", "8"], 30);
}
/// Every sample flagged as sync (as in an MP4 without `stss`): the GOP cache must still start
/// decoding at a real VP9 key frame.
#[test]
fn vp9_random_access_ignores_bogus_sync_flags() {
use crate::gop::{GopCache, VideoSamples};
let Some(path) = fixture_path(
"noise_vp9_g20.ivf",
&["-f", "lavfi", "-i", "testsrc2=s=176x144:r=25", "-frames:v", "50", "-c:v", "libvpx-vp9", "-g", "20", "-deadline", "realtime", "-speed", "8"],
) else {
return;
};
let Some(reference) = reference_yuv(&path, "yuv420p") else { return };
let data = std::fs::read(&path).unwrap();
let mut chunks = Vec::new();
let mut p = 32;
while p + 12 <= data.len() {
let sz = u32::from_le_bytes(data[p..p + 4].try_into().unwrap()) as usize;
chunks.push(data[p + 12..p + 12 + sz].to_vec());
p += 12 + sz;
}
struct AllSync(Vec<Vec<u8>>);
impl VideoSamples for AllSync {
fn count(&self) -> usize {
self.0.len()
}
fn pts(&self, i: usize) -> i64 {
i as i64
}
fn sync_before(&self, i: usize) -> usize {
i
}
fn sample_at(&self, t: i64) -> Option<usize> {
(t >= 0 && (t as usize) < self.0.len()).then_some(t as usize)
}
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
Ok(self.0[i].clone())
}
fn make_decoder(&self) -> crate::Result<Box<dyn crate::VideoDecoder>> {
Ok(Box::new(crate::video::Vp9Decoder::new(None)))
}
}
let s = AllSync(chunks);
let cache = GopCache::new(None);
let frame_len = 176 * 144 * 3 / 2;
for k in [37usize, 5, 49, 21, 20, 19] {
let f = cache.frame(&s, k as i64).unwrap();
assert!(yuv_bytes(&f) == reference[k * frame_len..(k + 1) * frame_len], "frame {k} differs from ffmpeg");
}
}
/// VP9 RGB (profile 1, colour space sRGB; planes G, B, R) decodes to RGBA in the right order.
#[test]
fn mkv_vp9_rgb() {
let Some(b) = fixture(
"orange_vp9_gbrp.mkv",
&["-f", "lavfi", "-i", "color=c=0xe08020:s=128x96:r=25:d=0.4", "-c:v", "libvpx-vp9", "-pix_fmt", "gbrp", "-deadline", "realtime", "-lossless", "1"],
) else {
return;
};
let src = crate::open_bytes("orange_vp9_gbrp.mkv", b).unwrap();
let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 5))).unwrap();
let px = f.to_rgba8();
let c = &px[(48 * 128 + 64) * 4..][..3];
// (the lavfi colour source is converted to RGB by ffmpeg, which rounds by a level)
for (got, want) in c.iter().zip([0xe0u8, 0x80, 0x20]) {
assert!((*got as i32 - want as i32).abs() <= 2, "{c:?}");
}
}
/// ffmpeg + libopus decode of a container fixture (pre-skip / codec delay / edit list applied) as
/// interleaved f32 at 48 kHz: the reference for our Opus path.
fn opus_reference(name: &str) -> Option<Vec<f32>> {
+141
View File
@@ -25,6 +25,12 @@ pub trait VideoDecoder: Send {
fn intra_only(&self) -> bool {
false
}
/// Whether decoding can start at `sample` (`None`: unknown, trust the container's sync flags).
/// Containers may flag samples as sync that the codec cannot start from (an MP4 without
/// `stss` marks every sample), so codecs that can tell say so.
fn is_random_access(&self, _sample: &[u8]) -> Option<bool> {
None
}
}
/// A factory returns `None` when it does not handle the entry.
@@ -220,6 +226,141 @@ pub fn hevc_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
}
}
/// Our pure-Rust VP9 decoder (profiles 0-3, 8/10/12-bit; tile columns and loop filter decode in
/// parallel).
pub struct Vp9Decoder {
dec: filmcraft_vp9::Decoder,
/// Container colour (vpcC / Matroska `Colour`): transfer and primaries are not in the VP9
/// bitstream.
transfer: Option<filmcraft_color::Transfer>,
primaries: Option<filmcraft_color::Primaries>,
}
/// Colour primaries from an ISO/IEC 23091-2 code.
pub(crate) fn primaries_from_code(c: u8) -> Option<filmcraft_color::Primaries> {
use filmcraft_color::Primaries;
match c {
1 => Some(Primaries::Bt709),
5 => Some(Primaries::Bt601_625),
6 => Some(Primaries::Bt601_525),
9 => Some(Primaries::Bt2020),
12 => Some(Primaries::P3D65),
_ => None,
}
}
impl Vp9Decoder {
pub fn new(cfg: Option<&filmcraft_isobmff::VpcConfig>) -> Self {
Self {
dec: filmcraft_vp9::Decoder::new(),
transfer: cfg.and_then(|c| filmcraft_color::Transfer::from_code(c.transfer_characteristics)),
primaries: cfg.and_then(|c| primaries_from_code(c.colour_primaries)),
}
}
fn convert(&self, p: filmcraft_vp9::Picture) -> DecodedFrame {
use filmcraft_color::{Matrix, Primaries, Range};
use filmcraft_frame::{Chroma, PixelData};
use filmcraft_vp9::Plane;
use std::sync::Arc;
let (w, h) = (p.width as usize, p.height as usize);
let (cw, ch) = (p.chroma_width as usize, p.chroma_height as usize);
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(p.width, p.height), ..filmcraft_color::ColorInfo::REC709 };
// color_space (7.2.2): 1 BT.601, 2 BT.709, 3 SMPTE-170, 4 SMPTE-240, 5 BT.2020, 7 sRGB.
match p.color.color_space {
1 | 3 => color.matrix = Matrix::Bt601,
2 | 4 => color.matrix = Matrix::Bt709,
5 => {
color.matrix = Matrix::Bt2020Ncl;
color.primaries = Primaries::Bt2020;
}
_ => {}
}
if let Some(t) = self.transfer {
color.transfer = t;
}
if let Some(pr) = self.primaries {
color.primaries = pr;
}
if p.color.full_range {
color.range = Range::Full;
}
let pts = p.pts;
if p.color.color_space == 7 {
// RGB (profiles 1 / 3, 4:4:4): the planes carry G, B, R.
let shift = p.bit_depth - 8;
let mut rgba = Vec::with_capacity(w * h * 4);
for i in 0..w * h {
rgba.extend_from_slice(&[(p.v.get(i) >> shift) as u8, (p.y.get(i) >> shift) as u8, (p.u.get(i) >> shift) as u8, 255]);
}
let mut frame = VideoFrame::rgba8(p.width, p.height, rgba);
frame.color = filmcraft_color::ColorInfo { range: Range::Full, transfer: filmcraft_color::Transfer::Srgb, ..color };
return DecodedFrame { pts, frame };
}
// 4:4:0 has no frame format of its own: chroma rows are repeated to 4:4:4.
let (chroma, rows_440) = match (p.subsampling_x, p.subsampling_y) {
(true, true) => (Chroma::C420, false),
(true, false) => (Chroma::C422, false),
(false, false) => (Chroma::C444, false),
(false, true) => (Chroma::C444, true),
};
fn expand<T: Copy>(v: &[T], cw: usize, ch: usize, h: usize, rows_440: bool) -> Vec<T> {
if !rows_440 {
return v[..cw * ch].to_vec();
}
let mut out = Vec::with_capacity(cw * h);
for y in 0..h {
out.extend_from_slice(&v[(y >> 1) * cw..(y >> 1) * cw + cw]);
}
out
}
let data = match (&p.y, &p.u, &p.v) {
(Plane::U8(y), Plane::U8(u), Plane::U8(v)) => PixelData::Yuv8 {
planes: [Arc::new(y[..w * h].to_vec()), Arc::new(expand(u, cw, ch, h, rows_440)), Arc::new(expand(v, cw, ch, h, rows_440))],
chroma,
alpha: None,
},
(Plane::U16(y), Plane::U16(u), Plane::U16(v)) => PixelData::Yuv16 {
planes: [Arc::new(y[..w * h].to_vec()), Arc::new(expand(u, cw, ch, h, rows_440)), Arc::new(expand(v, cw, ch, h, rows_440))],
chroma,
bits: p.bit_depth,
alpha: None,
},
_ => unreachable!("VP9 planes share one sample type"),
};
// render_size (the intended display size) is not applied: the container's display
// dimensions / pixel aspect describe the presentation.
let par = (1, 1);
DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO } }
}
}
impl VideoDecoder for Vp9Decoder {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?;
Ok(pics.into_iter().map(|p| self.convert(p)).collect())
}
fn flush(&mut self) -> Vec<DecodedFrame> {
self.dec.flush().into_iter().map(|p| self.convert(p)).collect()
}
fn reset(&mut self) {
self.dec.reset();
}
fn name(&self) -> &str {
"FilmCraft VP9"
}
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
Some(filmcraft_vp9::is_keyframe(sample))
}
}
pub fn vp9_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
match &e.codec {
CodecConfig::Vp9(c) => Some(Ok(Box::new(Vp9Decoder::new(Some(c))) as Box<dyn VideoDecoder>)),
_ => None,
}
}
/// Our ProRes decoder (every frame is intra; slices decode in parallel).
pub struct ProResDecoder;
+1
View File
@@ -19,6 +19,7 @@ filmcraft-codecs = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-edit = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-text = { workspace = true }
filmcraft-export = { workspace = true }
filmcraft-format = { workspace = true }
filmcraft-interchange = { workspace = true }
+86 -3
View File
@@ -63,15 +63,98 @@ impl AutoSavePrefs {
}
}
/// Preferences ▸ Playback (the parts trim-mode loop playback uses).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct PlaybackPrefs {
/// "Preroll: N seconds" (Play Around, trim-mode loop playback).
pub preroll_seconds: f64,
/// "Postroll: N seconds".
pub postroll_seconds: f64,
}
impl Default for PlaybackPrefs {
fn default() -> Self {
Self { preroll_seconds: 3.0, postroll_seconds: 2.0 }
}
}
/// Preferences ▸ Trim.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TrimPrefs {
/// "Large Trim Offset: N frames" (Trim Forward/Backward Many, the Trim Monitor's ±N buttons).
pub large_trim_offset: u32,
/// "Playhead position determines trim monitor loop playback" (else the edit point does).
pub playhead_determines_loop: bool,
}
impl Default for TrimPrefs {
fn default() -> Self {
Self { large_trim_offset: 5, playhead_determines_loop: false }
}
}
/// User preferences (persisted as JSON in the per-user data directory). Keys are dotted camelCase
/// paths: `autoSave.enabled`, `autoSave.intervalMinutes`, …
/// paths: `autoSave.enabled`, `autoSave.intervalMinutes`, `playback.prerollSeconds`, …
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Preferences {
pub auto_save: AutoSavePrefs,
pub audio: AudioPrefs,
pub playback: PlaybackPrefs,
pub trim: TrimPrefs,
}
/// Preferences ▸ Audio (the mixer-automation part) and the Track Mixer panel-menu toggle.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AudioPrefs {
/// "Automatch Time" (s): how long Touch takes to return to the existing automation.
pub automatch_time: f64,
/// "Large Volume Adjustment" (dB) for the Increase/Decrease Clip Volume Many commands.
pub large_volume_adjustment: f64,
/// Automation Keyframe Optimization: "Linear keyframe thinning".
pub linear_keyframe_thinning: bool,
/// "Minimum time interval thinning".
pub minimum_time_interval_thinning: bool,
/// "Minimum time" (ms).
pub minimum_time_ms: u32,
/// Track Mixer ▸ "Switch to Touch after Write".
pub switch_to_touch_after_write: bool,
}
impl Default for AudioPrefs {
fn default() -> Self {
Self {
automatch_time: 1.0,
large_volume_adjustment: 6.0,
linear_keyframe_thinning: true,
minimum_time_interval_thinning: false,
minimum_time_ms: 20,
switch_to_touch_after_write: true,
}
}
}
impl AudioPrefs {
fn clamp(&mut self) {
self.automatch_time = if self.automatch_time.is_finite() { self.automatch_time.clamp(0.0, 30.0) } else { 1.0 };
self.large_volume_adjustment = if self.large_volume_adjustment.is_finite() { self.large_volume_adjustment.clamp(0.0, 96.0) } else { 6.0 };
self.minimum_time_ms = self.minimum_time_ms.clamp(1, 10_000);
}
}
impl Preferences {
fn clamp(&mut self) {
self.auto_save.clamp();
self.audio.clamp();
let secs = |v: f64| if v.is_finite() { v.clamp(0.0, 60.0) } else { 0.0 };
self.playback.preroll_seconds = secs(self.playback.preroll_seconds);
self.playback.postroll_seconds = secs(self.playback.postroll_seconds);
self.trim.large_trim_offset = self.trim.large_trim_offset.clamp(1, 1000);
}
pub fn to_value(&self) -> Value {
serde_json::to_value(self).unwrap_or_default()
}
@@ -117,14 +200,14 @@ impl Preferences {
};
*slot = value;
let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
p.auto_save.clamp();
p.clamp();
*self = p;
Ok(())
}
pub fn load(path: &Path) -> Self {
let mut p: Preferences = fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
p.auto_save.clamp();
p.clamp();
p
}
+61 -24
View File
@@ -876,20 +876,16 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("clip.audioGain", "Audio Gain…", ["Clip"], Some("G"), r#"{"db":f64,"relative":bool}"#, has_selection, |s, p| {
let db = f64_p(p, "db").unwrap_or(0.0);
let rel = bool_p(p, "relative").unwrap_or(true);
let sel = with_links(s, &s.state.selection.clone());
s.edit_sequence("Audio Gain", |q, _, _| {
for t in q.audio_tracks.iter_mut() {
for i in t.items.iter_mut().filter(|i| sel.contains(&i.id)) {
i.gain_db = if rel { i.gain_db + db } else { db };
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"clip.audioGain",
"Audio Gain…",
["Clip"],
Some("G"),
r#"{"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#,
has_selection,
|s, p| crate::mixer::audio_gain(s, p)
),
query!("clip.audioPeak", "Audio Clip Peak Amplitude", r#"{"clips":[id]?}"#, |s, p| crate::mixer::audio_peak(s, p)),
cmd!("clip.frameHold", "Add Frame Hold", ["Clip", "Video Options"], None, "{}", has_selection, |s, _| {
let t = s.playhead();
let sel = s.state.selection.clone();
@@ -916,7 +912,7 @@ fn build() -> Vec<CommandSpec> {
s.events.push(crate::Event::OpenSequence(id));
Ok(Value::Null)
}),
cmd!("sequence.close", "Close Sequence", [], None, r#"{"item":id}"#, has_seq, |s, p| {
cmd!("sequence.close", "Close Sequence", [], None, r#"{"item":id?}"#, has_seq, |s, p| {
let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
s.state.open_sequences.retain(|x| *x != id);
if s.state.active_sequence == Some(id) {
@@ -1252,8 +1248,10 @@ fn build() -> Vec<CommandSpec> {
Ok(Value::Null)
}),
// ================= Source monitor =================
cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id}"#, always, |s, p| {
let id = item_p(p, "item").ok_or_else(|| bad("source.open", "need `item`"))?;
cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?}"#, always, |s, p| {
let id = item_p(p, "item")
.or(s.state.project_selection.first().copied())
.ok_or_else(|| bad("source.open", "need `item` (or a Project panel selection)"))?;
s.project.item(id).ok_or_else(|| bad("source.open", "no such item"))?;
s.state.source_item = Some(id);
s.state.source_playhead = Tick::ZERO;
@@ -1313,6 +1311,9 @@ fn build() -> Vec<CommandSpec> {
let clips: Vec<ClipId> =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
let clips = with_links(s, &clips);
// selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive)
s.state.edit_points.clear();
s.state.trim_shift = Default::default();
if bool_p(p, "toggle").unwrap_or(false) {
for c in clips {
if let Some(i) = s.state.selection.iter().position(|x| *x == c) {
@@ -1404,16 +1405,43 @@ fn build() -> Vec<CommandSpec> {
}),
cmd!("trim.clear", "Clear Edit Point Selection", [], None, "{}", has_edit_points, |s, _| {
s.state.edit_points.clear();
s.state.trim_shift = Default::default();
s.trim_play.around = None;
Ok(Value::Null)
}),
cmd!("trim.toggleType", "Toggle Trim Type", [], Some("Ctrl+T"), "{}", has_edit_points, |s, _| crate::trim::toggle_type(s)),
cmd!("trim.backward", "Trim Backward", [], Some("Alt+Left"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": -1}))),
cmd!("trim.forward", "Trim Forward", [], Some("Alt+Right"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": 1}))),
cmd!("trim.backwardMany", "Trim Backward Many", [], Some("Alt+Shift+Left"), "{}", has_edit_points, |s, _| crate::trim::nudge(
s,
&json!({"frames": -5})
)),
cmd!("trim.forwardMany", "Trim Forward Many", [], Some("Alt+Shift+Right"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": 5}))),
cmd!("trim.backwardMany", "Trim Backward Many", [], Some("Alt+Shift+Left"), "{}", has_edit_points, |s, _| {
let n = -(s.prefs.trim.large_trim_offset as i64);
crate::trim::nudge(s, &json!({"frames": n}))
}),
cmd!("trim.forwardMany", "Trim Forward Many", [], Some("Alt+Shift+Right"), "{}", has_edit_points, |s, _| {
let n = s.prefs.trim.large_trim_offset as i64;
crate::trim::nudge(s, &json!({"frames": n}))
}),
cmd!("trim.applyDefaultTransition", "Apply Default Transitions to Selection", ["Sequence"], None, "{}", has_edit_points, |s, _| {
crate::trim::apply_default_transitions(s)
}),
cmd!("trim.shuttle", "Dynamic Trim (Shuttle)", [], None, r#"{"direction":"forward|reverse","slow":bool?,"clock":seconds}"#, has_edit_points, |s, p| {
crate::trim::shuttle(s, p)
}),
cmd!("trim.shuttleStop", "Dynamic Trim Stop", [], None, r#"{"clock":seconds?}"#, has_seq, |s, p| crate::trim::stop(s, p)),
cmd!("trim.cancelDynamic", "Cancel Dynamic Trim", [], None, "{}", has_seq, |s, _| crate::trim::cancel(s)),
cmd!("trim.playAround", "Play Around Edit", [], None, r#"{"clock":seconds,"loop":bool=true,"toggle":bool?}"#, has_seq, |s, p| {
crate::trim::play_around(s, p)
}),
CommandSpec {
id: "trim.tick",
label: "Advance Trim Playback",
menu: &[],
shortcut: None,
params: r#"{"clock":seconds}"#,
enabled: has_seq,
run: |s, p| crate::trim::tick(s, p.get("clock").and_then(Value::as_f64).unwrap_or(0.0)),
journal: false,
},
query!("trim.monitor", "Trim Monitor State", "{}", |s, _| Ok(crate::trim::monitor_info(s))),
cmd!("trim.nudge", "Trim by Frames", [], None, r#"{"frames":i64}"#, has_edit_points, |s, p| crate::trim::nudge(s, p)),
cmd!("trim.extendToPlayhead", "Extend Selected Edit to Playhead", [], Some("E"), "{}", has_edit_points, |s, _| { crate::trim::extend_to_playhead(s) }),
cmd!("trim.ripplePrevious", "Ripple Trim Previous Edit to Playhead", [], Some("Q"), "{}", has_seq, |s, _| crate::trim::to_playhead(
@@ -1575,6 +1603,9 @@ fn build() -> Vec<CommandSpec> {
cmd!("effects.apply", "Apply Effect", [], None, r#"{"clips":[id]?,"effect":"gaussian_blur|Gaussian Blur|…"}"#, has_seq, |s, p| {
let name = str_p(p, "effect").ok_or_else(|| bad("effects.apply", "need `effect`"))?;
let def = filmcraft_project::effect::find_effect_by_name(name).ok_or_else(|| bad("effects.apply", format!("unknown effect `{name}`")))?;
if filmcraft_project::graphic::is_layer_id(def.id) {
return Err(bad("effects.apply", "graphic layers are added with graphics.newText / graphics.newShape"));
}
if matches!(def.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition) {
let mut q = p.clone();
q["effect"] = json!(def.id);
@@ -1795,7 +1826,10 @@ fn build() -> Vec<CommandSpec> {
Ok(Value::Array(
command_specs()
.iter()
.map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok()}))
.map(|c| {
let all: Vec<&str> = s.shortcuts.for_command(c.id).iter().map(|b| b.keys.as_str()).collect();
json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": s.shortcuts.primary(c.id), "shortcuts": all, "defaultShortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok()})
})
.collect(),
))
}),
@@ -1814,6 +1848,9 @@ fn build() -> Vec<CommandSpec> {
)),
];
v.extend(crate::captions::commands());
v.extend(crate::mixer::commands());
v.extend(crate::graphics::commands());
v.extend(crate::shortcuts::commands());
// Labels as individual commands (Edit ▸ Label ▸ <name>)
for l in Label::ALL {
let _ = l;
@@ -1982,7 +2019,7 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result<Value> {
Ok(Value::Null)
}
fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
Some(match template {
ParamValue::Float(_) => ParamValue::Float(v.as_f64()?),
ParamValue::Vec2(_) => {
+551
View File
@@ -0,0 +1,551 @@
//! Graphics commands (`graphics.*`, `fonts.list`): graphic clips with text and shape layers.
//!
//! A graphic clip's layers are the `graphic_text` / `graphic_shape` effect instances on the clip
//! (see `filmcraft_project::graphic`). Commands address a clip by `clip` (default: the first
//! selected graphic clip, else the topmost graphic clip under the playhead) and a layer by
//! `layer` = index among the clip's graphic layers, 0 = back (default: the selected layer, else
//! the frontmost). Property values are keyframe-aware: on an animated property they set the value
//! at the playhead.
use filmcraft_geom::Vec2;
use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer};
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, ParamValue, Sequence, TrackKind};
use filmcraft_render::graphic_clip::{layer_local_bounds, layer_quad};
use filmcraft_time::{Tick, TimeRange};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, f64_p, has_seq, str_p, time_p, u64_p};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
type Enabled = fn(&Session) -> std::result::Result<(), String>;
fn spec(
id: &'static str,
label: &'static str,
menu: &'static [&'static str],
shortcut: Option<&'static str>,
params: &'static str,
enabled: Enabled,
run: Run,
) -> CommandSpec {
CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true }
}
fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) -> CommandSpec {
CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: false }
}
/// Whether a track item is a graphic clip.
pub fn is_graphic(s: &Session, seq: &Sequence, c: ClipId) -> bool {
seq.find_item(c).and_then(|(_, it)| s.project.item(it.item)).is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. }))
}
/// The graphic clip a command acts on: `clip`, else the first selected graphic clip, else the
/// topmost graphic clip under the playhead.
pub fn target_clip(s: &Session, p: &Value) -> Option<ClipId> {
let seq = s.active_sequence()?;
if let Some(c) = u64_p(p, "clip").map(ClipId) {
return is_graphic(s, seq, c).then_some(c);
}
if let Some(c) = s.state.selection.iter().copied().find(|c| is_graphic(s, seq, *c)) {
return Some(c);
}
let t = s.playhead();
seq.video_tracks.iter().rev().filter_map(|tr| tr.item_at(t)).map(|it| it.id).find(|c| is_graphic(s, seq, *c))
}
fn has_graphic(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
target_clip(s, &Value::Null).map(|_| ()).ok_or_else(|| "select a graphic clip".into())
}
/// Effect index of graphic layer `layer` (or the selected / frontmost layer) of `clip`.
fn layer_effect_index(s: &Session, clip: ClipId, p: &Value) -> Result<(usize, usize)> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics", "no such clip"))?;
let idx = layer_indices(&it.effects);
if idx.is_empty() {
return Err(bad("graphics", "the graphic has no layers"));
}
let l = match p.get("layer") {
Some(Value::Number(n)) => n.as_u64().unwrap_or(0) as usize,
Some(Value::String(name)) => (0..idx.len())
.find(|&i| layer_display_name(&it.effects[idx[i]], i).eq_ignore_ascii_case(name))
.ok_or_else(|| bad("graphics", format!("no layer named `{name}`")))?,
_ => s.state.graphic_layers.first().copied().filter(|l| *l < idx.len()).unwrap_or(idx.len() - 1),
};
let e = *idx.get(l).ok_or_else(|| bad("graphics", format!("no layer {l}")))?;
Ok((l, e))
}
fn vec2_p(p: &Value, k: &str) -> Option<Vec2> {
let a = p.get(k)?.as_array()?;
Some(Vec2::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
/// Find or create the graphic source item for the active sequence's frame size.
fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32, rate: filmcraft_time::FrameRate) -> ItemId {
if let Some((id, _)) =
p.items.iter().find(|(_, i)| matches!(i.kind, ItemKind::Graphic { width, height, rate: r } if width == w && height == h && r == rate))
{
return *id;
}
p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: w, height: h, rate }, None)
}
/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let t = time_p(s, p, "").unwrap_or_else(|| s.playhead());
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
let want_track = u64_p(p, "track").map(|v| v as usize);
let name = name.to_string();
s.edit("New Graphic", |pr, st| {
let (w, h, rate) = {
let q = pr.sequence(seq_id).ok_or(EngineError::NoSequence)?;
(q.settings.width, q.settings.height, q.settings.frame_rate)
};
let src = graphic_source(pr, w, h, rate);
let t = rate.snap(t);
let dur = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration());
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
ti.name = name;
ti.effects.push(layer);
for e in &mut ti.effects {
filmcraft_project::resolve_auto_points(e, (w, h), (w, h));
}
let id = ti.id;
let track_id = filmcraft_project::TrackId(pr.alloc_id());
let q = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
let range = TimeRange::new(t, dur);
let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range));
let idx = match want_track {
Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i,
Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))),
_ => {
let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1);
match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) {
Some(i) => i,
None => {
let n = q.video_tracks.len() + 1;
q.video_tracks.push(filmcraft_project::Track::new(track_id, TrackKind::Video, format!("Video {n}")));
q.video_tracks.len() - 1
}
}
}
};
q.video_tracks[idx].items.push(ti);
q.video_tracks[idx].sort();
q.check().map_err(EngineError::Other)?;
st.selection = vec![id];
st.graphic_layers = vec![0];
Ok(id)
})
}
/// Add a layer to an existing graphic clip; returns the new layer index.
fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::EffectInstance) -> Result<usize> {
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
filmcraft_project::resolve_auto_points(&mut layer, frame, frame);
s.edit_sequence("Add Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
// new layers go in front: after the last graphic layer
let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1);
it.effects.insert(pos, layer);
let n = layer_indices(&it.effects).len() - 1;
st.selection = vec![clip];
st.graphic_layers = vec![n];
Ok(n)
})
}
/// Map friendly property names to parameter ids.
fn param_id(k: &str) -> &str {
match k {
"fontSize" => "size",
"fontStyle" | "style" => "font_style",
"fillColor" | "color" => "fill_color",
"strokeColor" => "stroke_color",
"strokeWidth" => "stroke_width",
"backgroundColor" => "background_color",
"shadowColor" => "shadow_color",
"baselineShift" => "baseline_shift",
"fauxBold" => "faux_bold",
"fauxItalic" => "faux_italic",
"boxWidth" => "box_width",
"anchorPoint" => "anchor",
"cornerRadius" => "corner_radius",
other => other,
}
}
/// JSON value for a property, accepting names for choices ("center", "small caps", "ellipse"…).
fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<ParamValue> {
if let (ParamValue::Choice(_), Value::String(name)) = (template, v) {
let opts: &[&str] = match id {
"align" => graphic::ALIGN_OPTS,
"caps" => graphic::CAPS_OPTS,
"stroke_type" | "stroke2_type" => graphic::STROKE_OPTS,
"shape" => SHAPE_OPTS,
_ => &[],
};
let n = name.to_ascii_lowercase().replace('_', " ");
let n = if n == "centre" { "center".to_string() } else { n };
return opts.iter().position(|o| o.to_ascii_lowercase() == n).map(|i| ParamValue::Choice(i as u32));
}
crate::commands::json_to_param(template, v)
}
/// Set properties `props` on a layer (keyframe-aware at time `tl`).
fn set_props(s: &mut Session, clip: ClipId, eidx: usize, props: &serde_json::Map<String, Value>, tl: Tick, label: &str) -> Result<()> {
let props = props.clone();
s.edit_sequence(label, |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 = it.effects.get_mut(eidx).ok_or_else(|| bad("graphics.set", "no such layer"))?;
for (k, v) in &props {
if k == "enabled" {
e.enabled = v.as_bool().unwrap_or(true);
continue;
}
let id = param_id(k);
let prm = e.params.get_mut(id).ok_or_else(|| bad("graphics.set", format!("no property `{k}`")))?;
let pv = to_param(&prm.value, id, v).ok_or_else(|| bad("graphics.set", format!("`{k}`: value has the wrong type")))?;
prm.set_at(mt, pv);
}
Ok(())
})
}
/// Layers of a graphic clip with their evaluated bounds (sequence/canvas pixels).
fn list_layers(s: &Session, clip: ClipId) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics.list", "no such clip"))?;
let size = match s.project.item(it.item).map(|p| &p.kind) {
Some(ItemKind::Graphic { width, height, .. }) => (*width, *height),
_ => (seq.settings.width, seq.settings.height),
};
let ph = s.playhead();
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
let layers: Vec<Value> = layer_indices(&it.effects)
.iter()
.enumerate()
.filter_map(|(i, &ei)| {
let e = &it.effects[ei];
let sp = eval_layer(e, mt, size)?;
let q = layer_quad(&sp);
let (kind, text) = match &sp.content {
LayerContent::Text(t) => ("text", Some(t.text.clone())),
LayerContent::Shape(sh) => (SHAPE_OPTS.get(sh.shape as usize).copied().unwrap_or("Shape"), None),
};
Some(json!({
"layer": i,
"effectIndex": ei,
"name": layer_display_name(e, i),
"kind": kind,
"text": text,
"enabled": e.enabled,
"position": [sp.transform.position.x, sp.transform.position.y],
"localBounds": layer_local_bounds(&sp),
"quad": q.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>(),
}))
})
.collect();
Ok(json!({"clip": clip.0, "name": it.name, "start": it.start.0, "duration": it.duration.0, "canvas": [size.0, size.1], "layers": layers}))
}
fn quad_bounds(q: &[Vec2; 4]) -> [f64; 4] {
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
for p in q {
b = [b[0].min(p.x), b[1].min(p.y), b[2].max(p.x), b[3].max(p.y)];
}
b
}
/// Layer bounds of `layers` (layer indices) of `clip` at the playhead: (effect index, position, bounds).
fn layer_boxes(s: &Session, clip: ClipId, layers: &[usize]) -> Result<(Vec<(usize, Vec2, [f64; 4])>, (u32, u32))> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics.align", "no such clip"))?;
let size = match s.project.item(it.item).map(|p| &p.kind) {
Some(ItemKind::Graphic { width, height, .. }) => (*width, *height),
_ => (seq.settings.width, seq.settings.height),
};
let mt = it.source_time_at(s.playhead().clamp(it.start, it.end() - Tick(1)));
let idx = layer_indices(&it.effects);
let mut out = Vec::new();
for &l in layers {
let ei = *idx.get(l).ok_or_else(|| bad("graphics.align", format!("no layer {l}")))?;
let sp = eval_layer(&it.effects[ei], mt, size).ok_or_else(|| bad("graphics.align", "bad layer"))?;
out.push((ei, sp.transform.position, quad_bounds(&layer_quad(&sp))));
}
Ok((out, size))
}
fn layers_p(s: &Session, clip: ClipId, p: &Value) -> Result<Vec<usize>> {
if let Some(a) = p.get("layers").and_then(Value::as_array) {
return Ok(a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect());
}
if !s.state.graphic_layers.is_empty() {
return Ok(s.state.graphic_layers.clone());
}
let (l, _) = layer_effect_index(s, clip, p)?;
Ok(vec![l])
}
fn move_layers(s: &mut Session, clip: ClipId, moves: Vec<(usize, Vec2)>, label: &str) -> Result<()> {
let tl = s.playhead();
s.edit_sequence(label, |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)));
for (ei, pos) in &moves {
if let Some(prm) = it.effects.get_mut(*ei).and_then(|e| e.params.get_mut("position")) {
prm.set_at(mt, ParamValue::Vec2(*pos));
}
}
Ok(())
})
}
pub fn commands() -> Vec<CommandSpec> {
vec![
spec(
"graphics.newText",
"Text",
&["Graphics and Titles", "New Layer"],
Some("Cmd+T"),
r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"newClip":bool?,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
has_seq,
|s, p| {
let text = str_p(p, "text").unwrap_or("New Text").to_string();
let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0));
let mut layer = new_text_layer(&text, pos, f64_p(p, "size").unwrap_or(100.0));
for (k, id) in [("font", "font"), ("fontStyle", "font_style")] {
if let Some(v) = str_p(p, k) {
layer.params.insert(id.into(), filmcraft_project::Param::new(ParamValue::Text(v.into())));
}
}
let into = if p.get("newClip").and_then(Value::as_bool) == Some(true) { None } else { u64_p(p, "clip").map(ClipId) };
let into = into.filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
let (clip, layer_i) = match into {
Some(c) => (c, add_layer(s, c, layer)?),
None => {
let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string();
(new_graphic_clip(s, layer, &name, p)?, 0)
}
};
Ok(json!({"clip": clip.0, "layer": layer_i}))
},
),
spec(
"graphics.newShape",
"Shape",
&["Graphics and Titles", "New Layer"],
None,
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#,
has_seq,
|s, p| {
let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase();
let k = SHAPE_OPTS
.iter()
.position(|o| o.to_ascii_lowercase() == shape)
.ok_or_else(|| bad("graphics.newShape", format!("unknown shape `{shape}`")))? as u32;
let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0));
let size = vec2_p(p, "size").unwrap_or(Vec2::new(400.0, 200.0));
let points: Vec<[f32; 2]> = p
.get("points")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|q| Some([q.get(0)?.as_f64()? as f32, q.get(1)?.as_f64()? as f32])).collect())
.unwrap_or_default();
if k == 3 && points.len() < 3 {
return Err(bad("graphics.newShape", "a path needs at least 3 points"));
}
let layer = new_shape_layer(k, pos, size, points);
let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
let (clip, layer_i) = match into {
Some(c) => (c, add_layer(s, c, layer)?),
None => (new_graphic_clip(s, layer, "Shape", p)?, 0),
};
Ok(json!({"clip": clip.0, "layer": layer_i}))
},
),
spec(
"graphics.setText",
"Edit Text",
&[],
None,
r#"{"clip":id?,"layer":n|name?,"text":str,"merge":bool? (coalesce with the previous Edit Text undo step)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.setText", "no graphic clip"))?;
let (_, ei) = layer_effect_index(s, clip, p)?;
let text = str_p(p, "text").ok_or_else(|| bad("graphics.setText", "need `text`"))?.to_string();
let merge = p.get("merge").and_then(Value::as_bool).unwrap_or(false) && s.history.undo.last().is_some_and(|h| h.0 == "Edit Text");
let mut props = serde_json::Map::new();
props.insert("text".into(), Value::String(text));
let ph = s.playhead();
set_props(s, clip, ei, &props, ph, "Edit Text")?;
if merge && s.history.undo.len() >= 2 {
// keep the snapshot from before the typing session
s.history.undo.pop();
}
Ok(Value::Null)
},
),
spec(
"graphics.set",
"Set Graphic Properties",
&[],
None,
r##"{"clip":id?,"layer":n|name?,"props":{"font":"Inter","font_style":"Bold","size":120,"align":"center","tracking":50,"leading":0,"fill_color":"#ffcc00","stroke":true,"stroke_width":6,"background":true,"shadow":true,"position":[x,y],"scale":100,"rotation":0,"opacity":100,…},"time":ticks?}"##,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.set", "no graphic clip"))?;
let (_, ei) = layer_effect_index(s, clip, p)?;
let props = p.get("props").and_then(Value::as_object).ok_or_else(|| bad("graphics.set", "need `props`"))?.clone();
let tl = time_p(s, p, "").unwrap_or_else(|| s.playhead());
set_props(s, clip, ei, &props, tl, "Change Graphic Property")?;
Ok(Value::Null)
},
),
spec("graphics.selectLayer", "Select Graphic Layer", &[], None, r#"{"clip":id?,"layers":[n]}"#, has_graphic, |s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.selectLayer", "no graphic clip"))?;
let layers: Vec<usize> =
p.get("layers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()).unwrap_or_default();
s.state.selection = vec![clip];
s.state.graphic_layers = layers;
Ok(Value::Null)
}),
spec("graphics.deleteLayer", "Delete Graphic Layer", &[], None, r#"{"clip":id?,"layer":n?}"#, has_graphic, |s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.deleteLayer", "no graphic clip"))?;
let (_, ei) = layer_effect_index(s, clip, p)?;
s.edit_sequence("Delete Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
it.effects.remove(ei);
st.graphic_layers.clear();
Ok(())
})?;
Ok(Value::Null)
}),
spec(
"graphics.arrangeLayer",
"Arrange Graphic Layer",
&[],
None,
r#"{"clip":id?,"layer":n?,"to":"front|back|forward|backward"|index}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.arrangeLayer", "no graphic clip"))?;
let (l, _) = layer_effect_index(s, clip, p)?;
let to = p.get("to").cloned().unwrap_or(json!("front"));
let n = s.state.graphic_layers.len();
let _ = n;
s.edit_sequence("Arrange Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let idx = layer_indices(&it.effects);
let last = idx.len() - 1;
let dest = match &to {
Value::Number(v) => (v.as_u64().unwrap_or(0) as usize).min(last),
Value::String(w) => match w.as_str() {
"front" => last,
"back" => 0,
"forward" => (l + 1).min(last),
"backward" => l.saturating_sub(1),
o => return Err(bad("graphics.arrangeLayer", format!("unknown `to` `{o}`"))),
},
_ => last,
};
let e = it.effects.remove(idx[l]);
let mut idx2 = layer_indices(&it.effects);
let insert_at = if dest >= idx2.len() { idx2.pop().map_or(idx[0], |x| x + 1) } else { idx2[dest] };
it.effects.insert(insert_at, e);
st.graphic_layers = vec![dest];
Ok(())
})?;
Ok(Value::Null)
},
),
spec(
"graphics.align",
"Align Layers",
&[],
None,
r#"{"clip":id?,"layers":[n]?,"align":"left|hcenter|right|top|vcenter|bottom","to":"frame|selection"="frame" (one layer always aligns to the frame)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (boxes, size) = layer_boxes(s, clip, &layers)?;
let how = str_p(p, "align").ok_or_else(|| bad("graphics.align", "need `align`"))?;
let to_sel = str_p(p, "to") == Some("selection") && boxes.len() > 1;
let r = if to_sel {
boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])])
} else {
[0.0, 0.0, size.0 as f64, size.1 as f64]
};
let mut moves = Vec::new();
for (ei, pos, b) in boxes {
let (dx, dy) = match how {
"left" => (r[0] - b[0], 0.0),
"right" => (r[2] - b[2], 0.0),
"hcenter" | "center" => ((r[0] + r[2]) / 2.0 - (b[0] + b[2]) / 2.0, 0.0),
"top" => (0.0, r[1] - b[1]),
"bottom" => (0.0, r[3] - b[3]),
"vcenter" | "middle" => (0.0, (r[1] + r[3]) / 2.0 - (b[1] + b[3]) / 2.0),
o => return Err(bad("graphics.align", format!("unknown alignment `{o}`"))),
};
moves.push((ei, Vec2::new(pos.x + dx, pos.y + dy)));
}
move_layers(s, clip, moves, "Align Layers")?;
Ok(Value::Null)
},
),
spec(
"graphics.distribute",
"Distribute Layers",
&[],
None,
r#"{"clip":id?,"layers":[n] (3 or more)?,"axis":"horizontal|vertical"}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.distribute", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (mut boxes, _) = layer_boxes(s, clip, &layers)?;
if boxes.len() < 3 {
return Err(bad("graphics.distribute", "select three or more layers"));
}
let vertical = str_p(p, "axis") == Some("vertical");
let c = |b: &[f64; 4]| if vertical { (b[1] + b[3]) / 2.0 } else { (b[0] + b[2]) / 2.0 };
boxes.sort_by(|a, b| c(&a.2).total_cmp(&c(&b.2)));
let (first, last) = (c(&boxes[0].2), c(&boxes[boxes.len() - 1].2));
let n = boxes.len() - 1;
let moves = boxes
.iter()
.enumerate()
.map(|(i, (ei, pos, b))| {
let d = first + (last - first) * i as f64 / n as f64 - c(b);
(*ei, if vertical { Vec2::new(pos.x, pos.y + d) } else { Vec2::new(pos.x + d, pos.y) })
})
.collect();
move_layers(s, clip, moves, "Distribute Layers")?;
Ok(Value::Null)
},
),
query("graphics.list", "List Graphic Layers", r#"{"clip":id?}"#, |s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.list", "no graphic clip"))?;
list_layers(s, clip)
}),
query("fonts.list", "List Fonts", r#"{"system":bool=true (scan the system font folders)}"#, |_, p| {
if p.get("system").and_then(Value::as_bool).unwrap_or(true) {
filmcraft_text::fonts::scan_system();
}
Ok(json!(filmcraft_text::families().into_iter().map(|(f, st)| json!({"family": f, "styles": st})).collect::<Vec<_>>()))
}),
]
}
#[cfg(test)]
#[path = "graphics_tests.rs"]
mod tests;
+130
View File
@@ -0,0 +1,130 @@
use serde_json::json;
use crate::Session;
use filmcraft_project::graphic::{LayerContent, eval_layer, layer_indices};
use filmcraft_project::{ClipId, ItemKind};
use filmcraft_time::Tick;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn layers(s: &Session, clip: ClipId) -> Vec<filmcraft_project::graphic::LayerSpec> {
let q = s.active_sequence().unwrap();
let (_, it) = q.find_item(clip).unwrap();
layer_indices(&it.effects).into_iter().filter_map(|i| eval_layer(&it.effects[i], Tick::ZERO, (q.settings.width, q.settings.height))).collect()
}
fn text_of(l: &filmcraft_project::graphic::LayerSpec) -> String {
match &l.content {
LayerContent::Text(t) => t.text.clone(),
_ => panic!("not text"),
}
}
#[test]
fn new_text_makes_a_graphic_clip_above_the_footage() {
let mut s = demo();
let t = s.playhead();
let r = s.execute("graphics.newText", json!({"text": "Hello", "position": [200, 300]})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
let q = s.active_sequence().unwrap();
let (tid, it) = q.find_item(clip).unwrap();
let track_index = q.video_tracks.iter().position(|tr| tr.id == tid).unwrap();
let below_busy = q.video_tracks[..track_index].iter().any(|tr| tr.item_at(t).is_some());
assert!(below_busy || track_index == 0, "placed above the clip under the playhead");
assert_eq!(it.name, "Hello");
assert!(matches!(s.project.item(it.item).unwrap().kind, ItemKind::Graphic { .. }));
assert_eq!(it.duration, q.settings.frame_rate.snap_nearest(Tick::from_seconds_f64(5.0)));
assert!(!it.has_standard_effects(), "layers are not fx");
assert_eq!(s.state.selection, vec![clip]);
let ls = layers(&s, clip);
assert_eq!(ls.len(), 1);
assert_eq!(text_of(&ls[0]), "Hello");
assert_eq!((ls[0].transform.position.x, ls[0].transform.position.y), (200.0, 300.0));
// a second text layer in the same clip, then a shape
let r2 = s.execute("graphics.newText", json!({"text": "World", "clip": clip.0})).unwrap();
assert_eq!(r2["layer"], 1);
s.execute("graphics.newShape", json!({"shape": "ellipse", "clip": clip.0, "size": [100, 50]})).unwrap();
assert_eq!(layers(&s, clip).len(), 3);
let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap();
assert_eq!(list["layers"][2]["kind"], "Ellipse");
assert_eq!(list["layers"][1]["name"], "World");
// undo removes the shape layer again
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layers(&s, clip).len(), 2);
}
#[test]
fn typing_coalesces_into_one_undo_step_and_props_set() {
let mut s = demo();
let r = s.execute("graphics.newText", json!({"text": ""})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
let before = s.history.undo.len();
for (i, t) in ["T", "Ti", "Tit", "Titl", "Title"].iter().enumerate() {
s.execute("graphics.setText", json!({"clip": clip.0, "layer": 0, "text": t, "merge": i > 0})).unwrap();
}
assert_eq!(s.history.undo.len(), before + 1, "one undo step for the typing session");
assert_eq!(text_of(&layers(&s, clip)[0]), "Title");
s.execute("graphics.set", json!({"clip": clip.0, "props": {"font_style": "Bold", "fontSize": 140, "align": "center", "fill_color": "#ffcc00", "stroke": true, "caps": "small caps"}})).unwrap();
let l = &layers(&s, clip)[0];
let LayerContent::Text(t) = &l.content else { panic!() };
assert_eq!((t.style.as_str(), t.size, t.align, t.caps), ("Bold", 140.0, 1, 2));
assert_eq!(l.appearance.strokes.len(), 1);
assert!((l.appearance.fill.unwrap()[1] - 0.8).abs() < 0.01);
assert!(s.execute("graphics.set", json!({"clip": clip.0, "props": {"nope": 1}})).is_err());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(text_of(&layers(&s, clip)[0]), "");
}
#[test]
fn align_and_distribute_layers() {
let mut s = demo();
let (w, h) = {
let q = s.active_sequence().unwrap();
(q.settings.width as f64, q.settings.height as f64)
};
let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [300, 200], "size": [100, 60]})).unwrap();
let clip = r["clip"].as_u64().unwrap();
s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip, "position": [500, 400], "size": [40, 40]})).unwrap();
s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip, "position": [1500, 900], "size": [40, 40]})).unwrap();
s.execute("graphics.align", json!({"clip": clip, "layers": [0], "align": "left"})).unwrap();
s.execute("graphics.align", json!({"clip": clip, "layers": [0], "align": "bottom"})).unwrap();
let l = layers(&s, ClipId(clip));
assert!((l[0].transform.position.x - 50.0).abs() < 1e-6, "left edge on the frame edge");
assert!((l[0].transform.position.y - (h - 30.0)).abs() < 1e-6);
s.execute("graphics.align", json!({"clip": clip, "layers": [1, 2], "align": "vcenter", "to": "selection"})).unwrap();
let l = layers(&s, ClipId(clip));
assert!((l[1].transform.position.y - l[2].transform.position.y).abs() < 1e-6);
s.execute("graphics.distribute", json!({"clip": clip, "layers": [0, 1, 2], "axis": "horizontal"})).unwrap();
let l = layers(&s, ClipId(clip));
let xs: Vec<f64> = l.iter().map(|x| x.transform.position.x).collect();
assert!(((xs[1] - xs[0]) - (xs[2] - xs[1])).abs() < 1e-6, "{xs:?}");
let _ = w;
}
#[test]
fn graphic_renders_in_the_program_and_survives_save() {
let mut s = demo();
let r = s.execute("graphics.newText", json!({"text": "BIG", "size": 300, "position": [100, 500]})).unwrap();
let clip = r["clip"].as_u64().unwrap();
s.execute("graphics.set", json!({"clip": clip, "props": {"fill_color": "#ff0000"}})).unwrap();
let img = s.render_program(0.25).unwrap();
let red = img.px.chunks(4).filter(|p| p[0] > 0.9 && p[1] < 0.05 && p[2] < 0.05).count();
assert!(red > 200, "red text in the program: {red}");
let dir = std::env::temp_dir().join(format!("fc-gfx-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("g.fcproj");
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s2.project.items, s.project.items, "graphic clips round-trip");
let _ = std::fs::remove_dir_all(&dir);
let fonts = s.execute("fonts.list", json!({"system": false})).unwrap();
assert!(fonts.as_array().unwrap().iter().any(|f| f["family"] == "Inter"));
}
+76 -1
View File
@@ -12,9 +12,13 @@ pub mod autosave;
pub mod captions;
pub mod commands;
pub mod demo;
pub mod graphics;
pub mod interchange;
pub mod media_pool;
pub mod mixer;
pub mod previews;
pub mod shortcut_presets;
pub mod shortcuts;
pub mod trim;
use std::sync::Arc;
@@ -77,6 +81,9 @@ pub struct History {
pub redo: Vec<(String, Arc<Project>)>,
/// Labels of all applied states, oldest first (History panel).
pub limit: usize,
/// Key of the last [`Session::edit_merged`] step: a continuous gesture (a fader drag) with the
/// same key folds into that one undo step.
pub merge_key: Option<String>,
}
impl History {
@@ -124,9 +131,15 @@ pub struct EditorState {
/// Selected edit points (trim mode).
#[serde(default)]
pub edit_points: Vec<trim::EditPoint>,
/// Trim Monitor Out/In shift counters for the selected edit point.
#[serde(default)]
pub trim_shift: trim::TrimShift,
/// Selected captions (caption tracks / Captions panel).
#[serde(default)]
pub caption_selection: Vec<ClipId>,
/// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip.
#[serde(default)]
pub graphic_layers: Vec<usize>,
}
/// Events for frontends (drained each frame).
@@ -162,6 +175,14 @@ pub struct Session {
pub loaded_schema: u32,
/// Render preview files + render-bar segments (shared with the frontend's frame workers).
pub previews: Arc<previews::PreviewStore>,
/// Audio Track Mixer automation pass in progress.
pub mixrec: mixer::Recorder,
/// Dynamic (J/K/L) trimming and trim-mode loop playback in progress.
pub trim_play: trim::TrimPlayback,
/// Keyboard shortcuts (active bindings, presets; `shortcuts.*` commands).
pub shortcuts: shortcuts::Shortcuts,
/// Nesting depth of [`Session::execute`] (commands that run other commands).
exec_depth: u32,
}
/// A background job with shared progress.
@@ -221,6 +242,10 @@ impl Session {
persistence: None,
loaded_schema: filmcraft_format::SCHEMA_VERSION,
previews: Arc::new(previews::PreviewStore::temp()),
mixrec: Default::default(),
trim_play: Default::default(),
shortcuts: shortcuts::Shortcuts::new(),
exec_depth: 0,
}
}
@@ -230,6 +255,7 @@ impl Session {
pub fn start_autosave(&mut self, cfg: autosave::AutosaveConfig) -> std::io::Result<()> {
let prefs_path = cfg.data_dir.join("preferences.json");
self.prefs = autosave::Preferences::load(&prefs_path);
self.shortcuts.set_dir(&cfg.data_dir);
self.prefs_path = Some(prefs_path);
self.persistence = Some(autosave::Persistence::start(&cfg, self.prefs.auto_save.clone())?);
self.sync_persistence();
@@ -311,9 +337,16 @@ impl Session {
/// Run a command by id.
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value> {
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if self.exec_depth == 0 && spec.journal && self.trim_play.active() {
self.settle_trim_playback(id);
}
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
self.exec_depth += 1;
let r = (spec.run)(self, &params);
self.exec_depth -= 1;
self.sync_persistence();
// playback reads the newest snapshot (mixer moves, mutes… are heard while playing)
self.previews.live.publish_project(self.project.clone());
if r.is_ok() && spec.journal {
self.journal.push((id.to_string(), params));
if self.journal.len() > 10_000 {
@@ -323,6 +356,21 @@ impl Session {
r
}
/// Another command arrives while trim-mode playback runs: a dynamic trim is committed first
/// (so e.g. Undo undoes it as one step); loop playback stops unless it is a trim command.
fn settle_trim_playback(&mut self, id: &str) {
const LIVE: [&str; 5] = ["trim.shuttle", "trim.tick", "trim.shuttleStop", "trim.cancelDynamic", "trim.playAround"];
if LIVE.contains(&id) {
return;
}
if self.trim_play.dynamic.is_some() {
trim::commit(self);
}
if !id.starts_with("trim.") {
self.trim_play.around = None;
}
}
pub fn is_enabled(&self, id: &str) -> bool {
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
}
@@ -338,6 +386,25 @@ impl Session {
self.history.undo.remove(0);
}
self.history.redo.clear();
self.history.merge_key = None;
self.state = st;
self.bump();
Ok(r)
}
/// Like [`Session::edit`], but consecutive calls with the same `key` (and no other edit in
/// between) share one undo step: dragging a control is one undoable change.
pub fn edit_merged<R>(&mut self, label: &str, key: &str, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<R>) -> Result<R> {
let merge = self.history.merge_key.as_deref() == Some(key) && self.history.undo.last().is_some_and(|u| u.0 == label);
if !merge {
let r = self.edit(label, f)?;
self.history.merge_key = Some(key.to_string());
return Ok(r);
}
let mut p = (*self.project).clone();
let mut st = self.state.clone();
let r = f(&mut p, &mut st)?;
self.project = Arc::new(p);
self.state = st;
self.bump();
Ok(r)
@@ -367,6 +434,7 @@ impl Session {
pub fn undo(&mut self) -> Option<String> {
let (label, prev) = self.history.undo.pop()?;
self.history.merge_key = None;
let cur = std::mem::replace(&mut self.project, prev);
self.history.redo.push((label.clone(), cur));
self.fix_state();
@@ -376,6 +444,7 @@ impl Session {
pub fn redo(&mut self) -> Option<String> {
let (label, next) = self.history.redo.pop()?;
self.history.merge_key = None;
let cur = std::mem::replace(&mut self.project, next);
self.history.undo.push((label.clone(), cur));
self.fix_state();
@@ -479,7 +548,7 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
},
filmcraft_project::ItemKind::Sequence(s) => Some(s.duration()),
filmcraft_project::ItemKind::Subclip { range, .. } => Some(range.end()),
filmcraft_project::ItemKind::AdjustmentLayer { .. } => None,
filmcraft_project::ItemKind::AdjustmentLayer { .. } | filmcraft_project::ItemKind::Graphic { .. } => None,
}
}
@@ -488,6 +557,12 @@ mod autosave_tests;
#[cfg(test)]
mod file_tests;
#[cfg(test)]
mod mixer_tests;
#[cfg(test)]
mod previews_tests;
#[cfg(test)]
mod shortcuts_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod trim_tests;
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
//! Audio Track Mixer commands and automation recording.
use super::*;
use filmcraft_project::mixer::{LANE_VOLUME, MASTER_STRIP};
use filmcraft_project::{AutomationMode, ParamValue};
use filmcraft_time::TICKS_PER_SECOND;
use serde_json::json;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn sec(x: f64) -> i64 {
(x * TICKS_PER_SECOND as f64) as i64
}
fn a1(s: &Session) -> &filmcraft_project::Track {
&s.active_sequence().unwrap().audio_tracks[0]
}
fn vol_at(s: &Session, x: f64) -> f64 {
a1(s).lane_value(LANE_VOLUME, Tick(sec(x)))
}
fn set_mode(s: &mut Session, strip: &str, mode: &str) {
s.execute("mixer.setStrip", json!({"strip": strip, "mode": mode})).unwrap();
}
#[test]
fn every_mixer_command_is_registered_with_a_label() {
for id in [
"mixer.inspect",
"mixer.setStrip",
"mixer.setValue",
"mixer.touch",
"mixer.release",
"mixer.recordStart",
"mixer.recordStop",
"mixer.addSubmix",
"mixer.deleteSubmix",
"mixer.addInsert",
"mixer.removeInsert",
"mixer.setInsert",
"mixer.addSend",
"mixer.setSend",
"mixer.removeSend",
"mixer.setKeyframe",
"mixer.deleteKeyframe",
"mixer.moveKeyframe",
"mixer.clearLane",
"mixer.writeAutomation",
] {
let c = commands::find(id).unwrap_or_else(|| panic!("{id} missing"));
assert!(!c.label.is_empty() && !c.params.is_empty());
}
let s = Session::default();
assert!(s.clone_disabled("mixer.setStrip"), "needs a sequence");
}
impl Session {
fn clone_disabled(&self, id: &str) -> bool {
!self.is_enabled(id)
}
}
#[test]
fn strip_settings_undo_redo_and_inspect() {
let mut s = demo();
s.execute("mixer.setStrip", json!({"strip": "A1", "volumeDb": -6.0, "pan": -30.0, "mode": "Touch", "recordArm": true, "solo": true})).unwrap();
let t = a1(&s);
assert_eq!((t.volume_db, t.pan, t.mixer.mode, t.mixer.record_arm, t.solo), (-6.0, -30.0, AutomationMode::Touch, true, true));
s.execute("mixer.setStrip", json!({"strip": "Mix", "volumeDb": -3.0})).unwrap();
assert_eq!(s.active_sequence().unwrap().master_volume_db, -3.0);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(a1(&s).volume_db, 0.0);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(a1(&s).volume_db, -6.0);
let v = s.execute("mixer.inspect", json!({})).unwrap();
let strips = v["strips"].as_array().unwrap();
assert_eq!(strips.last().unwrap()["ref"], "Mix");
assert_eq!(strips[0]["ref"], "A1");
assert_eq!(strips[0]["mode"], "Touch");
// errors
assert!(s.execute("mixer.setStrip", json!({"strip": "A99"})).is_err());
assert!(s.execute("mixer.setValue", json!({"strip": "Mix", "lane": "pan", "value": 3})).is_err(), "Mix has no pan");
}
#[test]
fn submixes_sends_and_inserts() {
let mut s = demo();
let r = s.execute("mixer.addSubmix", json!({"name": "Reverb"})).unwrap();
assert_eq!(r["ref"], "S1");
s.execute("mixer.addSubmix", json!({})).unwrap();
s.execute("mixer.addSend", json!({"strip": "A1", "target": "S1", "levelDb": -6.0, "preFader": true})).unwrap();
s.execute("mixer.addInsert", json!({"strip": "S1", "effect": "studio_reverb"})).unwrap();
s.execute("mixer.addInsert", json!({"strip": "A1", "effect": "dynamics", "postFader": true})).unwrap();
s.execute("mixer.setInsert", json!({"strip": "A1", "slot": 0, "params": {"threshold": -30.0}})).unwrap();
s.execute("mixer.setStrip", json!({"strip": "S1", "output": "S2"})).unwrap();
assert!(s.execute("mixer.setStrip", json!({"strip": "S2", "output": "S1"})).is_err(), "no feedback");
assert!(s.execute("mixer.addSend", json!({"strip": "S2", "target": "S1"})).is_err(), "no feedback");
assert!(s.execute("mixer.addInsert", json!({"strip": "A1", "effect": "gaussian_blur"})).is_err());
let q = s.active_sequence().unwrap();
assert_eq!(q.audio_tracks[0].mixer.sends[0].level_db, -6.0);
assert!(q.audio_tracks[0].effects[0].post_fader);
assert_eq!(q.audio_tracks[0].effects[0].param("threshold").unwrap().value, ParamValue::Float(-30.0));
// the mix renders through the graph (the demo has audio on A1)
let provider = s.media.provider(s.project.clone(), s.services.clone());
let b = filmcraft_render::audio::mix_sequence(&s.project, q, 0, 4_800, &provider);
assert_eq!(b.frames(), 4_800);
// deleting a submix re-routes and drops sends
let s1 = q.submix_tracks[0].id.0;
s.execute("mixer.deleteSubmix", json!({"strip": s1})).unwrap();
let q = s.active_sequence().unwrap();
assert!(q.audio_tracks[0].mixer.sends.is_empty());
assert_eq!(q.submix_tracks.len(), 1);
s.execute("mixer.removeInsert", json!({"strip": "A1", "slot": 0})).unwrap();
assert!(a1(&s).effects.is_empty());
}
#[test]
fn keyframes_by_command_and_pen_edits() {
let mut s = demo();
s.execute("mixer.setKeyframe", json!({"strip": "A1", "time": sec(1.0), "value": -10.0})).unwrap();
s.execute("mixer.setKeyframe", json!({"strip": "A1", "time": sec(2.0), "value": 0.0})).unwrap();
assert_eq!(vol_at(&s, 0.0), -10.0);
assert!((vol_at(&s, 1.5) + 5.0).abs() < 1e-9);
s.execute("mixer.moveKeyframe", json!({"strip": "A1", "time": sec(2.0), "newTime": sec(3.0), "value": -20.0})).unwrap();
assert!((vol_at(&s, 2.0) + 15.0).abs() < 1e-9);
s.execute("mixer.deleteKeyframe", json!({"strip": "A1", "time": sec(1.0)})).unwrap();
assert_eq!(vol_at(&s, 0.0), -20.0);
// a fader move on an automated lane (Read, stopped) adds a keyframe at the playhead
s.execute("playhead.set", json!({"seconds": 5.0})).unwrap();
s.execute("mixer.setValue", json!({"strip": "A1", "value": -3.0})).unwrap();
assert_eq!(vol_at(&s, 5.0), -3.0);
assert_eq!(a1(&s).lane_keyframes(LANE_VOLUME).len(), 2);
// mute keyframes hold
s.execute("mixer.setKeyframe", json!({"strip": "A1", "lane": "mute", "time": sec(1.0), "value": true})).unwrap();
s.execute("mixer.setKeyframe", json!({"strip": "A1", "lane": "mute", "time": sec(2.0), "value": false})).unwrap();
assert_eq!(a1(&s).lane_value("mute", Tick(sec(1.9))), 1.0);
s.execute("mixer.clearLane", json!({"strip": "A1", "lane": "volume"})).unwrap();
assert!(a1(&s).lane_keyframes(LANE_VOLUME).is_empty());
// effect parameter lanes
s.execute("mixer.addInsert", json!({"strip": "A1", "effect": "amplify"})).unwrap();
s.execute("mixer.setKeyframe", json!({"strip": "A1", "lane": "fx.0.gain", "time": sec(1.0), "value": 6.0})).unwrap();
assert!(a1(&s).automated_lanes().contains(&"fx.0.gain".to_string()));
}
/// Simulate a fader gesture: `(seconds, dB)` samples as the UI would send them each frame.
fn gesture(s: &mut Session, strip: &str, pts: &[(f64, f64)]) {
for &(t, v) in pts {
s.execute("mixer.touch", json!({"strip": strip, "lane": "volume", "value": v, "time": sec(t)})).unwrap();
}
}
#[test]
fn touch_records_while_held_and_returns_over_automatch() {
let mut s = demo();
set_mode(&mut s, "A1", "Touch");
s.execute("mixer.recordStart", json!({"time": 0})).unwrap();
assert!(s.execute("mixer.recordStart", json!({})).is_err(), "already recording");
// hold -10 dB from 1 s to 2 s (frames every 1/30 s)
let pts: Vec<(f64, f64)> = (0..=30).map(|i| (1.0 + i as f64 / 30.0, -10.0)).collect();
gesture(&mut s, "A1", &pts);
// the audio follows the fader while recording
assert_eq!(s.previews.live.get(a1(&s).id, LANE_VOLUME).map(|o| o.value), Some(-10.0));
s.execute("mixer.release", json!({"strip": "A1", "time": sec(2.0)})).unwrap();
let r = s.execute("mixer.recordStop", json!({"time": sec(5.0)})).unwrap();
assert_eq!(r["lanes"], 1);
assert!(!s.previews.live.is_active());
assert_eq!(vol_at(&s, 0.5), 0.0, "before the touch: unchanged");
assert_eq!(vol_at(&s, 1.5), -10.0);
assert!((vol_at(&s, 2.5) + 5.0).abs() < 0.01, "halfway back after 0.5 s of the 1 s automatch: {}", vol_at(&s, 2.5));
assert_eq!(vol_at(&s, 3.5), 0.0, "back to the old automation");
// thinned: a held value is a few keyframes, not 31
assert!(a1(&s).lane_keyframes(LANE_VOLUME).len() <= 5, "{:?}", a1(&s).lane_keyframes(LANE_VOLUME));
// one undo step removes the pass
s.execute("edit.undo", json!({})).unwrap();
assert!(a1(&s).lane_keyframes(LANE_VOLUME).is_empty());
}
#[test]
fn touch_over_existing_automation_and_custom_automatch() {
let mut s = demo();
s.execute("prefs.set", json!({"key": "audio.automatchTime", "value": 2.0})).unwrap();
s.execute("mixer.writeAutomation", json!({"strip": "A1", "points": [[0, -20.0], [sec(8.0), -20.0]]})).unwrap();
set_mode(&mut s, "A1", "Touch");
s.execute("mixer.recordStart", json!({"time": sec(0.5)})).unwrap();
gesture(&mut s, "A1", &[(1.0, 0.0), (1.5, 0.0)]);
s.execute("mixer.release", json!({"strip": "A1", "time": sec(1.5)})).unwrap();
s.execute("mixer.recordStop", json!({"time": sec(6.0)})).unwrap();
assert_eq!(vol_at(&s, 0.9), -20.0);
assert_eq!(vol_at(&s, 1.25), 0.0);
assert!((vol_at(&s, 2.5) + 10.0).abs() < 0.05, "{}", vol_at(&s, 2.5));
assert_eq!(vol_at(&s, 4.0), -20.0);
assert_eq!(vol_at(&s, 7.0), -20.0);
}
#[test]
fn latch_holds_until_stop() {
let mut s = demo();
s.execute("mixer.writeAutomation", json!({"strip": "A1", "points": [[0, 0.0], [sec(8.0), 0.0]]})).unwrap();
set_mode(&mut s, "A1", "Latch");
s.execute("mixer.recordStart", json!({"time": 0})).unwrap();
gesture(&mut s, "A1", &[(1.0, -4.0), (1.5, -8.0), (2.0, -12.0)]);
s.execute("mixer.release", json!({"strip": "A1", "time": sec(2.0)})).unwrap();
s.execute("mixer.recordStop", json!({"time": sec(4.0)})).unwrap();
assert_eq!(vol_at(&s, 0.5), 0.0);
assert!((vol_at(&s, 1.25) + 6.0).abs() < 1e-6);
assert_eq!(vol_at(&s, 3.0), -12.0, "latched");
assert_eq!(vol_at(&s, 3.99), -12.0);
assert!(vol_at(&s, 4.5) > -12.0, "existing automation after the stop resumes");
}
#[test]
fn write_overwrites_from_start_and_switches_to_touch() {
let mut s = demo();
s.execute("mixer.writeAutomation", json!({"strip": "A1", "points": [[0, -30.0], [sec(8.0), 0.0]]})).unwrap();
s.execute("mixer.setStrip", json!({"strip": "A1", "mode": "Write"})).unwrap();
s.execute("mixer.recordStart", json!({"time": sec(2.0)})).unwrap();
let start_val = -30.0 + 30.0 * 2.0 / 8.0;
// untouched until 3 s: the fader's value at the start is written
gesture(&mut s, "A1", &[(3.0, -6.0), (3.5, -6.0)]);
s.execute("mixer.release", json!({"strip": "A1", "time": sec(3.5)})).unwrap();
s.execute("mixer.recordStop", json!({"time": sec(5.0)})).unwrap();
assert!((vol_at(&s, 1.0) - (-30.0 + 30.0 / 8.0)).abs() < 0.05, "before the pass: unchanged");
assert!((vol_at(&s, 2.5) - start_val).abs() < 0.05, "{}", vol_at(&s, 2.5));
assert_eq!(vol_at(&s, 4.0), -6.0);
assert_eq!(vol_at(&s, 4.9), -6.0, "Write holds the last value to the stop");
assert_eq!(a1(&s).mixer.mode, AutomationMode::Touch, "Switch to Touch after Write");
// pan and mute are written too
assert!(a1(&s).lane_keyframes("pan").len() >= 2 && a1(&s).lane_keyframes("mute").len() >= 2);
// the preference can keep Write
s.execute("prefs.set", json!({"key": "audio.switchToTouchAfterWrite", "value": false})).unwrap();
s.execute("mixer.setStrip", json!({"strip": "A1", "mode": "Write"})).unwrap();
s.execute("mixer.recordStart", json!({"time": sec(6.0)})).unwrap();
s.execute("mixer.recordStop", json!({"time": sec(7.0)})).unwrap();
assert_eq!(a1(&s).mixer.mode, AutomationMode::Write);
}
#[test]
fn read_mode_does_not_record_and_commits_once_without_a_pass() {
let mut s = demo();
s.execute("mixer.recordStart", json!({"time": 0})).unwrap();
gesture(&mut s, "A1", &[(1.0, -6.0), (2.0, -9.0)]);
s.execute("mixer.release", json!({"strip": "A1", "time": sec(2.0)})).unwrap();
let r = s.execute("mixer.recordStop", json!({"time": sec(3.0)})).unwrap();
assert_eq!(r["lanes"], 0);
assert!(a1(&s).lane_keyframes(LANE_VOLUME).is_empty());
// stopped: a drag is heard live, then committed as one undo step
let undo0 = s.history.undo.len();
gesture(&mut s, "A1", &[(0.0, -2.0), (0.0, -4.0), (0.0, -5.0)]);
assert_eq!(a1(&s).volume_db, 0.0);
s.execute("mixer.release", json!({"strip": "A1"})).unwrap();
assert_eq!(a1(&s).volume_db, -5.0);
assert_eq!(s.history.undo.len(), undo0 + 1);
}
#[test]
fn recorded_pass_plays_back_through_the_mix() {
let mut s = demo();
// only A1 (the demo has music on another track)
s.execute("mixer.setStrip", json!({"strip": "A1", "solo": true})).unwrap();
set_mode(&mut s, "A1", "Latch");
s.execute("mixer.recordStart", json!({"time": 0})).unwrap();
gesture(&mut s, "A1", &[(0.5, -96.0)]);
s.execute("mixer.release", json!({"strip": "A1", "time": sec(0.5)})).unwrap();
s.execute("mixer.recordStop", json!({"time": sec(2.0)})).unwrap();
let q = s.active_sequence().unwrap();
let seq_id = s.state.active_sequence.unwrap();
let provider = s.media.provider(s.project.clone(), s.services.clone());
let b = s.previews.mix(&s.project, seq_id, 48_000, 4_800, &provider);
assert!(b.channels[0].iter().all(|x| x.abs() < 1e-6), "faded out by the recorded automation");
let before = filmcraft_render::audio::mix_sequence(&s.project, q, 0, 4_800, &provider);
assert!(before.channels[0].iter().any(|x| x.abs() > 1e-4), "audible before the move");
// master strip automation
s.execute("mixer.setKeyframe", json!({"strip": "Mix", "time": 0, "value": -6.0})).unwrap();
assert_eq!(s.active_sequence().unwrap().master_volume_at(Tick(sec(1.0))), -6.0);
assert!(s.active_sequence().unwrap().strip(MASTER_STRIP).is_some());
}
#[test]
fn audio_gain_modes_and_peak() {
let mut s = demo();
let clips: Vec<u64> = a1(&s).items.iter().take(3).map(|i| i.id.0).collect();
s.execute("timeline.select", json!({"clips": clips})).unwrap();
let pk = s.execute("clip.audioPeak", json!({})).unwrap();
let peak = pk["peakDb"].as_f64().unwrap();
assert!(peak.is_finite() && peak > -60.0, "{pk}");
// adjust (and the old {db, relative} form)
s.execute("clip.audioGain", json!({"mode": "adjust", "db": -2.0})).unwrap();
assert_eq!(a1(&s).items[0].gain_db, -2.0);
s.execute("clip.audioGain", json!({"db": 1.0, "relative": true})).unwrap();
assert_eq!(a1(&s).items[0].gain_db, -1.0);
s.execute("clip.audioGain", json!({"mode": "set", "db": 3.0})).unwrap();
assert!(a1(&s).items.iter().take(3).all(|i| i.gain_db == 3.0));
// normalize max peak: the loudest clip lands on the target, the others keep their offsets
s.execute("clip.audioGain", json!({"mode": "normalizeMax", "db": -1.0})).unwrap();
let pk = s.execute("clip.audioPeak", json!({})).unwrap();
assert!((pk["peakDb"].as_f64().unwrap() + 1.0).abs() < 1e-6, "{pk}");
let g: Vec<f64> = a1(&s).items.iter().take(3).map(|i| i.gain_db).collect();
assert!(g.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-9), "same adjustment for all: {g:?}");
// normalize all peaks: every clip on the target
s.execute("clip.audioGain", json!({"mode": "normalizeAll", "db": -6.0})).unwrap();
let pk = s.execute("clip.audioPeak", json!({})).unwrap();
for c in pk["clips"].as_array().unwrap() {
let total = c["sourcePeakDb"].as_f64().unwrap() + c["gainDb"].as_f64().unwrap();
assert!((total + 6.0).abs() < 1e-6, "{c}");
}
assert!(s.execute("clip.audioGain", json!({"mode": "loud"})).is_err());
}
#[test]
fn default_audio_transition_is_used_by_apply() {
let mut s = demo();
s.execute("effects.setDefaultTransition", json!({"effect": "Exponential Fade"})).unwrap();
assert_eq!(s.state.default_audio_transition, "exponential_fade");
assert!(s.execute("effects.setDefaultTransition", json!({"effect": "amplify"})).is_err());
let clip = a1(&s).items[1].id.0;
s.execute("sequence.applyAudioTransition", json!({"clip": clip})).unwrap();
let tr = &a1(&s).transitions;
assert_eq!(tr.len(), 1);
assert_eq!(tr[0].effect.effect, "exponential_fade");
}
+7 -3
View File
@@ -79,6 +79,8 @@ pub struct PreviewStore {
audio_memo: Mutex<Vec<AudioMemo>>,
/// Bumped whenever the set of preview files changes.
pub generation: AtomicU64,
/// Live mixer state shared with playback: held controls, meters, newest project snapshot.
pub live: Arc<filmcraft_render::mixer::LiveMix>,
}
fn video_name(hash: &str) -> String {
@@ -244,9 +246,11 @@ impl PreviewStore {
/// live mix ([`filmcraft_render::audio::mix_sequence`]) everywhere else.
pub fn mix(&self, project: &Arc<Project>, seq: ItemId, start: i64, frames: usize, sources: &dyn filmcraft_render::SourceProvider) -> AudioBuffer {
let Some(q) = project.sequence(seq) else { return AudioBuffer::silence(48_000, 2, frames) };
let live = Some(&*self.live);
let has_audio = self.files.read().unwrap_or_else(|e| e.into_inner()).iter().any(|n| n.ends_with(".wav"));
if !has_audio {
return filmcraft_render::audio::mix_sequence(project, q, start, frames, sources);
// held mixer controls are heard live (rendered previews don't know them)
if !has_audio || self.live.is_active() {
return filmcraft_render::mixer::mix_graph(project, q, start, frames, sources, live);
}
let segs = self.audio_segments(project, seq);
let mut out = AudioBuffer::silence(q.settings.sample_rate, 2, frames);
@@ -271,7 +275,7 @@ impl PreviewStore {
}
}
_ => {
let b = filmcraft_render::audio::mix_sequence(project, q, pos, n, sources);
let b = filmcraft_render::mixer::mix_graph(project, q, pos, n, sources, live);
for c in 0..2 {
out.channels[c][off..off + n].copy_from_slice(&b.channels[c.min(b.channels.len() - 1)][..n]);
}
+247
View File
@@ -0,0 +1,247 @@
//! Built-in keyboard shortcut tables (command id, keys, panel; "" = application-wide).
//!
//! Key assignments are functional facts about how other editors are operated; these tables map
//! them onto FilmCraft's own command ids. No files from any other application are used.
//! Keys use FilmCraft's canonical notation (`Cmd` = ⌘ on macOS / Ctrl elsewhere, `Ctrl` = the
//! macOS Control key, `Alt` = ⌥ Option / Alt; `Backspace` = the Mac "delete" key, `Delete` = the
//! forward-delete key).
pub type Entry = (&'static str, &'static str, &'static str);
/// FilmCraft additions to the per-command defaults: panel-specific shortcuts.
pub const FILMCRAFT_PANEL: &[Entry] = &[
("project.delete", "Backspace", "Project"),
("file.newBin", "Cmd+B", "Project"),
("edit.undo", "Left", "History"),
("edit.redo", "Right", "History"),
("sequence.close", "Cmd+W", "Timeline"),
];
/// Premiere Pro's default keyboard (macOS) mapped onto FilmCraft commands. FilmCraft Default takes
/// every entry whose command has no shortcut yet and whose key is free (the shortcut audit);
/// the "Premiere Pro Compatible" preset applies all of them.
pub const PREMIERE: &[Entry] = &[
// Tools
("tool.selection", "V", ""),
("tool.trackSelectBackward", "Shift+A", ""),
("tool.trackSelectForward", "A", ""),
("tool.ripple", "B", ""),
("tool.rolling", "N", ""),
("tool.rateStretch", "R", ""),
("tool.razor", "C", ""),
("tool.slip", "Y", ""),
("tool.slide", "U", ""),
("tool.pen", "P", ""),
("tool.hand", "H", ""),
("tool.zoom", "Z", ""),
("tool.type", "T", ""),
// Application
("app.preferences.autoSave", "Cmd+,", ""),
("app.keyboardShortcuts", "Cmd+Alt+K", ""),
// File
("file.newProject", "Cmd+Alt+N", ""),
("file.newSequence", "Cmd+N", ""),
("file.newBin", "Cmd+/", ""),
("file.newBin", "Cmd+B", "Project"),
("file.open", "Cmd+O", ""),
("file.save", "Cmd+S", ""),
("file.saveAs", "Cmd+Shift+S", ""),
("file.saveCopy", "Cmd+Alt+S", ""),
("file.import", "Cmd+I", ""),
("mode.export", "Cmd+M", ""),
// Edit
("edit.undo", "Cmd+Z", ""),
("edit.redo", "Cmd+Shift+Z", ""),
("edit.cut", "Cmd+X", ""),
("edit.copy", "Cmd+C", ""),
("edit.paste", "Cmd+V", ""),
("edit.pasteInsert", "Cmd+Shift+V", ""),
("edit.clear", "Delete", ""),
("edit.clear", "Backspace", "Timeline"),
("edit.rippleDelete", "Shift+Delete", ""),
("edit.rippleDelete", "Alt+Backspace", "Timeline"),
("edit.duplicate", "Cmd+Shift+/", ""),
("edit.selectAll", "Cmd+A", ""),
("edit.deselectAll", "Cmd+Shift+A", ""),
// Clip
("clip.speedDuration", "Cmd+R", ""),
("source.insert", ",", ""),
("source.overwrite", ".", ""),
("clip.enable", "Cmd+Shift+E", ""),
("clip.link", "Cmd+L", ""),
("clip.group", "Cmd+G", ""),
("clip.ungroup", "Cmd+Shift+G", ""),
("clip.audioGain", "G", ""),
// Sequence
("sequence.renderEffectsInToOut", "Enter", ""),
("sequence.matchFrame", "F", ""),
("sequence.addEdit", "Cmd+K", ""),
("sequence.addEditAllTracks", "Cmd+Shift+K", ""),
("trim.edit", "Shift+T", ""),
("trim.extendToPlayhead", "E", ""),
("sequence.applyVideoTransition", "Cmd+D", ""),
("sequence.applyAudioTransition", "Cmd+Shift+D", ""),
("trim.applyDefaultTransition", "Shift+D", ""),
("sequence.lift", ";", ""),
("sequence.extract", "'", ""),
("view.zoomIn", "=", ""),
("view.zoomOut", "-", ""),
("sequence.snap", "S", ""),
// Markers
("markers.markIn", "I", ""),
("markers.markOut", "O", ""),
("markers.markClip", "X", ""),
("markers.markSelection", "/", ""),
("markers.goToIn", "Shift+I", ""),
("markers.goToOut", "Shift+O", ""),
("markers.clearIn", "Alt+I", ""),
("markers.clearOut", "Alt+O", ""),
("markers.clearInOut", "Alt+X", ""),
("markers.add", "M", ""),
("markers.goNext", "Shift+M", ""),
("markers.goPrev", "Cmd+Shift+M", ""),
("markers.clearCurrent", "Alt+M", ""),
("markers.clearAll", "Cmd+Alt+M", ""),
// Window
("window.workspace.reset", "Alt+Shift+0", ""),
("window.workspace.editing", "Alt+Shift+1", ""),
("window.workspace.assembly", "Alt+Shift+2", ""),
("window.workspace.color", "Alt+Shift+3", ""),
("window.workspace.effects", "Alt+Shift+4", ""),
("window.workspace.audio", "Alt+Shift+5", ""),
("window.workspace.captionsandgraphics", "Alt+Shift+6", ""),
("window.panel.Project", "Shift+1", ""),
("window.panel.Source", "Shift+2", ""),
("window.panel.Timeline", "Shift+3", ""),
("window.panel.Program", "Shift+4", ""),
("window.panel.EffectControls", "Shift+5", ""),
("window.panel.AudioTrackMixer", "Shift+6", ""),
("window.panel.Effects", "Shift+7", ""),
("window.panel.MediaBrowser", "Shift+8", ""),
("window.panel.AudioClipMixer", "Shift+9", ""),
// Captions
("captions.newTrack", "Cmd+Alt+A", ""),
("captions.add", "Cmd+Alt+C", ""),
("captions.next", "Cmd+Alt+Down", "Timeline"),
("captions.previous", "Cmd+Alt+Up", "Timeline"),
// Navigation and playback
("playhead.nextEdit", "Down", ""),
("playhead.prevEdit", "Up", ""),
("playhead.start", "Home", ""),
("playhead.end", "End", ""),
("playhead.stepBack", "Left", ""),
("playhead.stepBack5", "Shift+Left", ""),
("playhead.stepForward", "Right", ""),
("playhead.stepForward5", "Shift+Right", ""),
("playback.toggle", "Space", ""),
("playback.reverse", "J", ""),
("playback.stop", "K", ""),
("playback.forward", "L", ""),
("playback.slowReverse", "Shift+J", ""),
("playback.slowForward", "Shift+L", ""),
("playback.playAround", "Shift+K", ""),
("playback.inToOut", "Alt+K", ""),
("view.zoomToSequence", "\\", ""),
// Trimming
("trim.ripplePrevious", "Q", ""),
("trim.rippleNext", "W", ""),
("trim.previous", "Alt+Q", ""),
("trim.next", "Alt+W", ""),
("trim.toggleType", "Ctrl+T", ""),
("trim.backward", "Alt+Left", ""),
("trim.backwardMany", "Alt+Shift+Left", ""),
("trim.forward", "Alt+Right", ""),
("trim.forwardMany", "Alt+Shift+Right", ""),
// Panels
("project.delete", "Backspace", "Project"),
("source.open", "Shift+O", "Project"),
("edit.undo", "Left", "History"),
("edit.redo", "Right", "History"),
];
/// Final Cut Pro's default keys for the equivalent FilmCraft commands.
pub const FINAL_CUT: &[Entry] = &[
("tool.selection", "A", ""),
("tool.razor", "B", ""),
("tool.ripple", "T", ""),
("tool.zoom", "Z", ""),
("tool.hand", "H", ""),
("source.insert", "W", ""),
("source.overwrite", "D", ""),
("sequence.addEdit", "Cmd+B", ""),
("sequence.addEditAllTracks", "Cmd+Shift+B", ""),
("edit.rippleDelete", "Backspace", ""),
("sequence.lift", "Shift+Backspace", ""),
("markers.markIn", "I", ""),
("markers.markOut", "O", ""),
("markers.markClip", "X", ""),
("markers.clearInOut", "Alt+X", ""),
("markers.add", "M", ""),
("markers.goNext", "Ctrl+'", ""),
("markers.goPrev", "Ctrl+;", ""),
("playhead.nextEdit", "Down", ""),
("playhead.prevEdit", "Up", ""),
("playhead.stepForward5", "Shift+Right", ""),
("playhead.stepBack5", "Shift+Left", ""),
("playback.toggle", "Space", ""),
("playback.reverse", "J", ""),
("playback.stop", "K", ""),
("playback.forward", "L", ""),
("playback.loop", "Cmd+L", ""),
("playback.inToOut", "/", ""),
("sequence.snap", "N", ""),
("view.zoomIn", "Cmd+=", ""),
("view.zoomOut", "Cmd+-", ""),
("view.zoomToSequence", "Shift+Z", ""),
("sequence.applyVideoTransition", "Cmd+T", ""),
("clip.speedDuration", "Cmd+R", ""),
("clip.enable", "V", ""),
("clip.nest", "Alt+G", ""),
("sequence.matchFrame", "Shift+F", ""),
("trim.extendToPlayhead", "Shift+X", ""),
("trim.backward", ",", ""),
("trim.forward", ".", ""),
("trim.backwardMany", "Shift+,", ""),
("trim.forwardMany", "Shift+.", ""),
("file.newBin", "Alt+N", ""),
("mode.export", "Cmd+E", ""),
("sequence.renderEffectsInToOut", "Ctrl+R", ""),
("sequence.renderInToOut", "Ctrl+Shift+R", ""),
];
/// Avid Media Composer's default keys for the equivalent FilmCraft commands.
pub const AVID: &[Entry] = &[
("playback.reverse", "J", ""),
("playback.stop", "K", ""),
("playback.forward", "L", ""),
("playback.toggle", "Space", ""),
("markers.markIn", "I", ""),
("markers.markIn", "E", ""),
("markers.markOut", "O", ""),
("markers.markOut", "R", ""),
("markers.markClip", "T", ""),
("markers.clearIn", "D", ""),
("markers.clearOut", "F", ""),
("markers.clearInOut", "G", ""),
("markers.goToIn", "Q", ""),
("markers.goToOut", "W", ""),
("source.insert", "V", ""),
("source.overwrite", "B", ""),
("sequence.lift", "Z", ""),
("sequence.extract", "X", ""),
("sequence.addEdit", "H", ""),
("playhead.stepBack5", "1", ""),
("playhead.stepForward5", "2", ""),
("playhead.stepBack", "3", ""),
("playhead.stepForward", "4", ""),
("playhead.prevEdit", "A", ""),
("playhead.nextEdit", "S", ""),
("trim.edit", "U", ""),
("trim.backwardMany", "M", ""),
("trim.backward", ",", ""),
("trim.forward", ".", ""),
("trim.forwardMany", "/", ""),
("view.zoomIn", "Cmd+]", ""),
("view.zoomOut", "Cmd+[", ""),
("markers.add", "F5", ""),
];
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
//! Keyboard shortcuts: chords, presets, conflicts, panel scope, undo, files.
use super::*;
use crate::shortcuts::{Binding, Chord, CommandInfo, Platform, Shortcuts};
use serde_json::json;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("fc-shortcuts-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The frontend's commands, as the egui UI registers them (a few are enough here).
fn with_ui_commands(s: &mut Session) {
s.shortcuts.register_external(vec![
CommandInfo::new("playback.toggle", "Play/Stop", &[], Some("Space")),
CommandInfo::new("playback.forward", "Shuttle Right", &[], Some("L")),
CommandInfo::new("playback.slowForward", "Shuttle Slow Right", &[], None),
CommandInfo::new("playback.playAround", "Play Around", &[], None),
CommandInfo::new("tool.razor", "Razor Tool", &[], Some("C")),
CommandInfo::new("app.keyboardShortcuts", "Keyboard Shortcuts…", &["Edit"], Some("Cmd+Alt+K")),
]);
}
#[test]
fn chords_parse_normalise_and_display_per_os() {
let c = Chord::parse("⌥⇧⌘K").unwrap();
assert_eq!(c.canonical(), "Cmd+Alt+Shift+K");
assert_eq!(Chord::parse("shift+opt+command+k").unwrap(), c);
assert_eq!(c.display(Platform::Mac), "⌥⇧⌘K");
assert_eq!(c.display(Platform::Windows), "Ctrl+Alt+Shift+K");
assert_eq!(Chord::parse("Return").unwrap().canonical(), "Enter");
assert_eq!(Chord::parse("Forward Delete").unwrap().canonical(), "Delete");
assert_eq!(Chord::parse("Shift+Left").unwrap().display(Platform::Mac), "⇧←");
// Windows text: Ctrl is the primary modifier
assert_eq!(Chord::parse_for("Ctrl+Shift+Z", Platform::Windows).unwrap().canonical(), "Cmd+Shift+Z");
assert_eq!(Chord::parse_for("Win+T", Platform::Windows).unwrap().canonical(), "Ctrl+T");
// macOS Control folds into Ctrl elsewhere
assert_eq!(Chord::parse("Ctrl+T").unwrap().effective(Platform::Windows), Chord::parse("Cmd+T").unwrap());
assert!(Chord::parse("Cmd+").is_err());
assert!(Chord::parse("Hyper+K").is_err());
}
#[test]
fn defaults_follow_the_registry_and_the_premiere_audit() {
let mut s = Session::default();
with_ui_commands(&mut s);
let sc = &s.shortcuts;
assert_eq!(sc.preset, "FilmCraft Default");
assert_eq!(sc.primary("sequence.addEdit").as_deref(), Some("Cmd+K"));
assert_eq!(sc.primary("playback.toggle").as_deref(), Some("Space"));
// the audit adopts Premiere defaults for commands that had none
let audit = sc.audit();
let has = |c: &str, k: &str, p: Option<&str>| audit.iter().any(|b| b.command == c && b.keys == k && b.panel.as_deref() == p);
assert!(has("trim.applyDefaultTransition", "Shift+D", None), "{audit:?}");
assert!(has("playback.slowForward", "Shift+L", None));
assert!(has("playback.playAround", "Shift+K", None));
assert!(has("source.open", "Shift+O", Some("Project")));
assert_eq!(sc.primary("trim.applyDefaultTransition").as_deref(), Some("Shift+D"));
// no conflicts in any built-in preset (macOS)
for name in crate::shortcuts::BUILTIN_PRESETS {
let mut t = Session::default();
with_ui_commands(&mut t);
t.shortcuts.load_preset(name).unwrap();
assert!(t.shortcuts.conflicts(Platform::Mac).is_empty(), "{name}: {:?}", t.shortcuts.conflicts(Platform::Mac));
}
// command.list reports the live bindings
let list = s.execute("command.list", json!({})).unwrap();
let ae = list.as_array().unwrap().iter().find(|c| c["id"] == "sequence.addEdit").unwrap();
assert_eq!(ae["shortcut"], json!("Cmd+K"));
}
#[test]
fn assign_reassigns_conflicts_and_undoes() {
let mut s = Session::default();
let r = s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Shift+K"})).unwrap();
assert_eq!(r["reassigned"][0]["command"], json!("sequence.addEditAllTracks"), "{r}");
assert!(s.shortcuts.primary("sequence.addEditAllTracks").is_none());
assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Shift+K"), "replaces Cmd+Z");
let hit = s.execute("shortcuts.resolve", json!({"keys": "⇧⌘K"})).unwrap();
assert_eq!(hit["command"], json!("edit.undo"));
assert!(s.shortcuts.modified);
// add a second shortcut, keep a conflict on purpose
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Z", "add": true})).unwrap();
assert_eq!(s.shortcuts.for_command("edit.undo").iter().filter(|b| b.panel.is_none()).count(), 2);
let r = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap();
assert_eq!(r["conflicts"].as_array().unwrap().len(), 1, "{r}");
let c = s.execute("shortcuts.conflicts", json!({"platform": "mac"})).unwrap();
assert_eq!(c["conflicts"][0]["keys"], json!("M"));
// undo / redo within the editor
s.execute("shortcuts.undo", json!({})).unwrap();
assert!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].as_array().unwrap().is_empty());
s.execute("shortcuts.undo", json!({})).unwrap();
s.execute("shortcuts.undo", json!({})).unwrap();
assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z"));
assert_eq!(s.shortcuts.primary("sequence.addEditAllTracks").as_deref(), Some("Cmd+Shift+K"));
s.execute("shortcuts.redo", json!({})).unwrap();
assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Shift+K"));
// clear
let r = s.execute("shortcuts.clear", json!({"command": "edit.undo"})).unwrap();
assert_eq!(r["removed"], json!(2), "Cmd+Shift+K and History ▸ Left");
assert!(s.shortcuts.for_command("edit.undo").is_empty());
assert!(s.execute("shortcuts.set", json!({"command": "no.such", "keys": "K"})).is_err());
assert!(s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "K", "panel": "Nowhere"})).is_err());
// editor Cancel restores the set it opened with
s.shortcuts.begin_editing();
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "U"})).unwrap();
s.shortcuts.cancel_editing();
assert!(s.shortcuts.for_command("edit.undo").is_empty());
}
#[test]
fn panel_shortcuts_override_application_ones_with_focus() {
let s = Session::default();
let left = Chord::parse("Left").unwrap();
let p = Platform::Mac;
assert_eq!(s.shortcuts.resolve(&left, None, p).unwrap().command, "playhead.stepBack");
assert_eq!(s.shortcuts.resolve(&left, Some("Timeline"), p).unwrap().command, "playhead.stepBack");
assert_eq!(s.shortcuts.resolve(&left, Some("History"), p).unwrap().command, "edit.undo");
let bs = Chord::parse("Backspace").unwrap();
assert_eq!(s.shortcuts.resolve(&bs, Some("Project"), p).unwrap().command, "project.delete");
assert_eq!(s.shortcuts.resolve(&bs, None, p).unwrap().command, "edit.clear");
let ov = s.shortcuts.overrides(p);
assert!(ov.iter().any(|(panel, k, cmd, app)| panel == "History" && k == "Left" && cmd == "edit.undo" && app == "playhead.stepBack"), "{ov:?}");
// Windows: macOS-Control shortcuts fold into Ctrl and can clash
let mut t = Session::default();
t.execute("shortcuts.set", json!({"command": "edit.copy", "keys": "Ctrl+C", "add": true, "keepConflicts": true})).unwrap();
assert!(t.shortcuts.conflicts(Platform::Mac).is_empty());
assert!(!t.shortcuts.conflicts(Platform::Windows).is_empty() || t.shortcuts.for_command("edit.copy").len() == 2);
let k = t.execute("shortcuts.forKey", json!({"key": "C"})).unwrap();
assert!(k.as_array().unwrap().iter().any(|b| b["command"] == "edit.copy" && b["keys"] == "Ctrl+C"), "{k}");
}
#[test]
fn compat_presets_map_other_editors() {
let mut s = Session::default();
with_ui_commands(&mut s);
s.execute("shortcuts.loadPreset", json!({"name": "Premiere Pro Compatible"})).unwrap();
assert_eq!(s.shortcuts.primary("markers.clearIn").as_deref(), Some("Alt+I"));
assert_eq!(s.shortcuts.primary("edit.clear").as_deref(), Some("Delete"));
assert!(s.shortcuts.for_command("edit.clear").iter().any(|b| b.panel.as_deref() == Some("Timeline") && b.keys == "Backspace"));
s.execute("shortcuts.loadPreset", json!({"name": "Final Cut Pro Compatible"})).unwrap();
assert_eq!(s.shortcuts.primary("source.insert").as_deref(), Some("W"));
assert_eq!(s.shortcuts.primary("tool.razor").as_deref(), Some("B"));
assert!(s.shortcuts.primary("trim.rippleNext").is_none(), "W was taken by Insert");
s.execute("shortcuts.loadPreset", json!({"name": "Avid Media Composer Compatible"})).unwrap();
assert_eq!(s.shortcuts.primary("sequence.lift").as_deref(), Some("Z"));
assert_eq!(s.shortcuts.for_command("markers.markIn").len(), 2, "I and E");
assert!(!s.shortcuts.modified);
assert!(s.execute("shortcuts.loadPreset", json!({"name": "Nope"})).is_err());
s.execute("shortcuts.loadPreset", json!({"name": "FilmCraft Default"})).unwrap();
assert_eq!(s.shortcuts.primary("source.insert").as_deref(), Some(","));
}
#[test]
fn custom_presets_persist_export_and_import() {
let dir = temp_dir("presets");
let mut s = Session::default();
s.shortcuts.set_dir(&dir);
s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+Shift+B"})).unwrap();
let r = s.execute("shortcuts.savePreset", json!({"name": "My Keys"})).unwrap();
assert!(std::path::Path::new(r["path"].as_str().unwrap()).exists());
let p = s.execute("shortcuts.presets", json!({})).unwrap();
assert_eq!(p["active"], json!("My Keys"));
assert_eq!(p["custom"], json!(["My Keys"]));
assert!(s.execute("shortcuts.savePreset", json!({"name": "FilmCraft Default"})).is_err());
// the active set survives a restart
let mut t = Shortcuts::new();
t.set_dir(&dir);
assert_eq!(t.preset, "My Keys");
assert_eq!(t.primary("sequence.addEdit").as_deref(), Some("Cmd+Shift+B"));
// switch away and back
s.execute("shortcuts.loadPreset", json!({"name": "FilmCraft Default"})).unwrap();
assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+K"));
s.execute("shortcuts.loadPreset", json!({"name": "My Keys"})).unwrap();
assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+Shift+B"));
// export, then import a Windows-notation file
let out = dir.join("export.json");
s.execute("shortcuts.export", json!({"path": out.to_string_lossy()})).unwrap();
let f: crate::shortcuts::PresetFile = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
assert!(f.bindings.contains(&Binding { command: "sequence.addEdit".into(), keys: "Cmd+Shift+B".into(), panel: None }));
let win = dir.join("win.json");
std::fs::write(
&win,
serde_json::to_vec(&json!({"format": "filmcraft-keyboard-shortcuts", "version": 1, "name": "From Windows", "platform": "windows",
"bindings": [{"command": "edit.undo", "keys": "Ctrl+Z"}, {"command": "sequence.addEdit", "keys": "Ctrl+Alt+K"}, {"command": "no.such", "keys": "K"}]}))
.unwrap(),
)
.unwrap();
let r = s.execute("shortcuts.import", json!({"path": win.to_string_lossy()})).unwrap();
assert_eq!(r["name"], json!("From Windows"));
assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z"));
assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+Alt+K"));
assert_eq!(s.shortcuts.bindings.len(), 2, "unknown commands dropped");
s.execute("shortcuts.deletePreset", json!({"name": "My Keys"})).unwrap();
assert!(s.execute("shortcuts.deletePreset", json!({"name": "FilmCraft Default"})).is_err());
assert_eq!(s.execute("shortcuts.presets", json!({})).unwrap()["custom"], json!(["From Windows"]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_searches_labels_and_keys() {
let s = Session::default();
let r = s.shortcuts.commands();
assert!(r.iter().any(|c| c.id == "trim.forward"));
assert!(!r.iter().any(|c| c.id == "timeline.move"), "commands with required params are not bindable");
let mut s = s;
let v = s.execute("shortcuts.list", json!({"query": "ripple delete"})).unwrap();
assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "edit.rippleDelete"), "{v}");
let v = s.execute("shortcuts.list", json!({"query": "cmd+k", "platform": "windows"})).unwrap();
assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "sequence.addEdit"), "{v}");
let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap();
assert_eq!(v.as_array().unwrap().len(), 2, "{v}");
}
+374 -2
View File
@@ -4,11 +4,24 @@
//! on the edge shared with the adjacent clip on the same track. Trimming delegates to the
//! `timeline.trim` / `timeline.roll` commands so linked partners and sync locks behave the same as
//! with the mouse.
//!
//! **Trim Monitor** ([`monitor_info`]): while edit points are selected the Program monitor shows the
//! outgoing clip's last frame and the incoming clip's first frame, with the Out/In shift counters
//! ([`TrimShift`]) that count how far each side moved since the edit point was selected.
//!
//! **Dynamic trimming** ([`TrimPlayback`]): in trim mode J/L "play" the edit point — it moves at the
//! shuttle speed (L forward, J backward, pressing again doubles the speed up to 8×, Shift = slow)
//! and the sequence updates live. K stops and commits: the whole dynamic trim is **one** undo step.
//! Space loops playback around the edit (Preferences ▸ Playback preroll/postroll). Everything is
//! driven by a caller-supplied clock (`clock`, seconds): the UI passes its frame time, tests pass
//! exact values, so dynamic trims are deterministic.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use filmcraft_project::{ClipId, Sequence, TrackId};
use filmcraft_project::{ClipId, Project, Sequence, TrackId, TrackKind};
use filmcraft_time::{Tick, TimeRange};
use crate::{EngineError, Result, Session};
@@ -34,6 +47,15 @@ impl TrimKind {
}
}
/// Trim Monitor counters: how far the outgoing clip's Out point and the incoming clip's In point
/// moved since the edit point was selected.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrimShift {
pub out_shift: Tick,
pub in_shift: Tick,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditPoint {
pub clip: ClipId,
@@ -82,6 +104,7 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
s.state.edit_points = vec![ep];
}
s.state.selection.clear();
reset_shift(s);
Ok(json!({"editPoints": s.state.edit_points}))
}
@@ -132,6 +155,7 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result<Value> {
}
s.state.edit_points = points;
s.state.selection.clear();
reset_shift(s);
Ok(json!({"editPoints": s.state.edit_points}))
}
@@ -145,6 +169,31 @@ pub fn toggle_type(s: &mut Session) -> Result<Value> {
/// Trim every selected edit point by `delta` (edit point movement; positive = later).
fn trim_all(s: &mut Session, delta: Tick) -> Result<Value> {
let applied = trim_all_raw(s, delta)?;
let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default();
add_shift(s, first);
Ok(json!({"applied": applied}))
}
/// Add an applied movement of the primary edit point to the Trim Monitor's shift counters.
fn add_shift(s: &mut Session, d: Tick) {
let Some(ep) = s.state.edit_points.first() else { return };
match (ep.kind, ep.out) {
(TrimKind::Roll, _) => {
s.state.trim_shift.out_shift += d;
s.state.trim_shift.in_shift += d;
}
(_, true) => s.state.trim_shift.out_shift += d,
(_, false) => s.state.trim_shift.in_shift += d,
}
}
/// Reset the Trim Monitor counters (a new edit point selection starts a new trim session).
fn reset_shift(s: &mut Session) {
s.state.trim_shift = TrimShift::default();
}
fn trim_all_raw(s: &mut Session, delta: Tick) -> Result<Vec<Value>> {
if s.state.edit_points.is_empty() {
return Err(EngineError::Other("no edit points selected".into()));
}
@@ -164,7 +213,7 @@ fn trim_all(s: &mut Session, delta: Tick) -> Result<Value> {
};
applied.push(r.get("delta").cloned().unwrap_or(Value::Null));
}
Ok(json!({"applied": applied}))
Ok(applied)
}
/// `trim.nudge {frames}`: Trim Forward/Backward (±1), Many (±5 by default).
@@ -227,3 +276,326 @@ pub fn to_playhead(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({"range": [range.start.0, range.end().0]}))
}
// ------------------------------------------------------------------ Trim Monitor
/// One side of the Trim Monitor: the clip and the frame shown for it.
fn side_json(s: &Session, seq: &Sequence, clip: ClipId, outgoing: bool) -> Option<Value> {
let (tid, it) = seq.find_item(clip)?;
let rate = seq.settings.frame_rate;
let fd = rate.frame_duration();
let t = if outgoing { it.end() - fd } else { it.start };
let media_time = it.source_time_at(t.clamp(it.start, it.end() - Tick(1)));
let track = seq.track(tid)?;
let tracks = seq.tracks(track.kind);
let idx = tracks.iter().position(|x| x.id == tid).unwrap_or(0) + 1;
let track_name = format!("{}{idx}", if track.kind == TrackKind::Video { "V" } else { "A" });
let item_rate = s.project.item(it.item).map(|i| i.frame_rate()).unwrap_or(rate);
Some(json!({
"clip": it.id.0, "item": it.item.0, "name": it.name, "track": track_name, "video": track.kind == TrackKind::Video,
"time": t.0, "mediaTime": media_time.0, "mediaFrame": item_rate.frame_at(media_time),
"sourceIn": it.source_in.0, "sourceOut": it.source_out().0,
}))
}
/// The (outgoing, incoming) clips at an edit point.
pub fn sides(seq: &Sequence, ep: &EditPoint) -> (Option<ClipId>, Option<ClipId>) {
match roll_pair(seq, ep) {
Some((l, r)) => (Some(l), Some(r)),
None if ep.out => (Some(ep.clip), None),
None => (None, Some(ep.clip)),
}
}
/// `trim.monitor`: everything the Trim Monitor shows, for the primary (first) edit point.
pub fn monitor_info(s: &Session) -> Value {
let Some(seq) = s.active_sequence() else { return Value::Null };
let Some(ep) = s.state.edit_points.first() else { return json!({"active": false}) };
let rate = seq.settings.frame_rate;
let (out_c, in_c) = sides(seq, ep);
let tp = &s.trim_play;
json!({
"active": true,
"kind": ep.kind,
"edge": if ep.out { "out" } else { "in" },
"editTime": edit_time(seq, ep).map(|t| t.0),
"editPoints": s.state.edit_points.len(),
"outgoing": out_c.and_then(|c| side_json(s, seq, c, true)),
"incoming": in_c.and_then(|c| side_json(s, seq, c, false)),
"outShift": rate.frame_at(s.state.trim_shift.out_shift),
"inShift": rate.frame_at(s.state.trim_shift.in_shift),
"largeTrimOffset": s.prefs.trim.large_trim_offset,
"dynamic": tp.dynamic.as_ref().map(|d| json!({"offsetFrames": rate.frame_at(d.offset), "offset": d.offset.0, "speed": d.speed, "atLimit": d.at_limit})),
"playAround": tp.around.as_ref().map(|a| json!({"start": a.start.0, "end": a.end.0, "loop": a.looping})),
})
}
/// `trim.applyDefaultTransition` (Apply Default Transitions to Selection, Trim Monitor button): the
/// default video/audio transition at every selected edit point.
pub fn apply_default_transitions(s: &mut Session) -> Result<Value> {
let pts = s.state.edit_points.clone();
let mut ids = Vec::new();
for ep in &pts {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let Some((tid, _)) = seq.find_item(ep.clip) else { continue };
let video = seq.track(tid).is_some_and(|t| t.kind == TrackKind::Video);
let cmd = if video { "sequence.applyVideoTransition" } else { "sequence.applyAudioTransition" };
if let Ok(r) = s.execute(cmd, json!({"clip": ep.clip.0, "edge": if ep.out { "out" } else { "in" }})) {
ids.push(r["transition"].clone());
}
}
if ids.is_empty() {
return Err(EngineError::Other("no transition could be applied at the selected edit points".into()));
}
Ok(json!({"transitions": ids}))
}
// ------------------------------------------------------------------ dynamic trimming
/// Trim-mode playback state (not project data; never saved).
#[derive(Clone, Debug, Default)]
pub struct TrimPlayback {
pub dynamic: Option<DynamicTrim>,
pub around: Option<PlayAround>,
}
impl TrimPlayback {
pub fn active(&self) -> bool {
self.dynamic.is_some() || self.around.is_some()
}
}
/// A J/K/L trim in progress.
#[derive(Clone, Debug)]
pub struct DynamicTrim {
/// The project before the trim: the one undo step pushed on commit, and the base every live
/// update re-applies the offset to (so updates never accumulate rounding or clamping).
before: Arc<Project>,
shift_before: TrimShift,
/// Edit point movement applied so far (whole frames).
pub offset: Tick,
/// Shuttle speed (× real time); negative = backward, 0 = held (stopped at a limit).
pub speed: f64,
anchor_clock: f64,
anchor_offset: Tick,
/// The trim ran into a limit (media handle, neighbour, sync) and stopped there.
pub at_limit: bool,
}
/// Loop (or one-shot) playback around the edit: preroll before it to postroll after it.
#[derive(Clone, Debug)]
pub struct PlayAround {
pub start: Tick,
pub end: Tick,
pub looping: bool,
clock0: f64,
}
fn clock_p(p: &Value) -> f64 {
p.get("clock").and_then(Value::as_f64).unwrap_or(0.0)
}
fn direction_p(p: &Value) -> f64 {
match p.get("direction") {
Some(Value::String(d)) if d.starts_with('r') || d.starts_with('b') || d.eq_ignore_ascii_case("j") => -1.0,
Some(v) if v.as_f64().is_some_and(|x| x < 0.0) => -1.0,
_ => 1.0,
}
}
/// Whole frames of movement for `secs` of edit point travel (truncated toward zero).
fn frames_for(s: &Session, secs: f64) -> Tick {
let rate = s.sequence_rate();
let mag = rate.frame_at(Tick::from_seconds_f64(secs.abs()));
rate.tick_of(if secs < 0.0 { -mag } else { mag })
}
/// `trim.shuttle {direction: "forward"|"reverse", slow?, clock}` (L / J in trim mode): start a
/// dynamic trim, speed it up (pressing the same direction again doubles the speed, up to 8×) or
/// reverse it.
pub fn shuttle(s: &mut Session, p: &Value) -> Result<Value> {
let clock = clock_p(p);
let dir = direction_p(p);
let slow = p.get("slow").and_then(Value::as_bool).unwrap_or(false);
s.trim_play.around = None;
if s.trim_play.dynamic.is_none() {
s.trim_play.dynamic = Some(DynamicTrim {
before: s.project.clone(),
shift_before: s.state.trim_shift,
offset: Tick::ZERO,
speed: 0.0,
anchor_clock: clock,
anchor_offset: Tick::ZERO,
at_limit: false,
});
} else {
// bring the offset up to date before changing speed
tick(s, clock)?;
}
let rate = s.sequence_rate();
let d = s.trim_play.dynamic.as_mut().expect("dynamic trim");
let speed = if slow {
0.25 * dir
} else if d.speed * dir > 0.0 && !d.at_limit {
(d.speed * 2.0).clamp(-8.0, 8.0)
} else {
dir
};
d.speed = speed;
d.anchor_clock = clock;
d.anchor_offset = d.offset;
d.at_limit = false;
Ok(json!({"speed": speed, "offsetFrames": rate.frame_at(d.offset)}))
}
/// `trim.tick {clock}`: advance dynamic trimming / loop playback to `clock` (call once per frame).
pub fn tick(s: &mut Session, clock: f64) -> Result<Value> {
if let Some(d) = s.trim_play.dynamic.clone() {
let want = d.anchor_offset + frames_for(s, (clock - d.anchor_clock).max(0.0) * d.speed);
if want != d.offset {
let got = apply_dynamic(s, want)?;
if let Some(dd) = s.trim_play.dynamic.as_mut() {
dd.offset = got;
if got != want {
// ran into a limit: hold there (K still commits)
dd.at_limit = true;
dd.speed = 0.0;
dd.anchor_offset = got;
dd.anchor_clock = clock;
}
}
}
} else if let Some(a) = s.trim_play.around.clone() {
let len = (a.end - a.start).seconds().max(1e-9);
let mut el = (clock - a.clock0).max(0.0);
if a.looping {
el %= len;
} else if el >= len {
s.trim_play.around = None;
s.set_playhead(a.end - s.sequence_rate().frame_duration());
return Ok(status(s));
}
s.set_playhead(a.start + Tick::from_seconds_f64(el));
}
Ok(status(s))
}
fn status(s: &Session) -> Value {
let rate = s.sequence_rate();
let tp = &s.trim_play;
json!({
"playing": tp.active(),
"dynamic": tp.dynamic.as_ref().map(|d| json!({"offsetFrames": rate.frame_at(d.offset), "speed": d.speed, "atLimit": d.at_limit})),
"playhead": s.playhead().0,
})
}
/// Re-apply `offset` to the project as it was before the dynamic trim, without touching the undo
/// history or the journal. Returns the offset that could actually be applied.
fn apply_dynamic(s: &mut Session, offset: Tick) -> Result<Tick> {
let Some(d) = s.trim_play.dynamic.clone() else { return Ok(Tick::ZERO) };
let (undo_len, journal_len, limit) = (s.history.undo.len(), s.journal.len(), s.history.limit);
let redo = std::mem::take(&mut s.history.redo);
s.history.limit = usize::MAX;
s.project = d.before.clone();
let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, offset) };
let applied = match r {
Ok(a) => a.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(),
Err(_) => {
// refused outright (e.g. it would break sync): stay where we were
s.project = d.before.clone();
if d.offset != Tick::ZERO {
let _ = trim_all_raw(s, d.offset);
}
d.offset
}
};
s.history.undo.truncate(undo_len);
s.history.limit = limit;
s.history.redo = redo;
s.journal.truncate(journal_len);
s.state.trim_shift = d.shift_before;
add_shift(s, applied);
// the Program monitor follows the moving edit
if let Some(t) = s.active_sequence().and_then(|q| s.state.edit_points.first().and_then(|e| edit_time(q, e))) {
s.set_playhead(t);
}
Ok(applied)
}
/// `trim.shuttleStop {clock?}` (K in trim mode): stop dynamic trimming and commit it as one undo
/// step, and stop loop playback.
pub fn stop(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = p.get("clock").and_then(Value::as_f64)
&& s.trim_play.dynamic.is_some()
{
tick(s, c)?;
}
s.trim_play.around = None;
Ok(commit(s))
}
/// Commit a dynamic trim in progress (one undo step labelled after the trim kind).
pub fn commit(s: &mut Session) -> Value {
let Some(d) = s.trim_play.dynamic.take() else { return json!({"committed": false}) };
let rate = s.sequence_rate();
if d.offset == Tick::ZERO || Arc::ptr_eq(&d.before, &s.project) {
s.project = d.before;
s.state.trim_shift = d.shift_before;
return json!({"committed": false, "offsetFrames": 0});
}
let label = match s.state.edit_points.first().map(|e| e.kind) {
Some(TrimKind::Roll) => "Dynamic Rolling Edit",
Some(TrimKind::Ripple) => "Dynamic Ripple Trim",
_ => "Dynamic Trim",
};
s.history.undo.push((label.to_string(), d.before));
if s.history.undo.len() > s.history.limit {
s.history.undo.remove(0);
}
s.history.redo.clear();
json!({"committed": true, "label": label, "offsetFrames": rate.frame_at(d.offset), "offset": d.offset.0})
}
/// `trim.cancelDynamic` (Esc): drop a dynamic trim in progress, restoring the edit.
pub fn cancel(s: &mut Session) -> Result<Value> {
s.trim_play.around = None;
if let Some(d) = s.trim_play.dynamic.take() {
s.project = d.before;
s.state.trim_shift = d.shift_before;
s.fix_state();
if let Some(t) = s.active_sequence().and_then(|q| s.state.edit_points.first().and_then(|e| edit_time(q, e))) {
s.set_playhead(t);
}
s.revision += 1;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
}
Ok(Value::Null)
}
/// `trim.playAround {clock, loop?: true, toggle?}` (Space / Shift+K in trim mode): play from
/// preroll before the edit (or the playhead, per Preferences ▸ Trim) to postroll after it.
pub fn play_around(s: &mut Session, p: &Value) -> Result<Value> {
if s.trim_play.dynamic.is_some() {
commit(s);
}
if s.trim_play.around.is_some() && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) {
s.trim_play.around = None;
return Ok(status(s));
}
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let edit = s.state.edit_points.first().and_then(|e| edit_time(seq, e));
let center = match edit {
Some(e) if !s.prefs.trim.playhead_determines_loop => e,
_ => s.playhead(),
};
let rate = seq.settings.frame_rate;
let dur = seq.duration();
let start = rate.snap((center - Tick::from_seconds_f64(s.prefs.playback.preroll_seconds)).max(Tick::ZERO));
let end = rate.snap((center + Tick::from_seconds_f64(s.prefs.playback.postroll_seconds)).min(dur.max(center + rate.frame_duration())));
let end = end.max(start + rate.frame_duration());
let looping = p.get("loop").and_then(Value::as_bool).unwrap_or(true);
s.trim_play.around = Some(PlayAround { start, end, looping, clock0: clock_p(p) });
s.set_playhead(start);
Ok(json!({"start": start.0, "end": end.0, "loop": looping}))
}
+212
View File
@@ -0,0 +1,212 @@
//! Trim Monitor + dynamic (J/K/L) trimming, driven by an exact simulated clock.
use super::*;
use serde_json::json;
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
for t in ["V2", "V3", "A2", "A3"] {
let _ = s.execute("timeline.setTrack", json!({"track": t, "syncLock": false}));
}
s
}
/// V1 clips as (id, start, end).
fn v1(s: &Session) -> Vec<(u64, Tick, Tick)> {
s.active_sequence().unwrap().video_tracks[0].items.iter().map(|i| (i.id.0, i.start, i.end())).collect()
}
/// Whole frames covered by `secs` of real time at the sequence rate.
fn frames(s: &Session, secs: f64) -> i64 {
s.sequence_rate().frame_at(Tick::from_seconds_f64(secs))
}
/// Select the cut between V1 clips 0 and 1 as `kind` (roll / ripple out of clip 0).
fn select_first_cut(s: &mut Session, kind: &str) -> (u64, Tick) {
let c = v1(s);
s.execute("trim.selectEditPoint", json!({"clip": c[0].0, "edge": "out", "kind": kind})).unwrap();
(c[0].0, c[0].2)
}
#[test]
fn dynamic_roll_forward_is_one_undo_step() {
let mut s = demo();
let (left, cut) = select_first_cut(&mut s, "roll");
let rate = s.sequence_rate();
let undo0 = s.history.undo.len();
let journal0 = s.journal.len();
let dur0 = s.active_sequence().unwrap().duration();
s.execute("trim.shuttle", json!({"direction": "forward", "clock": 10.0})).unwrap();
// live: the cut moves with the clock, no history yet
s.execute("trim.tick", json!({"clock": 10.5})).unwrap();
let mid = v1(&s).into_iter().find(|c| c.0 == left).unwrap();
assert_eq!(mid.2, cut + rate.tick_of(frames(&s, 0.5)), "half a second of roll");
assert_eq!(s.playhead(), mid.2, "the Program monitor follows the edit");
assert_eq!(s.history.undo.len(), undo0, "no undo step while trimming");
let info = s.execute("trim.monitor", json!({})).unwrap();
assert_eq!(info["dynamic"]["offsetFrames"], json!(frames(&s, 0.5)), "{info}");
assert_eq!(info["outShift"], json!(frames(&s, 0.5)));
assert_eq!(info["inShift"], json!(frames(&s, 0.5)), "roll moves both sides");
// K commits
let r = s.execute("trim.shuttleStop", json!({"clock": 11.0})).unwrap();
assert_eq!(r["committed"], json!(true), "{r}");
assert_eq!(r["offsetFrames"], json!(frames(&s, 1.0)));
let after = v1(&s).into_iter().find(|c| c.0 == left).unwrap();
assert_eq!(after.2, cut + rate.tick_of(frames(&s, 1.0)));
assert_eq!(s.active_sequence().unwrap().duration(), dur0, "roll keeps the duration");
assert_eq!(s.history.undo.len(), undo0 + 1, "exactly one undo step");
assert_eq!(s.history.undo.last().unwrap().0, "Dynamic Rolling Edit");
assert!(
s.journal[journal0..].iter().all(|(id, _)| id.starts_with("trim.")),
"live updates are not journaled as separate edits: {:?}",
&s.journal[journal0..]
);
assert!(s.trim_play.dynamic.is_none());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut, "undo restores the cut in one step");
}
#[test]
fn pressing_l_again_doubles_speed_and_j_reverses() {
let mut s = demo();
let (left, cut) = select_first_cut(&mut s, "roll");
let rate = s.sequence_rate();
let r = s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap();
assert_eq!(r["speed"], json!(1.0));
let r = s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.5})).unwrap();
assert_eq!(r["speed"], json!(2.0));
// 0.5 s at 1× + 0.5 s at 2×
let a = frames(&s, 0.5);
s.execute("trim.tick", json!({"clock": 1.0})).unwrap();
let expect = a + frames(&s, 1.0);
assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut + rate.tick_of(expect));
// J: reverse at 1×
let r = s.execute("trim.shuttle", json!({"direction": "reverse", "clock": 1.0})).unwrap();
assert_eq!(r["speed"], json!(-1.0));
s.execute("trim.tick", json!({"clock": 1.25})).unwrap();
let back = expect - frames(&s, 0.25);
assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut + rate.tick_of(back));
// slow (Shift+J / Shift+L) = quarter speed
let r = s.execute("trim.shuttle", json!({"direction": "forward", "slow": true, "clock": 1.25})).unwrap();
assert_eq!(r["speed"], json!(0.25));
let r = s.execute("trim.shuttleStop", json!({"clock": 2.25})).unwrap();
assert_eq!(r["offsetFrames"], json!(back + frames(&s, 0.25)));
assert_eq!(s.history.undo.last().unwrap().0, "Dynamic Rolling Edit");
}
#[test]
fn dynamic_ripple_backward_shortens_and_stops_at_a_limit() {
let mut s = demo();
let (clip, end0) = select_first_cut(&mut s, "ripple");
let rate = s.sequence_rate();
let start0 = v1(&s)[0].1;
let v1_end = |s: &Session| v1(s).last().unwrap().2;
let dur0 = v1_end(&s);
s.execute("trim.shuttle", json!({"direction": "reverse", "clock": 0.0})).unwrap();
s.execute("trim.tick", json!({"clock": 0.25})).unwrap();
let n = frames(&s, 0.25);
assert_eq!(v1(&s)[0].2, end0 - rate.tick_of(n));
assert_eq!(v1_end(&s), dur0 - rate.tick_of(n), "ripple pulls later material in");
// keep going for a minute: the clip can't get shorter than one frame
s.execute("trim.tick", json!({"clock": 600.0})).unwrap();
let info = s.execute("trim.monitor", json!({})).unwrap();
assert_eq!(info["dynamic"]["atLimit"], json!(true), "{info}");
assert_eq!(info["dynamic"]["speed"], json!(0.0));
let c = v1(&s).into_iter().find(|c| c.0 == clip).unwrap();
assert_eq!(c.2 - c.1, rate.frame_duration(), "held at the one-frame limit");
assert_eq!(c.1, start0);
let r = s.execute("trim.shuttleStop", json!({})).unwrap();
assert_eq!(r["label"], json!("Dynamic Ripple Trim"));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v1_end(&s), dur0);
}
#[test]
fn another_command_commits_first_and_cancel_restores() {
let mut s = demo();
let (left, cut) = select_first_cut(&mut s, "roll");
let undo0 = s.history.undo.len();
s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap();
s.execute("trim.tick", json!({"clock": 0.5})).unwrap();
// Undo mid-trim: the dynamic trim is committed, then undone as a single step
s.execute("edit.undo", json!({})).unwrap();
assert!(s.trim_play.dynamic.is_none());
assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut);
assert_eq!(s.history.undo.len(), undo0);
assert_eq!(s.history.redo.last().unwrap().0, "Dynamic Rolling Edit");
// Esc cancels without an undo step (and restores the counters)
let shift0 = s.execute("trim.monitor", json!({})).unwrap()["outShift"].clone();
s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap();
s.execute("trim.tick", json!({"clock": 0.5})).unwrap();
assert_ne!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut);
s.execute("trim.cancelDynamic", json!({})).unwrap();
assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut);
assert_eq!(s.history.undo.len(), undo0);
assert_eq!(s.execute("trim.monitor", json!({})).unwrap()["outShift"], shift0);
// K with nothing moved commits nothing
s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap();
let r = s.execute("trim.shuttleStop", json!({"clock": 0.0})).unwrap();
assert_eq!(r["committed"], json!(false));
assert_eq!(s.history.undo.len(), undo0);
}
#[test]
fn play_around_loops_preroll_to_postroll() {
let mut s = demo();
let (_, cut) = select_first_cut(&mut s, "roll");
let rate = s.sequence_rate();
s.execute("prefs.set", json!({"values": {"playback.prerollSeconds": 1.0, "playback.postrollSeconds": 0.5}})).unwrap();
let r = s.execute("trim.playAround", json!({"clock": 100.0})).unwrap();
let start = rate.snap(cut - Tick::from_seconds_f64(1.0));
let end = rate.snap(cut + Tick::from_seconds_f64(0.5));
assert_eq!(r["start"], json!(start.0));
assert_eq!(r["end"], json!(end.0));
assert_eq!(s.playhead(), start);
s.execute("trim.tick", json!({"clock": 101.0})).unwrap();
assert_eq!(s.playhead(), rate.snap(start + Tick::from_seconds_f64(1.0)));
// loops: 1.5 s later we're back at the start + (elapsed mod length)
let len = (end - start).seconds();
let el = 1.75 % len;
s.execute("trim.tick", json!({"clock": 101.75})).unwrap();
assert_eq!(s.playhead(), rate.snap(start + Tick::from_seconds_f64(el)));
// trim buttons keep the loop running; other commands stop it
s.execute("trim.forward", json!({})).unwrap();
assert!(s.trim_play.around.is_some());
s.execute("markers.add", json!({})).unwrap();
assert!(s.trim_play.around.is_none());
// playhead mode
s.execute("prefs.set", json!({"key": "trim.playheadDeterminesLoop", "value": true})).unwrap();
s.execute("playhead.set", json!({"time": (cut + rate.tick_of(48)).0})).unwrap();
let r = s.execute("trim.playAround", json!({"clock": 0.0, "loop": false})).unwrap();
assert_eq!(r["start"], json!(rate.snap(cut + rate.tick_of(48) - Tick::from_seconds_f64(1.0)).0));
s.execute("trim.tick", json!({"clock": 5.0})).unwrap();
assert!(s.trim_play.around.is_none(), "one-shot play-around stops at the postroll");
}
#[test]
fn trim_monitor_sides_and_shift_counters() {
let mut s = demo();
let c = v1(&s);
s.execute("trim.selectEditPoint", json!({"clip": c[1].0, "edge": "in", "kind": "ripple"})).unwrap();
let info = s.execute("trim.monitor", json!({})).unwrap();
assert_eq!(info["outgoing"]["clip"], json!(c[0].0), "{info}");
assert_eq!(info["incoming"]["clip"], json!(c[1].0));
assert_eq!(info["outgoing"]["time"], json!((c[0].2 - s.sequence_rate().frame_duration()).0), "outgoing shows its last frame");
assert_eq!(info["incoming"]["time"], json!(c[1].1));
assert_eq!(info["edge"], json!("in"));
s.execute("prefs.set", json!({"key": "trim.largeTrimOffset", "value": 3})).unwrap();
s.execute("trim.forwardMany", json!({})).unwrap();
s.execute("trim.forward", json!({})).unwrap();
let info = s.execute("trim.monitor", json!({})).unwrap();
assert_eq!(info["inShift"], json!(4), "large offset 3 + 1: {info}");
assert_eq!(info["outShift"], json!(0), "a ripple In trim leaves the outgoing side alone");
// a new selection starts new counters
s.execute("trim.selectEditPoint", json!({"clip": c[1].0, "edge": "in", "kind": "roll"})).unwrap();
assert_eq!(s.execute("trim.monitor", json!({})).unwrap()["inShift"], json!(0));
// Apply Default Transitions to Selection at the edit point
let r = s.execute("trim.applyDefaultTransition", json!({})).unwrap();
assert_eq!(r["transitions"].as_array().unwrap().len(), 1, "{r}");
let seq = s.execute("sequence.inspect", json!({})).unwrap();
assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty());
}
+1
View File
@@ -26,4 +26,5 @@ rayon = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif"] }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
filmcraft-codecs = { workspace = true }
+1 -1
View File
@@ -65,7 +65,7 @@ fn mjpeg_mov_roundtrip() {
let a = src.audio(0, 24_000, 48_000).unwrap();
let pk = a.peaks()[0];
assert!((pk - 0.5).abs() < 0.05, "tone at -6 dB: {pk}");
if let Some(ffprobe) = ["/opt/homebrew/bin/ffprobe", "/usr/bin/ffprobe"].into_iter().find(|p| std::path::Path::new(p).exists()) {
if let Some(ffprobe) = filmcraft_testkit::ffprobe_or_skip("export mjpeg frame count") {
let out = std::process::Command::new(ffprobe)
.args(["-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", &path])
.output()
+10 -2
View File
@@ -19,6 +19,9 @@
//! Schema history:
//! - **v1** (M0–M11): the bare serialized `Project` object with a `"version": 1` field.
//! - **v2** (M11.1): the envelope above; `Project.version` dropped (the envelope carries it).
//! - **v3** (M10.2): graphic clips (`ItemKind::Graphic`, text/shape layers). The shape of older
//! data is unchanged (no-op step); the bump makes builds without graphics refuse such files
//! cleanly instead of failing to parse them.
pub mod atomic;
pub mod autosave;
@@ -37,7 +40,7 @@ pub type Migration = fn(Value) -> Result<Value, String>;
/// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never
/// edit a shipped one.
pub const MIGRATIONS: &[Migration] = &[v1_to_v2];
pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3];
/// The schema version this build writes (and the newest it reads).
pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1;
@@ -211,13 +214,18 @@ fn v1_to_v2(doc: Value) -> Result<Value, String> {
}))
}
/// v2 → v3: graphic clips added; existing data needs no change.
fn v2_to_v3(doc: Value) -> Result<Value, String> {
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_version_matches_table() {
assert_eq!(SCHEMA_VERSION, 2);
assert_eq!(SCHEMA_VERSION, 3);
}
#[test]
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "filmcraft-golden"
description = "Golden-image tests for the CPU renderer/compositor and GPU-vs-CPU parity (test-only crate)"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish = false
[lints]
workspace = true
# Test-only: the library is empty and everything is a dev-dependency, so the crate adds no edges
# to the layer graph. Goldens live in `goldens/` (see docs/testing.md §3).
[dependencies]
[dev-dependencies]
filmcraft-testkit = { workspace = true }
filmcraft-time = { workspace = true }
filmcraft-geom = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-gpu = { workspace = true }
pollster = "0.4"
wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] }
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@@ -0,0 +1,8 @@
asset: blend_modes.png
title: Blend modes (Multiply, Screen, Overlay, Difference)
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (blend_modes). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,8 @@
asset: crop.png
title: Crop effect
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (crop). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,8 @@
asset: gaussian_blur.png
title: Gaussian Blur effect
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (gaussian_blur). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,8 @@
asset: graphic_shapes.png
title: Graphic clip: ellipse, polygon, path and rotated text with background
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (graphic_shapes). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,8 @@
asset: graphic_title.png
title: Graphic clip: title text with stroke and shadow, lower-third bar
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (graphic_title). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,8 @@
asset: lumetri_basic.png
title: Lumetri Color basic correction
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (lumetri_basic). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -0,0 +1,8 @@
asset: text_burnin.png
title: Timecode and Clip Name burn-in text
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (text_burnin). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,8 @@
asset: transform_opacity.png
title: Motion transform and opacity
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (transform_opacity). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,8 @@
asset: transition_cross_dissolve.png
title: Cross Dissolve at 50%
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (transition_cross_dissolve). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,8 @@
asset: transition_dip_to_black.png
title: Dip to Black at 25%
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (transition_dip_to_black). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,8 @@
asset: transition_wipe.png
title: Wipe at 50%
author: FilmCraft contributors
source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1)
notes: Golden reference image generated by crates/golden/tests/golden.rs (transition_wipe). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.
+8
View File
@@ -0,0 +1,8 @@
//! Golden-image tests for FilmCraft's renderer (test-only crate; the library is empty).
//!
//! `tests/golden.rs` builds small procedural projects (no media files), renders one frame of each
//! through the CPU reference compositor (`filmcraft-render`) and compares it with a committed PNG
//! in `goldens/` using `filmcraft_testkit::golden`. The same scenes are composited on the GPU
//! (`filmcraft-gpu`) when an adapter is available and compared with the CPU result.
//!
//! Regenerate references with `FILMCRAFT_BLESS=1 cargo test -p filmcraft-golden`.
+453
View File
@@ -0,0 +1,453 @@
//! Golden-image tests: procedural projects rendered through the CPU compositor and compared with
//! committed reference PNGs (`goldens/*.png`, made by this test with `FILMCRAFT_BLESS=1`), plus
//! GPU-vs-CPU parity on the same scenes when a GPU adapter is available.
//!
//! Tolerances (8-bit sRGB levels, see `filmcraft_testkit::golden`):
//! - CPU vs golden: `Tolerance::RENDER` — PSNR ≥ 45 dB, max abs ≤ 12, 99th percentile ≤ 2.
//! - GPU vs CPU: 99th percentile of the per-pixel max channel difference ≤ 6 and mean < 1.5
//! (antialiased edges and half-float textures differ slightly; the CPU is the reference).
use std::path::PathBuf;
use std::sync::Arc;
use filmcraft_geom::Vec2;
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{DemoScene, Generator, MediaSource, SharedSource};
use filmcraft_project::{
ClipId, EffectInstance, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackItem, TrackKind, Transition,
TransitionId, find_effect,
};
use filmcraft_render::{RenderOptions, SourceMap, render_sequence};
use filmcraft_testkit::golden::{Rgba8, Tolerance, assert_golden, diff};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
const W: u32 = 320;
const H: u32 = 180;
const RATE: FrameRate = FrameRate::FPS_24;
/// A procedurally generated project and the frame to render.
struct Scene {
project: Project,
seq: ItemId,
sources: SourceMap,
frame: i64,
}
struct Builder {
p: Project,
map: SourceMap,
seq: ItemId,
next_transition: u64,
}
impl Builder {
fn new(video_tracks: usize) -> Self {
let mut p = Project::new("golden");
let seq = p.new_sequence("golden", SequenceSettings { width: W, height: H, frame_rate: RATE, ..Default::default() }, video_tracks, 0, None);
Builder { p, map: SourceMap::default(), seq, next_transition: 1 }
}
/// A generated media item (640×360, 24 fps, 10 s).
fn media(&mut self, g: Generator) -> ItemId {
let src = GeneratorSource::new(g, 640, 360, RATE, Tick(10 * TICKS_PER_SECOND));
let info = src.info().clone();
let id = self.p.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(src.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
}),
None,
);
self.map.0.insert(id, Arc::new(src) as SharedSource);
id
}
fn demo(&mut self, scene: DemoScene) -> ItemId {
self.media(Generator::Demo(scene))
}
/// Place `item` on video track `track` (frames), scaled to the frame size.
fn place(&mut self, track: usize, item: ItemId, start: i64, dur: i64) -> ClipId {
let mut ti = self.p.make_track_item(item, TrackKind::Video, RATE.tick_of(start), TimeRange::new(Tick::ZERO, RATE.tick_of(dur)), RATE).unwrap();
ti.scale_to_frame = true;
let id = ti.id;
let s = self.p.sequence_mut(self.seq).unwrap();
s.video_tracks[track].items.push(ti);
s.video_tracks[track].sort();
id
}
fn clip(&mut self, id: ClipId) -> &mut TrackItem {
self.p.sequence_mut(self.seq).unwrap().find_item_mut(id).unwrap().1
}
/// Set intrinsic-effect parameters (`motion`, `opacity`).
fn fixed(&mut self, id: ClipId, effect: &str, params: &[(&str, ParamValue)]) {
let e = self.clip(id).effect_mut(effect).unwrap();
set(e, params);
}
/// Append a standard effect with parameters.
fn effect(&mut self, id: ClipId, effect: &str, params: &[(&str, ParamValue)]) {
let mut e = find_effect(effect).unwrap_or_else(|| panic!("no effect {effect}")).instance();
set(&mut e, params);
self.clip(id).effects.push(e);
}
/// A transition of `dur` frames centred on the cut at `cut` between `a` and `b` on `track`.
fn transition(&mut self, track: usize, effect: &str, a: ClipId, b: ClipId, cut: i64, dur: i64) {
let id = TransitionId(self.next_transition);
self.next_transition += 1;
let tr = Transition {
id,
effect: find_effect(effect).unwrap_or_else(|| panic!("no transition {effect}")).instance(),
start: RATE.tick_of(cut - dur / 2),
duration: RATE.tick_of(dur),
from: Some(a),
to: Some(b),
align: Default::default(),
reverse: false,
};
self.p.sequence_mut(self.seq).unwrap().video_tracks[track].transitions.push(tr);
}
fn at(self, frame: i64) -> Scene {
Scene { project: self.p, seq: self.seq, sources: self.map, frame }
}
}
fn set(e: &mut EffectInstance, params: &[(&str, ParamValue)]) {
let id = e.effect.clone();
for (k, v) in params {
e.param_mut(k).unwrap_or_else(|| panic!("{id}: no param {k}")).value = v.clone();
}
}
fn fl(v: f64) -> ParamValue {
ParamValue::Float(v)
}
fn pt(x: f64, y: f64) -> ParamValue {
ParamValue::Vec2(Vec2::new(x, y))
}
fn blend(name: &str) -> ParamValue {
ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == name).unwrap_or_else(|| panic!("blend {name}")) as u32)
}
// ---- scenes ----
/// Motion (scale, rotation, position) and 70 % opacity over a full-frame background.
fn transform_opacity() -> Scene {
let mut b = Builder::new(2);
let bg = b.demo(DemoScene::OceanSunset);
let fg = b.demo(DemoScene::Aurora);
b.place(0, bg, 0, 48);
let top = b.place(1, fg, 0, 48);
b.fixed(top, "motion", &[("scale", fl(45.0)), ("rotation", fl(20.0)), ("position", pt(215.0, 70.0))]);
b.fixed(top, "opacity", &[("opacity", fl(70.0))]);
b.at(12)
}
/// Four quadrant layers over a background in Multiply, Screen, Overlay and Difference.
fn blend_modes() -> Scene {
let mut b = Builder::new(5);
let bg = b.demo(DemoScene::OceanSunset);
let fg = b.demo(DemoScene::Dunes);
b.place(0, bg, 0, 48);
for (i, (mode, x, y)) in [("Multiply", 80.0, 45.0), ("Screen", 240.0, 45.0), ("Overlay", 80.0, 135.0), ("Difference", 240.0, 135.0)].into_iter().enumerate()
{
let c = b.place(i + 1, fg, 0, 48);
b.fixed(c, "motion", &[("scale", fl(50.0)), ("position", pt(x, y))]);
b.fixed(c, "opacity", &[("blend", blend(mode))]);
}
b.at(12)
}
/// Gaussian Blur on colour bars (sharp edges show the kernel).
fn gaussian_blur() -> Scene {
let mut b = Builder::new(1);
let bars = b.media(Generator::BarsAndTone);
let c = b.place(0, bars, 0, 48);
b.effect(c, "gaussian_blur", &[("blurriness", fl(12.0))]);
b.at(12)
}
/// Lumetri Color basic correction: temperature, exposure, contrast, highlights/shadows, saturation.
fn lumetri_basic() -> Scene {
let mut b = Builder::new(1);
let bg = b.demo(DemoScene::OceanSunset);
let c = b.place(0, bg, 0, 48);
b.effect(
c,
"lumetri",
&[
("temperature", fl(25.0)),
("exposure", fl(0.6)),
("contrast", fl(30.0)),
("highlights", fl(-30.0)),
("shadows", fl(25.0)),
("saturation", fl(135.0)),
],
);
b.at(12)
}
/// Crop (left/top/right/bottom) of a top layer revealing the background.
fn crop() -> Scene {
let mut b = Builder::new(2);
let bg = b.demo(DemoScene::OceanSunset);
let bars = b.media(Generator::BarsAndTone);
b.place(0, bg, 0, 48);
let c = b.place(1, bars, 0, 48);
b.effect(c, "crop", &[("left", fl(15.0)), ("top", fl(10.0)), ("right", fl(25.0)), ("bottom", fl(20.0))]);
b.at(12)
}
/// Two clips with a 12-frame transition centred on the cut at frame 24, rendered at `frame`.
fn transition(effect: &str, frame: i64) -> Scene {
let mut b = Builder::new(1);
let a = b.demo(DemoScene::OceanSunset);
let c = b.demo(DemoScene::Aurora);
let ca = b.place(0, a, 0, 24);
let cb = b.place(0, c, 24, 24);
b.transition(0, effect, ca, cb, 24, 12);
b.at(frame)
}
/// Timecode and Clip Name burn-ins (text engine: JetBrains Mono / Inter).
fn text_burnin() -> Scene {
let mut b = Builder::new(1);
let bg = b.demo(DemoScene::CityNight);
let c = b.place(0, bg, 0, 48);
b.effect(c, "timecode", &[("position", pt(160.0, 40.0)), ("size", fl(20.0))]);
b.effect(c, "clip_name", &[("position", pt(160.0, 145.0)), ("size", fl(12.0))]);
b.at(30)
}
/// A graphic clip on V2 over `bg` on V1, with `layers` (graphic layer effect instances).
fn graphic_scene(bg: DemoScene, layers: Vec<EffectInstance>) -> Scene {
let mut b = Builder::new(2);
let bgi = b.demo(bg);
b.place(0, bgi, 0, 48);
let g = b.p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: W, height: H, rate: RATE }, None);
let c = b.place(1, g, 0, 48);
for mut l in layers {
filmcraft_project::resolve_auto_points(&mut l, (W, H), (W, H));
b.clip(c).effects.push(l);
}
b.at(12)
}
/// A title: bold centred text with an outer stroke and a soft drop shadow, a lower-third bar with
/// rounded corners and a background-boxed caption line.
fn graphic_title() -> Scene {
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
let mut bar = new_shape_layer(0, Vec2::new(160.0, 150.0), Vec2::new(280.0, 34.0), vec![]);
set(&mut bar, &[("corner_radius", fl(8.0)), ("fill_color", ParamValue::Color([0.12, 0.35, 0.8, 1.0])), ("opacity", fl(85.0))]);
let mut title = new_text_layer("Night Drive", Vec2::new(160.0, 80.0), 44.0);
set(
&mut title,
&[
("font_style", ParamValue::Text("Bold".into())),
("align", ParamValue::Choice(1)),
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(2.5)),
("stroke_color", ParamValue::Color([0.05, 0.05, 0.1, 1.0])),
("shadow", ParamValue::Bool(true)),
("shadow_distance", fl(5.0)),
("shadow_blur", fl(8.0)),
("tracking", fl(40.0)),
],
);
let mut sub = new_text_layer("Directed by Nobody", Vec2::new(160.0, 156.0), 16.0);
set(
&mut sub,
&[
("align", ParamValue::Choice(1)),
("font", ParamValue::Text("Noto Serif".into())),
("faux_italic", ParamValue::Bool(true)),
("caps", ParamValue::Choice(2)),
("fill_color", ParamValue::Color([1.0, 0.92, 0.6, 1.0])),
],
);
graphic_scene(DemoScene::CityNight, vec![bar, title, sub])
}
/// Shapes: ellipse with centre stroke, rotated polygon with inner stroke, a pen path, rotated
/// boxed text with a background and 2 strokes.
fn graphic_shapes() -> Scene {
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
let mut ell = new_shape_layer(1, Vec2::new(70.0, 60.0), Vec2::new(100.0, 70.0), vec![]);
set(
&mut ell,
&[
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(6.0)),
("stroke_type", ParamValue::Choice(1)),
("stroke_color", ParamValue::Color([1.0, 1.0, 1.0, 1.0])),
],
);
let mut poly = new_shape_layer(2, Vec2::new(250.0, 60.0), Vec2::new(80.0, 80.0), vec![]);
set(
&mut poly,
&[
("sides", fl(5.0)),
("rotation", fl(18.0)),
("fill_color", ParamValue::Color([0.2, 0.8, 0.4, 1.0])),
("stroke", ParamValue::Bool(true)),
("stroke_type", ParamValue::Choice(2)),
("stroke_width", fl(5.0)),
],
);
let path = new_shape_layer(3, Vec2::new(70.0, 140.0), Vec2::new(0.0, 0.0), vec![[-40.0, 20.0], [0.0, -25.0], [40.0, 20.0], [0.0, 5.0]]);
let mut txt = new_text_layer("Rotated\nTwo lines", Vec2::new(215.0, 140.0), 20.0);
set(
&mut txt,
&[
("rotation", fl(-12.0)),
("align", ParamValue::Choice(1)),
("background", ParamValue::Bool(true)),
("background_radius", fl(6.0)),
("background_size", fl(6.0)),
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(1.5)),
("stroke2", ParamValue::Bool(true)),
("stroke2_width", fl(3.5)),
],
);
graphic_scene(DemoScene::Aurora, vec![ell, poly, path, txt])
}
/// (name, title, scene).
fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
vec![
("transform_opacity", "Motion transform and opacity", transform_opacity),
("blend_modes", "Blend modes (Multiply, Screen, Overlay, Difference)", blend_modes),
("gaussian_blur", "Gaussian Blur effect", gaussian_blur),
("lumetri_basic", "Lumetri Color basic correction", lumetri_basic),
("crop", "Crop effect", crop),
("transition_cross_dissolve", "Cross Dissolve at 50%", || transition("cross_dissolve", 24)),
("transition_dip_to_black", "Dip to Black at 25%", || transition("dip_to_black", 21)),
("transition_wipe", "Wipe at 50%", || transition("wipe", 24)),
("text_burnin", "Timecode and Clip Name burn-in text", text_burnin),
("graphic_title", "Graphic clip: title text with stroke and shadow, lower-third bar", graphic_title),
("graphic_shapes", "Graphic clip: ellipse, polygon, path and rotated text with background", graphic_shapes),
]
}
fn render_cpu(s: &Scene) -> Rgba8 {
let img = render_sequence(&s.project, s.seq, RATE.tick_of(s.frame), RenderOptions::default(), &s.sources);
Rgba8::new(img.w as u32, img.h as u32, img.over_black_rgba8())
}
fn golden_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("goldens").join(format!("{name}.png"))
}
fn check(name: &str) {
let (_, title, make) = scenes().into_iter().find(|(n, _, _)| *n == name).unwrap();
let img = render_cpu(&make());
assert_eq!((img.w, img.h), (W, H));
let d = assert_golden(&golden_path(name), &img, Tolerance::RENDER, title, &format!("crates/golden/tests/golden.rs ({name})"));
eprintln!("{name}: {d}");
}
macro_rules! goldens {
($($name:ident),* $(,)?) => {
mod cpu {
$(
#[test]
fn $name() {
super::check(stringify!($name));
}
)*
}
#[test]
fn every_scene_has_a_test() {
let tested = [$(stringify!($name)),*];
for (n, _, _) in scenes() {
assert!(tested.contains(&n), "scene {n} has no golden test");
}
}
};
}
goldens!(
transform_opacity,
blend_modes,
gaussian_blur,
lumetri_basic,
crop,
transition_cross_dissolve,
transition_dip_to_black,
transition_wipe,
text_burnin,
graphic_title,
graphic_shapes
);
/// Sanity: the scenes are not trivially empty or identical to each other.
#[test]
fn scenes_are_distinct() {
let imgs: Vec<(&str, Rgba8)> = scenes().into_iter().map(|(n, _, f)| (n, render_cpu(&f()))).collect();
for (n, img) in &imgs {
let lit = img.px.chunks_exact(4).filter(|p| p[0] as u32 + p[1] as u32 + p[2] as u32 > 30).count();
assert!(lit > img.px.len() / 4 / 4, "{n}: mostly black");
}
for i in 0..imgs.len() {
for j in i + 1..imgs.len() {
let d = diff(&imgs[i].1, &imgs[j].1).unwrap();
assert!(d.psnr < 30.0, "{} and {} are near-identical ({d})", imgs[i].0, imgs[j].0);
}
}
}
// ---- GPU parity ----
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::default();
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
}
#[test]
fn gpu_matches_cpu_on_golden_scenes() {
let Some((dev, q)) = device() else {
eprintln!("SKIPPED (gpu parity): no GPU adapter");
return;
};
let mut c = filmcraft_gpu::GpuCompositor::new(&dev, &q);
let mut failures = Vec::new();
for (name, _, make) in scenes() {
let s = make();
let t = RATE.tick_of(s.frame);
let cpu = render_cpu(&s);
let plan = filmcraft_render::plan::plan_frame(&s.project, s.seq, t, RenderOptions::default(), &s.sources);
let kind = if matches!(plan, filmcraft_render::plan::FramePlan::Layers { .. }) { "layers" } else { "cpu image" };
c.composite(&plan);
let (gw, gh, mut px) = c.read_output().expect("GPU readback");
px.chunks_exact_mut(4).for_each(|p| p[3] = 255);
let gpu = Rgba8::new(gw, gh, px);
let d = diff(&cpu, &gpu).unwrap();
// mean over RGB only (alpha is equal by construction)
let mean_rgb = d.mean_abs * 4.0 / 3.0;
eprintln!("{name} ({kind}): GPU vs CPU {d}");
if d.p99 > 6 || mean_rgb >= 1.5 {
let dir = filmcraft_testkit::golden::failures_dir();
let _ = filmcraft_testkit::golden::write_png(&dir.join(format!("{name}.gpu.png")), &gpu);
let _ = filmcraft_testkit::golden::write_png(&dir.join(format!("{name}.cpu.png")), &cpu);
failures.push(format!("{name} ({kind}): {d}; images in {}", dir.display()));
}
}
assert!(failures.is_empty(), "GPU differs from the CPU reference:\n{}", failures.join("\n"));
}
+7 -4
View File
@@ -33,6 +33,9 @@ struct Uploaded {
code_scale: f32,
chroma: (u32, u32),
last_used: u64,
/// The uploaded pixel buffers, kept alive while cached: the cache key is the buffer address,
/// and a freed buffer's address can be reused by a different frame (stale texture).
_pixels: PixelData,
}
pub struct GpuCompositor {
@@ -330,7 +333,7 @@ impl GpuCompositor {
let up = match &f.data {
PixelData::Rgba8(d) => {
let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba8UnormSrgb, d, 4);
Uploaded { views: [v, dummy(), dummy()], kind: 0, code_scale: 1.0, chroma: (w, h), last_used: self.clock }
Uploaded { views: [v, dummy(), dummy()], kind: 0, code_scale: 1.0, chroma: (w, h), last_used: self.clock, _pixels: f.data.clone() }
}
PixelData::RgbaF32(d) => {
let owned;
@@ -342,7 +345,7 @@ impl GpuCompositor {
}
};
let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba16Float, half, 8);
Uploaded { views: [v, dummy(), dummy()], kind: 1, code_scale: 1.0, chroma: (w, h), last_used: self.clock }
Uploaded { views: [v, dummy(), dummy()], kind: 1, code_scale: 1.0, chroma: (w, h), last_used: self.clock, _pixels: f.data.clone() }
}
PixelData::Yuv8 { planes, chroma, .. } => {
let (sx, sy) = chroma.shifts();
@@ -350,7 +353,7 @@ impl GpuCompositor {
let y = self.plane_texture(w, h, wgpu::TextureFormat::R8Unorm, &planes[0], 1);
let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[1], 1);
let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[2], 1);
Uploaded { views: [y, u, v], kind: 2, code_scale: 255.0, chroma: (cw, ch), last_used: self.clock }
Uploaded { views: [y, u, v], kind: 2, code_scale: 255.0, chroma: (cw, ch), last_used: self.clock, _pixels: f.data.clone() }
}
PixelData::Yuv16 { planes, chroma, bits, .. } => {
let (sx, sy) = chroma.shifts();
@@ -368,7 +371,7 @@ impl GpuCompositor {
let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[1], 2);
let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[2], 2);
let _ = Chroma::C420;
Uploaded { views: [y, u, v], kind: 2, code_scale: scale, chroma: (cw, ch), last_used: self.clock }
Uploaded { views: [y, u, v], kind: 2, code_scale: scale, chroma: (cw, ch), last_used: self.clock, _pixels: f.data.clone() }
}
};
self.uploads.insert(key, up);
+18
View File
@@ -125,3 +125,21 @@ fn prepared_upload_matches_inline_conversion() {
assert!(inline == prepared, "prepared upload differs");
}
}
/// The upload cache is keyed by pixel-buffer address, so it must keep the buffer alive: otherwise
/// a new frame allocated at a freed frame's address is drawn with the stale texture (seen as
/// whole frames from a previous composite in `crates/golden` GPU parity).
#[test]
fn upload_cache_keeps_buffers_alive() {
let Some((dev, q)) = device() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut c = GpuCompositor::new(&dev, &q);
let px = Arc::new(vec![200u8; 16 * 8 * 4]);
let frame = Arc::new(VideoFrame { width: 16, height: 8, data: PixelData::Rgba8(px.clone()), ..(*yuv_frame(16, 8)).clone() });
let plan = FramePlan::Layers { width: 16, height: 8, layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0 }] };
c.composite(&plan);
drop(plan);
assert!(Arc::strong_count(&px) > 1, "cached upload must own its pixel buffer");
}
+1
View File
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+5 -10
View File
@@ -437,17 +437,12 @@ type Planes3 = (Vec<u8>, Vec<u8>, Vec<u8>);
/// Validate the hand-built stream and its expected output with ffmpeg when available.
fn cross_check_ffmpeg(stream: &[u8], expected: &[Planes3], name: &str) {
let ff = std::path::Path::new("/opt/homebrew/bin/ffmpeg");
if !ff.exists() {
eprintln!("ffmpeg not found; skipping oracle check of {name}");
return;
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/h264");
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join(format!("{name}.{}.h264", std::process::id()));
let dst = dir.join(format!("{name}.{}.yuv", std::process::id()));
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip(name) else { return };
let dir = filmcraft_testkit::fixtures_dir("h264");
let src = filmcraft_testkit::temp_path(&dir.join(format!("{name}.h264")));
let dst = filmcraft_testkit::temp_path(&dir.join(format!("{name}.yuv")));
std::fs::write(&src, stream).unwrap();
let ok = std::process::Command::new(ff)
let ok = std::process::Command::new(&ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&src)
.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"])
+7 -16
View File
@@ -307,19 +307,13 @@ pub fn fixture(name: &str) -> &'static Fixture {
FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}"))
}
/// ffmpeg (see `filmcraft_testkit::oracle`).
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
filmcraft_testkit::ffmpeg()
}
pub fn fixtures_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/h264");
std::fs::create_dir_all(&d).expect("create fixtures dir");
d
filmcraft_testkit::fixtures_dir("h264")
}
fn run(cmd: &mut Command) -> bool {
@@ -339,15 +333,12 @@ fn run(cmd: &mut Command) -> bool {
/// Generate (if needed) the fixture stream and its ffmpeg reference decode.
/// Returns None (with a message) when ffmpeg is unavailable.
pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
let Some(ff) = ffmpeg() else {
eprintln!("SKIP {}: ffmpeg not found", f.name);
return None;
};
let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?;
let dir = fixtures_dir();
let h264 = dir.join(format!("{}.h264", f.name));
let yuv = dir.join(format!("{}.yuv", f.name));
if !h264.exists() {
let tmp = dir.join(format!("{}.tmp.{}.h264", f.name, std::process::id()));
let tmp = filmcraft_testkit::temp_path(&h264);
let mut vf = format!("{}=size={}x{}:rate=25,format=yuv420p", f.source, f.width, f.height);
if !f.filter.is_empty() {
vf.push(',');
@@ -364,13 +355,13 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
if !run(&mut c) {
// Platform encoders (VideoToolbox) are optional; libx264 fixtures must generate.
assert!(f.args.contains(&"-c:v"), "fixture generation failed for {}", f.name);
eprintln!("SKIP {}: encoder unavailable", f.name);
eprintln!("SKIPPED ({}): encoder unavailable", f.name);
return None;
}
std::fs::rename(&tmp, &h264).unwrap();
}
if !yuv.exists() {
let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, std::process::id()));
let tmp = filmcraft_testkit::temp_path(&yuv);
let mut c = Command::new(&ff);
c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&h264);
c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"]).arg(&tmp);
+11
View File
@@ -95,3 +95,14 @@ fn coverage_report() {
);
}
}
/// Pre-generate every fixture and its reference decode (`cargo xtask fixtures`).
#[test]
#[ignore]
fn generate_fixtures() {
let dir = common::fixtures_dir();
for f in common::FIXTURES {
let outs = [dir.join(format!("{}.h264", f.name)), dir.join(format!("{}.yuv", f.name))];
filmcraft_testkit::fixtures::generate_and_report(&format!("h264/{}", f.name), &outs, || common::ensure(f));
}
}
+3
View File
@@ -17,3 +17,6 @@ threads = ["dep:rayon"]
filmcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+6 -16
View File
@@ -95,29 +95,19 @@ pub fn synth(w: usize, h: usize, t: usize) -> Yuv {
Yuv { w, h, y, u, v }
}
/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`.
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(p.into());
}
}
None
filmcraft_testkit::ffmpeg_or_skip("h264enc oracle")
}
/// ffprobe (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`.
pub fn ffprobe() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe", "/usr/bin/ffprobe"] {
if Path::new(p).exists() {
return Some(p.into());
}
}
None
filmcraft_testkit::ffprobe_or_skip("h264enc oracle")
}
/// Per-test output directory under `<workspace>/target/fixtures/h264enc/`.
pub fn out_dir(name: &str) -> PathBuf {
let base = std::env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target"));
let d = base.join("fixtures").join("h264enc").join(name);
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir(&format!("h264enc/{name}"))
}
/// Decode an Annex-B file with ffmpeg; returns (stderr, raw yuv420p bytes).
+1
View File
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+5 -8
View File
@@ -546,14 +546,10 @@ fn as_u8(p: &crate::Plane) -> &[u8] {
/// Compare with ffmpeg's decode of the same stream (skipped when ffmpeg is absent).
fn check_ffmpeg(name: &str, stream: &[u8], pics: &[crate::Picture]) {
let Some(ff) = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].iter().find(|p| std::path::Path::new(p).exists()) else {
eprintln!("SKIP ffmpeg check for {name}");
return;
};
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/hevc");
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join(format!("synth_{name}.hevc"));
let yuv = dir.join(format!("synth_{name}.yuv"));
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip(name) else { return };
let dir = filmcraft_testkit::fixtures_dir("hevc");
let src = filmcraft_testkit::temp_path(&dir.join(format!("synth_{name}.hevc")));
let yuv = filmcraft_testkit::temp_path(&dir.join(format!("synth_{name}.yuv")));
std::fs::write(&src, stream).unwrap();
let st = std::process::Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
@@ -577,6 +573,7 @@ fn check_ffmpeg(name: &str, stream: &[u8], pics: &[crate::Picture]) {
}
}
let _ = std::fs::remove_file(&yuv);
let _ = std::fs::remove_file(&src);
}
fn run(name: &str, tiles: Tiles, pcm_lf_disabled: bool, segments: Vec<Segment>) {
+7 -22
View File
@@ -85,19 +85,13 @@ pub fn fixture(name: &str) -> &'static Fixture {
FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}"))
}
/// ffmpeg (see `filmcraft_testkit::oracle`).
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
filmcraft_testkit::ffmpeg()
}
pub fn fixtures_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/hevc");
std::fs::create_dir_all(&d).expect("create fixtures dir");
d
filmcraft_testkit::fixtures_dir("hevc")
}
fn run(cmd: &mut Command) -> bool {
@@ -121,15 +115,12 @@ pub fn pix_fmt(bd: u32) -> &'static str {
/// Generate (if needed) the fixture stream and its ffmpeg reference decode.
/// Returns None (with a message) when ffmpeg or the encoder is unavailable.
pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
let Some(ff) = ffmpeg() else {
eprintln!("SKIP {}: ffmpeg not found", f.name);
return None;
};
let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?;
let dir = fixtures_dir();
let hevc = dir.join(format!("{}.hevc", f.name));
let yuv = dir.join(format!("{}.yuv", f.name));
if !hevc.exists() {
let tmp = dir.join(format!("{}.tmp.{}.hevc", f.name, unique()));
let tmp = filmcraft_testkit::temp_path(&hevc);
let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, pix_fmt(f.bit_depth));
if !f.filter.is_empty() {
vf.push(',');
@@ -148,13 +139,13 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
// Platform encoders (VideoToolbox) are optional; libx265 fixtures must generate.
assert!(f.args.contains(&"-c:v"), "fixture generation failed for {}", f.name);
let _ = std::fs::remove_file(&tmp);
eprintln!("SKIP {}: encoder unavailable", f.name);
eprintln!("SKIPPED ({}): encoder unavailable", f.name);
return None;
}
std::fs::rename(&tmp, &hevc).unwrap();
}
if !yuv.exists() {
let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, unique()));
let tmp = filmcraft_testkit::temp_path(&yuv);
let mut c = Command::new(&ff);
c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&hevc);
c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt(f.bit_depth)]).arg(&tmp);
@@ -316,9 +307,3 @@ pub fn check_pts(pics: &[Picture]) -> Result<(), String> {
}
Ok(())
}
/// Unique temp-file suffix: process id plus a per-process counter (tests run on parallel threads).
fn unique() -> String {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
format!("{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}
+11
View File
@@ -91,3 +91,14 @@ fn compare_detects_mismatch() {
let err = common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth).unwrap_err();
assert!(err.contains("plane V"), "{err}");
}
/// Pre-generate every fixture and its reference decode (`cargo xtask fixtures`).
#[test]
#[ignore]
fn generate_fixtures() {
let dir = common::fixtures_dir();
for f in common::FIXTURES {
let outs = [dir.join(format!("{}.hevc", f.name)), dir.join(format!("{}.yuv", f.name))];
filmcraft_testkit::fixtures::generate_and_report(&format!("hevc/{}", f.name), &outs, || common::ensure(f));
}
}
+3
View File
@@ -469,6 +469,8 @@ pub(crate) fn empty_sequence(settings: SequenceSettings) -> Sequence {
start_timecode: 0,
master_volume_db: 0.0,
master_effects: Vec::new(),
master_mixer: Default::default(),
submix_tracks: Vec::new(),
caption_tracks: Vec::new(),
}
}
@@ -594,6 +596,7 @@ pub(crate) fn transition_effect(name: &str, audio: bool, report: &mut Report) ->
enabled: true,
params: Default::default(),
masks: vec![],
post_fader: false,
})
}
+1 -1
View File
@@ -427,7 +427,7 @@ pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option<BinId>)
mk.id = MarkerId(target.alloc_id());
}
}
ItemKind::AdjustmentLayer { .. } => {}
ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => {}
}
new_items.insert(it.id, it);
}
+1
View File
@@ -14,5 +14,6 @@ filmcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
proptest = { workspace = true }
serde_json = { workspace = true }
+3 -1
View File
@@ -423,7 +423,9 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f
}
None => {
let first = tb.sizes.first().copied().unwrap_or(0);
if !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) {
// A uniform size of 0 would mean "table follows" (ISO/IEC 14496-12 §8.7.3), so
// all-empty samples must still be written as a table.
if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) {
b.u32(first);
b.u32(tb.sizes.len() as u32);
} else {
+12 -25
View File
@@ -6,37 +6,27 @@ use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Find a tool silently (see `filmcraft_testkit::oracle`).
pub fn tool(name: &str) -> Option<PathBuf> {
for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
let p = Path::new(dir).join(name);
if p.exists() {
return Some(p);
}
match name {
"ffmpeg" => filmcraft_testkit::ffmpeg(),
"ffprobe" => filmcraft_testkit::ffprobe(),
_ => filmcraft_testkit::oracle::find_tool(name),
}
let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false);
ok.then(|| PathBuf::from(name))
}
/// ffmpeg, or `None` after printing `SKIPPED` (a failure with `FILMCRAFT_REQUIRE_ORACLES=1`).
pub fn ffmpeg() -> Option<PathBuf> {
let f = tool("ffmpeg");
if f.is_none() {
eprintln!("ffmpeg not found; skipping oracle test");
}
f
filmcraft_testkit::ffmpeg_or_skip("isobmff oracle")
}
/// ffprobe, or `None` after printing `SKIPPED` (a failure with `FILMCRAFT_REQUIRE_ORACLES=1`).
pub fn ffprobe() -> Option<PathBuf> {
let f = tool("ffprobe");
if f.is_none() {
eprintln!("ffprobe not found; skipping oracle test");
}
f
filmcraft_testkit::ffprobe_or_skip("isobmff oracle")
}
pub fn fixture_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/isobmff");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("isobmff")
}
pub fn out_dir() -> PathBuf {
@@ -120,10 +110,7 @@ pub fn fixture_specs() -> Vec<(&'static str, Vec<&'static str>)> {
}
fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> bool {
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let ext = out.extension().unwrap().to_str().unwrap();
let tmp = out.with_extension(format!("tmp{}_{n}.{ext}", std::process::id()));
let tmp = filmcraft_testkit::temp_path(out);
let st = Command::new(ff).args(["-v", "error", "-y", "-nostdin"]).args(args).arg(&tmp).output();
match st {
Ok(o) if o.status.success() => std::fs::rename(&tmp, out).is_ok(),
@@ -139,7 +126,7 @@ fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> bool {
/// Path to a fixture, generating it with ffmpeg if needed. `None` if ffmpeg is missing or the
/// encoder isn't available.
pub fn fixture(name: &str) -> Option<PathBuf> {
let ff = tool("ffmpeg")?;
let ff = ffmpeg()?;
let path = fixture_dir().join(name);
if path.exists() {
return Some(path);
+10
View File
@@ -271,3 +271,13 @@ fn specific_properties() {
let v = &f.tracks[f.track_of_kind(TrackKind::Video).unwrap()];
assert!(v.references.iter().any(|(k, ids)| k.to_string() == "tmcd" && ids == &vec![t.id]));
}
/// Pre-generate every fixture (`cargo xtask fixtures`).
#[test]
#[ignore]
fn generate_fixtures() {
for name in all_fixture_names() {
let out = [fixture_dir().join(name)];
filmcraft_testkit::fixtures::generate_and_report(&format!("isobmff/{name}"), &out, || fixture(name));
}
}
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 2f1e39c31dcb71e2f413c6f8046cb8976a8d51bab762a3d6cecce91feeda3a96 # shrinks to tracks = [(0, [S { size: 0, duration: 1, cto: 0, sync: false }])], order = [], mov = false, fast = false, with_pcm = false, start = None
+17
View File
@@ -204,3 +204,20 @@ fn large_offsets_use_co64_and_largesize_mdat() {
z.extend_from_slice(&[0, 0, 0, 0, b'f', b'r', b'e', b'e', 1, 2, 3]);
assert!(open(z.as_slice()).is_ok());
}
/// Found by `progressive_roundtrip`: only zero-byte samples must not be written as uniform
/// `stsz` size 0, which readers take to mean "a size table follows".
#[test]
fn all_empty_samples_roundtrip() {
for n in [1usize, 3] {
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
w.add_track(TrackConfig::new(entry(0), 1000)).unwrap();
for _ in 0..n {
w.write_sample(0, WriteSample { data: &[], duration: 1, composition_offset: 0, is_sync: false }).unwrap();
}
let out = w.finish().unwrap().into_inner();
let f = open(out.as_slice()).unwrap();
assert_eq!(f.tracks[0].samples.len(), n);
assert!(f.tracks[0].samples.iter().all(|s| s.size == 0));
}
}
+1
View File
@@ -12,4 +12,5 @@ workspace = true
[dependencies]
[dev-dependencies]
filmcraft-testkit = { workspace = true }
serde_json = { workspace = true }
+20 -24
View File
@@ -6,32 +6,13 @@ use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
pub fn tool(name: &str) -> Option<PathBuf> {
for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
let p = Path::new(dir).join(name);
if p.exists() {
return Some(p);
}
}
let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false);
ok.then(|| PathBuf::from(name))
}
/// (ffmpeg, ffprobe), or `None` (test skipped) if either is missing.
/// (ffmpeg, ffprobe), or `None` after printing `SKIPPED` (see `filmcraft_testkit::oracle`).
pub fn tools() -> Option<(PathBuf, PathBuf)> {
match (tool("ffmpeg"), tool("ffprobe")) {
(Some(a), Some(b)) => Some((a, b)),
_ => {
eprintln!("ffmpeg/ffprobe not found; skipping oracle test");
None
}
}
Some((filmcraft_testkit::ffmpeg_or_skip("matroska oracle")?, filmcraft_testkit::ffprobe_or_skip("matroska oracle")?))
}
pub fn fixture_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/matroska");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("matroska")
}
const V: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25"];
@@ -44,6 +25,22 @@ fn write_subs(dir: &Path) {
std::fs::write(dir.join("in.vtt"), vtt).unwrap();
}
/// Every fixture `spec`/`fixture` knows (for `cargo xtask fixtures`).
pub const ALL_FIXTURES: &[&str] = &[
"h264_aac.mkv",
"vp9_opus.webm",
"flac.mkv",
"vorbis.mkv",
"subs.mkv",
"webvtt.webm",
"cues_front.mkv",
"hevc_hdr.mkv",
"prores_pcm.mkv",
"mjpeg_ac3.mkv",
"av1.mkv",
"live.mkv",
];
/// Fixture name → ffmpeg arguments (after `-y -v error`, before the output path).
/// `None` for fixtures written through a pipe (no Cues, unknown sizes).
pub fn spec(name: &str) -> Option<Vec<String>> {
@@ -93,8 +90,7 @@ pub fn fixture(ffmpeg: &Path, name: &str) -> Option<PathBuf> {
return Some(out);
}
write_subs(&dir);
let tid = format!("{:?}", std::thread::current().id()).replace(|c: char| !c.is_ascii_alphanumeric(), "");
let tmp = dir.join(format!("{name}.{}.{tid}.tmp", std::process::id()));
let tmp = filmcraft_testkit::temp_path(&out);
let ok = match spec(name) {
Some(args) => {
let mut c = Command::new(ffmpeg);
+11
View File
@@ -360,3 +360,14 @@ fn not_matroska() {
assert!(matches!(open(&ebml[..]), Err(Error::NotMatroska(_))));
let _ = Value::Null;
}
/// Pre-generate every fixture (`cargo xtask fixtures`).
#[test]
#[ignore]
fn generate_fixtures() {
let Some((ffmpeg, _)) = tools() else { return };
for name in ALL_FIXTURES {
let out = [fixture_dir().join(name)];
filmcraft_testkit::fixtures::generate_and_report(&format!("matroska/{name}"), &out, || fixture(&ffmpeg, name));
}
}

Some files were not shown because too many files have changed in this diff Show More