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>
@@ -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 |
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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, ¶ms);
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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", ""),
|
||||
];
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
@@ -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`.
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
|
||||
rayon = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -19,3 +19,4 @@ thiserror = { workspace = true }
|
||||
rayon = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ filmcraft-bitstream = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ workspace = true
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||