Compare commits

...
1382 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 71438c627b Release 0.7.2: install the wasm-bindgen CLI for the web release build
The shared release workflow installs trunk, but FilmCraft's web build
runs the wasm-bindgen CLI directly (cargo xtask web), so the 0.7.1 Web
job failed with "wasm-bindgen CLI not found". In CI, package.sh now
installs the CLI at xtask's WASM_BINDGEN version, as the per-app
release.yml did before the move to craft-common-actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:37:09 -04:00
Brandon ThomasandClaude Opus 5.5 0879bd7a7e Release 0.7.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:10 -04:00
Brandon ThomasandClaude Opus 5.5 84518da96f Release workflow: pass the Azure signing secrets so Windows builds are signed (#1169)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:08:21 -04:00
Brandon ThomasandClaude Opus 5.5 0541d4d182 Release prep: 0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:29 -04:00
Ikki Nishihara 78fe0102bd fix(timeline): 縦スクロールバーを横バーと同じ幅に揃える (#1189)
(cherry picked from commit e65699b125)
2026-10-11 23:22:17 -04:00
Ikki Nishihara 5f05761ded fix(timeline): 波形をクリップ背景の暗い同系色で表示する (#1183)
(cherry picked from commit 5e1c985925)
2026-10-11 23:22:17 -04:00
Ikki Nishihara b8ff6b09ad fix(timeline): 無効クリップのラベル色を保持する (#1190)
(cherry picked from commit f6248ddf5d)
2026-10-11 23:22:17 -04:00
Ikki Nishihara 2ad63fd7ff fix(timeline): HomeとEndで再生位置を常に表示する (#1188)
(cherry picked from commit c79e77a132)
2026-10-11 23:22:17 -04:00
Brandon Thomas 1fdfd8324d Merge pull request #1217 from liangforstudy/fix-movable-dialogs
dialogs: drag them by the title bar (open centred, no longer anchored)
2026-10-11 23:15:46 -04:00
Brandon Thomas eb37bea42e Merge pull request #1193 from OorjitSethi/split/editor-redraw
ui: reduce sequence copying and cache transcript redraw data
2026-10-11 23:15:45 -04:00
Brandon Thomas 5c14985990 Merge pull request #1198 from n-delic/fix-1124-cmd-d-selection
#1124: ⌘D / ⇧⌘D apply transitions to the selected clips or edit points
2026-10-11 23:15:45 -04:00
Brandon Thomas 54653a15a6 Merge pull request #1168 from haranishi/fix/1135-close-gap-shortcut
fix: Close Gapをショートカットに割り当てられるようにする
2026-10-11 23:15:44 -04:00
Brandon ThomasandClaude Opus 5.5 a2573d9228 Selected-clip transitions honour an explicit edge
sequence.applyVideoTransition {edge: "out"} with a clip selected put
transitions on both free edges (speed_duration_moves_fade_out_to_the_new_clip_end
failed); with edge given only that side is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:15:36 -04:00
Brandon Thomas a1726ac09b Merge remote-tracking branch 'origin/main' into fix-1198 2026-10-11 23:13:26 -04:00
Brandon Thomas ca36cf9a6a Merge pull request #1052 from Auipp/fix/timeline-escape-drag
timeline: cancel drag and restore playhead on Escape (#580)
2026-10-11 22:53:29 -04:00
Brandon ThomasandClaude Opus 5.5 8f400822fa ui-egui: drop an unused import in the timeline Escape test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:53:20 -04:00
Brandon ThomasandClaude Opus 5.5 4b31803461 Merge main into #1052: Escape keeps restoring scrub, volume, pan and the marquee's selection next to #608, #1028, #1090 and #1104
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:52:40 -04:00
Brandon ThomasandClaude Opus 5.5 4ccc1a84c4 gpu: devices whose effect pipelines fail validation fall back to CPU effects (from #37)
The only part of #37 not already on main: FxStage::new runs inside a
validation error scope, so a device where Rgba32Float is not renderable
(software Vulkan/GL) logs and renders effects on the CPU instead of panicking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:44:12 -04:00
Brandon Thomas 66c9bc6173 Merge pull request #1063 from vbasky/fix/1058-linux-vaapi-encode-export-hang
Linux: VA-API EncSlice probe and bounded export GPU wait (#1058)
2026-10-11 22:41:23 -04:00
Brandon ThomasandClaude Opus 5.5 0a3abdc3d0 Keep the bounded GPU readback wait (the #1058 fix); leave out the VA-API encode probe
The probe only logged and always returned None, opened a VA display on every
H.264 Auto export, and made VA-API decode fail to load where libva lacks the
new symbol. A real VA-API encoder can come in its own PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:41:16 -04:00
Brandon Thomas 96959e5490 Merge remote-tracking branch 'origin/main' into fix-1063 2026-10-11 22:39:15 -04:00
Brandon Thomas 9532dff5dc Merge pull request #1067 from vangel76/perf-hevc-u8
#1039: 8-bit HEVC pictures as u8, in-place motion compensation (−14…23 % per frame), big-picture analysis
2026-10-11 22:36:31 -04:00
Brandon Thomas 4201c1feed Merge pull request #1070 from luniiya/feat/theme-favorites
UI: favorite themes and appearance shortcuts
2026-10-11 22:34:19 -04:00
Brandon ThomasandClaude Opus 5.5 d6a10470f3 i18n: theme favorites strings stay out of the menus-only catalogs (de, ru, uk); Indonesian gets them
de, ru and uk hold only menu labels (their tests check every entry is one);
Indonesian mirrors the Spanish catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:34:11 -04:00
Brandon ThomasandClaude Opus 5.5 452d366156 Merge main into #1070 (tests, catalogs); drop the ATTRIBUTION line for drawing code (no asset)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:29:50 -04:00
Brandon Thomas ef12831b36 Merge pull request #964 from mjohnson7cd/fix/2093-korean-ui-font
fix(ui): add Korean font fallback for UI text
2026-10-11 22:27:31 -04:00
Brandon ThomasandClaude Opus 5.5 8a6ea57886 Merge main into #964; the Hangul issue is artcraft#2093
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:27:23 -04:00
Brandon Thomas 34f6eb741d Merge remote-tracking branch 'origin/main' into fix-964 2026-10-11 22:27:15 -04:00
Brandon Thomas 2f4fba1def Merge pull request #1165 from MarekSpodBiedry/issue-1137
Trim MP3 encoder delay when decoding audio files (#1137)
2026-10-11 22:26:23 -04:00
Brandon ThomasandClaude Opus 5.5 85357ede03 codecs: gapless trimming for MP3 files only
Other formats' start and priming (AAC edit lists, …) are handled and
oracle-tested elsewhere; enable_gapless for them could move their audio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:26:15 -04:00
Brandon Thomas 812a7108d0 Merge remote-tracking branch 'origin/main' into fix-1165 2026-10-11 22:25:08 -04:00
Brandon Thomas 8264eb4077 Merge pull request #865 from earlvanze/perf/audio-block-processing
M7: optimize audio DSP and isolate the unchanged mixer throughput gate
2026-10-11 22:24:07 -04:00
Brandon ThomasandClaude Opus 5.5 84fd122bd2 Drop the benchmark log (it cites private development logs); the PR description keeps the summary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:22:46 -04:00
Brandon Thomas cd7f932853 Merge remote-tracking branch 'origin/main' into fix-865 2026-10-11 22:22:32 -04:00
Brandon Thomas f5289f69bd Merge pull request #1046 from vbasky/fix/907-median-radius
Median Radius is not capped at four working pixels
2026-10-11 22:21:35 -04:00
Brandon ThomasandClaude Opus 5.5 121e7ba27e render: Median radius stays within the parameter's range (0–100 source pixels)
The filter costs radius² per pixel; with only the image size as a cap, a
project file's radius could make one 4K frame take hours.
Regression test: median_radius_stays_in_the_parameter_range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:21:27 -04:00
Brandon Thomas 5f7745d592 Merge remote-tracking branch 'origin/main' into fix-1046 2026-10-11 22:19:41 -04:00
Brandon Thomas f364eff00a Merge pull request #837 from vangel76/gap-exr-import
#828: import OpenEXR stills and image sequences
2026-10-11 22:18:13 -04:00
Brandon ThomasandClaude Opus 5.5 59bf7cc21f Merge main into #837 (EXR next to DNG and APNG); cap EXR chunk size, fallible chunk buffers
A tiny header naming ~1000 channels on a 64M-pixel line sized each chunk
buffer at hundreds of GB and Vec::with_capacity aborted the process.
Chunks over 256 MB are refused, and the RLE / zlib buffers reserve with
try_reserve_exact. Regression test: huge_scan_lines_are_refused_before_allocating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:18:05 -04:00
Brandon Thomas 841df3ca5e Merge remote-tracking branch 'origin/main' into HEAD 2026-10-11 22:14:56 -04:00
Brandon ThomasandClaude Opus 5.5 b10d24ef5c timeline: Escape during a box selection restores the selection it began with (#608 + #1028 regression)
#608 drops any timeline drag on Escape before the release runs, but the
live box from #1028 had already changed the selection, and only the
release put it back (timeline_marquee_ui escape_cancels_the_box...).
Escape now restores the Marquee's starting selection when it drops it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:13:14 -04:00
Brandon ThomasandClaude Opus 5.5 8a36e82f2e ui: stopping playback also closes the scrub stream (#1109 + #1031 regression)
Since #1109 a press on the ruler moves the playhead at once, so the press
already opens the scrub stream. The drag start then calls stop(), which
stopped the audio device but left scrub.open set, so later grains never
restarted it (meters_ui the_meters_move_while_scrubbing: no output).
stop() now marks the scrub stream closed with the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:13:14 -04:00
Brandon ThomasandClaude Opus 5.5 0ec8c73e8f masks: the pen tool no longer deadlocks egui when a drag places a point (#667 regression)
pen_overlay read resp.drag_started_by() inside ui.data_mut(), which holds
the context's write lock that drag_started_by needs. A Shift-drag that
placed a point hung for egui's 10 s lock timeout, then panicked
(shift_constrains_pen_mask_segments_to_45_degrees got 3 points, not 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:13:14 -04:00
Brandon ThomasandClaude Opus 5.5 ab85b3d757 ui-egui: the hot-number click test clears its font texture upload (#1166)
The test ran egui frames without a renderer and dropped the output's
textures delta, which epaint 0.36 turns into a panic. The global panic hook
then failed crash::tests::hook_records_and_logs_worker_panics too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:13:14 -04:00
Brandon ThomasandClaude Opus 5.5 9761c9e39b tts-text: fractions take operands of up to three digits (#954 regression)
#954 let fraction operands run to any cardinal length, so the "7/2026"
left after an impossible date's "13/" read "seven over two thousand
twenty-six" instead of a slash and the year (normalize::tests::dates).
Three digits covers every case #954 added (7/100, 100/7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:13:14 -04:00
Brandon Thomas 03ecfaafe2 Merge pull request #1159 from haranishi/fix/967-effect-keyframe-selection
fix: エフェクトコントロールの矩形選択とShiftスナップを修正 (#967)
2026-10-11 22:12:53 -04:00
Brandon ThomasandClaude Opus 5.5 f460e4630e Merge main into #1159 (keyframe lane uses the selection module, which handles Escape itself; tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:12:45 -04:00
Brandon Thomas b6884188d8 Merge pull request #1139 from itrycodinganditsbad/fix-1101
fix(timeline): snap sub-frame timeline.place to the frame grid; inspect counts export's frames (#1101)
2026-10-11 22:09:23 -04:00
Brandon ThomasandClaude Opus 5.5 bbacbf2750 Merge main into #1139: frame-grid snapping moves into place_one (#746); saturating end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:09:15 -04:00
liangforstudyandClaude Opus 5.5 dd69fc594e dialogs: drag them by the title bar (open centred, no longer anchored)
About FilmCraft, System Compatibility Report, Sequence Settings and the
other modal dialogs were placed with `Window::anchor(CENTER_CENTER)`,
and egui makes anchored windows immovable, so dragging the title bar did
nothing. They now open at the centre of the app window through a
centre pivot and `default_pos` (`widgets::dialog_center`, or the
`CenteredDialog::centered` shorthand on one-line chains) and can be
moved like any other window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:07:49 +08:00
Brandon Thomas 9432300f0e Merge pull request #879 from vangel76/gap-exr-compression
#874: compressed (RLE / ZIPS / ZIP) OpenEXR export
2026-10-11 22:01:49 -04:00
Brandon ThomasandClaude Opus 5.5 71ceebe857 Merge main into #879 (export schema next to FLAC, catalogs, parity table)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:01:41 -04:00
Brandon Thomas 30673c84a2 Merge pull request #1068 from vangel76/feat-cinemadng
#1065: CinemaDNG / DNG import with a clean-room decoder
2026-10-11 21:59:13 -04:00
Brandon ThomasandClaude Opus 5.5 192b24664e Merge main into #1068 (media dev-dependencies, still.rs docs after #931)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:59:05 -04:00
Brandon Thomas 79b48e5452 Merge pull request #931 from liangforstudy/feat/402-apng-animation
#402: animated GIF and APNG import as clips, with transparency
2026-10-11 21:55:59 -04:00
Brandon ThomasandClaude Opus 5.5 97c640352c media: as_chunks for the alpha scan (clippy::chunks_exact_with_const_size)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:55:46 -04:00
Brandon ThomasandClaude Opus 5.5 7c21ec5e13 Merge main into #1068 (engine dev-dependencies, docs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:55:08 -04:00
Brandon ThomasandClaude Opus 5.5 3284ddb993 Merge main into #931 (README, engine Cargo.toml)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:54:47 -04:00
Brandon Thomas 42f69c654c Merge pull request #1129 from itrycodinganditsbad/fix-902
fix(menus): native menu follows command enablement (#902)
2026-10-11 21:52:49 -04:00
Brandon ThomasandClaude Opus 5.5 e283aa5303 Merge main into #1129: About/Discord and the translated predefined items take part in menu updates
Checked with cargo clippy --target aarch64-apple-darwin -p filmcraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:52:42 -04:00
Brandon Thomas 0594f86fc1 Merge pull request #1033 from ReReddo/fix/quick-export-location
export: type the file name and pick the location in Quick Export
2026-10-11 21:47:10 -04:00
Brandon Thomas 6650a4be0f Merge pull request #604 from MrCloudy2/fix/nonblocking-file-dialogs-260
#260: file dialogs on Linux no longer stop the app (GNOME "not responding")
2026-10-11 21:47:09 -04:00
Brandon ThomasandClaude Opus 5.5 f8701860d8 Merge main into #604: dialog hook in the new app creator; Install… keeps showing why a template was refused (#611)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:47:02 -04:00
Brandon Thomas 7c5367740d Merge pull request #858 from vangel76/gap-launch-paths
#850: launch files open whatever the extension's case, with absolute paths
2026-10-11 21:42:22 -04:00
Brandon ThomasandClaude Opus 5.5 0cbc6e78dc Merge main into #858: launch arguments as OsString next to --graphics and the graphics fallback start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:42:14 -04:00
Brandon ThomasandClaude Opus 5.5 cc9206cb02 rustfmt after merging #1043 and #972
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:40:12 -04:00
Brandon Thomas ce3554e299 Merge pull request #972 from vbasky/fix/912-linked-rate-stretch
Linked Rate Stretch keeps one speed when a neighbour limits a partner
2026-10-11 21:38:42 -04:00
Brandon Thomas a2f18d9ad0 Merge pull request #1043 from vbasky/fix/920-source-marks
Source marks honour frame, seconds and timecode
2026-10-11 21:38:42 -04:00
Brandon ThomasandClaude Opus 5.5 0f8533535d Merge main into #972: linked Rate Stretch also leaves clips on locked tracks alone (#934)
rate_stretch_items skips clips on locked tracks and refuses the edit when
every clip is locked, which is what the command loop on main did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:38:34 -04:00
Brandon ThomasandClaude Opus 5.5 5f0dd80169 Merge main into #1043 (commands.rs, imports)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:33:26 -04:00
Brandon ThomasandClaude Opus 5.5 c492de207c rustfmt after merging #834
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:31:11 -04:00
Brandon Thomas 584d9a32c8 Merge pull request #864 from vangel76/fix-857-marker-color
#857: reject unknown marker colour names instead of storing Green
2026-10-11 21:25:38 -04:00
Brandon Thomas 46d252b1b6 Merge pull request #834 from vbasky/fix/696-audio-effects-controls
Show audio effects in Effect Controls on linked A/V clips
2026-10-11 21:25:38 -04:00
Brandon ThomasandClaude Opus 5.5 ccfd43f68c Merge main into #864 (imports)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:25:30 -04:00
Brandon ThomasandClaude Opus 5.5 5d9fc09981 Merge main into #834 (commands.rs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:24:19 -04:00
Brandon Thomas ce2895754d Merge pull request #873 from vangel76/fix-786-context-label-swatches
#786: right-click Label menus show the label colours
2026-10-11 21:22:11 -04:00
Brandon ThomasandClaude Opus 5.5 1d35f5d39b Merge main into #873: the clip Label menu keeps main's enablement, ids and errors, with the user's names and swatches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:22:03 -04:00
Brandon Thomas 17bfd84f88 Merge pull request #1095 from vangel76/fix-1092-project-shift-range
#1092: Project panel Shift-click selects the range from the anchor
2026-10-11 21:20:09 -04:00
Brandon ThomasandClaude Opus 5.5 de9a262e11 Merge main into #1095 (Escape-cancel in the icon view drag)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:20:01 -04:00
Brandon Thomas e33bfde092 Merge pull request #991 from FacedFour1207/ja-i18n-polish
i18n: complete and polish the Japanese catalog
2026-10-11 21:16:01 -04:00
Brandon Thomas 8bebb1078a Merge pull request #1161 from haranishi/fix/1143-marker-label
fix: マーカーの色変更と範囲帯の右クリックを修正 (#1143)
2026-10-11 21:16:01 -04:00
Brandon ThomasandClaude Opus 5.5 aa90fdc387 Merge main into #991 (ja catalog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:15:53 -04:00
Brandon ThomasandClaude Opus 5.5 1a111a275a Merge main into #1161 (tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:15:44 -04:00
Brandon Thomas 07b7ef5bc1 Merge pull request #845 from ReReddo/feat/number-field-math
number fields: type a calculation and get its result
2026-10-11 21:14:11 -04:00
Brandon Thomas 3f6ef5b47e Merge pull request #746 from aw4e/feat/multi-select-drag
Project panel: dragging one of several selected items places them all, back to back, in one undo step
2026-10-11 21:14:06 -04:00
OorjitSethiandCodex d795b9a25e PERF-01: isolate editor snapshot and transcript redraw optimizations
Split from #728 on current main. Full validation is recorded in the draft PR; the trim and mask-modifier failures reproduce on unchanged main.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-11 20:13:57 -05:00
Brandon Thomas 217c66c95a Merge pull request #866 from vangel76/gap-pt-br-catalog
#859: complete the Brazilian Portuguese (pt-br) interface translation
2026-10-11 21:12:31 -04:00
Brandon ThomasandClaude Opus 5.5 a48f6dc83a i18n(id): follow the renamed Scene Edit Detection options (#1048)
#1048 renamed two option labels after the Indonesian catalog (#878) was
written, so indonesian_covers_the_menus_and_has_no_dead_entries failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:12:19 -04:00
Brandon ThomasandClaude Opus 5.5 81108ace16 i18n(pt-br): follow the renamed Scene Edit Detection options (#1048)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:12:19 -04:00
Brandon ThomasandClaude Opus 5.5 c7f2b781b0 Merge main into #866 (Indonesian and the German menu docs landed meanwhile)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:09:23 -04:00
ReReddo dd833b4eab export: editable quick export file name and a clickable folder link
The Quick Export path field was refilled from the stored path every frame,
so typing into it was overwritten. It is now a file name field that keeps
what is typed while it has focus and applies each non-empty value; a typed
or pasted full path replaces the folder too.

Under it the folder is shown as an accent-coloured, underlined link with a
folder icon. Clicking it opens the save dialog in the current folder with
the current file name; a chosen path is applied and shown, cancelling
changes nothing, and Export writes there. A preset change now swaps only
the extension, so a chosen name is kept.

Export mode's File Name and Location fields had the same refill problem
when emptied and use the same draft handling.

The desktop save dialog hook accepts a full path and starts in its folder.
2026-10-12 04:07:39 +03:00
Brandon Thomas 3fdaa813e0 Merge pull request #1110 from MarekSpodBiedry/issue-1083
Type text committed by an input method into text layers on the monitor (#1083)
2026-10-11 21:07:37 -04:00
Brandon Thomas 7b84816ae5 Merge pull request #1025 from ReReddo/feat/monitor-time-zoom-bar
monitors: a zoom bar for the time ruler, larger transport buttons
2026-10-11 21:07:36 -04:00
Brandon Thomas 8146afb5f7 Merge pull request #1053 from vangel76/perf-hevc-decode
#1039: faster HEVC motion compensation and deblocking, bit-exact (−10…28% cycles per frame)
2026-10-11 21:02:52 -04:00
Brandon Thomas f704b46906 Merge pull request #1064 from MrCloudy2/feat/vp8-decoder-432
#432: VP8 video decodes (clean-room pure-Rust VP8 decoder), so VP8 WebM no longer shows black
2026-10-11 21:02:52 -04:00
Brandon Thomas 5ce44e2bd8 Merge pull request #650 from JordyUSA/feat/598-avi-import
#598: import AVI files (clean-room AVI / OpenDML demuxer)
2026-10-11 21:02:51 -04:00
Brandon Thomas 8ec1a36b9f Merge pull request #613 from JordyUSA/feat/flac-export
Export: FLAC (clean-room encoder; .flac files and FLAC audio in MP4/MOV)
2026-10-11 21:02:50 -04:00
Brandon Thomas 06e5ac6651 Merge pull request #819 from vangel76/gap-mp2-decoder
#809: own MPEG audio Layer I / II decoder plays MP2 at ffmpeg's default bit rates
2026-10-11 21:02:50 -04:00
Brandon Thomas d75b0772af Merge pull request #667 from ReReddo/feat/monitor-transform-handles
program monitor, effect controls: on-screen transform handles for Motion and Transform
2026-10-11 21:02:49 -04:00
Brandon ThomasandClaude Opus 5.5 47b0688483 Merge main into #650; AVI frames pass an unspecified transfer to vui_color
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:02:34 -04:00
Brandon Thomas c7f6eccb18 Merge remote-tracking branch 'origin/main' into fix-650 2026-10-11 21:00:53 -04:00
ReReddo 71713e9bff number fields: type a calculation and get its result (#817)
A number field took only a plain number; anything else was thrown away.
Now every number field takes a calculation and applies its result:
the blue drag values (widgets::hot_number, in Effect Controls,
Properties, Lumetri and the mixers) and the dialogs' and Settings'
number fields (egui DragValue, through the new widgets::number_drag).

- New calc module: + - * / (and the multiplication and division signs),
  brackets, decimals and signs; a unit (letters, %, degrees) and spaces
  are ignored, but letters between digits (1e3, 10x20) are refused
  rather than dropped. A calculation starting with * or / applies to
  the field's current value; + and - at the start are signs. Bad input
  gives nothing, never a panic: the text is capped at 256 characters,
  nesting at 64, and only finite results come back.
- hot_number opens with its value selected, so a typed *2 applies to
  it; End then /3 adds to it.
- The transition duration field reads timecode first (1.00 is a
  second) and takes a calculation in frames when the text is neither a
  frame count nor a timecode.

Tests: calc (calculations, units, a leading * or /, bad input);
mixer_ui (the clip mixer's dB value and the Audio Gain dialog take
calculations).
2026-10-12 04:00:40 +03:00
Brandon ThomasandClaude Opus 5.5 383fafea8e export: rustfmt settings tests after merging #1113
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:56:58 -04:00
Brandon Thomas 94f2025199 Merge pull request #955 rustfmt follow-up 2026-10-11 20:56:46 -04:00
Brandon ThomasandClaude Opus 5.5 2d1ffc1418 masks_ui: Shift-clicks carry their modifiers in the gesture
Since UI.6 (#881) control gestures set the modifiers of every frame they
run, so the Shift that the test pushed by hand before plain ui.click calls
was replaced and the #862 constraint test failed on main. The clicks now
pass modifiers {shift: true}, like the drag already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:56:45 -04:00
ReReddo 1f56c14152 effect controls: a handle's parameters stay one undo step; tests twirl the effect open
Rebasing on main's undo coalescing (#887): its run() reads the press memory once before the loop, so every parameter written in the first frame of a press began its own step, and a Program monitor handle that changes position and anchor point together took two. Only the first write of a frame begins the step now. The keyframe menu tests collapse the built-in effects by clicking their headers, which selects the effect since the handles change; they click the twirl arrow.
2026-10-12 03:40:43 +03:00
Brandon ThomasandClaude Opus 5.5 a351076890 rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:37:07 -04:00
Brandon Thomas d4580cad48 Merge pull request #1113 from japbcoelho/fix/export-unknown-settings-keys
Export: unknown keys in `settings` are an error, not ignored
2026-10-11 20:34:42 -04:00
Brandon Thomas b653b5d80f Merge pull request #1085 from carlitosteso3/fix/xml-audio-gain-371
#371: split stereo halves from FCP7 XML play on their own side
2026-10-11 20:34:42 -04:00
Brandon Thomas 43d0d5c218 Merge pull request #1027 from ReReddo/fix/timeline-outer-track-resize
timeline: drag the outer track edges to resize
2026-10-11 20:34:41 -04:00
Brandon Thomas 920b6c40e3 Merge pull request #1015 from MarekSpodBiedry/issue-960
Keep the voice-over start point when recording past the end of the timeline (#960)
2026-10-11 20:34:41 -04:00
Brandon Thomas 0fd26c9f54 Merge pull request #1014 from MarekSpodBiedry/issue-981
Count VideoToolbox sessions, frames and declined requests in perf.stats (#981)
2026-10-11 20:34:40 -04:00
Brandon Thomas 59444d4e76 Merge pull request #992 from JordyUSA/fix/928-restore-sequences-wired
#928: the schema reports Restore open sequences as wired
2026-10-11 20:34:39 -04:00
Brandon Thomas 2d10351ae6 Merge pull request #832 from vbasky/fix/773-clear-close-sequence-shortcut
Clear Close Sequence also clears File Close (Cmd+W)
2026-10-11 20:34:39 -04:00
Brandon Thomas 7ebc8e2bc2 Merge pull request #1119 from itrycodinganditsbad/fix-895
fix(captions): apply body and div timing to TTML paragraphs (#895)
2026-10-11 20:34:38 -04:00
Brandon Thomas cfc47a5440 Merge pull request #1106 from MarekSpodBiedry/issue-1102
Text editing on the Program monitor: double-click and Ctrl/Cmd+A select all the text (#1102)
2026-10-11 20:34:38 -04:00
Brandon Thomas 3809dae370 Merge pull request #1105 from haranishi/fix/910-leading-zero-digits
fix: ハイフン区切り数字の先頭ゼロを保持 #910
2026-10-11 20:34:37 -04:00
Brandon Thomas 6ead2249a8 Merge pull request #1073 from keller-lukas/docs/localization-parity-german
docs: German in the localization parity table (follow-up to #630)
2026-10-11 20:34:37 -04:00
Brandon Thomas 1b42e5b2f9 Merge pull request #957 from Ni-zav/fix/908-tiff-size-estimate-s20261011T0357Z-b83c
fix(export): estimate TIFF sequence size using the actual channel count
2026-10-11 20:34:35 -04:00
Brandon Thomas ed062c1d82 Merge pull request #955 from Ni-zav/fix/904-marked-playback-out-frame-s20261011T0357Z-b83c
fix(playback): rest Program marked playback on the Out frame
2026-10-11 20:34:34 -04:00
Brandon Thomas 6e0a6a65ed Merge pull request #885 from FredApps/fix/persistent-decode-failure-slate
monitors: show the unreadable slate for persistently failing (damaged) media
2026-10-11 20:32:44 -04:00
Brandon Thomas 5f922d8abe Merge pull request #872 from vangel76/fix-854-gif-frame-delays
#854: GIF frame delays add up to the sequence duration
2026-10-11 20:32:43 -04:00
Brandon Thomas 22d069560a Merge pull request #843 from vangel76/gap-workflow-acceptance
#835: workflow acceptance tests through the MCP server, checked by ffprobe
2026-10-11 20:32:42 -04:00
Brandon Thomas a9a4a575eb Merge pull request #839 from vbasky/fix/697-copy-paste-shortcuts
Paste cut clips even when old OS clipboard files linger
2026-10-11 20:32:41 -04:00
Brandon Thomas 466588f756 Merge pull request #513 from KitsuneSemCalda/add-omastore-manifest
Add OmaStore manifest
2026-10-11 20:32:40 -04:00
Brandon Thomas 77321a506d Merge pull request #971 from vbasky/fix/914-multipoint-undo
One undo for a multi-point Trim by Frames
2026-10-11 20:29:58 -04:00
Brandon Thomas ebbe9240c7 Merge pull request #945 from vbasky/fix/909-fcpxml-point-markers
Import FCPXML one-frame markers as points
2026-10-11 20:29:57 -04:00
Brandon Thomas 3a1ffdcbab Merge pull request #933 from vbasky/fix/893-save-demo-path
Opening Demo Project does not keep the previous save path
2026-10-11 20:29:57 -04:00
Brandon Thomas dab9630d68 Merge pull request #846 from MrCloudy2/fix/export-hevc-stall-480-v2
#480: exports no longer stop for good when a clip's decoding uses rayon (rotated iPhone HEVC clips)
2026-10-11 20:29:56 -04:00
Brandon Thomas 9e9c3c315f Merge pull request #836 from vangel76/gap-parity-effects
#831: cargo xtask parity measures effects and transitions (--area)
2026-10-11 20:29:55 -04:00
Brandon Thomas 883f5f2d84 Merge pull request #1146 from haranishi/fix/959-shuttle-maintain-pitch
fix(audio): シャトル再生で音程維持設定を反映 (#959)
2026-10-11 20:24:50 -04:00
Brandon Thomas 4916f731df Merge pull request #1035 from ReReddo/feat/label-presets
settings: label colour presets (built-ins, import, export, delete)
2026-10-11 20:24:49 -04:00
Brandon Thomas 884ac783fc Merge pull request #1013 from MarekSpodBiedry/issue-997
Lumetri panel: truncate a long clip name in the header so the control rows stay in view (#997)
2026-10-11 20:24:48 -04:00
Brandon Thomas 388bef268c Merge pull request #984 from JordyUSA/fix/922-aac-8ch
#922: eight-channel AAC reports and returns eight channels
2026-10-11 20:24:47 -04:00
Brandon Thomas 963ede7277 Merge pull request #976 from vbasky/fix/916-automate-dissolve
Clamp Automate Cross Dissolve to the placed overlap
2026-10-11 20:24:46 -04:00
Brandon Thomas 06348de6ee Merge pull request #970 from vbasky/fix/917-locked-track-clip-id
Refuse timeline.move when the source track is locked
2026-10-11 20:24:46 -04:00
Brandon Thomas c2cb113fb8 Merge pull request #969 from vbasky/fix/918-speed-fade-end
Move fade-out with a non-ripple Speed/Duration change
2026-10-11 20:24:45 -04:00
Brandon Thomas 2e33561d50 Merge pull request #954 from Ni-zav/fix/897-899-tts-numeric-readings-s20261011T0357Z-b83c
fix(tts-text): preserve magnitude, fractions and leading zeros in narration
2026-10-11 20:24:44 -04:00
Brandon ThomasandClaude Opus 5.5 1d72887045 tts-text: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:24:21 -04:00
ReReddo 42e04de087 program monitor: left button only, rotate anywhere outside the box, Premiere's anchor point (#639)
Follow-ups to the transform handles from trying them by hand:

- Only the left button drags on the Program monitor. A middle or right drag
  moved the transform box; the same held for the mask body, vertices,
  Bezier, feather and expansion handles and the pen, the Essential Graphics
  layer boxes and the monitor guides and rulers, which now take the left
  button only too.
- Anywhere just outside the box rotates, beside an edge as beside a corner,
  as in Premiere. The rotate pointer is a curved double arrow that faces
  away from the box wherever the pointer is around it. A click a little
  further out (beyond the rotate zone) still lets go of the box.
- The anchor point is Premiere's circled cross (radius 7, the cross inside
  the circle, thin lines crossing at the exact point) and takes hold within
  11 points. Over it the pointer is the plain arrow with a small anchor
  point beside it, so it reads apart from the move pointer over the picture.
- Effect Controls: a hovered or selected effect header (and mask row) is
  highlighted from one point in, so it no longer cuts through the focused
  panel's outline (effect_controls::row_fill).

Tests: transform_handles_ui gains only_the_left_button_drags_the_handles,
guides_masks_and_graphics_take_the_left_button_only and
rotating_from_beside_an_edge.
2026-10-12 03:24:14 +03:00
ReReddo ee6415a1dd program monitor, effect controls: on-screen transform handles for Motion and Transform (#639)
Selecting Motion in Effect Controls (its header or one of its properties) draws a box
around the clip's picture in the Program monitor, with eight handles and the anchor
point. Double-clicking a clip's picture selects its Motion (and the clip). The Transform
effect gets the same box for its own properties while it is the selected effect.

- Drag inside the box: Position. Drag a handle: Scale about the anchor point; with
  Uniform Scale off a side handle changes only the width and a top/bottom handle only
  the height (Motion: scale / scale_width; Transform: scale_height / scale_width).
  Drag just outside a corner: Rotation. Drag the anchor point: Anchor Point, with
  Position changing by the same amount so the picture stays put.
- Values go through effects.setParam at the playhead on every frame of the drag, with
  Effect Controls' merge/begin handling (effect_controls::run, #201): a stopwatch-on
  property gets a keyframe there, and one drag is one undo step even when it changes
  two parameters.
- The handles are drawn a little smaller than the Essential Graphics ones (5/4 pt
  squares, finer anchor marker) but grab within 8 pt; there is no system rotate
  cursor, so a code-drawn one replaces it over the rotate zone.
- Clicking an effect header in Effect Controls now selects the effect instead of
  twirling it; only the arrow twirls (new id effectControls.effect.<fx>.twirl). The
  selected header is highlighted like a selected mask row.

Engine: EditorState.selected_effect and the effects.select command (journal off, like
masks.select). The selection is the effect id plus which instance of it, not its index,
because applying an effect inserts it first. Selecting an effect clears the mask
selection and selecting or creating a mask clears the effect selection; undo, redo and
effects.remove drop or follow it. effects.setParam's merge also takes a key, so a
gesture that changes several parameters is one undo step.

Automation ids: program.transform.box, program.transform.handle.<0-7>,
program.transform.rotate.<0-3>, program.transform.anchor.

Tests: transform_handles_ui (select, move, corner and edge scale, rotate, anchor,
keyframe at the playhead, one undo step per drag, header selects / arrow twirls,
Transform effect, double-click selection); engine tests for effects.select and the
merge key. Tests that clicked headers to fold effects now click the arrow.
2026-10-12 03:23:49 +03:00
Brandon ThomasandClaude Opus 5.5 c8275f294b Trim mode rolls each selected edit point once (fixes the #870 regression)
timeline.roll follows linked partners since #870, but Trim mode already
selects the linked audio edit point next to the video one and rolls every
selected point, so a linked cut moved twice (trim_mode_roll_ripple_and_toggle:
+10 frames instead of +5). timeline.roll takes an optional followLinks
(default true); Trim mode passes false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:21:35 -04:00
awe a078906ac1 Merge upstream/main into feat/multi-select-drag 2026-10-12 07:17:20 +07:00
NDelicandClaude Opus 5.5 24f4ed00a8 #1124: ⌘D / ⇧⌘D apply transitions to the selected clips or edit points
Apply Video / Audio Transition worked only at an edit point under the
playhead. As in Premiere, with clips selected they now add the transition
at the selected clips' free edges (video or audio side only), and with an
edit point selected at that edge only, wherever the playhead is. Edges
that already have a transition keep it; one undo step per press; the
call's effect / frames / params / reverse apply to every edge. With
nothing of that kind selected, the playhead behaviour is unchanged.

Shares the edge collection of Apply Default Transitions to Selection (⇧D).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:04:42 +02:00
Brandon Thomas 156086dc87 Merge pull request #946 from vbasky/fix/900-fcp7-xml-markers
Export FCP7 XML markers on source media
2026-10-11 19:49:51 -04:00
Brandon Thomas e86b5e6d33 Merge pull request #608 from ReReddo/fix/escape-cancels-drag
Escape cancels a drag in progress: drag-and-drop, value drags, color pickers and dialogs
2026-10-11 19:49:49 -04:00
Brandon Thomas a8cc702848 Merge pull request #1140 from seif9116/ui-arabic-hebrew-text
#395: Arabic and Hebrew in the interface: fallback fonts, captions in reading order
2026-10-11 19:49:48 -04:00
Brandon Thomas ccda02cb4a Merge pull request #1167 from MarekSpodBiedry/issue-1104
Timeline: dragging a clip above the top track or below the last one adds a new track (#1104)
2026-10-11 19:49:47 -04:00
Brandon ThomasandClaude Opus 5.5 95302ee2a5 Merge main into #608 (effect_controls drag stop, tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:39:05 -04:00
ReReddo acfea6e2d0 labels: Bright and Roles built-in label color presets
Two more built-in presets follow Default in Settings > Labels > Label
Color Presets. Bright is 16 saturated hues spaced around the colour
wheel plus a gray, named Red to Gray. Roles names the labels after what
clips are (B-Roll, Footage, Dialogue, Graphics, Interview, Sequence,
Visual Effects, Bin, Titles, Music, Sound Effects, Voice-Over, Captions,
Ambience, Color Grade, Stock), ordered so the label defaults for new
video, movies, audio, stills, sequences, Dynamic Link items, bins and
captions land on the matching role. Like Default they can be chosen
and reverted to but not deleted, and an imported preset with one of
their names is renamed "<name> (imported)".
2026-10-12 02:34:32 +03:00
ReReddo 4a09d92fa5 labels: label color presets in Settings > Labels
A Label Color Presets row under Label Defaults picks the 16 label names
and colours from a preset. The dropdown lists the built-in "Default"
(the labels FilmCraft ships with) and the presets in the Label Presets
folder beside the preferences, and reads "<name> (Modified)" once the
labels differ from the chosen preset. Icon buttons: Revert (back to the
preset's colours and names), Import (copies a preset file into the
folder and selects it), Export (saves the dialog's labels to a file
named as the preset), Show Folder, and Delete (user presets only).
Choosing a preset or reverting applies on OK like the other fields;
Cancel discards it.

Presets are small JSON files: a name and 16 labels, each a name and a
labels, names trimmed and at most 64 characters, valid colours); a bad
file shows its error in the dialog and changes nothing. The same is
reachable as labels.presets, labels.importPreset, labels.exportPreset
and labels.deletePreset, with the selection stored as labels.preset.
2026-10-12 02:34:32 +03:00
Brandon ThomasandClaude Opus 5.5 6b860ede89 Merge main into #1140 (docs/gaps.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:34:14 -04:00
Brandon ThomasandClaude Opus 5.5 e923025d29 Merge main into #1167; the new marquee-drag Move starts with no new track
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:33:04 -04:00
Brandon Thomas c83cb7c830 Merge remote-tracking branch 'origin/main' into fix-1167 2026-10-11 19:30:41 -04:00
Brandon Thomas 8ea545b103 Merge pull request #1099 from haranishi/fix/913-subclip-audio
fix: サブクリップ配置で親の音声を保持 #913
2026-10-11 19:26:20 -04:00
Brandon Thomas 40e38d2301 Merge pull request #1048 from ossini/feat/scene-edit-detection
#1018: Scene Edit Detection in the timeline clip's right-click menu
2026-10-11 19:26:19 -04:00
Brandon Thomas 2516a87220 Merge pull request #1012 from MarekSpodBiedry/issue-998
Reject non-finite numbers and empty LUT input ranges in .cube import (#998)
2026-10-11 19:26:18 -04:00
Brandon Thomas 3f52a705e8 Merge pull request #989 from jahvari/fix/windows-baseline-win01
WIN: stabilize Windows verification prerequisites
2026-10-11 19:26:18 -04:00
Brandon Thomas cc57068acc Merge pull request #988 from JordyUSA/fix/924-frame-primaries
#924: decoded H.264 / HEVC frames keep the signalled colour primaries
2026-10-11 19:18:36 -04:00
Brandon Thomas ed86d9f12e Merge pull request #975 from vbasky/fix/921-mcp-bridge-address
MCP invalid --bridge exits 2 like other usage errors
2026-10-11 19:18:36 -04:00
Brandon Thomas 1661344ba6 Merge pull request #891 from vbasky/fix/823-multi-audio-import
Import every MPEG audio stream, not only the first PID
2026-10-11 19:18:35 -04:00
Brandon Thomas d89b2788ef Merge pull request #876 from vbasky/fix/848-longgop-seek-preroll
LongGOP seek / time-to-first-frame, not the H.264 throughput gap
2026-10-11 19:18:35 -04:00
Brandon Thomas 1ff863c26f Merge pull request #869 from vbasky/fix/856-negative-times
Reject negative times on timeline.place and markers.add
2026-10-11 19:18:34 -04:00
Brandon Thomas bd2b1c57c1 Merge pull request #849 from vangel76/gap-audio-stream-recovery
#840: restart an audio stream the device drops instead of playing silent
2026-10-11 19:18:34 -04:00
Brandon Thomas a470620b8d Merge pull request #842 from vangel76/gap-dpx-packed-ycbcr
#841: import packed 10/12-bit and Y'CbCr DPX stills
2026-10-11 19:18:33 -04:00
Brandon Thomas ff4fedc494 Merge pull request #833 from vbasky/fix/785-explorer-timeline-drop
OS drops on an empty Timeline create a sequence
2026-10-11 19:18:33 -04:00
Brandon Thomas 7b285ca1df Merge pull request #1050 from vbasky/fix/937-media-offline
Clear used media as offline instead of stripping the timeline
2026-10-11 19:15:57 -04:00
Brandon Thomas 1f8c97b92a Merge pull request #1047 from whatever-industries/fix-extract-and-caption-targets
Fix Extract hard cutoffs and clipped caption font targets
2026-10-11 19:15:57 -04:00
Brandon Thomas 9a2b41a1e9 Merge pull request #1017 from JordyUSA/fix/965-hdr-playback
#965: HDR and wide-gamut media play on the GPU path
2026-10-11 19:15:56 -04:00
Brandon Thomas 0b52416804 Merge pull request #973 from vbasky/fix/915-match-frame-reverse
Match Frame on a reversed clip uses moving source time
2026-10-11 19:15:55 -04:00
Brandon Thomas c1c52d2c65 Merge pull request #934 from vbasky/fix/911-locked-clip-edits
Refuse trim and timing edits on a locked track
2026-10-11 19:15:55 -04:00
Brandon Thomas 919796ddfa Merge pull request #886 from vangel76/gap-av1-parallel-postfilter
#847: AV1 decode runs deblocking, CDEF and loop restoration in parallel row bands
2026-10-11 19:15:54 -04:00
Brandon Thomas aa6d8f42a4 Merge pull request #878 from vangel76/gap-indonesian
#867: Indonesian (id) interface translation
2026-10-11 19:15:54 -04:00
Brandon Thomas cc8eda65f0 Merge pull request #851 from vangel76/gap-mask-roundness-handle
#844: on-monitor corner-roundness handle for masks
2026-10-11 19:15:53 -04:00
Brandon Thomas 5911b429c6 Merge pull request #827 from vangel76/gap-gpu-safe-start
#820: fall back to DX12, then a software adapter, when the graphics start fails
2026-10-11 19:15:52 -04:00
Brandon ThomasandClaude Opus 5.5 0289cec1a0 gpu: as_chunks in the Extract tests (clippy::chunks_exact_with_const_size)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:15:44 -04:00
Brandon ThomasandClaude Opus 5.5 fdf43f440f Merge main into #886 (docs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:13:49 -04:00
Brandon ThomasandClaude Opus 5.5 c14395d25e Merge main into #851 (docs/gaps.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:13:24 -04:00
Brandon ThomasandClaude Opus 5.5 3494df4c90 engine: rustfmt project tools tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:12:17 -04:00
Brandon ThomasandClaude Opus 5.5 039dd06110 render: as_chunks in the Extract edge test (clippy::chunks_exact_with_const_size)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:12:10 -04:00
Brandon Thomas b2c00d4cac Merge pull request #932 from vbasky/fix/894-recovery-snapshots
Keep recovery snapshots when session metadata is unreadable
2026-10-11 19:06:59 -04:00
Brandon Thomas 0129bb4b59 Merge pull request #1084 from mjohnson7cd/fix/1072-import-captions-light-mode
Fix #1072: Text panel 'Import captions file…' button label invisible in light mode
2026-10-11 19:06:59 -04:00
Brandon Thomas bc514cbf78 Merge pull request #1097 from vangel76/fix-1091-home-end-scroll-to-playhead
#1091: Home / End and other jumps scroll the Timeline to the playhead
2026-10-11 19:06:58 -04:00
Brandon Thomas 011b506589 Merge pull request #1166 from MarekSpodBiedry/issue-1126
Hot-text number fields select the whole value when clicked (#1126)
2026-10-11 19:06:58 -04:00
Brandon Thomas 8aa928ee7c Merge pull request #1133 from haranishi/fix/1062-paused-meter-decay
fix: 再生停止後も音量メーターを更新 #1062
2026-10-11 19:06:57 -04:00
Brandon Thomas dae3b99e96 Merge pull request #1045 from vbasky/fix/926-underline-baseline
Underline follows baseline shift on whole-style text
2026-10-11 19:06:57 -04:00
Brandon Thomas 8a69dc6047 Merge pull request #1044 from vbasky/fix/927-vertical-underline
Underline vertical graphic text
2026-10-11 19:06:56 -04:00
Brandon Thomas 085622f030 Merge pull request #1026 from ReReddo/fix/monitor-wheel-zoom-speed
monitors: about 10% of zoom per wheel notch, exactly reversible
2026-10-11 19:06:56 -04:00
Brandon ThomasandClaude Opus 5.5 2ac8237318 text: rustfmt the underline shift test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:06:42 -04:00
Brandon Thomas 064c9b92e3 Merge pull request #1036 from ossini/fix/51-stereo-track-downmix
fix(render): 5.1 sources on a stereo track fold down instead of losing C and surrounds
2026-10-11 18:53:01 -04:00
Brandon Thomas 5bf95fb068 Merge pull request #1031 from ReReddo/feat/meters-adaptive-and-scrub
meters: adapt to the panel's size; react while scrubbing
2026-10-11 18:53:01 -04:00
Brandon Thomas 016156b19e Merge pull request #1030 from ReReddo/feat/tool-flyout-drag
tools: press and drag a grouped tool to open its flyout
2026-10-11 18:53:00 -04:00
Brandon Thomas cbceb65197 Merge pull request #1109 from MarekSpodBiedry/issue-1090
Timeline: a press on the ruler moves the playhead at once (#1090)
2026-10-11 18:52:59 -04:00
Brandon Thomas dc38fe8506 Merge pull request #1121 from itrycodinganditsbad/fix-1054
fix(cli): fail export/render that drew offline media unless --allow-offline (#1054)
2026-10-11 18:52:59 -04:00
Brandon Thomas 9dc3421bb1 Merge pull request #1151 from keller-lukas/fix/1145-macos-app-menu-i18n
macOS: app menu follows the interface language (#1145)
2026-10-11 18:52:58 -04:00
Brandon Thomas 20f618d378 Merge pull request #1144 from haranishi/fix/1075-panel-focus-outline
fix: パネルのフォーカス枠の欠けを修正 #1075
2026-10-11 18:52:57 -04:00
Brandon Thomas c3f0b5a5e7 Merge pull request #979 from mktk1117/fix/track-select-drag-moves
Timeline: a drag with Track Select Forward/Backward moves what it selects
2026-10-11 18:25:52 -04:00
Brandon Thomas f2e3486ee4 Merge pull request #956 from Ni-zav/fix/906-noise-frame-cadence-s20261011T0357Z-b83c
fix(render): update Noise grain on every frame using exact timestamps
2026-10-11 18:25:51 -04:00
Brandon ThomasandClaude Opus 5.5 94a61d824f render: fix clippy and rustfmt in the noise cadence test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:25:38 -04:00
Brandon Thomas 7e604c414c Merge pull request #949 from vbasky/fix/611-install-refused-reason
Essential Graphics: an Install refused shows why on the status bar (#611)
2026-10-11 18:23:12 -04:00
Brandon Thomas 02e6255255 Merge pull request #892 from vbasky/fix/887-coalesce-scale-undo
One undo for a Scale (and similar) mouse drag
2026-10-11 18:23:12 -04:00
Brandon Thomas 8d36decec2 Merge pull request #888 from opentechnepal/fix/243-vietnamese-ime-diacritics
#243: text fields keep unmodified keys so IMEs can compose
2026-10-11 18:23:11 -04:00
Brandon Thomas 4d516d57d1 Merge pull request #870 from vbasky/fix/855-roll-linked-audio
Roll linked audio with the picture cut
2026-10-11 18:23:11 -04:00
Brandon Thomas 34f9c1c162 Merge pull request #1138 from haranishi/fix/962-keyframe-graph-menu
fix: 値グラフからキーフレームの補間を選択 #962
2026-10-11 18:23:10 -04:00
Brandon Thomas 45201e4d80 Merge pull request #1120 from itrycodinganditsbad/fix-1055
fix(effects): keep a setParam keyframe outside the clip at its time (#1055)
2026-10-11 18:21:40 -04:00
Brandon Thomas 218641ad01 Merge pull request #1108 from MarekSpodBiedry/issue-1094
Timeline: keep continuous thumbnails fixed while the view zooms (#1094)
2026-10-11 18:21:39 -04:00
Brandon Thomas 634c15e106 Merge pull request #1107 from MarekSpodBiedry/issue-1100
Engine: list edge, params and reverse in the sequence.applyAudioTransition schema (#1100)
2026-10-11 18:21:39 -04:00
Brandon Thomas 33b03652e6 Merge pull request #1032 from ReReddo/feat/tab-scroll-switch
dock: the mouse wheel over a tab strip switches tabs
2026-10-11 18:21:38 -04:00
Brandon Thomas 551ed05915 Merge pull request #1028 from ReReddo/feat/timeline-live-marquee
timeline: box selection updates live while dragging
2026-10-11 18:21:38 -04:00
Brandon Thomas 22fe1017bb Merge pull request #986 from JordyUSA/fix/923-mkv-reordered-rate
#923: reordered Matroska video without DefaultDuration keeps its frame rate
2026-10-11 18:21:37 -04:00
Brandon Thomas c3e28a08e4 Merge pull request #792 from vangel76/gap-mkv-codec-delay
#790: Matroska AAC / MP3 / AC-3 trims the exact CodecDelay
2026-10-11 18:21:36 -04:00
Ikki Nishihara 82c162fee6 fix(engine): Close Gapをキーで実行できるようにする (#1135) 2026-10-12 07:03:22 +09:00
Brandon Thomas 3e08be8501 Merge pull request #995 from vangel76/gap-vp9-parallel-loopfilter
#980: VP9 decode loop-filters a frame as a superblock-row wavefront
2026-10-11 18:02:01 -04:00
Brandon Thomas bdeee0b686 Merge pull request #974 from vbasky/fix/919-new-sequence-form
Keep New Sequence open when frame size is rejected
2026-10-11 18:02:00 -04:00
Brandon Thomas 9f476e3b2f Merge pull request #952 from earlvanze/fix/export-loudness-after-limiter
Export: correct loudness lost to peak limiting
2026-10-11 18:02:00 -04:00
Brandon Thomas 76f46a4014 Merge pull request #947 from vbasky/fix/901-otio-nested-markers
Keep nested sequence markers in OTIO export
2026-10-11 18:01:59 -04:00
Brandon Thomas ca9eb55863 Merge pull request #944 from mjohnson7cd/fix/rf64-trailing-metadata
fix(media): RF64 trailing metadata stays outside audio duration
2026-10-11 18:01:59 -04:00
Brandon Thomas 011f03c812 Merge pull request #889 from vbasky/fix/810-reveal-in-finder
Reveal in Finder for Project clips with a local path
2026-10-11 18:01:58 -04:00
Brandon Thomas 32abb3f936 Merge pull request #862 from vangel76/gap-mask-pen-constrain
#853: Shift constrains pen mask segments to 45° steps
2026-10-11 18:01:58 -04:00
MarekSpodBiedry bc0ef5ad94 Trim MP3 encoder delay when decoding audio files (#1137) 2026-10-11 23:41:55 +02:00
MarekSpodBiedry 92eb836957 Timeline: dragging a clip above the top track or below the last one adds a new track (#1104) 2026-10-11 23:39:51 +02:00
MarekSpodBiedry 4763b553d9 Hot-text number fields select the whole value when clicked (#1126) 2026-10-11 23:38:23 +02:00
Ikki Nishihara 9a3e7d38ed fix: マーカーの色変更と範囲帯の右クリックを修正 (#1143) 2026-10-12 06:27:16 +09:00
Ikki Nishihara 1170afe6a6 fix: キーフレームの矩形選択と複数移動を修正 (#967) 2026-10-12 06:07:41 +09:00
Lukas KellerandClaude Opus 5.5 4ebcf3aa67 #1145: macOS app menu follows the interface language
About FilmCraft and Join the ArtCraft Discord… in the macOS app menu take
the translated labels of the same Help menu commands and are updated on
a language switch. Hide, Hide Others and Quit get their text from the
catalogs instead of muda's English defaults and keep their native
actions. menus::NATIVE_APP_MENU lists these native-only labels together
with the Settings submenu, so the app and the tests use the same keys.

German rows for the three new keys; "Quit FilmCraft" is already
translated in es, ja, zh-cn and fr (the app.quit shortcut), the other
languages show English until they have translations. The German menu
test accepts and requires the native app menu labels.

Closes #1145

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:20:36 +02:00
Ikki Nishihara f5a783769d fix(audio): シャトル再生の音程維持設定を反映 (#959) 2026-10-12 05:17:19 +09:00
Ikki Nishihara 470730f1ed fix(ui): パネル内容の後にフォーカス枠を描画 #1075 2026-10-12 04:34:22 +09:00
Fabio LussiandClaude Opus 5.5 f913243b3e fix(timeline): snap sub-frame timeline.place to the frame grid; inspect counts export's frames
timeline.place kept a sub-frame start and rounded the duration to whole
frames, so the clip ran past time + duration and the sequence ended
between frames. The start and the end now each snap to the nearest frame,
as a timeline drag already does. sequence.inspect's durationFrames now
comes from the same frame_span export uses, so the two always agree.

Fixes #1101

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:21:32 +02:00
seif cfe0c751e9 #395: Arabic and Hebrew in the interface: fallback fonts, captions in reading order
The interface had no face for Arabic or Hebrew, so caption text, track and media names showed
missing-glyph boxes in the Text panel and on the timeline while the Program monitor drew them.
crates/ui-egui/src/rtl.rs adds the craft-fonts Arab / Hebr faces, else one installed face per
script (Noto Sans Arabic, SF Arabic, Segoe UI...), as the last fallbacks of every UI font family,
as cjk.rs does for Chinese.

egui already shapes each font run with harfrust, so the letters join, but it lays runs out left to
right with no bidi pass and multi-word Arabic read backwards. rtl::Lines lays captions out in
reading order: lines are broken on the logical text as egui wraps it, each line is reordered with
unicode-bidi (UAX #9, as crates/text does for titles) and every right-to-left word is its own
layout section for egui's shaper. Caption blocks on the timeline and the Text panel caption list
use it; a caption being edited keeps egui's logical order, since TextEdit places the caret by
character index. Left-to-right text is laid out exactly as before.
2026-10-11 13:14:30 -06:00
Ikki Nishihara 4c41755b83 fix(ui): 値グラフのキーフレーム補間メニューを追加 #962 2026-10-12 04:12:35 +09:00
Ikki Nishihara e674be795f fix: 再生停止後も音量メーターを更新 #1062 2026-10-12 03:47:15 +09:00
Fabio LussiandClaude Opus 5.5 7b0ab0c8a3 fix(menus): native menu follows command enablement
The macOS menu bar built every item enabled and its updater never applied
enablement, so Edit > Undo and Redo stayed selectable with empty history.
Items are now created with their computed state, and the frame loop sends
the menu tree to the native menu whenever it changes; the updater applies
only the labels, shortcuts and enablement that differ.

Fixes #902

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:25:18 +02:00
Fabio LussiandClaude Opus 5.5 e394061827 fix(cli): fail export/render that drew offline media unless --allow-offline
Exports and rendered frames now record which offline items (missing,
made offline, unreadable) they drew the placeholder slate for. The engine
reports them as `offlineMedia` in file.exportMedia (wait), jobs.list
export entries and export.queue items; the bridge and MCP long exports
pass it through. filmcraft-cli export and render name each item on
stderr and exit 1, unless --allow-offline accepts the placeholders.

Fixes #1054

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:02:15 +02:00
Fabio LussiandClaude Opus 5.5 e370027e70 fix(captions): apply body and div timing to TTML paragraphs
The TTML/DFXP reader ignored begin/end/dur on <body>, so a body begin
offset was lost and captions landed early. Body and div are now treated
as par time containers: their begins offset descendants, their ends
clip them, paragraphs without an end last until the container ends,
and end + dur take the earlier. seq containers warn instead of being
silently mistimed.

Fixes #895

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:58:27 +02:00
japbcoelhoandClaude Opus 5.5 2c7fd7f1b2 Export: unknown keys in settings are an error, not ignored
A key put inside `settings` that ExportSettings doesn't have did nothing
and the export ran as if it were absent: `filmcraft-cli export --settings
'{"loudnessLufs":-14}'` (a flat parameter in the wrong place) exported at
-18.3 LUFS with no warning, and a misspelt nested key was dropped the same
way.

settings_from_params now compares the patch with the resolved settings and
refuses the first key they don't have, matching keys as `merge` does (the
camelCase spelling of a snake_case field, #531, counts):
`settings: unknown key `loudnessLufs` (see export.presets.get for the settings' keys)`.
A null value is accepted anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:28:17 -03:00
MarekSpodBiedry 78a4c73a1e Text editing on the Program monitor: double-click and Ctrl/Cmd+A select all the text (#1102) 2026-10-11 19:16:21 +02:00
MarekSpodBiedry 1faf783b32 Timeline: keep continuous thumbnails fixed while the view zooms (#1094) 2026-10-11 19:14:11 +02:00
MarekSpodBiedry adefe9c3fd Type text committed by an input method into text layers on the monitor (#1083) 2026-10-11 19:13:49 +02:00
MarekSpodBiedry c05040c1c0 Timeline: a press on the ruler moves the playhead at once (#1090) 2026-10-11 19:13:01 +02:00
MarekSpodBiedry 4823952c03 Engine: list edge, params and reverse in the sequence.applyAudioTransition schema (#1100) 2026-10-11 19:11:58 +02:00
Ikki Nishihara 6aa2510743 fix: ハイフン区切り数字の先頭ゼロを保持 #910 2026-10-12 02:10:13 +09:00
Ikki Nishihara 7a3d003194 fix: サブクリップ配置で親の音声を保持 #913 2026-10-12 01:55:24 +09:00
Fabio LussiandClaude Opus 5.5 b5306608cc fix(effects): keep a setParam keyframe outside the clip at its time
effects.setParam clamped an explicit `time` into the clip, so a keyframe
past the end replaced one sitting on the last frame and a ramp across a
cut was compressed to finish at the clip end. The same happened before
the start. An explicit time now keeps its keyframe where it was asked,
outside the clip if need be, as effects.moveKeyframe already allows; only
the playhead fallback stays pinned to the clip.

Fixes #1055

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:47:08 +02:00
vangel76andClaude Opus 5.5 d4e4512e70 #1091: Home / End and other jumps scroll the Timeline to the playhead
Problem: the Timeline followed the playhead only while playing, so a jump
while stopped (Home, End, edit points, markers) left the playhead off screen.

Fix: when the playhead of the shown sequence changes while stopped and falls
outside the view, the view scrolls to it with the page-scroll margin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:38:54 +02:00
vangel76andClaude Opus 5.5 ad08c61b0a #1092: Project panel Shift-click selects the range from the anchor
Problem: Shift-click in the Project panel toggled one item like Ctrl/Cmd-click; the items between were never selected and there was no anchor.

Fix: remember the last item clicked without Shift as the anchor (ProjectPanelUi::anchor); Shift-click selects the range anchor..clicked in the order the view shows (List, Icon, Freeform, search filter respected), Ctrl/Cmd+Shift adds it to the selection. Ctrl/Cmd-click still toggles. Pure helper click_selection with unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:31:36 +02:00
Dylan M. Taylor 0800a8b0aa ci: refactor workflows to use storytold/craft-common-actions (#1051)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:52:44 -04:00
Claude 8e1fb41258 #371: split stereo halves from FCP7 XML play on their own side
Premiere writes a stereo clip to FCP7 XML as two mono clip items on two
tracks (sourcetrack trackindex 1 and 2). The importer already picks
channel 0 and channel 1 for them, but left both centred, and a centred
one-channel clip plays on both sides. So both channels summed on L and
on R: +3 dB per channel for an uncorrelated stereo effect (and its L-R
doubled), +6 dB for dual-mono dialogue.

Import: a clip that plays one channel of a split two-channel file is
panned to that channel's side (channel 0 left, channel 1 right). An
Audio Pan filter on the clip item still overrides it.

Export: a one-channel clip always writes its Audio Pan, also when it is
centred, so a centred Breakout to Mono round-trips centred.

Measured with filmcraft-cli (import, export WAV): a split stereo file
and a split dual-mono file now come out at 0.00 dB and sample-identical
to the source (before: +3.01 dB and +6.02 dB per channel); a mono file
on a mono track was and stays at 0 dB.

Tests (crates/interchange/tests/fcp7.rs):
split_stereo_halves_are_panned_to_their_side and
split_stereo_half_keeps_its_audio_pan fail without the import change;
centred_mono_breakout_round_trips_centred fails without the export
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HrAqZmrrDLRpxBJatxvaJ
2026-10-11 10:50:36 -05:00
Mara Johnson 9e04b8b519 fix(text): Import captions file label uses theme text color (#1072) 2026-10-11 11:31:51 -04:00
Lukas KellerandClaude Opus 5.5 239c46063d docs: German ships the menus (localization parity, ROADMAP)
German (de) landed in #630: the localization parity table and the
ROADMAP language table now list it as menus only, 371 of 3,498 strings
(10.6%), native-reviewed. The localization dimension counts 2
menus-only and 5 none, and 5 of Premiere's 10 languages well and 2
partly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:24:11 +02:00
Luniiya 2b916fa530 UI: favorite themes and appearance shortcuts
Star a theme in the Preferences theme cards to favorite it. Right-click the
header's appearance button to switch between favorites in one click.
2026-10-11 16:12:36 +02:00
vangel76andClaude Opus 5.5 dd7a16788e #1065: import DNG stills and CinemaDNG folders as one clip
Problem: .dng files were not importable, and a DNG (a TIFF) would have
gone to the TIFF decoder, which reads at best the embedded preview. A
CinemaDNG shot (a folder of numbered DNG frames) could not be edited.

Fix: "dng" joins the importable still extensions. filmcraft-media
develops DNGs with filmcraft-dng into scene-linear Rec. 709 float
frames (transfer Linear, like OpenEXR), so highlights above 1.0 reach
the compositor. A single DNG is a still; a numbered folder imports
through the existing image-sequence path as one clip, whose frame rate
and start time code come from the first frame's CinemaDNG FrameRate and
TimeCodes tags (else Settings > Media > Indeterminate Media Timebase).
Lossy and JPEG XL DNGs are refused as unsupported by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:44:07 +02:00
vangel76andClaude Opus 5.5 28247afae7 #1065: clean-room DNG / CinemaDNG decoder crate
Problem: camera RAW is at 0% (docs/codec-parity.md, #383). CinemaDNG,
the one cinema RAW format with a public specification, could not be
read at all.

Fix: new L0 crate filmcraft-dng, written from the public DNG 1.4-1.7,
TIFF 6.0 / TIFF-EP, ITU-T T.81 and CinemaDNG specifications only (no
other decoder's source read; editions in the crate README):
- TIFF container with SubIFDs, bounded (64 IFDs, 4 levels, cycles end
  the walk) and checked offsets; the main raw IFD is the largest
  NewSubFileType 0 CFA / LinearRaw image.
- Raw data: 1-16-bit uncompressed (MSB-first packing) and lossless JPEG
  (process 14, predictors 1-7, 1-4 components, point transform, line
  restarts), strips and tiles, tiles decoded in parallel; allocations
  capped by size and by the data present.
- Processing per the DNG model: ActiveArea, LinearizationTable, per-site
  black levels with deltas, white levels, as-shot white balance with
  neutral highlight clipping, Malvar-He-Cutler Bayer demosaic (bilinear
  for other patterns), DefaultCrop, ColorMatrix / CameraCalibration /
  ForwardMatrix with illuminant interpolation, Bradford to linear
  Rec. 709, BaselineExposure; output scene-linear RGBA floats.
- CinemaDNG FrameRate, TimeCodes and ReelName tags.
- A test-only writer (feature `writer`) synthesises DNGs. Tests: raw
  samples bit-exact for every depth / predictor / layout, processing
  against hand-computed values, hostile files (every truncation, 12k
  mutations, cyclic IFDs, huge sizes, bad offsets), and LibRaw's
  unprocessed_raw as an external oracle reading the same samples
  (skipped when not installed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:44:00 +02:00
vangel76andClaude Opus 5.5 7faece80f0 #1039: HEVC interpolation taps without slice arrays
Problem: the interpolation filters took their taps as an array of T
slices built per block row with `array::from_fn`. The array went through
the stack and was read back with wider loads than it was written with
(store-forwarding stalls): about 10 % of the 8-tap luma interpolation
time, plus the per-tap Option handling.

Fix: the filters take the taps as a function (row shifted by k, or k-th
row from x), inlined into the multiply-add loop, so the tap addresses stay
in registers. Same arithmetic, same samples.

Tests: the interpolation unit tests (16-bit lanes against the 32-bit
filter, in-place against copied windows), conformance suite, identical
YUV on the five bench streams. User instructions -1 to -4 % on all five;
cycles -2 to -5 % except g250_500 (within the run-to-run noise of the
shared machine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:13:58 +02:00
MrCloudy2andClaude Opus 5.5 53a0d959fe #432: VP8 video decodes (clean-room pure-Rust VP8 decoder)
VP8 WebM showed black video: there was no VP8 decoder. New L0 crate
filmcraft-vp8, implemented from RFC 6386 sections 1-19 (tables extracted
from the RFC text, nothing from the attached reference decoder): every
coding tool, versions 0-3, hidden altref frames, size changes. Bit-exact
with libvpx on 62 libvpx VP8 test vectors, real-world FATE samples and
generated fixtures; never panics on corrupted input.

Wired into codecs for Matroska / WebM (V_VP8, including the alpha layer
of transparent WebM) and MP4 (vp08). VP8 colour is BT.601 (RFC 6386 9.2).
A hidden altref frame is a WebM block of its own with the time of the
frame that follows it: the VP8 time lookup now moves on to that shown
frame instead of repeating the previous one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:13:07 +02:00
Vikram Bhaskaran 5a4bbc5916 Stop Linux H.264 export hanging when NVENC is absent.
AMD and Intel GPUs expose VA-API EncSlice, not NVENC. The export path
still opened NVENC first and then waited forever on wgpu map_async
(radv). Probe EncSlice before NVENC, fall through to the software
encoder, and bound GPU readback waits to eight seconds so a wedged
device fails the GPU path instead of blocking the export.
2026-10-12 00:09:32 +11:00
vangel76andClaude Opus 5.5 f5edbe0f6a #1039: default HEVC prediction for default explicit weights
Problem: x265 (by default) and other encoders set weighted_pred_flag in the
PPS and then signal the default weights (2^denom, offset 0) for most
slices. Every block of those slices took the scalar 32-bit explicit
weighting path (8-265 / 8-266) instead of the 16-bit-lane default one, and
lost the full-sample copy shortcut.

Fix: when every list a block predicts from has the default weight and a
zero offset for a component, the block uses the default weighted
prediction (8-262 / 8-263) and, for uni-prediction at full-sample
positions, the reference copy. With log2WD = denom + shift1 the weighted
sums are exactly the default ones multiplied by 2^denom, so the samples
are identical.

Tests: a unit test checks the identity for every block width, bit depth
and denominator, and explicit weighting against 8-265 / 8-266 with extreme
weights and offsets; the weighted-prediction conformance fixtures; identical
YUV on the five bench streams. Cycles: -5 % on 1080p x265 medium, -8 % on
the weighted-prediction stream, -3 % on 10-bit, neutral elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:03 +02:00
vangel76andClaude Opus 5.5 eed3196ba7 #1039: count HEVC block statistics without branches
Problem: after each picture the decoder classified every 4x4 block for
DecodeStats with an if / else-if chain: about 130 000 unpredictable
branches per 1080p picture, 1.5-2 % of single-thread decoding time.

Fix: `block_counts` sums flag comparisons in one branch-free pass
(inter = coded - intra - skip), with the same results.

Tests: a unit test compares it with the if / else-if chain over every
flag combination; identical decoded YUV on the five bench streams.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:53:09 +02:00
vangel76andClaude Opus 5.5 0cd1c69775 #1039: HEVC motion compensation reads reference lines in place
Problem: every prediction block copied its whole reference window (block
plus filter margin, w + 7 by h + 7 luma samples) into a 16-bit scratch
buffer before interpolating, one line at a time with a row lookup per line.
After the move to 8-bit storage this copy was the largest single cost of
motion compensation (about 10 % of decoding time on 1080p streams with
much motion), and windows crossing the left or right picture edge were
copied sample by sample with a clamp each.

Fix: when the window lies inside the picture horizontally (almost always),
`Frame::window_rows` hands out the reference lines themselves (lines above
or below the picture replicated by clamping the line index, 8-228) and
the filters read the 8- or 16-bit samples directly: horizontal taps are the
line shifted by k, vertical taps the k-th line. Only windows crossing the
left or right edge are still copied, now as edge fill + run copy + edge
fill. The arithmetic is unchanged, so the prediction is identical.

Tests: in-place interpolation equals interpolation of the copied window
for random fractional vectors, block sizes, both chroma planes and both
sample types (2000 blocks each, inside and across every edge); edge
replicated lines equal the per-sample clamp for every offset; conformance
suite and identical YUV on the five bench streams at 1 and 3 threads.
User instructions on 1080p: -9 % (g250_500), -2 to -6 % elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:52:39 +02:00
vangel76andClaude Opus 5.5 a88c5bba1c #1039: store 8-bit HEVC pictures as u8
Problem: every picture was stored with 16-bit samples whatever its bit depth.
For 8-bit streams (almost all Main content) that doubled the memory traffic
of the hottest paths: motion-compensation reference windows and full-sample
copies, deblocking and SAO, the per-picture planes and published rows, and
the output copy, which also had to narrow every sample to u8.

Fix: a `Sample` trait (u8, u16) and `Frame` rows stored in the picture's
type (`Rows::U8` when luma and chroma are 8-bit, `Rows::U16` otherwise).
`PicState`, `SliceDecoder`, the interpolation / weighting stores, intra
prediction and the in-loop filters are generic over the sample type and
monomorphised once per type; the decoder picks the type per picture from
its SPS. All arithmetic stays in the same integer types as before, so the
output is unchanged sample for sample. 8-bit output rows are now copied as
they are. Reading a frame with the other sample type (never done: the DPB
is flushed when the bit depth changes) yields nothing instead of a panic,
and the non-threaded build no longer has an `expect` on unpublished rows.

Tests: new unit tests for the storage type, exact crops of both types and
harmless wrong-type access; the full-sample copy test now runs on u8 and
u16 frames; the ffmpeg conformance suite (with and without the threads
feature); identical decoded YUV on five 1080p/720p streams (8-bit, 10-bit,
weighted prediction) at 1 and 3 threads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:41:59 +02:00
vangel76andClaude Opus 5.5 2fe3a5e202 #1039: faster HEVC motion compensation and deblocking, bit-exact
Problem: the software HEVC decoder was 2.7-4x slower per core than ffmpeg
on 1080p Main. Motion compensation was about half of the profile: the
weighted-prediction stores clipped in emulated 32-bit SSE2 lanes, every
block line of a reference window was a libc memmove call, and full-sample
uni-prediction went through window copy, shift and store. Deblocking
divided per edge to find the slice of each block.

Fix (output unchanged, sample for sample):
- put_uni / put_bi in 16-bit lanes (prediction samples are 14-bit; bi uses
  the exact halved-sum identity), dispatched on constant block widths.
- 8-bit first interpolation passes accumulate in 16-bit lanes (the
  filtered value stays within -24*255..88*255).
- Full-sample uni-prediction without weighting copies the reference
  samples straight into the picture (Frame::copy_block), and block lines
  are copied as fixed-size arrays instead of memmove calls.
- Deblocking takes the slices from edge_params instead of dividing per
  edge, returns bS 0 for identical motion in one slice, and filters luma
  edges on a gathered 4x8 block.
- 8-bit output planes are copied to u8 directly.

Tests: equivalence against the 32-bit formulas and the previous filter
(new unit tests), the ffmpeg conformance suite, and identical decoded YUV
on five 1080p/720p streams (8-bit, 10-bit, weighted prediction).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:16:44 +02:00
Auipp f315c46457 timeline: cancel drag and restore playhead on Escape (#580)
Pressing Escape during an active drag in the Timeline cancels the operation without committing changes on mouse release. For ruler scrub, restores the playhead to its starting time.
2026-10-11 15:02:09 +03:00
Vikram Bhaskaran 9891fd11a4 Keep timeline clips when Project Clear removes used media.
Clear used to strip every track item that referenced a deleted Project file. Those clips now stay on the sequence as Media Offline, and media.relink still finds the item. Unused media, bins, and generators are still removed.
2026-10-11 22:59:16 +11:00
Vikram Bhaskaran b082d6ab0b Honor frame/seconds/timecode on Source Mark In/Out and Mark Split (#920)
Source marks only read `time` (ticks) and otherwise used the Source playhead,
so agents passing `frame:24` marked frame 0. Parse the same time formats as
Program, using the Source item rate.
2026-10-11 22:49:23 +11:00
Vikram Bhaskaran 66c2e63092 Draw underlines for vertical graphic text.
The vertical layout path never emitted underline rectangles, so layer and
run underlines were a no-op. Place a tall strip to the right of each
underlined cell (vertical-rl) and compare horizontal vs vertical "A" at 60px.
2026-10-11 22:49:08 +11:00
Vikram Bhaskaran 6251b39b8f #907: apply Median radius in source pixels without a 4px cap
Radius was clamped to 4 working pixels, so values above 4 matched radius 4
at full resolution. Scale by px_scale and cap only to the image size.
2026-10-11 22:48:24 +11:00
Vikram Bhaskaran 3fd70ff774 Shift whole-style underlines with baseline_shift.
Plain layout() underlines sat on the unshifted baseline while layout_rich()
runs already subtracted baseline_shift. Match the two so underline y tracks
the glyphs for a full-paragraph style.
2026-10-11 22:44:29 +11:00
Oskar 18b5db246e #1018: Scene Edit Detection options use Premiere's wording
"Create a bin of subclips from each detected cut point" and "Create a
clip marker at each detected cut point". Drops the old strings from the
catalogs.
2026-10-11 13:37:49 +02:00
OskarandClaude Opus 5.5 c2fae80f6d i18n: es, fr, ja and zh-cn for Premiere's Scene Edit Detection wording
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:33:19 +02:00
Oskar a3445bbc1a #1018: Scene Edit Detection… in the timeline clip's right-click menu
The command and its dialog already existed in the Clip menu, but the
clip context menu, where people look for it, didn't offer it. It now sits
after Speed/Duration… and opens the same dialog.
2026-10-11 13:23:57 +02:00
whatever-industries f10fda5883 release-gate: Fix Extract edges and caption font targeting
Handle zero-softness Extract as a hard cutoff in both CPU and GPU paths.
The former 0.0001-wide substitute fade introduced grey edge pixels and
amplified transfer-function rounding beyond the existing parity tolerance.
Retain the positive-softness calculation and add cutoff, inversion and
premultiplied-alpha regressions without relaxing the comparison tolerance.

Constrain caption font automation targets to the visible part of each
row. A partly clipped row could previously expose a click point outside
the popup, leaving the selected font unchanged. Preserve the existing
font-loading behavior, assets and automation IDs.
2026-10-11 04:06:14 -07:00
Oskar faeca7aeab audio: regression test for 5.1 sources on stereo tracks 2026-10-11 12:58:23 +02:00
Oskar d22775dd41 audio: 5.1 sources on a stereo track fold down instead of losing C and surrounds
A six-channel clip on a stereo track played only source channels 0 and 1,
so the centre (dialogue in film mixes), LFE and surrounds were dropped.
Fold it down with ITU-R BS.775, as the Mix and the Source Monitor already
do. A six-channel Modify > Audio Channels pick folds the same way; mono
and stereo picks keep playing exactly the picked channels.
2026-10-11 12:58:23 +02:00
ReReddo 6b7f48d04d Escape cancels a value drag, color pick or dialog drag in progress (#580)
The same rule as for drag-and-drop: Escape while the mouse button is held
takes the drag back and leaves everything as it was before the press.

- Value drags in panels (hot numbers, sliders and color pickers in Effect
  Controls, Properties, Lumetri and elsewhere): the engine gets
  Session::undo_mark / revert_to, and the app marks the history when a press
  begins and reverts to the mark on Escape. revert_to drops the steps without
  adding them to redo.
- Lumetri made one undo step per frame of a drag; it now goes through
  effect_controls::run and merges them into one, as Effect Controls does
  (Properties does since #522).
- Drags that commit on release (keyframes, influence handles, graph keys,
  trim monitor, caption size, audio FX editor, monitor guides) skip the commit
  when Escape ends them. UI-only sliders (Project and Media Browser thumbnail
  size, Export settings) go back to their value at the press; a sequence tab
  dragged out of the dock goes back home.
- Dialogs: Escape with a button held no longer closes the dialog
  (widgets::escape_closes, also for the modal's own close). Dialogs with a
  draft keep it at the press and put it back on Escape
  (widgets::revert_drag_on_escape), color pickers included.

Tests: reverting_to_a_mark_takes_back_a_gesture_without_redo,
reverting_to_a_lost_mark_does_nothing; escape_drag_ui gains a
Properties value drag, the thumbnail-size slider and the Properties color
picker; clip_dialogs_ui and settings_ui check that a dialog field and a
Settings color go back while the dialog stays open.
2026-10-11 13:51:23 +03:00
ReReddo 197639988f Escape cancels a drag-and-drop in progress (#580)
egui ends a drag when Escape is pressed, and FilmCraft treated that stop as a
release: the drag was committed wherever the pointer was. Escape now abandons
it: nothing moves, nothing is dropped, nothing goes on the undo stack.

- Timeline: clip moves, trims, rolls, slips, slides and stretches, caption
  blocks and automation keyframes. The clips stay put and stay selected.
- Items carried between panels (a clip or sequence from the Project panel,
  Source monitor drags, effects, presets, graphics templates): the payload
  is cleared in drag_ghost. panels::cancel_drag / drag_cancelled keep a
  cancelled drag from restarting until the button comes up.
- Freeform card moves go back, and the next drag works.
- Media Browser drags, which import on drag start, take the import back even
  when released over a panel.

Tests: escape_drag_ui (5 tests: Timeline clip, caption, Project panel item,
Freeform card, effect; each with the same drag without Escape as a control)
and escape_takes_back_a_media_browser_drag. Automation keyframes have no
test fixture. The ui-egui suite passes except the two Windows CRLF failures
fixed in #488.
2026-10-11 13:50:23 +03:00
JordyUSAandClaude Opus 5.5 2d4422f408 #965: HDR and wide-gamut media play on the GPU path
On a Rec. 709 sequence every PQ, HLG, BT.2020 or P3 clip left the GPU
path: each frame was converted to a float image on the CPU (curve, HLG
OOTF, gamut matrix, BT.2390 tone mapping, desaturation) and resampled
there. A 2160x3840 10-bit HLG clip took 90 ms per frame (48 ms scaled to
fit a 1080x1920 sequence) against 33 ms at 30 fps; the same clip in SDR
took 18 ms.

The compositor's shader now runs that input transform itself:
InputTransform::gpu_params gives its stages as numbers (log curves and
camera-gamut compression stay on the CPU), PlanLayer::input carries the
transform for Y'CbCr layers on a plain pipeline, and execute_cpu applies
the same transform as the CPU reference. HDR clips now plan in 15-19 ms
per frame, and the GPU result is within one output code of the CPU's for
HLG, PQ (with and without a mastering peak) and BT.2020 SDR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:57:45 +07:00
MarekSpodBiedry da4a086676 Keep the voice-over start point when recording past the end of the timeline (#960) 2026-10-11 11:39:26 +02:00
MarekSpodBiedry 41ea382bc6 Reject non-finite numbers and empty LUT input ranges in .cube import (#998) 2026-10-11 11:37:53 +02:00
MarekSpodBiedry d702608eaf Count VideoToolbox sessions, frames and declined requests in perf.stats (#981) 2026-10-11 11:35:18 +02:00
MarekSpodBiedry 5c11f84770 Lumetri panel: truncate a long clip name in the header so the control rows stay in view (#997) 2026-10-11 11:35:08 +02:00
ReReddo 5a755aecd0 monitors: ruler ticks follow the time zoom
The ticks on the monitors' time ruler were spaced evenly across the
bar whatever the zoom, so zooming or scrolling left them looking
static. They now sit on whole frames, placed through the same mapping
as the playhead, markers and In/Out, with the step chosen from the
on-screen width of a frame as on the Timeline's ruler: minor ticks at
least 12 pt apart and major ticks at least 110 pt apart, the minor step
always dividing the major one. Zooming in spreads them out and fills in
finer steps; scrolling moves them with the time they mark.
2026-10-11 12:15:28 +03:00
ReReddo 2ea2e24cd2 tools: press and drag a grouped tool to open its flyout
Pressing on a grouped tool and dragging the pointer a few points in any
direction opens its flyout straight away, without waiting for the long
press. Letting go over a flyout row picks that tool and closes the
flyout; letting go anywhere else leaves it open, as after a long press.
Right-click and the 0.35 s long press work as before.
2026-10-11 11:45:55 +03:00
ReReddo ab62994511 monitors: zoomed time ruler follows the playhead while playing
While the Program or Source monitor plays and its time ruler is zoomed
in, the zoom range follows the playhead the same way the Timeline does,
honouring Settings > Timeline > Timeline Playback Auto-Scrolling: page
scrolling moves the range so the playhead is near its left edge when it
runs off the right edge or jumps before the left one; smooth scrolling
keeps the playhead in the middle; no scrolling leaves the range alone.
The range keeps its width. Scrubbing, clicking and moving the playhead
while stopped never move it.
2026-10-11 11:35:47 +03:00
ReReddo 1d1d51cbbd meters: adapt the layout to narrow and short panels, move while scrubbing
The Audio Meters panel now leaves out what doesn't fit instead of
drawing text over other text. As it narrows, the loudness readout drops
its LUFS/dBTP units, then the readout goes, then the dB scale goes so
the bars keep their room. As it gets shorter, the scale numbers thin out
to every 6, 12 or 24 dB and then go, and the channel letters and "dB"
under the bars go. At the default size it looks as before. The scale
numbers are listed under the automation id audioMeters.scale.

While the playhead is scrubbed with audio scrubbing on, the meters (and
the Audio Track Mixer's) show the grains being heard, as they do in
playback.
2026-10-11 11:34:02 +03:00
ReReddo 9f4814335b timeline: update box selection live while it is dragged
The clips a box touches are selected as it is dragged, and a clip that
leaves the box as it shrinks is deselected again, instead of the
selection only changing on release. Shift or Ctrl, held when the drag
begins, add to the selection the drag started with, as in the Project
panel. Escape cancels the box and puts that selection back. The
release still runs a single timeline.select, so the drag is one
selection change.
2026-10-11 11:33:58 +03:00
ReReddo a17b4292fe monitors: time zoom bar under the ruler, larger transport buttons
The Program and Source monitors get a zoom scroll bar under their time
ruler, drawn like the Timeline's: drag a handle at either end of the
thumb to zoom in or out (down to five frames), drag the middle to
scroll, or press beside the thumb to move it there. The ruler then
shows only that part: playhead, markers, In/Out handles and ticks are
mapped to it, clicks and scrubs set a time within it, and anything
outside it is not drawn. Each monitor keeps its own range in its view
state (time_zoom).

The transport buttons are now 28 pt square with larger icons, spaced
6 pt apart and centred; on a narrow monitor the spacing tightens before
buttons move to the overflow menu.
2026-10-11 11:29:47 +03:00
ReReddo c76d8211d1 timeline: drag the outer track edges to change track heights
The top edge of the highest video track now resizes the video tracks,
like the lines between headers do (same cursor, drag up to grow). The
bottom edge of the last audio track can now also be grabbed from just
below the line, in the empty space under the tracks, so both outer
edges work the same way. Each edge has an automation id
(timeline.track.<V3>.resizeTop, timeline.track.<A1>.resize).
2026-10-11 11:22:23 +03:00
ReReddo 76e7bfb8c9 dock: switch tabs with the mouse wheel over a tab strip
Scrolling over a panel group's tab strip makes the next tab active
(wheel down) or the previous one (wheel up), one tab per wheel notch.
Trackpad scrolling adds up until it reaches a notch, so a small swipe
moves one tab rather than several. It stops at the first and last tab
and does nothing over the panel's content. Works for docked and
floating groups, and steps through the Timeline's sequence tabs too.
2026-10-11 11:19:29 +03:00
vangel76andClaude Opus 5.5 ce63803934 #980: VP9 decode loop-filters a frame as a superblock-row wavefront
Problem: the post stage of a VP9 frame (band assembly, loop filter,
publication) ran on one thread. Later frames wait per band for their
references, so the serial loop filter (~30 % of the profile) set the
pace of every inter frame; 16 threads were only ~1.8x one thread.

Fix: rows of a frame's loop filter run as a wavefront, row r two
superblocks behind row r - 1 (what the raster order of 8.8.1 allows),
taken by the post thread and helper jobs on the decoder's pool. No lock
is held while a superblock is filtered: each row filters its own band
and rows exchange copies of the 8 rows the filter reaches across a band
boundary. Output stays bit-identical; one thread / wasm run the rows in
order as before. A row is only taken by a running thread, so waits
always make progress, and helpers run under catch_unwind (a failure
publishes placeholder bands as before). New conformance fixture
narrow_tall (2 superblock columns) and a repeated multi-thread 1080p
decode test. 1.2-1.5x faster with 16 threads on a shared machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:18:44 +02:00
ReReddo 2781df8b26 monitors: gentler, reversible wheel zoom
The mouse wheel over the Program and Source monitors now zooms about
10% per notch instead of 40%, exponential in the wheel travel, so one
notch in followed by one notch out returns to the same magnification.
Pinch zoom, the 10%-1600% range and zooming about the pointer are
unchanged.
2026-10-11 11:15:53 +03:00
JordyUSAandClaude Opus 5.5 a4053c0735 #928: the schema reports Restore open sequences as wired
file.open restores the saved sequence tabs and views when
timeline.restoreOpenSequences is on, but prefs.schema still marked the
preference wired:false ("stored, no effect yet").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:00:30 +07:00
JordyUSAandClaude Opus 5.5 0166dc1d6c #924: decoded H.264 / HEVC frames keep the signalled colour primaries
vui_color took the VUI matrix, transfer and range but not the primaries,
so every decoded H.264 / HEVC frame reported BT.709 primaries. Stream info
read them, so a BT.2020 file without a colr box said BT.2020 in its info
and BT.709 on its frames (Rec709 instead of Rec2020 for the renderer).
Pass colour_primaries through, for the software and hardware decoders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:35:06 +07:00
FacedFour1207andClaude Opus 5.5 4da2c0ce40 i18n: complete and polish the Japanese catalog
Add the 20 strings the Japanese catalog was missing (caption track
deletion, panel docking, eyedropper, edit point trim types, transport
overflow, effect shutter angle), update the hardware decoding note to the
current English source (NVDEC), and fix house-style slips (half-width
parentheses, a space before パネル, 長さ for Duration, Replicate no longer
shares 複製 with Duplicate).

Add a test that requires a Japanese entry for every menu label, tl!
literal and dynamic name, as the Chinese catalog already has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:34:15 +09:00
JordyUSAandClaude Opus 5.5 424ec6014b #923: reordered Matroska video without DefaultDuration keeps its frame rate
Without DefaultDuration the rate came from the median step between
neighbouring blocks in file order. Blocks are in decode order, so with
B-frames neighbours are two or more frames apart in presentation time and
a 25 fps clip reported 12.5. Step between the sorted presentation times
instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:28:42 +07:00
JordyUSAandClaude Opus 5.5 d348dccd21 #922: eight-channel AAC reports and returns eight channels
The MP4 reader took the AudioSpecificConfig's channel configuration as a
count, but it is an index into ISO/IEC 14496-3 Table 1.19: configuration 7
(7.1) reported seven channels and dropped the last decoded one, and 12
(7.1 back, what ffmpeg 9 writes for 7.1) reported twelve. Count from the
table (with Amd 4: 11 → 7, 12 and 14 → 8, 13 → 24), and decode
configurations 11 and 12, reordered to the WAV/SMPTE order used for 5.1.
Checked against ffmpeg's decode channel by channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:26:28 +07:00
Takahiro Miki 14b5f428d9 timeline: a drag with Track Select Forward/Backward moves what it selects
With the Track Select Forward tool (A) a press selected every clip from the
pointer on, but a drag never started, so the clips could not be moved with
the tool as in Premiere Pro. A drag now starts a move of the selection, with
Option copying as with the Selection tool.

A press on a transition over a cut did nothing with these tools; it now
counts as its track, as the clips under it do.

Regression test: track_select_drag_ui (the drag test fails before the fix).
2026-10-11 00:20:20 -07:00
vangel76andClaude Opus 5.5 6dd829dd61 #820: count only quick deaths, two in a row; notice with reset and --graphics
Review on #827: a start marker left pending by any early exit (force-quit,
hang, unrelated crash, two launches racing) moved the user down a rung for
good, with no way back but deleting a file.

- Each start writes its own attempt file (graphics-start/<pid>-<n>.json)
  that is removed after two painted frames, on a normal exit, or 15 s after
  launch whatever happened. A later launch only counts attempts that are
  older than that window, so a hang, a later crash or force-quit, and an
  instance that is still starting are never failures.
- graphics-start.json now holds {mode, failures}: two failed starts in a row
  in the same mode move on to the next one; a start that paints resets the
  count. A wgpu start-up error still moves on at once.
- All files are written to a per-process temp name and renamed into place,
  so concurrent launches never see or leave half a file.
- A fallback mode shows a notice (graphics.notice) with Use Default Graphics
  (graphics.notice.reset) and Close; es, fr, ja and zh-cn strings.
- --graphics default|dx12|software picks a mode by hand and keeps it;
  `--graphics default` undoes a fallback. Modes this platform lacks are
  refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:14:08 +02:00
vangel76andClaude Opus 5.5 a147e2551e #809: MP2 Matroska oracle robust to older ffmpeg; bounded ID3 skip and subbands
ffmpeg's CLI handles Matroska priming differently by version: 9.x trims
the 481-sample CodecDelay, 2023-24 static builds keep the priming
samples, so comparing from sample 2 put the priming ramp against our
tone (max error = peak 0.125). The .mkv case now searches lags within
two samples of 0 (trimmed) and of the stream's own CodecDelay (kept),
over the interior of both decodes; the plain .mp2 file stays at lag 0.

Review nits: the ID3v2 skip offset uses saturating_add (test with the
largest tag size); every Layer II table's sblimit is checked <= 32 at
compile time and clamped to SUBBANDS before indexing the per-subband
arrays (row() is a match, so it cannot index out of range).

Verified with ffmpeg 9.0.2 and the static N-71064 (2024-06) build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:56:31 +02:00
Vikram Bhaskaran 82609afec9 Fix Automate to Sequence dissolve duration when overlap is clamped.
Placement already limited overlap to half of each clip, but Cross Dissolve
still used the requested overlapFrames, so a 30-frame dissolve on two
20-frame clips started before the first clip. Apply the clamped per-cut
overlap as the transition length.
2026-10-11 17:56:18 +11:00
vangel76 fc3b7ae9fc Merge remote-tracking branch 'upstream/main' into gap-mp2-decoder 2026-10-11 08:53:44 +02:00
vangel76andClaude Opus 5.5 c2c7f1206c #790: Matroska audio oracle checks the encoded signal, robust to older ffmpeg
ffmpeg's CLI handles Matroska priming differently by version: 9.x trims
CodecDelay, 2023-24 static builds keep the priming samples (AAC 1024,
AC-3 / E-AC-3 256, MP3 1105), so a +-40-sample best-lag search against
its decode landed on a spurious minimum (4 or 24 samples).

matroska_audio_oracle now has ffmpeg encode a reference signal we
generate (low-passed xorshift noise) and asserts our decode lines up
with that signal to the sample, the stream's own CodecDelay trimmed:
version-independent, and it fails on the old ms-rounded trim (-16).
ffmpeg's decode is compared after a best-lag search bounded by the
CodecDelay; its lag is reported, the waveform must match (< 1e-3).
E-AC-3 joins the cases. eac3_oracle's .mkv case accepts lag 0 or the
256-sample priming; MP4 / MOV / TS stay strictly at lag 0.

Verified with ffmpeg 9.0.2 and the static N-71064 (2024-06) build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:53:27 +02:00
Vikram Bhaskaran b388c61684 Keep New Sequence open when the engine rejects the frame size.
OK used to close the dialog even after file.newSequence failed, so
reopening reset the name and settings. Stay open on validation
errors so one field can be fixed.
2026-10-11 17:52:32 +11:00
Vikram Bhaskaran 93be6e7da1 cli: exit 2 for invalid mcp --bridge address
`mcp --bridge` treated a non-loopback address as a command failure (exit 1)
while `exec` already mapped the same parse error through usage (exit 2).
2026-10-11 17:50:07 +11:00
vangel76 33271cdd6c Merge remote-tracking branch 'upstream/main' into gap-launch-paths
# Conflicts:
#	apps/filmcraft/src/args.rs
#	apps/filmcraft/src/main.rs
2026-10-11 08:48:28 +02:00
vangel76 3b48565ecb Merge remote-tracking branch 'upstream/main' into gap-mkv-codec-delay 2026-10-11 08:48:22 +02:00
vangel76andClaude Opus 5.5 00ad651814 Merge upstream/main into gap-gpu-safe-start
Conflict in apps/filmcraft/src/main.rs: kept the graphics start loop and
app_creator, and carried over --control-input-only (#881) into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:47:25 +02:00
Vikram Bhaskaran 82c318ed5a #915: Match Frame replacement maps source frames on reversed clips
Replace With Clip ▸ Match Frame used a forward-only offset, so a reversed
clip lined the Source monitor up with the wrong media time (source 50 at
sequence 20 became 69). Align source_in from reverse-aware source mapping.
2026-10-11 17:46:35 +11:00
Vikram Bhaskaran df282bcdc1 #918: keep fade-out on the clip end after Speed/Duration
Non-ripple clip.speedDuration shortened the clip but left one-sided
out-edge transitions at the old timeline range. Reposition them with
the same edge-follow used for a regular trim.
2026-10-11 17:14:16 +11:00
Vikram Bhaskaran 361ba9d58c #914: one undo step for a multi-point Trim by Frames.
Selecting two clip edges and trimming by frames applied each edge as its own
timeline.trim, so Undo restored only the last clip. Collapse those edits (and
extend-to-playhead / apply-default-transition) into one history snapshot so
Undo and Redo cover every selected point.
2026-10-11 17:13:54 +11:00
Vikram Bhaskaran d79248489b Refuse timeline.move when the clip's source track is locked.
Moving off a locked track used to skip delete_items and overwrite a
second item with the same clip id (#917).
2026-10-11 17:12:33 +11:00
Vikram Bhaskaran 53b99ae335 Keep linked rate-stretch partners at one speed.
A neighbour on only one track used to clamp that partner alone, so picture
and sound stayed linked at different rates. The tightest room now limits
the whole group.
2026-10-11 17:11:52 +11:00
JordyUSAandClaude Opus 5.5 773e1a3c72 Merge origin/main into feat/flac-export
Keep FLAC beside main's new AAC Audio format (21 formats, both audio-only
presets), use is_mp4_with / is_mp4_video in audio_codec() and the Audio
Format picker so MP4 (incl. AV1) offers AAC or FLAC, and add flacLevel to
main's export.media params.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:32:13 +07:00
Mara Johnson acc7cf5203 fix(ui): Korean text in the interface draws with an installed Korean font (#2093) 2026-10-11 01:14:11 -04:00
awe a8dac81d97 Fix two edge cases in multi-select placement
A selection that holds a bin beside items (one id space) made the whole drop
fail with "no such item"; only items are placed now.

Session::edit_group raised the history limit while a group ran and did not
restore it if the group panicked. It now restores it and rolls the group back
before the panic goes on.
2026-10-11 11:12:24 +07:00
Nigel Ihza Firdausy 8859e98fb7 export: estimate RGB TIFF sequence size from written channels 2026-10-11 11:09:25 +07:00
Earl Co 5d7dc800a8 M7: correct export loudness after peak limiting
Bound post-limiter loudness correction and cover high-crest signals, infeasible targets, cancellation, and output alignment. Focused tests and lint pass; full workspace validation pending for draft review.
2026-10-11 06:09:02 +02:00
Nigel Ihza Firdausy 4969601e9a render: advance Noise grain from exact frame ticks 2026-10-11 11:06:35 +07:00
Nigel Ihza Firdausy a24e0055c6 ui: leave marked Program playback on its last included frame 2026-10-11 11:04:16 +07:00
Vikram Bhaskaran eea3afa8be Essential Graphics: an Install refused shows why on the status bar (#611).
The Browse panel's Install… button calls the module's route directly and drops its
result, while only menus.rs surfaced a refused file — so installing another
application's template package (an EffectCraft .ectemplate ZIP renamed to .fcgt,
the case in the report) looked like nothing happened. The engine has refused ZIP
archives with a clear message since M10.7; the route now puts that message on the
status bar itself, whichever caller arrives.

Headless: clicking Install with the picker pointed at a ZIP-named file requires
the status bar to name the ZIP archive; without the surfacing the status stays
empty — the reporter's exact symptom.
2026-10-11 15:03:16 +11:00
Vikram Bhaskaran 3da5dec8d4 Export FCP7 XML master-clip media markers (xmeml 4/5).
Timeline clipitem markers already round-tripped; source-media annotations on
master clips were omitted with an empty export report. Write those markers on
<clip> so re-import keeps them.

Fixes storytold/filmcraft#900.
2026-10-11 15:03:07 +11:00
Vikram Bhaskaran 50f9cfecc0 Fix OTIO nested sequence vs clip instance markers (#901)
Export was overwriting Stack markers with the using clip's markers, so a nest with no instance markers lost its sequence markers, and instance markers were imported onto the nested sequence. Keep sequence markers on the Stack and store clip instance markers in metadata.filmcraft.clip.
2026-10-11 15:02:25 +11:00
Vikram Bhaskaran b13567439b Fix FCPXML one-frame markers importing as ranges
FCPXML writes point markers as one frame at the sequence rate. Import
compared duration to 1 tick, so a 24fps one-frame marker became a
range. Treat duration ≤ one sequence frame as a FilmCraft point.
2026-10-11 15:01:54 +11:00
Nigel Ihza Firdausy c6ced89015 tts-text: preserve leading zeros, fraction operands and ordinal magnitude 2026-10-11 10:59:58 +07:00
Mara Johnson e283f4dee7 fix(media): RF64 trailing metadata stays outside audio duration
Use ds64.dataSize for an RF64 data-size sentinel instead of the rest of
the file, so chunks after the audio are no longer decoded as samples.

Fixes #925
2026-10-10 23:57:14 -04:00
jahvari e60a7e43bf WIN-03: Normalize packaging fixture line endings in tests 2026-10-10 22:39:51 -05:00
jahvariandCodex 61df55ff43 WIN-02: Make parallel GOP cache test deterministic
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-10 22:39:51 -05:00
jahvariandCodex fbbb57d325 WIN-02: Serialize shared codec test fixture publication
Prevent concurrent fixture producers from replacing files held open by
external decoders. Add a four-consumer Windows sharing regression.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-10 22:39:51 -05:00
Vikram Bhaskaran 7fef64de6b #911: refuse timing edits on locked tracks
Trim, roll, slip, slide, rate stretch and Speed/Duration now fail with
"track is locked" instead of changing clips on a locked track, matching
same-track move. Linked partners on locked tracks are left alone.
2026-10-11 14:09:05 +11:00
Vikram Bhaskaran 0a4be7b968 Clear the saved path when opening the demo project.
file.openDemoProject left Session.path pointing at the previous
.fcproj, so File ▸ Save overwrote that project with the demo.
Match New Project: drop the path (and the associated preview dir)
so Save requires Save As and the original file stays intact.
2026-10-11 14:04:39 +11:00
Vikram Bhaskaran 8d62b7fe1a Keep recovery snapshots when session.json is missing or corrupt.
Startup scan used to delete an unlocked recovery session whenever
session metadata could not be parsed, even if snapshot.fcproj was
intact. Offer that snapshot for recovery instead of removing it.
2026-10-11 14:03:25 +11:00
liangforstudyandClaude Opus 5.5 979e9a0c56 #402: animated GIF and APNG import as clips, with transparency
APNG and animated GIF files opened as stills showing only their first
frame. They now open as movie clips (crates/media/src/animated.rs):

- every frame is decoded and composited onto the full canvas by the
  image crate (APNG blend/dispose ops, GIF disposal), keeping alpha
- each frame lasts its own delay; GIF delays <= 10 ms play as 100 ms
  and APNG delays < 10 ms as 10 ms, as browsers do
- consecutive identical frames are merged; the clip is one pass of the
  animation and its timebase is the most common frame length
- hostile input: at most 10,000 frames / 1 GiB of decoded pictures; a
  damaged frame ends the clip at the frames before it; files with one
  frame (or none that decode) still open as stills, as before

Tests: media unit tests (timing, transparency, merging, single-frame
files stay stills, truncated/bit-flipped files never panic) and an
engine test that imports an animated GIF and reopens it from a saved
project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkQ2PDrNFjs9Vm8T44tdjA
2026-10-11 10:44:15 +08:00
awe fb4546e9d6 Merge upstream/main into feat/multi-select-drag 2026-10-11 09:40:44 +07:00
awe 8056e62f65 Merge upstream/main into feat/multi-select-drag
Carry #754's "new" track handling into place_one, and add it to items: the
first item adds the track and the rest of the list follows it onto that track.
2026-10-11 09:34:36 +07:00
Vikram Bhaskaran ba4dacdeff #887: coalesce Scale (and similar) hot-text drags into one undo
A drag of Effect Controls / Properties numbers already used merge+begin, but
the press-start memory was only written on frames that emitted setParam.
egui drops unused temp data after a quiet frame, so a Scale scrub that
paused between percents began a new undo step for every 1%. Refresh the
drag session on idle frames while the button is down; still begin a new
step on the next press. Same keep-alive for graphics property drags.
2026-10-11 13:05:18 +11:00
Vikram Bhaskaran 488259414b Enable Project panel Reveal in Finder for clips with a media file.
file.reveal is enabled when a selected clip has a local path, greyed out
with no selection, and the OS opener reveals the file (xdg-open of the
parent folder on Linux).
2026-10-11 13:02:55 +11:00
Vikram Bhaskaran f733230b9f #823: import every MPEG audio stream, not only the first PID
Transport and program streams listed a single audio stream, so file.import
and New Sequence From Clip kept one clip. Probe every audio PID (capped),
decode each independently, and place linked clips per stream. MP4/MKV
already did this; MXF same-rate PCM is still interleaved as one stream.
2026-10-11 13:01:20 +11:00
skylar taylor-barrick 4e2d0bc2d3 Merge pull request #881 from skylartaylor/codex/ui-suite-integration-20261010
Share docking transactions and preserve FilmCraft panel behavior
2026-10-10 19:46:53 -06:00
opentechnepal 3f9c1421d9 contributors: credit opentechnepal
Takes the display name from the GitHub profile (sync_github_name), so it
tracks the profile rather than a copy here.
2026-10-11 03:46:18 +02:00
opentechnepal 42dac5f699 #243: text fields keep unmodified keys so IMEs can compose
A focused text field now owns every unmodified key, so an input method can
compose there. Vietnamese Telex builds its diacritics from plain letters
(`a` + `s` → `á`, `a` + `r` → `ả`, `a` + `j` → `ạ`), and the editor bound
`A` `B` `C` `H` `J` `K` `L` `N` `P` `R` `T` `U` `V` `Y` `Z` as single-key tool
shortcuts, which consumed those presses before any field saw them. No
diacritic could be typed (#243).

`egui_wants_keyboard_input` was not enough to guard this: it is read before the
pass lays the UI out, so it still reports the previous pass, and it is true for
any focused widget, so using it would leave the editor without its single-key
tool shortcuts while a slider has focus. `text_edit_focused` is the precise
check.

Chords with a modifier keep firing while a field is focused: an IME does not
consume Ctrl+S and friends. Esc (trim cancel) is handled before the guard and
is unaffected.

Tests: the four cases in `text_focus_shortcut_tests` fail without the guard
(`an_unmodified_key_reaches_a_focused_text_field`,
`the_telex_trigger_keys_are_left_to_the_field`) and pass with it.
2026-10-11 03:45:01 +02:00
KitsuneSemCalda f4bbbd2f64 Fix OmaStore exec paths (no usr/ prefix in the release tarball) 2026-10-10 22:31:17 -03:00
vangel76andClaude Opus 5.5 1e6f42eda2 #847: AV1 decode runs deblocking, CDEF and loop restoration in parallel row bands
Problem: the AV1 in-loop filters ran on one thread per frame (postfilter::apply
had an unused pool). On a single-tile stream they are a quarter to a third of a
frame, and since references are waited for as whole frames, that serial tail
sat on the critical path of every frame that references it.

Fix: the three filters run in bands of 64 luma rows on the decoder's existing
rayon pool, one join per filter per frame. CDEF and loop restoration write only
their own rows of a separate output. The loop filter's edges never touch the
same sample, and only a band's top horizontal edges reach into the band above:
each band refilters the vertical edges of a copy of the 8 rows above it, runs
those edges against the copy, and the samples they changed are written back.
With one thread the code path is the old one. Output stays bit-exact (1 vs 16
threads vs libdav1d; new 4:2:2 / 4:4:4 libaom fixtures). perf.rs gains a
low-delay 1080p and a single-tile 2160p fixture and the all-core stage
breakdown. 16 threads: 1080p GOP 1.16-1.30x, low delay 1.28x, 2160p 1.14x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:11:27 +02:00
FredApps 5ed23c9424 monitors: show the unreadable slate for persistently failing frames
A damaged file whose frames fail to decode or read again and again
rendered black with no explanation. In the monitors only, after 3
consecutive Decode/Io failures of the same item its failing frames show
the existing MEDIA UNREADABLE slate; a decoded frame clears it. Pending
(still-loading) reads never count, and exports never see it.
2026-10-11 03:04:56 +02:00
vangel76andClaude Opus 5.5 482783e538 #809: Cargo.lock picks up the 0.6.0 workspace version for filmcraft-mpa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:00:46 +02:00
vangel76 b74202696d Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-11 03:00:38 +02:00
skylar taylor-barrick 1996723225 UI.6: refresh shared UI integration against current main 2026-10-10 18:51:04 -06:00
vangel76andClaude Opus 5.5 d066f82e26 #874: compressed (RLE / ZIPS / ZIP) OpenEXR export
Problem: OpenEXR sequences (#806) were written uncompressed only, because the
workspace had no deflate encoder; the files are 2-4x larger than necessary and
Premiere offers an EXR compression choice.

Fix: a small clean-room DEFLATE (RFC 1951) encoder in a zlib (RFC 1950)
wrapper in filmcraft-export (greedy LZ77 with bounded hash chains; stored,
fixed or dynamic Huffman per block, whichever is smallest; length-limited
codes). The EXR writer gains RLE, ZIPS (1 line) and ZIP (16 lines) chunks with
the format's byte interleave + delta predictor; chunks that do not shrink are
stored raw. New ExportSettings::exr_compression (default None, as before),
`exrCompression` param on file.exportMedia, Compression combo in Export mode
(es/fr/ja/zh-cn strings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:46:53 +02:00
vangel76andClaude Opus 5.5 615b89a364 #867: Indonesian (id) interface translation
Problem: Indonesian, one of the 12 key languages in
docs/localization-parity.md, had no catalog (0%), so Indonesian users got
the interface in English and System Language never picked Indonesian.

Fix: add crates/ui-egui/src/i18n/id.tsv, clean-room translations of every
entry in the Spanish catalog plus the native macOS Settings menu (3,541),
with a glossary in its header. Language::Id ("Bahasa Indonesia") is
persisted as `id`; it appears in Edit > Language (also the native macOS
menu), Settings > General, and System Language picks it for `id-*` locales
and the legacy `in-*` code older Java/Android runtimes report. Tests cover
the core menus, reject entries without a Spanish counterpart, check glyph
coverage with the bundled fonts, switching/persistence and locale
matching. Attribution sidecar, ATTRIBUTION.md row, docs (gaps.md G14,
localization-parity, ROADMAP, ui-egui README) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:41:31 +02:00
Vikram Bhaskaran f9f7ea4666 #848: LongGOP seek / time-to-first-frame, not decoder throughput
Pin GOP-cache eviction on the requested pts so a burst past the playhead
does not drop the target and walk the GOP again. Preroll only schedules
the frames that gate clock start. bench_playback no longer meters preroll
jobs as on-clock latency.

This is the seek stall from #848, not the ~3× per-core gap vs ffmpeg.
2026-10-11 11:35:59 +11:00
vangel76andClaude Opus 5.5 50560a8364 #786: right-click Label menus show the label colours
Problem: #802 gave Edit > Label a colour swatch per label, but the Label
submenus of the Project panel item menu and the Timeline clip menu still
listed bare built-in names: no colour, no custom names from Preferences >
Labels, and no automation ids.

Fix: a shared menus::label_choice draws each choice as Edit > Label does
(the user's name, a swatch of the user's colour in the left gutter) and
registers it (project.itemMenu.label.<Label>,
timeline.clipMenu.edit.label.<Label>); both context menus use it, and their
Label submenu buttons get automation ids too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:28:15 +02:00
vangel76andClaude Opus 5.5 7625ddf4de #854: GIF frame delays add up to the sequence duration
Problem: the GIF exporter gave every frame the same delay of
1000·den/num ms, which the encoder truncates to whole centiseconds.
At 24 fps each 41.67 ms frame became 4 cs, so a 4.0 s sequence played
in 3.84 s (4 % fast); other non-integer rates drifted the same way.

Fix: each frame's delay now runs from its rounded start time to the
next frame's (gif_delay_cs), so delays alternate (4, 4, 5, 4, 4, 4 cs
at 24 fps) and add up to the exact duration. Checked i128 math also
replaces the unchecked `(1000 * den) as u32` cast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:22:09 +02:00
vangel76andClaude Opus 5.5 c6d47e20f0 #859: complete the Brazilian Portuguese (pt-br) interface translation
Problem: the Portuguese (Brazil) catalog, one of Premiere's interface
languages and one of the 12 key languages in docs/gaps.md G14, covered
only the menus (348 of ~3,540 strings); panels, dialogs, Settings pages,
effect names, the Keyboard Shortcuts dialog and status messages showed
in English.

Fix: pt-br.tsv now has every entry Spanish has plus the native macOS
Settings item, written from the plain meaning of the English text with
ordinary Brazilian video-editing vocabulary (infinitive commands,
polite imperative instructions, sentence case); the header records the
conventions and a term glossary. Like Japanese and French, tests
require only the menus (now every menu label, not just the core set),
so later strings show in English until translated; a Portuguese entry
without a Spanish one fails, and a new test checks the bundled fonts
cover every Portuguese letter. The core-menu set that Ukrainian,
German, Russian and French must cover moves from pt-br.tsv (no longer
menus-only) to the menus-only Ukrainian catalog, unchanged in content
for those tests. Attribution sidecar and ATTRIBUTION.md entry added.
Docs: G14, localization-parity, ROADMAP language row, ui-egui README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:17:58 +02:00
Vikram Bhaskaran 111991591b Roll linked audio with the picture cut (#855).
timeline.roll follows linked partners at the same through-edit when linked
selection is on, so a +12 frame roll on V1 also moves A1. The command
reports linked:false when the cut is not followed as a pair. Reversed
clips keep the unmoved-edge media mapping (#526/#558).
2026-10-11 11:17:47 +11:00
vangel76andClaude Opus 5.5 c0f1383d66 #857: reject unknown marker colour names instead of storing Green
Problem: program-monitor markers.add (and markers.addRange, addRangeInOut,
addChapter, addFlashCue) mapped `color` with Label::from_name and fell back
to Green on any unknown name, so `{color:"Red"}` succeeded and stored Green;
markers.edit silently ignored an unknown colour.

Fix: one marker_color_p helper turns a non-label `color` into a bad-params
error that lists the accepted label names; every marker command (and the
Source monitor path, which already rejected it) uses it. Absent or null
still means the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:17:05 +02:00
Earl Co dcabbda07d M7: optimize settled mixer DSP and isolate throughput gate 2026-10-11 02:17:02 +02:00
Vikram Bhaskaran b964b9970a #856: Reject negative times on timeline.place and markers.add
Clips and markers before sequence start were accepted, then unreachable
in the GUI and cut off on export. Time, frame and seconds before 0 now
return a parameter error; zero remains valid.
2026-10-11 11:16:50 +11:00
vangel76andClaude Opus 5.5 1edcaa4e78 #853: Shift constrains pen mask segments to 45° steps
Problem: the pen mask tool ignored Shift, so straight horizontal,
vertical or diagonal mask edges could not be drawn precisely (Premiere
26.0 constrains mask lines with Shift). Separately, path_from_json
accepted the `{"p","in","out"}` vertex form through serde first, which
silently dropped the `in`/`out` tangents, so a pen mask's dragged-out
curves were committed as corners.

Fix: with Shift held, a new pen point snaps onto the nearest 45° ray
from the previous point (in screen space), the rubber-band preview
follows, and a Shift-drag constrains the Bézier handle it pulls out.
path_from_json now parses the vertex forms before falling back to the
serialized MaskPath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:09:49 +02:00
vangel76andClaude Opus 5.5 8e61e610cc #850: launch files open whatever the extension's case, with absolute paths
Problem: the desktop app picked the project among its launch files with a
case-sensitive ends_with(".fcproj"), so CUT.FCPROJ opened through a Windows
file association or the Linux MIME glob was imported as media. Relative
launch paths were stored as given and went missing from another folder.
std::env::args panicked on a non-Unicode argument. filmcraft-cli import
used canonicalize, which stores \\?\ verbatim paths on Windows.

Fix: args::project_and_media finds the project by a case-insensitive
extension and makes every launch path absolute with std::path::absolute
(no verbatim prefix, symlinks kept). Both apps read args_os; the desktop
app keeps --data-dir exactly. filmcraft-cli import uses std::path::absolute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:54:49 +02:00
vangel76andClaude Opus 5.5 09f627a73a #844: on-monitor corner-roundness handle for masks
Problem: masks.roundCorners (#813) rounded rectangle/polygon mask corners only from the
command line, control channel or MCP; the Program monitor had no Premiere 26-style
corner-roundness handle.

Fix: a selected mask with sharp corners between straight edges shows a roundness handle
just inside its top-left corner (automation id program.mask.roundness). Dragging it into
the shape moves it along the corner bisector, previews the rounded outline live with the
radius, and on release dispatches one masks.roundCorners (one undo step per drag). The
radius is capped where no corner can grow further. Ellipses, pen masks and rounded corners
show no handle. Tooltip translated (es/fr/ja/zh-cn). New headless UI test
mask_roundness_ui; docs/gaps.md G18 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:51:51 +02:00
vangel76andClaude Opus 5.5 4a3b2a20f1 #840: restart an audio stream the device drops instead of playing silent
Problem: the cpal error callback marked the stream failed on its first
error of any kind, so the UI stopped audio and played the rest of the
playback without sound. cpal's ALSA backend reports transient errors
(POLLERR after an xrun, spurious poll returns, short writes) that way
and keeps the stream alive; after POLLERR it no longer calls back.
Reports: a second or two of audio, then silence while meters move (#23).

Fix: AudioOut::recover, called every playback frame, lets CpalOut
restart the stream: at once when the device is gone, or when an error
was reported and no samples were consumed for 300 ms. The restarted
stream reuses the shared play-ahead mixer and played-frame clock and
must keep the sample rate and channel count; at most 3 restarts in
10 s, then playback falls back to the wall clock as before. Errors
while samples keep flowing no longer silence playback. The device
callback runs the mixer under catch_unwind: a panic plays silence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:39:41 +02:00
Brandon ThomasandClaude Opus 5.5 2ff3f5e266 Release prep: 0.6.0 and a Linux riscv64 release build (#830)
- release.yml: new `linux-riscv64` job, cross-compiled on ubuntu-24.04 against
  ports.ubuntu.com riscv64 libs (ported from GridCraft), tar.gz only, CLI
  smoke-tested under QEMU. Installs libasound2-dev:riscv64 for cpal/ALSA; the
  VA-API and NVDEC/NVENC paths are loaded with libloading at run time, so they
  need no riscv64 libraries. The draft-release job now needs it, so its tarball
  is uploaded and covered by SHA256SUMS.txt.
- packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE /
  EMULATOR support, as in GridCraft.
- README and docs/releasing.md list the riscv64 tarball.
- cargo xtask version set 0.6.0 (Cargo.toml, Cargo.lock); refresh contributors.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:28:41 -04:00
vangel76andClaude Opus 5.5 fc855e2b0e #835: workflow acceptance tests through the MCP server, checked by ffprobe
Problem: no test did a real editing job end to end. Codec and container
oracles exist, but nothing imported phone- or camera-like media, edited
it through the command layer and checked the exported file (G6).

Fix: apps/filmcraft-cli/tests/workflows.rs drives the `filmcraft-cli mcp`
binary over stdio on media generated with ffmpeg into target/fixtures:
- a VFR H.264 + AAC MP4: sequence from clip, extract, -6 dB audio gain,
  title, H.264 MP4 export;
- a long-GOP H.264 MKV with AAC + AC-3 tracks: ripple trims, colour matte,
  centred cross dissolve, save; a new CLI process exports ProRes.
ffprobe / ffmpeg check streams, frame count, duration, which source frame
lands where, A/V sync across the cut, title pixels, the dissolve mix and
audio levels. The tests skip without ffmpeg (testkit oracles).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:26:42 +02:00
vangel76andClaude Opus 5.5 42b990b72b #841: import packed 10/12-bit and Y'CbCr DPX stills
Problem: DPX import rejected the 10/12-bit packed layout (packing 0)
and the Y'CbCr descriptors (100 CbYCrY, 101 CbYACrYA, 102 CbYCr,
103 CbYCrA) with an "unsupported" error.

Fix: unpack packed components back to back, low bits first, each scan
line on a 32-bit boundary (as ffmpeg reads 12-bit packed files), and
convert Y'CbCr to RGB with the matrix the colorimetric field names
(BT.709; BT.601 for the 601 and composite codes; by picture size when
unspecified), in video range unless the reference codes span the full
code range; 4:2:2 chroma is shared by each pixel pair. Unit tests for
every new layout and hostile headers; an ffmpeg oracle test builds
packed 12-bit RGB and 8-bit 4:2:2 / 4:4:4 / 4:4:4:4 files from
ffmpeg's test picture and matches its decode within one 8-bit step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:26:31 +02:00
Brandon Thomas 2a9f39db63 Merge pull request #825 from vangel76/gap-aac-audio
#814: AAC Audio export (audio-only .m4a, or ADTS .aac)
2026-10-10 19:22:45 -04:00
vangel76andClaude Opus 5.5 74e1b1eb68 #828: import OpenEXR stills and image sequences
Problem: FilmCraft exports OpenEXR sequences (#806) but could not open
.exr files at all, so EXR plates and renders from other apps could not
be imported (docs/gaps.md G10).

Fix: a clean-room OpenEXR reader in filmcraft-media (still/exr.rs) for
single-part scan-line files with no, RLE, ZIPS or ZIP compression and
half / float / uint channels, with our own zlib/DEFLATE decoder (no new
dependency). R, G, B, A (or luminance Y) become premultiplied linear
RGBA float frames, the compositor's working format, so values above 1.0
survive; the data window is placed in the display window. Stills open
through open_bytes, sequences through ImageSequenceSource; tiled, deep,
multi-part, subsampled and PIZ / PXR24 / B44 / DWA files are refused
with a clear error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:18:11 +02:00
Vikram Bhaskaran 199223f3b9 #697: Paste cut clips from the keyboard even when old OS files linger
Copy and Cut snapshot files or an image already on the system clipboard.
Paste ignores that same media so Cmd+V and the Edit menu paste the clips
instead of re-importing stale files the platform left beside the clip names.
2026-10-11 10:16:50 +11:00
MrCloudy2andClaude Opus 5.5 f79a51dd24 #480: exports no longer stop for good when a clip's decoding uses rayon
A GopCache decodes with its lock held, and that decoding starts rayon work
(rotating iPhone frames, cropping, slice-threaded decoders). On the global pool
a worker that took part of it could, waiting inside it, pick up another render
job asking the same cache for a frame and block on the lock, while the lock
holder waited for the work on that worker's stack: exports of rotated iPhone
HEVC clips stopped making frames in about half the runs.

The locked decoding now runs on a separate decode pool, whose threads never wait
for a cache's lock; a caller that is a rayon worker waits on a plain thread so it
does no other rayon work while holding the lock. The cancel/catch-up/draft/
background hints travel with it (filmcraft_media::cancel::Context). Intra-only
streams crop and rotate before taking the lock again.

Second version of #662 (reverted): the regression test's stand-in decoder summed
with overflow, which panics in debug builds; it now wraps explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:16:43 +02:00
vangel76andClaude Opus 5.5 f8b37e1463 #831: cargo xtask parity measures effects and transitions (--area)
Problem: `cargo xtask parity` (#781, #782) measured only the menu bar; the
Effects panel's effects and transitions had no measured parity figure
(docs/gaps.md G7).

Fix: `--area effects|transitions` compares the catalogue the engine's
effect definitions list in the Effects panel (new `effect_catalog`
example in filmcraft-project, the panel's own top-folder filter) with a
reference of folder paths or bare names (default the local, never
committed plan/premiere/<area>.json). Same report, caps and `--min` as
menus; placement by folder; Presets / Lumetri Presets and the other
area's items are out of scope, so one panel snapshot serves both areas.
`--area menus` (default) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:15:53 +02:00
Brandon Thomas c47e883c27 Merge pull request #826 from vangel76/gap-tga-dpx-import
#821: import Targa and DPX stills and image sequences
2026-10-10 19:15:39 -04:00
Brandon Thomas 55b07f29ff Merge pull request #818 from vangel76/gap-mask-rounded-corners
#813: masks round their corners (rounded rectangle masks)
2026-10-10 19:15:33 -04:00
Brandon Thomas 4ecdb6ea71 Merge pull request #824 from ReReddo/feat/monitor-wheel-zoom
monitors: the wheel zooms about the pointer, the middle button pans
2026-10-10 19:10:45 -04:00
Brandon Thomas 5ad352ed04 Merge pull request #815 from vbasky/fix/new-bin-clear
Clear leftover item selection when making a new bin
2026-10-10 19:10:39 -04:00
Vikram Bhaskaran 03bf8b1f33 Fix OS drops on an empty Timeline creating a sequence.
Dragging files from the explorer onto the Timeline imported them into the
Project bin but never placed them when no sequence was open, because only
the clip-area interact path consumed pending_timeline_drop. The empty
Timeline now creates a sequence from the first imported item and places
any further items after it, matching the existing Project-panel drop.
2026-10-11 10:09:44 +11:00
Vikram Bhaskaran 995053922e Show Parametric EQ in Effect Controls on linked A/V clips.
Audio effects applied to the video side of a pair now land on the linked audio, and Effect Controls lists that clip's parameters and Custom Setup while the picture is selected.
2026-10-11 10:09:36 +11:00
vangel76 f8f16b6eca Merge remote-tracking branch 'upstream/main' into gap-aac-audio
# Conflicts:
#	crates/engine/src/commands.rs
#	docs/gaps.md
2026-10-11 01:09:25 +02:00
vangel76 bad190d234 Merge remote-tracking branch 'upstream/main' into gap-mp2-decoder
# Conflicts:
#	docs/codec-parity.md
#	docs/gaps.md
2026-10-11 01:07:54 +02:00
Vikram Bhaskaran d3444534a6 Fix Clear on Close Sequence also dropping File Close (Cmd+W).
File ▸ Close aliases Close Sequence. The shortcut editor targeted
sequence.close while Cmd+W lived on file.close (and Timeline), so Clear
left the chord bound. Treat the pair as one shortcut family.

Fixes storytold/filmcraft#773
2026-10-11 10:04:44 +11:00
Brandon ThomasandClaude Opus 5.5 f114b5f10c Revert #783 (Linux H.265 through NVENC): intermittent heap corruption in the CLI
With NVENC registered on Linux, `filmcraft-cli --test cli` aborts in 2 of 45 runs on an RTX 3090
(`malloc(): mismatching next->prev_size`, `malloc(): unaligned fastbin chunk detected`) right
after an export finishes; 0 of 30 runs fail with this reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:15 -04:00
vangel76 2ca656684e Merge remote-tracking branch 'upstream/main' into gap-mask-rounded-corners
# Conflicts:
#	docs/gaps.md
2026-10-11 01:00:22 +02:00
vangel76andClaude Opus 5.5 116364cf08 #820: fall back to DX12, then a software adapter, when the graphics start fails
Problem: a graphics driver that crashes inside wgpu instance or device
creation (Intel Vulkan igvk64.dll #512 #413 #793, AMD atio6axx.dll #477)
killed every launch the same way until the user set WGPU_BACKEND by hand;
a start-up error wgpu did report ended in an error dialog.

Fix: each start leaves a marker in the data folder (graphics-start.json),
cleared once the window has painted two frames. A marker still pending at
the next launch moves on along the platform's ladder (Windows: Vulkan+DX12,
DX12 only, software/WARP; Linux: default, software/lavapipe or llvmpipe)
and keeps the mode that works. A wgpu start-up error moves on within the
same process (eframe reuses its event loop). Fallback modes don't register
the GPU export renderer, whose own instance would load the failed driver.
An explicit WGPU_BACKEND leaves the marker alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:58:45 +02:00
vangel76andClaude Opus 5.5 fe52bc0b2c #821: import Targa and DPX stills and image sequences
Problem: Premiere imports Targa and DPX as stills and numbered sequences.
FilmCraft exports both (#779) but could not read either: the extensions
were not importable and the image crate has no DPX reader, so even our
own Targa / DPX renders could not come back in.

Fix: clean-room decoders in filmcraft-media. Targa from the TGA 2.0 spec:
image types 1/2/3 and RLE 9/10/11, colour maps, 15/16/24/32-bit, 8/16-bit
grayscale, any origin. DPX from SMPTE ST 268: first element, luma / RGB /
RGBA / ABGR, 8/16-bit, filled 10/12-bit (methods A and B), either byte
order, padded lines, orientations 0-3. Both bound allocations by the
data present and reject hostile headers with errors. Stills and image
sequences share one decode_raster entry point; "tga" and "dpx" join the
importable still extensions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:58:15 +02:00
vangel76andClaude Opus 5.5 b9f60842bb #814: AAC Audio export (audio-only .m4a, or ADTS .aac)
Problem: Premiere's "AAC Audio" format has no FilmCraft equivalent. AAC
was only written inside H.264 / H.265 / AV1 movies; the only audio-only
exports were Waveform Audio and AIFF.

Fix: a new `aac` export format ("AAC Audio") that encodes the sequence
mix with the existing AAC-LC encoder into an MPEG-4 audio file (.m4a,
encoder delay in an edit list), or a raw ADTS stream when the path ends
in .aac. Bitrate, sample rate, channels and loudness normalisation follow
the audio settings. Adds an "AAC Audio 48 kHz 320 kbps" Audio Only
preset, CLI extension guessing (.m4a / .aac), es/fr/ja/zh-cn strings, and
updates G13 (also noting ProRes 4444 / XQ is already in the export UI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:56:35 +02:00
ReReddo 5f9908905c monitors: the wheel zooms about the pointer, the middle button pans (#807)
Zooming a monitor took the zoom dropdown, which zooms about the middle
of the picture, and then scrolling or the Hand tool to bring the part
you wanted into view; at Fit the wheel did nothing.

Now, on the Program and Source monitors:

- The mouse wheel (or a pinch) zooms about the pointer, from Fit too,
  between the dropdown's 10% and 1600%: the point under the pointer
  stays under it. It zooms as fast as the Timeline's wheel zoom, through
  the same wheel_input.
- A middle-button drag that starts over the picture pans it, whatever
  the tool; the Hand tool still pans with the left button.
- Fit resets zoom and pan, and each monitor keeps its own, as before.
  Comparison View, which always fits both frames, ignores the wheel.

Tests: view_ui (wheel zoom about the pointer, middle-button pan, the 10%
floor, Fit, Source untouched).
2026-10-11 01:55:26 +03:00
vangel76andClaude Opus 5.5 39afd2f6c3 #809: own MPEG audio Layer I / II decoder plays MP2 at ffmpeg's default bit rates
Problem: symphonia rejects every Layer II frame whose bit rate / mode
combination ISO/IEC 11172-3 does not allow (mono above 192 kbit/s, stereo
below 64 kbit/s). ffmpeg writes mono MP2 at 384 kbit/s by default, so such
tracks played as silence in MPEG-TS / PS, Matroska and raw .mp2 files.

Fix: new L0 crate filmcraft-mpa, a clean-room Layer I / II decoder written
from ISO/IEC 11172-3 and 13818-3 (all modes incl. joint stereo, MPEG-1/2/2.5
rates, the disallowed combinations decoded with the allocation table their
bit rate per channel selects). PacketDecoder::mpeg_audio uses it for layers
I and II (packets of several frames too); raw .mp2 files decode with it,
resyncing after damage. Oracle tests vs ffmpeg: 73-84 dB SNR on ffmpeg /
twolame streams and synthetic Layer I / joint stereo frames; fuzzed damaged
frames never panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:48:45 +02:00
vangel76andClaude Opus 5.5 16493a7f70 #813: masks round their corners (rounded rectangle masks)
Problem: Premiere 26's redesigned mask tools give rectangle masks
rounded corners; FilmCraft masks only had sharp corners.

Fix: MaskPath::with_rounded_corners replaces each sharp corner between
straight edges by a circular fillet (two vertices joined by a cubic arc,
handles 4/3·tan(φ/4)·r, shrunk to fit half of each edge; hostile radii
and indices are clamped or ignored). masks.add takes `cornerRadius` for
a rounded rectangle, and the new masks.roundCorners command rounds an
existing mask's corners (or the listed vertices) in one undo step, on
every Mask Path keyframe so animated paths keep interpolating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:47:09 +02:00
Brandon Thomas 9efe8f3112 Merge pull request #812 from vangel76/gap-exr-sequences
#806: export OpenEXR image sequences
2026-10-10 18:44:25 -04:00
Brandon Thomas 2bbc9fdb35 Merge pull request #811 from vangel76/gap-morph-cut
#805: Morph Cut morphs along optical flow instead of cross-dissolving
2026-10-10 18:44:19 -04:00
Brandon Thomas b42b80b529 Merge pull request #808 from vangel76/gap-mp2-priming
#800: MP2 audio in MP4 and MOV decodes instead of playing silent
2026-10-10 18:44:13 -04:00
Vikram Bhaskaran d4ec47a4a3 Clear leftover Project item selection when making a new bin.
New Bin only selected the folder and started rename, so Clear still targeted clips that were selected before. The trash button and Delete then removed those items and left the bin. Clicking a bin already deselects items; creating one now does the same, and Clear drops the rename field so it does not keep editing a bin that is about to go.
2026-10-11 09:39:29 +11:00
Brandon Thomas 4a81aa1742 Merge pull request #804 from vangel76/gap-french-localization
#498: French (fr) interface translation
2026-10-10 18:35:20 -04:00
Brandon Thomas 71e25aa045 Merge pull request #803 from Ni-zav/fix/771-shuttle-audio-s20261010T214728Z-4f9c
fix(playback): keep audio during forward shuttle speeds (#771)
2026-10-10 18:35:14 -04:00
vangel76andClaude Opus 5.5 fb5863bc8a #806: export OpenEXR image sequences
Problem: Premiere offers OpenEXR as an image-sequence export format;
FilmCraft could not write EXR at all (docs/gaps.md G13).

Fix: a new export format (id exr, "OpenEXR") writing numbered .exr stills,
with a preset, the Include Alpha Channel option, the file.exportMedia format
list and es/ja/zh-cn strings. The writer in crates/export/src/raster.rs
follows the public OpenEXR file layout: version 2 single-part scan-line
file, half-float B/G/R(/A) channels, no compression, one scan line per
chunk, Rec.709 chromaticities; values are scene-linear (the pipeline's
transfer undone) with premultiplied alpha, as EXR defines. No new
dependency. ZIP / PIZ / DWA compression stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:34:28 +02:00
vangel76andClaude Opus 5.5 50ce8b090c #805: Morph Cut morphs along optical flow instead of cross-dissolving
Problem: Morph Cut rendered as a plain Cross Dissolve, so a jump cut in a
talking-head shot showed the subject twice at half opacity during the
transition, and the GPU plan mixed it as a dissolve too.

Fix: Morph Cut warps both frames along the dense optical flow between them
(the Lucas-Kanade flow of #780, crates/render/src/flow.rs) and blends, so the
subject moves from its place in the outgoing shot to its place in the incoming
one; where the frames do not correspond it stays the cross dissolve. Frames
inside a Morph Cut are planned whole on the CPU (no longer a GPU-mixable
"simple" transition) and cost as a complex transition in the preview estimate.
Morph Cut goldens re-blessed. No face tracking yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:32:08 +02:00
vangel76andClaude Opus 5.5 c9b9bf3aae #800: MP2 audio in MP4 and MOV decodes instead of playing silent
Problem: MP4 and QuickTime name MPEG audio without its layer (ffmpeg
stores MP2 as `mp4a` + esds object type 0x6B), and we always made the
layer III decoder for it. symphonia's decoder reads only the layer it
was made for, so every MP2 frame was rejected and the track played as
silence; MOV's `.mp2` sample entry was not recognised at all.

Fix: the MPEG audio decoder for MP4 / MOV is made on the first packet,
for the layer its frame header names (ISO/IEC 11172-3 2.4.2.3);
`.mp2` maps to the MPEG audio codec like `.mp3`; the track is labelled
"MPEG Audio" for layers I/II, as Matroska's is. Decoded tracks now match
ffmpeg sample-exactly (edit list trims the 481-sample encoder delay).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:26:39 +02:00
Brandon Thomas 120883d41c Merge pull request #802 from Ni-zav/fix/786-label-menu-swatches-s20261010T214728Z-4f9c
fix(menus): show configured label colors in Edit menu
2026-10-10 18:25:47 -04:00
vangel76andClaude Opus 5.5 73f9982c0c #498: French (fr) interface translation
Problem: French, one of Premiere's interface languages and one of the
12 key languages in docs/gaps.md G14, had no catalog; #498 asked for it.

Fix: a new fr.tsv catalog with every entry Spanish has (menus, panels,
dialogs, Settings pages, the Effects / Effect Controls registry, the
Keyboard Shortcuts dialog, status messages) plus the native macOS
Settings item, written from the plain meaning of the English text with
ordinary French editing vocabulary (infinitive commands, "vous", French
sentence case and spacing). Language::Fr is persisted as `fr`;
app.language.french joins Edit > Language (checkmark, native macOS menu),
Settings > General offers Français, and System Language picks French for
any fr-* locale. Like Japanese, tests require only the core menus, so
later strings show in English until translated; a French entry without
a Spanish one fails. A new test checks the bundled fonts cover every
French letter (the glyph check is shared with Ukrainian). Docs: G14,
localization-parity, ROADMAP language row, ui-egui README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:22:08 +02:00
Brandon Thomas 9f6d75350c Merge pull request #795 from vangel76/gap-ec-transition-timeline
#577: Effect Controls shows a selected transition's own timeline
2026-10-10 18:15:32 -04:00
Brandon Thomas bf7ef0e894 Merge pull request #794 from impostazioni/fix/alt-drag-duplicate
Timeline: Option-drag copies clips instead of moving them
2026-10-10 18:15:25 -04:00
Brandon Thomas de6dcc8ebf Merge pull request #789 from vangel76/gap-optical-flow
#780: Optical Flow time interpolation synthesises motion-compensated frames
2026-10-10 18:09:14 -04:00
Brandon Thomas 257d7af4ab Merge pull request #791 from vangel76/gap-image-sequences
#779: export JPEG, Targa and DPX image sequences
2026-10-10 18:09:10 -04:00
vangel76andClaude Opus 5.5 0b62ebb9ad #577: Effect Controls shows a selected transition's own timeline
Problem: with a transition selected, Effect Controls showed only its
settings. Premiere puts the transition's own timeline beside them (clip
A, clip B and the transition over their cut), where it can be slid
over the cut or resized by dragging; FilmCraft could do that only in the
Timeline.

Fix: right of the settings (the clip view's divider and width), a time
ruler over the cut, clip A above, the transition between, clip B below,
the cut and the playhead. Dragging the transition's middle slides it,
dragging an end changes its duration from that end (the Timeline's
rules, transitions::drag_span), measured against the view the drag began
in; each drag is one undoable sequence.setTransition. The ruler moves the
playhead. Every part has an automation id; no new UI strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:08:40 +02:00
vangel76andClaude Opus 5.5 3b1fa29663 #790: Matroska AAC / MP3 / AC-3 trims the exact CodecDelay
Problem: ffmpeg stores the encoder priming as CodecDelay in ns (1024 AAC
samples at 48 kHz = 21.333 ms, 256 AC-3 samples at 44.1 kHz = 5.805 ms)
and block timestamps in ms. The demuxer subtracts the delay rounded to
1 ms, and fixed-length packets were anchored on that rounded stamp, so
the whole track played up to 16 samples late against ffmpeg's decode.
Only Opus undid the rounding.

Fix: one stamp-to-samples conversion for every Matroska audio codec
that undoes the demuxer's rounding and removes the exact CodecDelay
(rounded to the nearest sample; the element holds whole ns), with
checked math against hostile timebases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:59:15 +02:00
Nigel Ihza Firdausy c7ad44bc19 test(menus): normalize regression test spacing 2026-10-11 04:55:59 +07:00
Brandon Thomas ab7318e788 Merge pull request #788 from vangel76/gap-eac3
#647: decode E-AC-3 (Dolby Digital Plus) audio
2026-10-10 17:52:57 -04:00
vangel76andClaude Opus 5.5 c673ee85b9 #779: export JPEG, Targa and DPX image sequences
Problem: Premiere offers JPEG, Targa and DPX as image-sequence export
formats; FilmCraft only wrote PNG, TIFF and BMP sequences (docs/gaps.md G13).

Fix: three new export formats (ids jpg, tga, dpx), numbered like the other
sequences, with presets, the export UI (JPEG quality slider, Targa alpha)
and es/ja/zh-cn strings. JPEG uses the image crate at the export quality;
Targa (TGA 2.0, uncompressed 24/32-bit) and DPX (SMPTE ST 268, 10-bit RGB
method A, BT.709) are written by a small hostile-input-safe writer in
crates/export/src/raster.rs. Format::is_image_sequence replaces the
hand-kept format lists. OpenEXR stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:51:41 +02:00
Nigel Ihza Firdausy 022e645a91 fix(playback): keep audio during forward shuttle speeds (#771) 2026-10-11 04:50:20 +07:00
vangel76andClaude Opus 5.5 e56ddd0e36 #780: Optical Flow time interpolation synthesises motion-compensated frames
Problem: a speed-changed clip set to Time Interpolation > Optical Flow
rendered exactly like Frame Blending, a cross-fade of the two neighbouring
source frames, so moving objects showed double images.

Fix: new filmcraft_render::flow. It computes dense coarse-to-fine
Lucas-Kanade flow both ways on a luma pyramid at most 640 px wide, drops
motion wherever standing still explains the window as well (flicker on
static content), forward-splats both flows to the in-between time
(Baker et al. 2011), and warps both frames there. Each fetch is weighted
by how well its own flow agrees, so uncovered background comes from the
frame that shows it. Pixels that look the same in both frames, or where
neither fetch agrees, fade to the plain cross-fade. media_image uses it
for OpticalFlow clips. docs/gaps.md G18 and target-app-parity updated
(also drops the #415 / #458 items, already fixed on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:50:15 +02:00
Brandon Thomas f346da650f Merge pull request #784 from vangel76/gap-render-in-out-range
#424: Render In to Out renders only the frames between In and Out
2026-10-10 17:49:34 -04:00
Brandon Thomas 8ae2c641a3 Merge pull request #782 from vangel76/gap-parity-menus
#781: cargo xtask parity measures menu coverage
2026-10-10 17:49:28 -04:00
Brandon Thomas 662d9d07b1 Merge pull request #783 from SilentYeti/nvenc-hevc-linux
Linux: H.265 export through NVENC
2026-10-10 17:49:22 -04:00
vangel76andClaude Opus 5.5 be98b9d7f7 #647: decode E-AC-3 (Dolby Digital Plus) audio
Problem: files with E-AC-3 audio (MP4 ec-3, MOV, Matroska A_EAC3, MPEG-TS)
imported silent: filmcraft-ac3 refused bsid 11-16.

Fix: clean-room E-AC-3 decoding from ATSC A/52:2012 Annex E in filmcraft-ac3:
bsi and audfrm syntax, 1/2/3/6-block syncframes, reduced sample rates,
frame-based exponent strategies (exponents reused across short syncframes),
SNR offset strategies, default band structures, spectral extension
(translation, notch filter, noise blending). Independent substream 0 is
decoded; dependent substreams and other programs are skipped (7.1 plays as
its 5.1 core). AHT and enhanced coupling are refused as unsupported. The
audio blocks share the AC-3 path; AC-3 output is unchanged. filmcraft-codecs
routes E-AC-3 in MP4/MOV, Matroska and TS to the decoder; the TS splitter
joins dependent substream frames to their access unit.

Tests: ffmpeg oracle for raw E-AC-3 (64.8-91.0 dB SNR) and in MP4, MOV,
Matroska and TS (79.5-91.3 dB, random access identical), mutation fuzz,
synthetic frames for headers, substream skipping, refusals and exponent
reuse across 1-block syncframes, spectral extension unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:49:03 +02:00
Nigel Ihza Firdausy 2890a42616 fix(menus): show current label colours in Edit menu (#786) 2026-10-11 04:48:16 +07:00
Brandon Thomas 5b0ef8755f Merge pull request #778 from MarekSpodBiedry/issue-770
Timeline: draw each audio track's own waveform, not track 1's (#770)
2026-10-10 17:44:05 -04:00
Brandon Thomas 85c1c6cacc Merge pull request #777 from MarekSpodBiedry/issue-769
text: the CJK system-font search no longer keeps every probed font file in memory (#769)
2026-10-10 17:44:00 -04:00
vangel76andClaude Opus 5.5 d676fdc720 #424: Render In to Out renders only the frames between In and Out
Problem: Render Effects In to Out and Render In to Out rendered every
render-bar segment that In/Out touched, in full. Segments run between
edit points, so In/Out inside one long clip rendered the whole clip
(often the whole timeline).

Fix: render only the marked frames of each segment as a partial preview,
`<hash>-<off>-<len>.mov`, frames counted from the segment start (so a
ripple keeps it, like whole `<hash>.mov` previews). The store indexes
parts per hash; the render bar is green over rendered frames only,
playback reads the part holding a frame, later renders fill only the
gaps, and Delete Render Files In to Out removes only files holding
marked frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:41:36 +02:00
SilentYetiandClaude Opus 5.5 c635d9af9e Linux: H.265 export through NVENC
The NVENC HEVC encoder (Main 8-bit SDR and Main 10 HDR PQ / HLG) was gated to
Windows. It now also runs on 64-bit Linux, on the CUDA device the H.264 encoder
already uses: the H.265 format and HDR probes are registered there and the
HEVC tests run on both systems.

NVENC registration on Linux now happens after the VA-API probe. Started beside
it, the background HEVC probe's first cuInit could deadlock with
libva-nvidia-driver, which calls cuInit from its library constructor under the
loader lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:41:04 -07:00
vangel76andClaude Opus 5.5 6aca7c1798 #781: cargo xtask parity measures menu coverage
Problem: the menu parity figure in docs/target-app-parity.md came from an
ad hoc label-matching script over string literals; nothing repeatable
measured the real menu bar (docs/gaps.md G7).

Fix: `cargo xtask parity` dumps the shipped menu bar (new
`menus::default_menu_items`, deterministic: English, default prefs,
built-in workspaces; printed by `examples/menu_tree.rs`) and compares it
with a reference list of menu paths, by default the local, never-committed
`plan/premiere/menus.json` (any JSON of paths via --reference; parsing is
depth- and size-capped). Presence and placement are reported separately
(same path, elsewhere, missing); app/Help/recent/account/macOS items are
out of scope. Writes a markdown checklist (target/parity/ or --out) and
--min fails below a presence threshold. Without a reference it prints our
inventory only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:39:57 +02:00
MarekSpodBiedry 8417e46090 Timeline: draw each audio track's own waveform, not track 1's (#770) 2026-10-10 23:22:52 +02:00
MarekSpodBiedry 62358d8c4e text: the CJK system-font search no longer keeps every probed font file in memory (#769) 2026-10-10 23:22:09 +02:00
Brandon Thomas ed141c74d0 Merge pull request #739 from mutombopr/av1-export-format-only
HW6: AV1 export format, enabled where a hardware encoder registers one
2026-10-10 17:15:49 -04:00
Brandon ThomasandClaude Opus 5.5 36cc163b44 Merge origin/main into #739: keep #600's CRF validation, and AV1 (hardware, like H.265) has no CRF mode either
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:15:40 -04:00
Brandon Thomas 843faef795 Merge pull request #625 from KattKnight/refresh-changed-media
Media: Automatically refresh growing files works (and catches re-renders)
2026-10-10 17:09:45 -04:00
Brandon ThomasandClaude Opus 5.5 fe47da6fdf Merge origin/main into #625 (keep both ja.tsv additions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:09:36 -04:00
Brandon Thomas c4bb63bb6d Merge pull request #772 from alex-brazh/fix/open-gop-leading-pictures
codecs: an open GOP's leading pictures decode from the GOP before, not as black frames
2026-10-10 17:01:42 -04:00
Brandon Thomas 77eeb5be29 Merge pull request #767 from vangel76/fix-313-timeline-double-click
#313: double-clicking a Timeline clip opens it in the Source monitor
2026-10-10 17:01:36 -04:00
Brandon Thomas b92fdde25b Merge pull request #765 from ReReddo/feat/rect-corner-handles
program monitor: drag a rectangle's corners round with on-canvas corner handles
2026-10-10 17:01:31 -04:00
Brandon Thomas 726ba5cf5c Merge pull request #763 from vangel76/fix-313-source-clip-history
#313: the Source monitor remembers its clips; Close and Close All
2026-10-10 17:01:25 -04:00
Brandon Thomas 47fc0677c7 Merge pull request #762 from vangel76/fix-626-unsupported-video-slate
#626: undecodable video shows the MEDIA UNREADABLE slate, not black
2026-10-10 17:01:19 -04:00
Brandon Thomas 88376964c3 Merge pull request #761 from maccagnit/gpu-export-no-backend
GPU export: a CLI built on its own exports on the CPU instead of failing
2026-10-10 17:01:13 -04:00
Brandon Thomas 74963e0e7e Merge pull request #760 from maccagnit/gpu-export-cpu-frames
GPU export: a frame planned as one CPU image skips the renderer pool (Auto was slower than Off)
2026-10-10 17:01:07 -04:00
Brandon Thomas 65a04ea2c0 Merge pull request #759 from vangel76/fix-284-maximize-restore
#284: panel menu Maximize Frame can be restored; an empty dock reopens panels
2026-10-10 17:01:01 -04:00
Brandon Thomas c1c2f91cfc Merge pull request #758 from vangel76/fix-266-save-prompt-on-quit
#266: quitting with unsaved changes asks to save first
2026-10-10 17:00:55 -04:00
Brandon Thomas 35527a38ba Merge pull request #757 from vangel76/fix-228-export-frame-pending
#228: Export Frame reports a frame still loading instead of saving it blank
2026-10-10 17:00:50 -04:00
Brandon Thomas c6dbc390a4 Merge pull request #756 from vangel76/fix-374-transition-follows-trim
#374: a clip's fades follow its edges on a regular trim
2026-10-10 17:00:43 -04:00
skylar taylor-barrick baadac2cf8 UI.9: integrate shared docking and preserve workspace behavior 2026-10-10 14:59:52 -06:00
impostazioniandClaude Opus 5.5 a8b3d7f703 Timeline: Option-drag copies clips instead of moving them
Holding Option (Alt) while dragging timeline clips now leaves the
originals in place and drops copies where the pointer lets go, as in
Premiere. The copies are selected; copies of linked clips are linked to
each other, and transitions that would travel with a move are copied.

- edit: copy_items (overwrite / insert, new ids, links and transitions)
- engine: timeline.move takes `copy: true`, returns `copied`
- ui: the drag remembers Option, shows the copy under the pointer while
  the originals stay, copy cursor
- tests: engine copy/link/locked-track test, headless Option-drag UI test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:52:28 +01:00
Brandon Thomas 7a36a6114a Merge pull request #610 from ivandrozdov1505-coder/l10n-ru
feat: Add Russian (ru) localization
2026-10-10 16:42:13 -04:00
Brandon ThomasandClaude Opus 5.5 5a94f761ff Merge origin/main into #610: Russian alongside Simplified Chinese and German; ROADMAP from main; rows for Next Appearance Mode and Project Notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:42:04 -04:00
Brandon Thomas aba95af675 Merge pull request #630 from keller-lukas/i18n-german
i18n: add German (de) interface translation
2026-10-10 16:34:10 -04:00
Brandon ThomasandClaude Opus 5.5 398befbeec Merge origin/main into #630: ROADMAP from main (restructured), contributors, German row for the new Project Notes menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:34:01 -04:00
Brandon Thomas 0a2f8dd9ca Merge pull request #704 from ossini/fix/ripple-delete
#648, #668: select a gap in the Timeline and close it with Delete (Ripple Delete)
2026-10-10 16:27:58 -04:00
Brandon ThomasandClaude Opus 5.5 e4f3cab5a0 Merge origin/main into #704 (gap and marker context menus side by side; contributors)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:27:48 -04:00
Alex Brazh 45f2b8e297 codecs: an open GOP's leading pictures decode from the GOP before, not as black frames
In an open GOP (iPhone HEVC; x265 `open-gop`) each CRA after the first is
followed in the file by RASL pictures shown before it, predicted from the
GOP before. GopCache started a decoding run at `sync_before(i)`, the sync
sample at or before the wanted sample in decoding order: for a RASL
picture that is the CRA after it, and the decoders (software and
VideoToolbox) rightly leave a run's leading RASL pictures out. The frame
was never produced, so the request failed ("frame not produced") and the
clip's layer was left out: a black frame (or the track below) wherever a
seek landed on one of the 3-in-24 RASL frames of iPhone video, such as a
clip trimmed to start on one, the parked playhead, and parallel export.

- `decode_start`: a sample shown before its sync sample starts the run at
  the sync sample before (the MPEG and MXF readers already did this in
  their own `sync_before`; this covers MP4/MOV and Matroska too).
- A running decoder that started after that point is not continued for
  it: it left the picture out.

Tests: a synthetic open-GOP stream in gop.rs, and an x265 open-GOP oracle
test (MP4 and Matroska, every frame from a fresh source and each CRA
before its leading pictures) against ffmpeg. Both fail without the fix.
2026-10-10 23:14:29 +03:00
Brandon Thomas 73723151ce Merge pull request #682 from vangel76/fix-342-prores-4444-export
#342: ProRes 4444 and 4444 XQ export, with alpha
2026-10-10 16:14:22 -04:00
Brandon ThomasandClaude Opus 5.5 fad5255dc9 media: importable tests after the items (clippy items_after_test_module, #677 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:08:58 -04:00
vangel76 2f49774895 Merge remote-tracking branch 'upstream/main' into fix-374-transition-follows-trim
# Conflicts:
#	crates/edit/src/tests.rs
2026-10-10 22:08:23 +02:00
Brandon Thomas b20ec56ded Merge pull request #677 from vbasky/fix/593-import-extension-case
Treat media extensions as case-insensitive on import
2026-10-10 16:07:13 -04:00
Brandon ThomasandClaude Opus 5.5 c16e712e5a Merge origin/main into #677 (keep both import tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:07:04 -04:00
MrCloudy2andClaude Opus 5.5 82242c3817 Merge origin/main into #260 (keep both new HostHooks fields: run_dialog, open_url)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:02:52 +02:00
Brandon Thomas 413b4449f4 Merge pull request #627 from JordyUSA/fix/374-waveform-trim
#374: trim preview keeps the waveform in place instead of squeezing it
2026-10-10 16:02:42 -04:00
Brandon ThomasandClaude Opus 5.5 e82f9bca71 Merge origin/main into #627 (keep both trim UI tests)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:02:33 -04:00
vangel76andClaude Opus 5.5 ad80801596 #313: double-clicking a Timeline clip opens it in the Source monitor
Problem: the Timeline only reacted to double-clicks on transitions and
nested sequences, so double-clicking an ordinary clip did nothing instead
of Premiere's opening it in the Source monitor.

Fix: `source.open` takes an optional `clip` (a clip of the active
sequence): its media opens in the Source monitor, parked on the frame
under the playhead when the playhead is over the clip, otherwise on the
clip's first frame. The Timeline's Selection-tool double-click on a clip
body (not its edge, not a nested sequence or graphic) dispatches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:01:54 +02:00
Brandon Thomas bd60bf4e57 Merge pull request #636 from JordyUSA/fix/project-dblclick-import
Project panel: double-click anywhere empty in Icon view opens Import
2026-10-10 15:58:49 -04:00
Brandon ThomasandClaude Opus 5.5 97c6e5fb92 Merge origin/main into #636: #607's view background already opens Import on a double-click anywhere empty; keep this PR's regression test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:58:39 -04:00
vangel76andClaude Opus 5.5 3190eab4ef #313: the Source monitor remembers its clips; Close and Close All
Problem: the Source monitor only knew the one clip it showed. Opening
another clip forgot the previous one, there was no list to switch back
from, and no way to close a Source clip (Premiere keeps a list of the
recently opened clips with Close / Close All).

Fix: EditorState keeps `source_history` (most recent first, at most 20),
updated after every command; items removed from the project drop out.
New commands `source.close` (the next most recent clip opens in its
place) and `source.closeAll`. The Source panel menu lists the recent
clips (the shown one ticked) to switch between, then Close / Close All.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:55:11 +02:00
Brandon Thomas a6b323056f Merge pull request #755 from vangel76/fix-227-transitions-follow-move
#227: transitions travel with the clips they sit on when moved
2026-10-10 15:53:26 -04:00
Brandon Thomas e43392a86c Merge pull request #754 from vangel76/fix-483-drop-new-track
#483: media dropped past the last track goes on a new track
2026-10-10 15:53:19 -04:00
Brandon Thomas 6d6492491d Merge pull request #753 from vangel76/fix-227-drag-lumetri-presets
#227: Lumetri presets can be dragged onto a Timeline clip
2026-10-10 15:53:14 -04:00
Brandon Thomas 6239d3d65e Merge pull request #751 from vangel76/fix-456-nest-bins
#456: a bin can be dragged into another bin and back out
2026-10-10 15:53:08 -04:00
Brandon Thomas 9fb10ded25 Merge pull request #750 from vangel76/fix-620-project-notes
#620: Project Notes panel, saved in the project
2026-10-10 15:53:02 -04:00
Brandon Thomas a270f5cf34 Merge pull request #749 from vangel76/fix-456-new-bin-name
#456: a new bin asks for its name
2026-10-10 15:52:55 -04:00
Brandon Thomas 7c919da28e Merge pull request #747 from vangel76/fix-719-lumetri-vignette-roundness
#719: positive Roundness rounds the Lumetri vignette
2026-10-10 15:52:49 -04:00
Brandon Thomas 4768d1a582 Merge pull request #745 from vangel76/fix-715-mkv-timestamp-origin
#715: a Matroska remux that keeps its timestamps starts at its first picture
2026-10-10 15:52:43 -04:00
Brandon Thomas 49948d2c65 Merge pull request #743 from ReReddo/fix/highlight-color
appearance: the Highlight Color drives the playhead, timecodes and focus
2026-10-10 15:52:38 -04:00
Brandon Thomas 735d1acbd7 Merge pull request #741 from vangel76/fix-695-clear-clicked-marker
#695: Timeline markers can be picked and cleared
2026-10-10 15:52:33 -04:00
Brandon Thomas e59fb46f77 Merge pull request #740 from vangel76/fix-712-keyboard-slide-linked
#712: keyboard Slide keeps linked picture and sound aligned at a neighbour limit
2026-10-10 15:52:26 -04:00
Brandon Thomas a1e0051060 Merge pull request #738 from mutombopr/av1-seqheader-format
HW5: AV1 sequence headers compare by picture format, so SVT-AV1 files stay on hardware
2026-10-10 15:52:20 -04:00
Brandon Thomas 7f772fc1c6 Merge pull request #681 from ReReddo/feat/effect-controls-row-lines
effect controls: row lines, a draggable divider, scrolling anywhere, larger keyframes
2026-10-10 15:52:14 -04:00
Brandon Thomas 1039e4830c Merge pull request #671 from vangel76/fix-284-reset-keeps-keyframes
#284: reset buttons keep keyframes; a reset button on every property
2026-10-10 15:52:08 -04:00
Brandon Thomas 8747305aa0 Merge pull request #663 from vangel76/fix-653-clamp-trim-drag
#653: a dragged trim edge stops at the end of the media
2026-10-10 15:52:03 -04:00
Brandon Thomas 9a13e7c8f3 Merge pull request #657 from vangel76/fix-642-open-links
#642: open Help/About links in the system browser on the desktop
2026-10-10 15:51:56 -04:00
Brandon Thomas cee18f1006 Merge pull request #651 from vangel76/fix-591-explicit-clip-errors
#591: named clips that cannot stand in for a selection say why
2026-10-10 15:51:49 -04:00
Brandon Thomas 93c86f1e0a Merge pull request #735 from vangel76/fix-710-version-after-options
#710: --version works after global options
2026-10-10 15:51:44 -04:00
Brandon Thomas e66fa7faf4 Merge pull request #727 from Ni-zav/fix/prefs-timeline-focus-schema-713-s20261010T1747Z-c64d9
fix(settings): mark inactive Timeline focus preference as unwired
2026-10-10 15:51:37 -04:00
Oskar 24d555eb7c Merge upstream/main into fix/ripple-delete
Conflicts:
- contributors/people.toml: keep both new entries (maxm11, ossini)
- timeline.rs: take upstream's Alt-click through timeline.select (it
  clears the gap selection), and its empty-space arm (#683); only an
  empty stretch of a track selects a gap
2026-10-10 21:48:36 +02:00
ReReddo 8f1b7c3494 program monitor: drag a rectangle's corners round (#744)
A rectangle's rounded corners could only be set by typing a Corner
Radius in Properties. As in Premiere, a selected rectangle now shows
four small circles just inside its corners: dragging one towards the
middle rounds all four corners (Corner Radius is one value), dragging it
back out squares them, and the radius stops at half the shorter side,
where the renderer caps it too.

The circles sit on each corner's diagonal at the radius, at least 12
points in, and show only on rectangles at least four times that across
on screen. While dragging, a white outline of the rounded rectangle
previews the result; the radius is set on release with graphics.set, one
undo step per drag. Automation ids: program.layer.<clip>.<layer>.corner.<n>.

Tests: graphics_handles_ui (the circles round all four corners, clamp
and undo; ellipses and tiny rectangles have none).
2026-10-10 22:46:27 +03:00
vangel76andClaude Opus 5.5 ba5727029a #626: undecodable video shows the MEDIA UNREADABLE slate, not black
Problem: a file that opens but whose picture FilmCraft cannot decode
(H.264 High 10 / 4:2:2 / 4:4:4, interlaced, a codec without a decoder)
rendered black with no sign of why. The H.264 and HEVC decoders'
"unsupported" errors reached the media source as generic decode errors,
and the renderer drops frames that fail.

Fix: the H.264 / HEVC wrappers report unsupported features as
CodecError::Unsupported. The media pool wraps every file source it opens
so a frame failing as unsupported renders the existing MEDIA UNREADABLE
slate and the item is marked Unreadable with the decoder's reason (as
for files that fail to open). Decodable frames, damaged frames and the
sound are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:46:06 +02:00
Tommaso MaccagniandClaude Opus 5.5 d215d0e8a3 gpu: a build without a wgpu backend exports on the CPU instead of failing
filmcraft-gpu asks wgpu for no backend (`default-features = false`); a build
that includes the app gets them through eframe. A CLI built on its own
(`cargo run --release -p filmcraft-cli`, as the README and
docs/contributing.md do) has none, and `wgpu::Instance::default()` panics
there: an export with GPU rendering on ended as "internal error (see the
crash log); the job was stopped".

ExportRenderer::new returns None in such a build, like a host without an
adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:40:37 +02:00
Tommaso MaccagniandClaude Opus 5.5 13d8e73e8e export: a frame planned as one CPU image does not take a pooled GPU renderer
With GPU rendering on, a frame the planner hands back whole (an adjustment
layer, a complex transition, an HDR / wide-gamut sequence) was still rendered
by render_sequence, but inside RendererPool::with: as many frames at a time as
there are pooled renderers (2 to 4) instead of one per export thread. A
timeline graded on an adjustment layer exported slower with Auto than with Off.

Pipeline::frame asks the planner first (plan::is_cpu_frame, the test
plan_frame itself makes) and renders such a frame on the CPU directly. It
counts as a fallback now, not as a GPU frame. The picture does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:40:37 +02:00
vangel76andClaude Opus 5.5 1ff2b9fc89 #284: panel menu Maximize Frame can be restored; an empty dock reopens panels
Problem: the panel menu's Maximize Frame replaced the whole dock tree with a
one-panel group, so there was no way back short of resetting the workspace,
and closing that panel left an empty dock. DockNode::open_near only inserted
beside the requested neighbour, Project or Program, so with those gone (or
no panel left) every Window > panel command was a silent no-op.

Fix: the panel menu now toggles the same Maximize/Restore Frame state as
` / Shift+` (labelled Restore Frame while maximized) and keeps the tree;
a maximized panel that was closed counts as restored, and switching
workspace clears it. open_near falls back to the first group, even an
empty one, as its doc comment promised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:37:09 +02:00
vangel76andClaude Opus 5.5 1580ebb520 #266: quitting with unsaved changes asks to save first
Problem: Quit, Cmd+Q or the window's close button exited at once with
unsaved changes, leaving them to the recovery journal, so every launch
after an unsaved session offered a recovery (docs/project-files.md: no
"Save changes?" prompt on quit yet).

Fix: a close request with unsaved changes is cancelled and the existing
Save Project prompt asks Cancel / Don't Save / Save (automation ids
quit.*), as in Premiere. Don't Save discards the changes and their
recovery snapshot; Save on a never-saved project (here and in Close
Project) opens the Save dialog and returns to the prompt when cancelled.
The control channel's app.quit still quits without asking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:33:45 +02:00
vangel76andClaude Opus 5.5 7f59211912 #228: Export Frame reports a frame still loading instead of saving it blank
Problem: file.exportFrame rendered and wrote the still without looking at
filmcraft_media::pending. On the web, a frame WebCodecs is still decoding
(or whose Blob bytes are still loading) is left out of the render, so the
command succeeded with a transparent/blank picture passed off as the
requested frame.

Fix: clear the pending flag before rendering and check it afterwards; when
a read was deferred, fail with "the frame is still loading; export it
again in a moment" and write nothing. A later call exports the real frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:31:27 +02:00
vangel76andClaude Opus 5.5 2cab09e69b #374: a clip's fades follow its edges on a regular trim
Problem: dragging a clip's edge with the Selection tool (a regular trim)
moved the edge but left a fade in from nothing / fade out to nothing where
it was; only Ripple trims moved transitions along with the cut.

Fix: after a regular trim, the trimmed clip's one-sided transitions are put
back on its edges, and shortened when the clip gets shorter than them.
Transitions between two clips are untouched (a regular trim cannot move
their cut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:29:55 +02:00
vangel76 95a9a7770a Merge upstream/main into fix-342-prores-4444-export
# Conflicts:
#	crates/engine/src/commands.rs
2026-10-10 21:27:52 +02:00
vangel76andClaude Opus 5.5 b86dcf7bb3 #227: transitions travel with the clips they sit on when moved
Problem: moving a clip dropped the transitions at its edges (#227, point 8
of #374). `edit::move_items` deletes the moved clips before placing them
again, and that deletion removes every transition that refers to them, so
a fade on a clip's edge, or the cut between two clips moved together,
vanished.

Fix: before the move, collect the transitions whose clips all move onto one
track by one offset, and put them back on that track shifted by the offset
once the clips have landed (overwrite and insert moves). A crossfade whose
partner stays behind is still dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:24:52 +02:00
Brandon Thomas 3665378384 Revert "Merge pull request #689 from Ni-zav/fix/654-track-header-rename-s20261010T1632Z-b8e4"
This reverts commit 826858dfec, reversing
changes made to 0a9c133b4e.
2026-10-10 15:24:48 -04:00
Brandon Thomas e78223db6d Revert "Merge pull request #662 from MrCloudy2/fix/export-hevc-stall-480"
This reverts commit aa8ad402cc, reversing
changes made to 5f9b5e1234.
2026-10-10 15:24:44 -04:00
Brandon Thomas b43e3828a9 Revert "Merge pull request #685 from vbasky/fix/linked-clip-delete"
This reverts commit 6145dcc568, reversing
changes made to f34a00c020.
2026-10-10 15:24:43 -04:00
Brandon ThomasandClaude Opus 5.5 4cdb15466e Captions: a TTML frame count that saturates the clock is rejected, not read as the end of time (#705 follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:24:43 -04:00
vangel76 c583157088 Merge upstream/main into fix-284-reset-keeps-keyframes
# Conflicts:
#	crates/engine/src/clip_ops_tests.rs
2026-10-10 21:24:34 +02:00
Brandon Thomas 334bccde88 Revert "Merge pull request #674 from vbasky/fix/542-limiter-lookahead"
This reverts commit 62a0eea519, reversing
changes made to 23ac344798.
2026-10-10 15:23:52 -04:00
vangel76andClaude Opus 5.5 cca7d5ef0e #483: media dropped past the last track goes on a new track
Problem: dropping a project item in the empty space above the top video
track or below the last audio track did nothing: the Timeline only
accepted drops on an existing track row, and timeline.place could only
target existing tracks.

Fix: timeline.place accepts "new" as `track` / `audioTrack`. It adds a
track after the last one of its kind for each stream the item carries,
then places the clip there, as one undo step (the new tracks are removed
again if the placement fails). The Timeline shows the drop ghost in the
empty space and places on a new video track (above) or audio track
(below); the other stream goes on the track of the same number, or on a
new one when that track does not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:23:42 +02:00
vangel76andClaude Opus 5.5 78a3fea222 #227: Lumetri presets can be dragged onto a Timeline clip
Problem: in the Effects panel, the Lumetri Presets rows and the wide-panel
thumbnail grid only sensed clicks. They applied through double-click or the
Apply menu, never through a drag, unlike every other effect and preset.

Fix: rows and grid cells sense drags and start an effect payload
`lumetri:<name>`. The Timeline's effect drop applies it to the clip under the
pointer with `lumetri.applyPreset` (one undo step). The drag ghost shows the
preset's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:21:26 +02:00
vangel76 ae488523a9 Merge remote-tracking branch 'upstream/main' into fix-620-project-notes
# Conflicts:
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/zh-cn.tsv
2026-10-10 21:15:04 +02:00
vangel76andClaude Opus 5.5 6f650a363a #456: a bin can be dragged into another bin and back out
Problem: a bin stayed wherever it was created. `project.moveToBin` only
moved project items, and bin rows and cards in the Project panel could not
be dragged, so the only way to nest bins was to create them inside each
other.

Fix: `project.moveToBin` also takes bin ids in `items` (as `project.delete`
does) and moves the bin with everything in it, skipping the root and any
move that would put a bin inside itself. In the Project panel, bin rows
(List view) and bin cards (Icon view) can be dragged: dropping one on
another bin nests it there, and dropping it on empty space moves it into
the bin the view shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:13:42 +02:00
vangel76 6067462b88 Merge upstream/main into fix-591-explicit-clip-errors 2026-10-10 21:13:24 +02:00
vangel76 22de920fd6 Merge remote-tracking branch 'upstream/main' into fix-710-version-after-options
# Conflicts:
#	apps/filmcraft-cli/src/args.rs
2026-10-10 21:12:17 +02:00
vangel76andClaude Opus 5.5 110c1783c3 #620: Project Notes panel, saved in the project
Problem: a project had nowhere to keep free text (to-dos, instructions
for whoever opens it next, frame numbers).

Fix: plain-text `Project::notes` (schema v15, no-op migration so older
builds refuse instead of dropping it), `project.notes` /
`project.setNotes {text, merge?}` engine commands (undoable, capped at
1 MiB; calls with one `merge` key share an undo step), and a
Window > Project Notes panel whose editor (`notes.text`) commits every
change, one typing session per undo step. Translations for es, ja,
zh-cn (and the panel title for pt-br, uk).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:09:13 +02:00
Brandon ThomasandClaude Opus 5.5 2140d9c9ab Restore green after the community batch: rustfmt, clippy as_chunks_mut in webm_alpha, zh-cn caption-track and es shutter-angle rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:07:59 -04:00
Brandon Thomas e214d84e84 Revert "Merge pull request #589 from Ni-zav/fix/547-japanese-font-priority-s20261010T084251Z-a6c2d9"
This reverts commit b1f2973c9d, reversing
changes made to 8fa24de32b.
2026-10-10 15:05:04 -04:00
vangel76 2a1c92e15a Merge remote-tracking branch 'upstream/main' into fix-653-clamp-trim-drag
# Conflicts:
#	crates/ui-egui/tests/timeline_trim_ui.rs
2026-10-10 21:02:44 +02:00
ReReddo ade56c6277 effect controls: draggable divider, scroll the whole panel (#643)
The split between the effect list and the keyframe area was fixed. Now a
divider between them drags left or right, as in Premiere: it shows a
resize cursor and turns blue under the pointer, and the width it sets
stays while FilmCraft runs (UiState::effect_controls_split, automation id
effectControls.divider). The effect list keeps the 260 points its rows
are laid out for (values from 150 points in, the reset button at the
end), the keyframe area at least 60.

Scrolling was tied to the effect list. The scroll area now spans the list
and the keyframe area, so the mouse wheel scrolls anywhere over the panel
and the scrollbar sits at its right edge. The ruler's click and drag are
handled after the rows, which now reach under it, so they still move the
playhead.

At narrow widths labels no longer run into what follows them: property,
effect and mask names are cut off with "…" (effect_controls::row_label),
and a dropdown narrows to the space left in its row.

Smaller readability fixes in the same panel:

- Keyframes in the lane are drawn at about Premiere's size (a diamond ~9
  points wide instead of ~7; Hold squares and Bezier circles to match),
  with a grab area to match.
- The keyframe navigator's arrows are taller (11 points, clickable over 20
  of the row's 22) and its diamond larger; Properties uses the same
  navigator, so it gets them too.
- Checkboxes (Bypass, Uniform Scale, a mask's Inverted) sit centred between
  their row lines (effect_controls::fit_to_row, which also fits dropdowns).

Tests: keyframes_ui effect_controls_divider_and_scrolling_anywhere (the
divider drags, keeps its width and both minimum widths; the wheel scrolls
the rows over the keyframe area).
2026-10-10 22:00:54 +03:00
ReReddo 5d6f690e36 effect controls: lines between property rows (#640)
Effect Controls now draws a thin line under every row, as Premiere does,
across the names, the values and the keyframe lane, so a row can be
followed from its name to its value and keyframes. Each line runs through
the middle of the spacing between rows, so a row sits centred between its
two lines. It covers every kind of row: effect headers, properties, Custom
Setup, the value and velocity graphs, the mask rows, and a selected
transition's settings. The colour is the theme's separator, so it follows
the dark and light themes.

- effect_controls::row_line draws the line; effect_controls::
  fit_dropdown_to_row keeps a dropdown (Blend Mode, a mask's mode) inside
  its row, clear of the lines.
- The keyframe lane's ruler and clip bar are now painted after the rows, on
  their own background, so rows scrolled up under them no longer draw over
  them (their lines, and keyframe diamonds, which could already do so).
2026-10-10 22:00:37 +03:00
vangel76andClaude Opus 5.5 ddb3890e68 #456: a new bin asks for its name
Problem: every New Bin entry (Cmd+B, File > New > Bin, the Project panel
menu, the footer button and the context menus) ran `file.newBin` with the
fixed name "New Bin", so each bin had to be renamed by hand afterwards.

Fix: `file.newBin` without a `name` from the UI makes the bin in the shown
bin (or the given parent), selects it and opens its inline rename field;
agents that pass `name` still name it directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:59:02 +02:00
vangel76 15bf7f9f48 Merge remote-tracking branch 'upstream/main' into fix-642-open-links
# Conflicts:
#	apps/filmcraft/src/main.rs
2026-10-10 20:57:56 +02:00
Brandon Thomas 794a25fc9e Merge pull request #658 from vbasky/fix/current-bugs
Fix silent WAV sample-rate substitution
2026-10-10 14:57:13 -04:00
Brandon Thomas ef52c3834f Merge pull request #737 from ReReddo/fix/shape-handles-size
graphics: shape handles change the shape's Size, Shift keeps the ratio
2026-10-10 14:57:06 -04:00
Brandon Thomas 8d737a4af2 Merge pull request #733 from vangel76/fix-721-aac-surround-order
#721: 5.1 AAC imports decode in WAV/SMPTE channel order
2026-10-10 14:57:00 -04:00
Brandon Thomas b04da9c426 Merge pull request #732 from vangel76/fix-714-mp4-no-edit-origin
#714: an MP4 without an edit list starts at its first picture
2026-10-10 14:56:54 -04:00
Brandon Thomas ba6668e942 Merge pull request #730 from vangel76/fix-709-gpu-rendering-value
#709: --gpu-rendering takes a space-separated value
2026-10-10 14:56:46 -04:00
Brandon Thomas 9686d1c49f Merge pull request #726 from Ni-zav/fix/edl-audio-transition-names-711-s20261010T1747Z-c64d9
fix(edl): retain named audio crossfades when importing EDL
2026-10-10 14:56:39 -04:00
Brandon Thomas c64df0171d Merge pull request #725 from Ni-zav/fix/tts-text-number-normalization-s20261010T1747Z-c64d9
fix(tts-text): preserve amounts and numeric units in narration
2026-10-10 14:56:33 -04:00
Brandon Thomas a1646560e6 Merge pull request #723 from impostazioni/fix/delete-caption-tracks
Captions: delete caption tracks from the timeline and Delete Tracks dialog
2026-10-10 14:56:28 -04:00
Brandon Thomas c6eea54dfc Merge pull request #722 from ReReddo/fix/settings-hex-field
settings: the hex colour field takes typing
2026-10-10 14:56:22 -04:00
Brandon Thomas c11f20cf9c Merge pull request #708 from impostazioni/fix/caption-font
Captions: render the track font and add a font picker
2026-10-10 14:56:17 -04:00
KattandClaude Opus 5.5 46b391957c Merge main: keep both new Japanese rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:26:12 +05:30
Brandon Thomas e7026e1b4c Merge pull request #706 from Ni-zav/fix/stl-literal-angle-text-s20261010T1655Z-d7c2
fix(captions): preserve literal angle brackets in EBU STL export
2026-10-10 14:56:11 -04:00
Brandon Thomas 2ed0295cce Merge pull request #705 from Ni-zav/fix/ttml-time-expression-overflow-s20261010T1650Z-d7c2
fix(captions): reject overflowing TTML times and round extreme clocks safely
2026-10-10 14:56:06 -04:00
Brandon Thomas ee1118bc21 Merge pull request #703 from ReReddo/fix/timeline-track-lines
timeline: lines between the tracks, a short playhead head
2026-10-10 14:56:01 -04:00
Brandon Thomas efb58176a4 Merge pull request #702 from vangel76/fix-411-folder-bins
#411: importing a folder keeps its structure as bins
2026-10-10 14:55:55 -04:00
Brandon Thomas 104355ae13 Merge pull request #701 from vangel76/fix-313-source-shuttle
#313: J/K/L shuttle the Source monitor both ways at up to 8x
2026-10-10 14:55:50 -04:00
Brandon Thomas 425c61a1ad Merge pull request #699 from ReReddo/fix/deselect-empty-space
timeline, text editing, program monitor: clicking empty space deselects
2026-10-10 14:55:44 -04:00
Brandon Thomas 33115e9a8a Merge pull request #698 from vangel76/fix-614-continuous-thumbnails
#614: Head, Head and Tail and Continuous video thumbnails in the Timeline
2026-10-10 14:55:39 -04:00
Brandon Thomas 23d1a37da2 Merge pull request #693 from vangel76/fix-67-file-reader-pool
#67: media files share a bounded pool of open descriptors
2026-10-10 14:55:33 -04:00
Brandon Thomas 12e714bf53 Merge pull request #692 from vangel76/fix-659-macos-microphone
#659: macOS app may use the microphone for voice-over
2026-10-10 14:55:26 -04:00
Brandon Thomas 0cf7a51ada Merge pull request #691 from vangel76/fix-279-streaming-wav
#279: WAV opens in place and reads only the samples asked for
2026-10-10 14:55:20 -04:00
Brandon Thomas b95a7f4eb5 Merge pull request #690 from vangel76/fix-680-shift-delete-ripple
#680: Shift+Delete ripple deletes with the Timeline focused
2026-10-10 14:55:15 -04:00
Brandon Thomas 826858dfec Merge pull request #689 from Ni-zav/fix/654-track-header-rename-s20261010T1632Z-b8e4
feat(timeline): rename video and audio tracks from their headers
2026-10-10 14:55:08 -04:00
Brandon Thomas 0a9c133b4e Merge pull request #688 from Ni-zav/fix/431-monitor-button-editor-s20261010T1632Z-b8e4
fix(monitor): make Button Editor customize transport controls
2026-10-10 14:55:03 -04:00
Brandon Thomas 7d424eb78f Merge pull request #686 from vbasky/fix/link-media-layout
Keep Link Media actions accessible for large lists and long paths
2026-10-10 14:54:57 -04:00
Brandon Thomas 6145dcc568 Merge pull request #685 from vbasky/fix/linked-clip-delete
Preserve individual linked-clip selections when deleting media
2026-10-10 14:54:50 -04:00
Brandon Thomas f34a00c020 Merge pull request #684 from vangel76/fix-239-position-graph
#239: Position keyframes get the value and velocity graphs
2026-10-10 14:54:45 -04:00
Brandon Thomas ea33e3cb00 Merge pull request #679 from vbasky/fix/415-transform-shutter
Apply Transform shutter angle when the override is on
2026-10-10 14:54:38 -04:00
Brandon Thomas b07df164fa Merge pull request #676 from vbasky/fix/592-import-replace
Add file.import replace mode for empty-project round-trips
2026-10-10 14:54:33 -04:00
Brandon Thomas db352fc963 Merge pull request #675 from vbasky/fix/458-ultra-key-setting
Apply Ultra Key Setting as a parameter preset
2026-10-10 14:54:27 -04:00
Brandon Thomas 62a0eea519 Merge pull request #674 from vbasky/fix/542-limiter-lookahead
Pass Hard Limiter look-ahead into the DSP limiter
2026-10-10 14:54:21 -04:00
Brandon Thomas 23ac344798 Merge pull request #673 from abdelkabirouadoukou/fix-monterey-constant-bitrate
platform: look up kVTCompressionPropertyKey_ConstantBitRate at run time
2026-10-10 14:54:16 -04:00
Brandon Thomas 66a7239115 Merge pull request #672 from vangel76/fix-457-ui-scale
#457: Settings > Appearance > UI Scale
2026-10-10 14:54:09 -04:00
Brandon Thomas b8cb56f815 Merge pull request #665 from vangel76/fix-333-fade-presets-merge
#333: Fade In and Fade Out presets add up on one clip
2026-10-10 14:54:04 -04:00
Brandon Thomas 087877fd72 Merge pull request #664 from vangel76/fix-461-interchange-stream-media
#461: interchange import links media through the streaming reader
2026-10-10 14:53:58 -04:00
Brandon Thomas aa8ad402cc Merge pull request #662 from MrCloudy2/fix/export-hevc-stall-480
#480: exports no longer stop for good when a clip's decoding uses rayon (rotated iPhone HEVC clips)
2026-10-10 14:53:51 -04:00
Brandon Thomas 5f9b5e1234 Merge pull request #656 from vangel76/fix-412-single-keyframe-select
#412: select a single keyframe in Effect Controls
2026-10-10 14:53:45 -04:00
Brandon Thomas 66a4208a6b Merge pull request #652 from vangel76/fix-456-project-select-all
#456: Select All in the Project panel takes only the shown bin
2026-10-10 14:53:40 -04:00
Brandon Thomas 6e3d5e7c77 Merge pull request #646 from maxm11/issue-536-bin-lookup
perf(export): index the bin tree once in closure() (#536 follow-up)
2026-10-10 14:53:34 -04:00
Brandon Thomas 1a8bdb43e4 Merge pull request #644 from maxm11/issue-460-rtmd-timecode
MP4: read the start timecode from a Sony rtmd track when there is no tmcd
2026-10-10 14:53:26 -04:00
vangel76andClaude Opus 5.5 dfbcadb88c #719: positive Roundness rounds the Lumetri vignette
Problem: the Lumetri vignette scaled its horizontal coordinate by
`1 + vround * 0.0` and applied aspect correction only for negative
Roundness, so 0 … +100 rendered identically (CPU path, the GPU op and the
GPU shader parameters alike). Negative values moved towards a circle,
the direction positive values should go.

Fix: one helper, `gpufx::lumetri_vignette_aspect`, scales the horizontal
coordinate by aspect^roundness: 0 keeps the ellipse that follows the
frame, +100 is a circle, negative values widen the oval. Non-finite or
out-of-range values fall back safely. The CPU path, the CPU FxOp and the
GPU parameter packing all use it, so they stay in parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:53:19 +02:00
Brandon Thomas 70d0e22920 Merge pull request #633 from 4papuce/fix/gpu-software-rasterizer
ui: reject software rasterizers for GPU compositing and say so
2026-10-10 14:53:19 -04:00
Brandon Thomas 9842fc0c15 Merge pull request #624 from MarekSpodBiedry/issue-378
Space out browser downloads so image-sequence exports are not dropped (#378)
2026-10-10 14:53:13 -04:00
Brandon Thomas 6f366f7666 Merge pull request #622 from MarekSpodBiedry/issue-587
Let a bin be cleared from its context menu, the trash button, the panel menu and Delete (#587)
2026-10-10 14:53:08 -04:00
Brandon Thomas 1879a30db8 Merge pull request #615 from ReReddo/feat/clipboard-paste
timeline, project panel: paste images and copied files from the system clipboard
2026-10-10 14:53:02 -04:00
Brandon Thomas df5be6ad78 Merge pull request #607 from ReReddo/feat/project-marquee-select
project panel: drag in empty space to marquee-select, in List, Icon and Freeform views
2026-10-10 14:52:56 -04:00
Brandon Thomas 97c39725cb Merge pull request #606 from JordyUSA/fix/pcm-large-files
Export: never crash on WAV/AIFF over 4 GiB; stream PCM to disk
2026-10-10 14:52:50 -04:00
Brandon Thomas 159c654d54 Merge pull request #600 from JordyUSA/feat/export-crf
Export: CRF (constant quality) for H.264
2026-10-10 14:52:44 -04:00
Brandon Thomas 9504266326 Merge pull request #599 from gyroninja/fix/trim-monitor-drag-deadlock
Trim Monitor: Make trim drags no longer deadlock
2026-10-10 14:52:38 -04:00
Brandon Thomas e6372efce4 Merge pull request #597 from liangforstudy/feat/402-webm-alpha
#402: WebM transparency (VP9 alpha in BlockAdditions)
2026-10-10 14:52:32 -04:00
Brandon Thomas 525c3b2845 Merge pull request #596 from MarekSpodBiedry/issue-481
Audio Clip Mixer: click the dB readout to type a volume (#481)
2026-10-10 14:52:25 -04:00
Brandon Thomas 539759ef18 Merge pull request #595 from MarekSpodBiedry/issue-476
Reject unknown mcp options instead of silently ignoring them (#476)
2026-10-10 14:52:19 -04:00
Brandon Thomas e0fc43f267 Merge pull request #594 from MarekSpodBiedry/issue-561
Keyboard slip: clamp linked clips by one shared delta (#529)
2026-10-10 14:52:12 -04:00
Brandon Thomas b1f2973c9d Merge pull request #589 from Ni-zav/fix/547-japanese-font-priority-s20261010T084251Z-a6c2d9
fix(i18n): prefer Japanese system glyphs over Chinese fallback (#547)
2026-10-10 14:52:06 -04:00
Brandon Thomas 8fa24de32b Merge pull request #585 from haranishi/fix/cfb-bound-chain-walks
CFB: bound the DIFAT and FAT chain walks by the file's own size
2026-10-10 14:52:00 -04:00
Brandon Thomas 8b56e0fec6 Merge pull request #584 from haranishi/fix/aaf-zero-size-weak-index
Hardening: an AAF weak-reference collection with a zero key size can't exhaust memory
2026-10-10 14:51:54 -04:00
vangel76andClaude Opus 5.5 57cacfefa8 #715: a Matroska remux that keeps its timestamps starts at its first picture
Problem: MkvSource used the raw Segment timestamps as media time and the
absolute Info/Duration endpoint as the clip length. A remux that keeps its
source's first PTS (ffmpeg -copyts, 1.48 s in the report) repeated its
opening picture until then and made a 3.48 s clip out of 2 s of media.

Fix: after opening, every track moves back by the earliest start among the
playable tracks (an audio track's start before CodecDelay, never below
zero), so a delay between picture and sound survives. The duration is the
Segment end minus that origin, never less than the main track's samples.
Zero-origin files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:48:37 +02:00
vangel76andClaude Opus 5.5 c13f2aeddb #695: Timeline markers can be picked and cleared
Problem: Markers > Clear Selected Marker removes only a marker exactly at
the playhead, but a click on a marker on the Timeline ruler left the
playhead on the frame under the pointer (with snapping off), and
right-clicking a marker opened the clip menu, so a marker placed on the
Timeline could not be removed from there.

Fix: a press on a marker's pentagon puts the playhead exactly on it, and
right-clicking a marker opens a marker menu (Clear Selected Marker, Clear
Markers; automation ids timeline.markerMenu.<command>). The pentagon's
rect is shared by drawing and hit-testing (timeline_hit::marker_rect).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:41:12 +02:00
ReReddo 1edd8d6e33 appearance: the Highlight Color drives the playhead, timecodes and focus (#707)
Settings > Appearance > Highlight Color replaced only the accent
(selections, primary buttons). The playhead, the timecodes, the focused
panel's outline, drag values and the track-targeting buttons stayed blue,
so a pink highlight left an interface that was mostly blue.

A highlight of one's own now colours those too: each keeps the
highlight's hue and is as much more or less saturated and bright as the
theme's own blue is than the theme's highlight (the playhead and
timecodes a little lighter, the targeting buttons a little deeper). With
the default highlight nothing is derived, so every theme looks exactly as
before. The track-targeting fill, written into the Timeline code, is a
theme token now (track_target).

Tests: theme (the default highlight leaves the five colours as designed
in every theme; a pink one gives them its hue, lighter and darker as the
default blues are).
2026-10-10 21:40:36 +03:00
vangel76andClaude Opus 5.5 ce6afffc6f #712: keyboard Slide keeps linked picture and sound aligned at a neighbour limit
Problem: timeline.slideLeft/Right(5) slid every expanded selection member by
the raw requested delta, so when a neighbour limited only one linked partner
(e.g. a short sound-only follower), picture and sound ended up at different
starts. timeline.slide already clamped to a common delta.

Fix: add edit::slide_items, which clamps the delta by the most restrictive
item and slides all items by it, and use it from both the keyboard Slide
commands and timeline.slide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:39:48 +02:00
ReReddo fdb0ce944d graphics: shape handles change the shape's Size, Shift keeps the ratio
Dragging a handle of a rectangle, ellipse or polygon in the Program
monitor changed the layer's Scale, and a corner scaled width and height
alike, so a shape could not be made wider or taller from a corner.

As in Premiere, the handles now change the shape's Size (Premiere's
Path) and leave Scale alone: the dragged side or corner follows the
pointer, the opposite one stays, and the anchor point is renumbered so
that it and Position stay on the same spot of the picture. A corner
moves freely; Shift keeps the proportions. A path has no Size, so it
still stretches (and its corners follow the same Shift rule).

The paragraph-text box and the shape box share the side-dragging code
(drag_sides).

Tests: graphics_handles_ui (a shape's handles change its Size and Shift
keeps the ratio; a polygon's handles keep the opposite corner).
2026-10-10 21:29:53 +03:00
vangel76andClaude Opus 5.5 bc5965e42b #710: --version works after global options
Problem: `filmcraft-cli --demo --version` (or `--compact -V`, `--data-dir d
--version`) failed with "missing subcommand" and exit 2. The version check only
looked at the very first argument, so a preceding global option left
`--version` to the option parser and no subcommand behind.

Fix: parse the arguments first and treat `--version` anywhere, or `-V` in the
subcommand position, as a version query (`Args::wants_version`), still before
the hardware decoders are registered. `--help` keeps priority.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:22:46 +02:00
vangel76andClaude Opus 5.5 d0617c849b #721: 5.1 AAC imports decode in WAV/SMPTE channel order
Problem: our AAC decoder outputs channels in AAC element order (C, L, R,
Ls, Rs, LFE for channel configuration 6) and PacketDecoder passed that
through unchanged, while the mixer, panner, loudness and export all use
WAV/SMPTE order (L, R, C, LFE, Ls, Rs). A 5.1 AAC import therefore put
centre in L, L in R, R in C, the surrounds one slot early and LFE in Rs.
The export path already reorders for the encoder; the decode path did not.

Fix: PacketDecoder reorders AAC output for channel configurations 3-6
into WAV/SMPTE order, like the AC-3 decoder and Opus mapping family 1
already do. The Opus reorder now shares the same bounds-checked helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:20:59 +02:00
vangel76andClaude Opus 5.5 14f04c8c90 #714: an MP4 without an edit list starts at its first picture
Problem: a video track with no edit list (x264 MP4 written without
elst, fragmented MP4) kept its raw first PTS, the B-frame composition
delay, as its origin. Media time zero repeated the first picture for
that delay, and since the clip lasts the media duration, the last
pictures never appeared in a sequence made from it.

Fix: when the video track has no edit list, move every track back by
the earliest start among the video and audio tracks, as ffmpeg does
with an input's start time. Video-only files now start at their first
picture; a file whose audio starts at zero keeps its A/V sync, and
explicit edit lists (empty edits, delays) are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:19:17 +02:00
KattandClaude Opus 5.5 121ae6958f Merge main: ROADMAP.md moved to the new docs layout
Our line goes where upstream now keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:48:01 +05:30
vangel76andClaude Opus 5.5 62928e9db2 #709: --gpu-rendering takes a space-separated value
Problem: `--gpu-rendering` was missing from the CLI's list of
value-taking options, so `export --gpu-rendering off out.wav` stored the
option without a value and turned `off` into the output path (or dropped
the setting silently when the option came after the filename).

Fix: list `--gpu-rendering` among the valued options, as documented in
the CLI help.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:11:00 +02:00
Brandon ThomasandClaude Opus 5.5 3ad5a59321 Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#700)
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:09:14 -04:00
Nigel Ihza Firdausy 30417a345d settings: reflect unwired Timeline focus preference in schema 2026-10-11 00:56:04 +07:00
impostazioniandClaude Opus 5.5 6d6ed3f33a Captions: delete caption tracks from the timeline and Delete Tracks dialog
captions.deleteTrack existed but nothing in the UI reached it, so extra
caption tracks could not be removed and piled up in the timeline.

- Caption track header right-click menu: Delete Caption Track, and
  Delete Empty Caption Tracks (disabled when none is empty). Automation
  ids `timeline.captionTrack.C<n>.menu.delete` / `.menu.deleteEmpty`.
- sequence.deleteTracks takes `"captions": "empty" | "C2" | id`. Unlike
  video and audio, every caption track may go (a sequence can have
  none). Unknown names/ids and other value types are errors. The
  selection of captions on deleted tracks is cleared; one undo step.
- Sequence > Delete Tracks... gets a Caption Tracks section (shown when
  the sequence has caption tracks), ids `deleteTracks.captions*`.
- Spanish strings for the new labels.

Tests: engine `delete_tracks_removes_caption_tracks`; headless UI tests
for the header menu and the dialog (delete_caption_tracks_ui.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:55:01 +01:00
ReReddo c8accc4946 settings: the hex colour field takes typing
The `#rrggbb` field next to a colour swatch (Appearance ▸ Highlight
Color) was refilled from the saved colour on every frame and kept an
edit only when the whole text was already a valid colour. One typed or
deleted character never is, so the field snapped back and nothing could
be typed.

While the field has focus it now keeps what is typed, and each valid
colour is saved as it comes; leaving the field shows the saved colour.

Tests: settings_ui::hex_field_takes_typing_a_character_at_a_time.
2026-10-10 20:53:43 +03:00
Nigel Ihza Firdausy 06ebdfa08a interchange: preserve named audio dissolves on EDL import 2026-10-11 00:53:37 +07:00
Nigel Ihza Firdausy 758330822f tts-text: preserve numeric groups, percentage units and decimal money 2026-10-11 00:52:00 +07:00
Manuel CamachoandClaude Opus 5.5 7f4bf2157b HW6: AV1 export format, enabled where a hardware encoder registers one (#30)
Format::Av1 in export settings and jobs, SampleEntry::av1 (av01 + av1C) in
isobmff, and the Export panel's Format list, profile and encoder rows. No
encoder ships here: like H.265, AV1 is listed greyed out until a platform
encoder registers a probe, and a job without one fails with "AV1 encoder not
available yet". Split out of #409; the VA-API AV1 encoder follows on main's
vaapi::va design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:46:43 -07:00
impostazioniandClaude Opus 5.5 6aa4146c16 Captions: render the track font and add a font picker
The caption style's `font` was stored and settable through
captions.setStyle, but the burn-in renderer always drew Inter SemiBold.
Lay out, wrap and measure captions with the track's family (Inter when
the name is empty or not installed), so the Program monitor and burned-in
exports use the chosen font.

Add a font dropdown to the caption style strip in the Text panel's
Captions tab (automation id `text.captions.style.font`, options
`text.captions.style.font.option.<family>`, registered only while
visible). macOS-private faces (names starting with '.') are not offered:
some lack a space glyph and run words together.

Tests: renderer regression test (a non-Inter family changes the pixels;
failed before), fallback test for empty/unknown names, and a headless UI
test that picks Noto Serif from the dropdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:29:09 +01:00
ReReddo 6d46d4f315 timeline: lines between the tracks, a short playhead head
- The line under each track was drawn in the Timeline background colour,
  the same as the tracks, so the tracks ran into each other. It now uses
  the separator colour the track headers already use, and the top video
  track also gets a line above it, in the headers and the track area.
- The playhead's head ran the full height of the ruler and covered the
  markers and the timecodes. It is now the short head Effect Controls
  uses, on the tick row.
2026-10-10 20:24:18 +03:00
vangel76andClaude Opus 5.5 a4050b4ffc #411: importing a folder keeps its structure as bins
Problem: mediaBrowser.import flattened a folder into its first-level
files (not recursive) and created no bins, so sub-folders were skipped
and the folder hierarchy was lost in the project.

Fix: a folder becomes a bin named after it (in `bin`, else the root)
holding its files, and each sub-folder with media a bin inside that, as
Premiere's Media Browser does. Folders without media get no bin. The
walk is bounded (depth 32, 4096 folders) against symlink loops. The
result also lists the new `bins`. Import as Image Sequence and plain
file imports behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:22:52 +02:00
vangel76andClaude Opus 5.5 78e9221a34 #614: document timeline.thumbnails outside the ui.set row
The ui.set table row is one long line; #412 (PR #656) also edits it, so
the two PRs conflicted with each other in docs/control-protocol.md
whichever merged second. The thumbnails parameter is now described in
its own paragraph below the table, leaving that row untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:22:42 +02:00
vangel76andClaude Opus 5.5 0c2cc293bc #342: translate the ProRes 4444 export presets
i18n::tests::spanish_translates_dynamic_names lists every built-in
export preset's name and description; the three new ProRes 4444 presets
had no Spanish entry, so ui-egui's lib tests failed. Adds them to es, ja
and zh-cn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:22:28 +02:00
awe cbd06b6c9a Project panel: dragging one of several selected items places them all, back to back, in one undo step
timeline.place takes items: [...] and file.newSequence takes fromItems: [...]; both run as
one undo step through the new Session::edit_group, which rolls back if any item fails.
Files dropped from the OS use the same command instead of one timeline.place per file,
so they are one undo step too.
2026-10-11 00:22:02 +07:00
vangel76andClaude Opus 5.5 25d9bff1dd #457: translate the UI Scale setting
i18n::tests::spanish_translates_dynamic_names lists every settings
label and choice; "UI Scale" and "Auto (system)" had no Spanish entry,
so ui-egui's lib tests failed. Adds them to es, ja and zh-cn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:18:04 +02:00
Oskar ac8b777447 contributors: add myself (ossini), shown with my GitHub profile name 2026-10-10 19:17:47 +02:00
vangel76andClaude Opus 5.5 3feadebbc4 #313: J/K/L shuttle the Source monitor both ways at up to 8x
Problem: with the Source monitor focused, L only ever played at normal
speed, and J and the slow shuttle commands returned "Source playback
currently supports normal forward speed": the Source clock always ran at 1x.

Fix: the Source clock runs at a signed speed. L/J play forward/backward
and pressing the same key again doubles the speed up to 8x; Shuttle Slow
Right/Left play at 1/4 speed. As in the Program monitor, speeds other than
normal forward play without sound, and backward playback stops on the
first frame. docs/monitors.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:16:40 +02:00
Oskar ca44d85450 #648, #668: click a gap in the Timeline, Delete closes it
Clicking empty time between two clips selects the gap, drawn as a light
block across it. Delete or Backspace closes it; right-clicking it opens
a menu with Ripple Delete. A cut's edit point menu right-clicked before
no longer stands in for the gap's menu.
2026-10-10 19:15:30 +02:00
Oskar c8f85af5a1 #648, #668: select a gap and close it with Delete or Ripple Delete
timeline.selectGap selects the empty time on one track between two
clips (or before the first one). Delete, Backspace (edit.clear) and
Ripple Delete then close it as one "Ripple Delete" undo step, as in
Premiere Pro. The selection lives in the editor state (gapSelection in
sequence.inspect), is replaced by a clip, transition or edit point
selection, and is dropped by an edit that changes the gap.

sequence.closeGap shares the same code.
2026-10-10 19:15:30 +02:00
Oskar 86d113e557 edit: Close Gap refuses a locked track
Closing a gap on a locked track skipped that track but still moved the
sync-locked ones, pulling them out of sync with it. It is now refused
with the usual "locked" error.
2026-10-10 19:15:30 +02:00
vangel76andClaude Opus 5.5 c85d0ac973 #614: Head, Head and Tail and Continuous video thumbnails in the Timeline
Problem: a video clip only ever showed its first frame. Show Video
Thumbnails was a plain on/off switch, so a wide clip at high zoom stayed a
flat colour after its head.

Fix: the wrench menu keeps Show Video Thumbnails and adds Head (the
default, as before), Head and Tail and Continuous under it. Continuous
tiles aspect-correct frames across the clip on a grid anchored at its head,
only where the panel shows them (at most 256 per clip), each frame the
source time under the tile's left edge (speed, reverse, frame holds,
multicam angles), requested at thumbnail priority through the existing
frame cache. A clip too narrow for the tiles falls back to its head. The
mode is UI state (`thumbnail_mode`, serde default Head, so saved settings
keep working) and `ui.set {timeline: {thumbnails}}` sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:08:48 +02:00
ReReddo 1ed57e6d4a timeline, text editing, program monitor: clicking empty space deselects (#683)
Deselecting by clicking somewhere empty only worked in some spots.

- Timeline: a click on no track at all (below the tracks, between the
  video and audio tracks) did nothing; it now deselects clips, transitions
  and edit points, as a click on an empty stretch of a track does, and a
  drag from there draws a box selection.
- Text editing: a text layer being edited took the keyboard back on every
  frame and read every key, so after clicking another panel, typing there
  (the Effects search, say) went into the text (also #227). Moving to
  another panel now ends the edit; Properties and Essential Graphics, which
  style the text, keep it. While another field has the keyboard the keys
  are its own, and the text takes the keyboard back only when nothing has
  it. Only a move counts, so an edit an agent starts with another panel
  focused goes on.
- Program monitor: a click around the picture, or on the picture's empty
  space, ends the text edit and deselects the graphic layers and the mask
  (graphics::deselect_on_monitor); before, only the picture's empty space
  ended an edit or deselected layers, and a mask stayed selected.

The transform box (#639) follows in its own change once that lands.

Tests: deselect_ui (3: empty Timeline space deselects and starts a box;
clicking another panel ends a text edit while Properties keeps it; empty
monitor space deselects a layer and a mask).
2026-10-10 20:08:38 +03:00
vangel76andClaude Opus 5.5 d8f22e9908 #67: media files share a bounded pool of open descriptors
Problem: every FileReader owned its std::fs::File for its whole
lifetime, so each open media source pinned one descriptor. A project
with hundreds of sources hit the soft limit (256 on macOS, 1024 on
Linux) and the remaining media failed to open or showed as offline.

Fix: FileReader keeps its path and borrows the file from a shared LRU
pool of at most MAX_OPEN_FILES (128) open files. Past the cap the least
recently read file is closed and its reader reopens it on the next
read; a file that is now shorter than when it was opened is refused
instead of being read as the old one. Dropping a reader closes its file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:11 +02:00
vangel76andClaude Opus 5.5 379b9b5dec #659: macOS app may use the microphone for voice-over
Problem: the macOS bundle had no NSMicrophoneUsageDescription and empty
hardened-runtime entitlements, so macOS never asked for microphone access
and voice-over recording got no input.

Fix: declare NSMicrophoneUsageDescription in Info.plist.in and grant
com.apple.security.device.audio-input in entitlements.plist; an xtask test
guards both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:00:52 +02:00
Nigel Ihza Firdausy 0133f1d717 fix(captions): keep trim-tolerant tag recognition 2026-10-10 23:58:13 +07:00
Nigel Ihza Firdausy fc69e9cad9 fix(captions): preserve whitespace-tolerant styling tags 2026-10-10 23:57:43 +07:00
Nigel Ihza Firdausy f9f0268b65 fix(captions): keep literal angle-bracket text in EBU STL exports 2026-10-10 23:57:01 +07:00
vangel76andClaude Opus 5.5 878000df07 #279: WAV opens in place and reads only the samples asked for
Problem: WAV had no streaming reader, so open_reader fell back to reading
the whole file into memory, and WavSource kept those bytes for its
lifetime. Importing a 90-minute WAV cost about its size in memory and
blocked until the whole file was read.

Fix: WavSource reads through a SharedReader. Opening walks the chunk
headers (fmt, data, bext, iXML) without touching the samples, and
audio() reads only the frame range a request needs, in bounded blocks.
open_reader takes WAV this way after the registered reader openers;
WavSource::parse keeps working on in-memory bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:54:30 +02:00
Nigel Ihza Firdausy 5763460a62 fix(captions): reject overflowing TTML times and round extreme clocks safely 2026-10-10 23:52:06 +07:00
vangel76andClaude Opus 5.5 86856f18b9 #680: Shift+Delete ripple deletes with the Timeline focused
Problem: the focused panel's shortcuts were all tried before the
application-wide ones. egui ignores extra Shift and Alt when matching a
key press, so with the Timeline focused its `Delete` (Clear, #243)
swallowed the app-wide `Shift+Delete` (Ripple Delete) and the clip was
removed without closing the gap.

Fix: try the most specific chords first across both scopes; the sort is
stable, so a panel shortcut still overrides an application shortcut on
the same chord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:49:48 +02:00
Vikram Bhaskaran 97243984d9 #496: Keep Link Media actions accessible for large lists and long paths. 2026-10-11 03:41:59 +11:00
Vikram Bhaskaran ff171b9c8c #680: Preserve individual linked-clip selections when deleting media. 2026-10-11 03:41:52 +11:00
Manuel CamachoandClaude Opus 5.5 5b9a909d47 HW5: AV1 sequence headers compare by picture format, so SVT-AV1 files stay on hardware (#30)
FrameStreamInfo::parameters_changed compared the in-band AV1 sequence header with
av1C's byte for byte. SVT-AV1 writes a provisional av1C header whose coding tool
flags (CDEF, restoration, warped motion...) differ from the in-band one, so every
such MP4 dropped to the software decoder from its first frame, on every hardware
backend. Headers now compare by what the picture format depends on: profile,
maximum size and colour configuration. A header that does not parse still counts
as a change. Regression test with an SVT-AV1 720p 10-bit av1C / in-band pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:41:45 -07:00
Nigel Ihza Firdausy 2fe4715d5c feat(timeline): rename tracks from their header (#654) 2026-10-10 23:41:13 +07:00
Nigel Ihza Firdausy 316435e54d fix(monitor): make Button Editor customize transport controls (#431) 2026-10-10 23:36:50 +07:00
vangel76andClaude Opus 5.5 5a243ea838 #239: Position keyframes get the value and velocity graphs
Problem: Effect Controls only offered the graph twirl-down for scalar
(Float) parameters. Position is a point (Vec2), so animating it showed
keyframes but never the value/velocity graphs that Scale has.

Fix: point parameters are graphable too. The value graph draws an X and
a Y curve with a draggable keyframe on each (a drag changes only that
coordinate); the influence handles sit on the X curve, since both
coordinates share them; the velocity graph shows the speed along the
path. NaN "auto" coordinates are skipped instead of drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:32:12 +02:00
vangel76andClaude Opus 5.5 4b67c44dbe #342: ProRes 4444 and 4444 XQ export, with alpha
Problem: the ProRes encoder already codes 4444 / 4444 XQ (optionally
with alpha), but export only knew the four 422 flavours: any other
proresProfile fell back to HQ and the encoder was always fed a 4:2:2
frame, so graphics could not round-trip with their alpha.

Fix: proresProfile accepts 4444 and 4444xq. The ProRes export encoder
builds a 4:4:4 frame for them and, with Include Alpha Channel, codes
the straight alpha of the unflattened picture (ExportSettings::
supports_alpha / keeps_alpha). Export Settings offers both profiles and
the alpha checkbox for them; new presets Apple ProRes 4444, 4444 with
Alpha and 4444 XQ; summary text and the MXF coding label name the
profile (and the MXF descriptor keeps its 12-bit 4:4:4 depth).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:12:54 +02:00
Abdelkabir Ouadoukou 0f08798af0 platform: look up kVTCompressionPropertyKey_ConstantBitRate at run time 2026-10-10 15:54:00 +00:00
Vikram Bhaskaran 9e77d9d786 Apply Transform shutter angle when the override is on.
The Transform effect listed a shutter angle but had no override, so motion blur never used it. Checking Use Effect Shutter Angle now samples the animated transform across that angle.
2026-10-11 02:48:52 +11:00
vangel76andClaude Opus 5.5 c4e61b3c83 #457: Settings > Appearance > UI Scale
Problem: FilmCraft had no UI scale preference. The interface always
followed the scale the desktop reported (150% on some X11 setups), and
only Ctrl+-/Ctrl++ could change it, for the session only.

Fix: add appearance.uiScale (Auto, 75%-300%) to Settings > Appearance.
A fixed value sets egui's zoom so a point is that many physical pixels
whatever the system reports; it is applied when the setting or the
system scale changes, so Ctrl+-/Ctrl++ keep working for the session,
and Auto gives the system's scale back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:45:50 +02:00
vangel76andClaude Opus 5.5 10ffc016b8 #284: reset buttons keep keyframes; a reset button on every property
Problem: Reset Effect replaced the effect with a fresh instance, so every
keyframe (and every mask) on it was deleted, and Effect Controls had no
way to reset a single property.

Fix: effects.reset now resets the parameters in place, as Premiere does:
a static parameter gets its default, an animated one keeps its keyframes
and gets the default as a keyframe at the playhead; masks stay. New
effects.resetParam does the same for one parameter, and every Effect
Controls property row has a reset button for it (automation id
effectControls.<effect>.<param>.reset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:44:06 +02:00
Vikram Bhaskaran ec7c43eea2 Treat media extensions as case-insensitive on import.
Camera files such as CLIP.MOV were hidden or skipped on Linux because sibling matching and tests assumed lowercase suffixes. Import, the media browser, and image-sequence numbering now accept mixed-case extensions.
2026-10-11 02:39:53 +11:00
Vikram Bhaskaran 0a1f9730c8 Add file.import replace mode for empty-project round-trips.
Headless import merged into the current project, so a demo or leftover timeline mixed into export. replace:true starts from file.newProject with the same name, then imports, which is the documented empty-project-start workflow.
2026-10-11 02:36:46 +11:00
Vikram Bhaskaran 80db16ebd5 Apply Ultra Key Setting as a parameter preset.
Relaxed and Aggressive previously only scaled two internal multipliers, so the matte stayed at Default. Switching Setting now writes Premiere-like matte values and the renderer uses that set, including Custom for hand-tuned keys.
2026-10-11 02:34:08 +11:00
vangel76andClaude Opus 5.5 6a995d7c0a #333: Fade In and Fade Out presets add up on one clip
Problem: presets.apply replaced an intrinsic effect (Opacity) wholesale, so
applying Fade Out after Fade In discarded the Fade In keyframes (and vice
versa).

Fix: when a preset lands on an intrinsic effect, keep the clip's existing
keyframes that lie outside the span the preset animates for that parameter.
Re-applying the same preset still replaces its own ramp; static preset values
behave as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:30:17 +02:00
Vikram Bhaskaran 6d1c4fdb84 Pass Hard Limiter look-ahead into the DSP limiter.
The project Look-Ahead Time parameter was stored but never applied, so 0 ms and 30 ms mixed the same clip. The limiter now sizes its look-ahead from that parameter and pre-rolls for the chosen window.
2026-10-11 02:28:52 +11:00
vangel76andClaude Opus 5.5 3c0fa2ab0b #461: interchange import links media through the streaming reader
Problem: after merging an FCP7 XML / OTIO / EDL / AAF document, the import
linked each media file by reading it whole into memory (services.read_file +
open_bytes). Multi-GB 4K files took minutes and stayed resident, while a
direct import opens them in well under a second.

Fix: link through MediaPool::open_file, as import_streamed does: the host's
random-access reader reads only the container index now and samples on
demand (hosts without a reader still read the file whole, as before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:21:56 +02:00
vangel76andClaude Opus 5.5 a902b11e80 #653: a dragged trim edge stops at the end of the media
Problem: dragging a clip edge in the Timeline previewed the raw pointer
delta, so the edge kept growing past the end of the clip's media (or into
the neighbouring clip) and only jumped back when the button came up and
`timeline.trim` clamped it (#653, point 2 of #374).

Fix: `commands::trim_delta` computes, without editing, the delta
`timeline.trim` would apply (the same clamp across linked partners, now
shared by both), and the Timeline's trim drag shows that delta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:19:58 +02:00
MrCloudy2andClaude Opus 5.5 69bb1dd33c #480: exports no longer stop for good when a clip's decoding uses rayon
A GopCache decodes with its lock held, and that decoding starts rayon work
(rotating iPhone frames, cropping, slice-threaded decoders). On the global pool
a worker that took part of it could, waiting inside it, pick up another render
job asking the same cache for a frame and block on the lock, while the lock
holder waited for the work on that worker's stack: exports of rotated iPhone
HEVC clips stopped making frames in about half the runs.

The locked decoding now runs on a separate decode pool, whose threads never wait
for a cache's lock; a caller that is a rayon worker waits on a plain thread so it
does no other rayon work while holding the lock. The cancel/catch-up/draft/
background hints travel with it (filmcraft_media::cancel::Context). Intra-only
streams crop and rotate before taking the lock again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:14:34 +02:00
JordyUSAandClaude Opus 5.5 4ce807263d Project panel test: drop an unneeded mut (clippy unused_mut)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:06:45 +07:00
Vikram Bhaskaran 170852d8db #533: Preserve explicit export sample rates to avoid silent substitution. 2026-10-11 02:00:58 +11:00
vangel76andClaude Opus 5.5 b24225afd3 #642: open Help/About links in the system browser on the desktop
Problem: every link (Help menu, About dialog, header Discord button, Import
screen, Settings and Keyboard help) went through egui's `ctx.open_url`. The
workspace builds eframe without its `links` feature, so egui-winit only logs
"Cannot open url - feature links not enabled" and nothing opens on Linux,
Windows or macOS.

Fix: add a `HostHooks::open_url` hook, as PhotoCraft does. `links::open` now
takes the app and calls the hook, falling back to `ctx.open_url` when it is
unset or fails (the web build keeps opening a new tab). The desktop app sets
it to `open::that_detached` (open 5, MIT), which does not block the UI
thread. Test: every Help link reaches the hook, and a failing hook falls
back to `ctx.open_url`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:58:26 +02:00
vangel76andClaude Opus 5.5 b6ba78e300 #412: select a single keyframe in Effect Controls
Problem: the keyframe lane highlighted every keyframe whose time equals the
playhead. A click on a keyframe moves the playhead there, so every
parameter's keyframe at that frame lit up (point 4 of #412).

Fix: a serde keyframe selection in the UI state (clip, effect index, param,
mask, media time). A click (or drag start) selects exactly that keyframe and
still moves the playhead; highlighting reads the selection. A moved keyframe
stays selected; references to deleted keyframes or clips no longer shown are
dropped. Readable via ui.inspect, settable via ui.set keyframeSelection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:57:20 +02:00
JordyUSAandClaude Opus 5.5 8ea94a8541 Merge main into feat/flac-export
No conflicts. main's new Chinese catalog requires every tl! string:
added zh-CN for the FLAC labels (format, preset, Audio Format entry,
Compression Level and its hint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:53:52 +07:00
JordyUSAandClaude Opus 5.5 360183e273 Merge main into feat/export-crf
Conflict in crates/engine/src/export_tools.rs: both sides added an
import (BitrateMode for CRF, GpuRendering from main); both kept.
main's new Chinese catalog requires every tl! string: added zh-CN for
"Quality (CRF)" and "CRF (constant quality)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:45:31 +07:00
vangel76andClaude Opus 5.5 7eaba0ddd5 #456: Select All in the Project panel takes only the shown bin
Problem: with the Project panel focused, Cmd+A ran project.selectAll,
which selected every item in the project: items in closed bins and in
bins of other tabs too.

Fix: project.selectAll takes an optional `bin` (its direct items) and
`expanded` (sub-bins twirled open in List view, whose items are shown
and count). Unknown or malformed ids are an error and leave the
selection alone; without `bin` it still selects the whole project. The
UI passes the shown tab's bin and, in List view, the open bins; while a
search filters the panel it selects exactly the rows shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:43:42 +02:00
JordyUSAandClaude Opus 5.5 0797ab4ed9 #598: import AVI files (Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3)
AviSource maps AVI streams onto the decoders, opened from bytes or a
streaming reader (Media Browser probe):
- Video: Motion JPEG, H.264 and HEVC (Annex B, our decoders) and
  uncompressed BGR24 / BGRX32 / YUY2 / UYVY / I420 / YV12 / NV12 / grey
  (new RawVideoDecoder). Empty "drop frame" chunks hold the picture
  before. AVI has no timestamps: H.264 with B-frames is stored in decode
  order, so the presentation order comes from the pictures' order counts
  (mxf::avc_presentation_order), keeping the slots the pictures occupy.
  A writer that kept a fine time base (ffmpeg copying an MP4's 1/600)
  fills the slots between pictures with empty chunks; the reported frame
  rate is the one the pictures play at. DivX / Xvid and DV are named and
  report that they have no decoder; their sound plays.
- Audio: PCM (integer and float) from the chunks; MPEG audio and AC-3
  frames, which AVI chunks need not follow, found again across chunk
  boundaries with the new filmcraft_mpegts::frame_bytes.
- Motion JPEG frames without Huffman tables (the AVI1 convention) decode
  with the standard ITU-T T.81 Annex K tables (also for MOV).

Tests against ffmpeg's decode of the same files: H.264 with B-frames
bit-exact under random access, also copied from a 1/600 MP4;
uncompressed video and PCM bit-exact; JPEG and MP3 / MP2 / AC-3 within
decoder tolerance; drop frames; DivX; JPEG without tables equal to the
same frame with them. Engine: an AVI imports, makes a sequence, plays
picture and sound, and probes without reading it whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:21:30 +07:00
JordyUSAandClaude Opus 5.5 2f4b59329e #598: filmcraft-avi, a clean-room AVI / OpenDML demuxer
New L0 crate written from the AVI RIFF file reference and OpenDML 1.02:
avih and per-stream strh / strf (BITMAPINFOHEADER, WAVEFORMATEX(TENSIBLE))
/ strn, and chunk tables from the best index a file has: the OpenDML indx
super index and ix## standard indexes (field indexes too), else idx1
(offsets relative to movi or absolute) plus a scan of RIFF AVIX
extensions it cannot see, else a scan of the movi lists (key frames then
unknown). Frames are read on demand through a ByteSource.

Sizes are clamped to the file, so a cut-off recording opens with what
is there; rec lists nest at most 3 deep and tables are bounded by what
the file could hold. Tests: hand-built files for every index path,
truncation, and every single-byte mutation and truncation (no panic);
ffmpeg-made files against ffprobe; an ignored test on a 1.16 GB OpenDML
file with AVIX extensions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:21:30 +07:00
Max Murphy 2472b890ac MP4 rtmd: reject drop-frame labels that counting skips
Drop-frame counting never emits the first base/15 frame labels of a minute
except each tenth (frames 00-01 at 29.97, 00-03 at 59.94). Accepting them let
a damaged 00:01:00;00 normalize to a different start time, so fields_to_frames
now rejects those labels and the start timecode is left unset.
2026-10-10 10:21:03 -04:00
Max Murphy 83fb73b9b1 MP4 rtmd: bound the header read and reject damaged timecode fields
Address review of #460:
- Read only the fixed-size rtmd header (18 bytes) instead of read_sample,
  which allocated the whole untrusted stsz size (a corrupt multi-GiB size
  could exhaust memory during import).
- Validate hours/minutes/seconds/frames against the frame base and drop the
  start timecode when they are out of range, instead of normalizing damaged
  metadata into a plausible but wrong offset.
2026-10-10 10:21:02 -04:00
Max Murphy cfebe8df14 Contributors: credit maxm11 (Max Murphy) 2026-10-10 10:20:45 -04:00
Max Murphy 23b4bf24b1 MP4: read the start timecode from a Sony rtmd track when there is no tmcd
Sony XAVC cameras such as the FX3 and a6400 keep the clip's start timecode
in an rtmd (real-time metadata) data track rather than a QuickTime tmcd
track, so probe reported start_timecode: null and timecode sync could not
use the footage. Read the hours/minutes/seconds/drop-frame/frames block from
the first rtmd sample and expose it as MediaInfo::start_timecode, falling
back only when no tmcd track is present (#460).
2026-10-10 10:20:33 -04:00
Max Murphy 4fcc437bf6 perf(export): index the bin tree once in closure()
The bin/item id split checked p.root.find_bin for every queued id, and
find_bin walks the whole tree for an item id, making a flat N-item export
O(N^2). Build the bin id set and each bin's direct children once, then
test membership and expand from the index, so closure() is O(N log N)
and bin expansion never re-walks the tree.
2026-10-10 10:05:01 -04:00
vangel76andClaude Opus 5.5 1169ef2a93 contributors: credit vangel76
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:08 +02:00
vangel76andClaude Opus 5.5 1e3d4a8a8c #591: named clips that cannot stand in for a selection say why
Problem: a command that takes `clips` / `clip` answered "no clips
selected" when the caller named clips that are not in the active
sequence. Over MCP or the control channel that reads as if the
explicit ids were ignored: a clip id of another sequence (for example
from `sequence.inspect {"item": …}`), an id that is no clip at all (a
track or project item id from the same inspect output) and a string
id like "29" all gave the same message.

Fix: when the selection check fails and the parameters named clips
under a key the command documents, but none is a clip of the active
sequence, the reason names the first one and what is wrong with it:
the sequence it is in (with the `sequence.open` call to make it
active), that no clip has that id, or that the value is not a clip id.
Enablement itself is unchanged: such a call is still refused, and a
call that names nothing keeps the selection's reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:48:55 +02:00
MrCloudy2andClaude Opus 5.5 7d6c53d03d Merge origin/main into #260; Chinese and Japanese for "A file dialog is already open"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:23:08 +02:00
JordyUSAandClaude Opus 5.5 8f0f689c85 Project panel: double-click anywhere empty in Icon view opens Import
In Icon view each row of cards was laid out as a hover-only strip, so
only the strip under the last row sensed clicks. With an empty project
the whole panel is that strip and double-click opened Import; as soon
as the project had cards (for example after placing audio in the
timeline, which adds the clip and its sequence), most of the visible
blank space sat beside or between the cards and ignored double-clicks.

A click-sensing background now covers the visible Icon view behind the
cards (registered first, so cards keep their own clicks) with the same
handlers as the empty strip: click deselects, double-click imports,
right-click opens the panel menu.

Tests double-click with real pointer input at 60 fps: between and beside
the demo project's bins, and beside the cards after a tone is placed on
A1; both failed before (0 imports) and pass now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:10:39 +07:00
KattandClaude Opus 5.5 b92078ec7e Media: a file that can't be opened is tried again once it changes, not on every look
A media file the refresh couldn't open (still being written, or not media
FilmCraft reads) kept its old stamp, so every look saw it as changed and
opened it again on the UI thread. It now keeps its new stamp and is tried
again once it changes again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:30:21 +05:30
Mario PodolanandXiaomi MiMo 7123e1fc46 ui: detect software rasterizers and say GPU acceleration is unavailable
When wgpu falls back to a software rasterizer (llvmpipe / SwiftShader /
softpipe - containers and VMs without GPU device access, missing drivers),
set_wgpu accepted it as the GPU compositor (llvmpipe satisfies the Rgba16Float
checks), so the preview quietly composited through WGSL on a CPU rasterizer:
GPU usage 0 %, CPU saturated, laggy playback, no visible explanation.

software_rasterizer() now detects CPU rasterizers (device_type == Cpu, or
llvmpipe / SwiftShader / softpipe in the name) and gpu_compositor_unsupported
rejects them first, so the monitors use the native CPU compositor, which
renders the same frames faster than wgpu-on-llvmpipe. The status bar shows the
reason ("GPU acceleration is unavailable; preview renders on the CPU
(software rasterizer (...))") in addition to the log line. New tl! string with
Spanish, Japanese and Simplified Chinese translations.

Test: gpu_fallback_tests::software_rasterizers_are_detected.

Co-Authored-By: Xiaomi MiMo <noreply@xiaomi.com>
2026-10-10 14:43:28 +02:00
Brandon Thomas 98ba9acbce Merge pull request #421 from mutombopr/gpu-rendering
GPU export rendering (on by default) and Vignette, Video Limiter, Lumetri Color on the GPU compositor
2026-10-10 08:35:25 -04:00
Lukas KellerandClaude Opus 5.5 63c9b17454 contributors: credit @keller-lukas as Lukas Keller
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:35:14 +02:00
Lukas KellerandClaude Opus 5.5 09165a383d i18n: German (de) joins Edit > Language
Language::De is persisted as `de` and translated through a new de.tsv
catalog that covers every menu label and the native macOS Settings menu,
written from the plain meaning of the English labels with informal "du"
and infinitive commands. The new app.language.german command checks,
switches and restores the language like the others, the native macOS
menu maps it back, Settings > General offers Deutsch, and System Language
picks German for any de-* locale (de-DE, de-AT, de-CH ...).

A new test, built like the Ukrainian one, fails when German misses a core
menu label (the Brazilian Portuguese entries, the top-level menus, the
macOS Settings menu) or keeps an entry that is no longer a menu label;
menu labels added later use English until they are translated. The
System Language test no longer uses `de` as an unsupported example.

Closes #490

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:35:14 +02:00
ReReddo 06b835d5a8 timeline, project panel: paste images and copied files from the system clipboard (#611)
Ctrl+V with a screenshot or an image copied in another app, or with files
copied in Explorer / Finder, now pastes them. In the Timeline they are
imported and placed at the playhead, end to end, without overwriting
anything: each clip goes on the first free track from the targeted one
outward (up for video, down for audio), and a track is added when every
track is busy there. Ctrl+Shift+V inserts them on the targeted tracks, as
Paste Insert does. In the Project panel Ctrl+V imports into the shown bin.
One undo step takes the paste back.

Copying clips already puts their names on the system clipboard as text, so
media on the clipboard was copied after them; without media, Paste pastes
the copied clips as before.

- engine: edit.pasteMedia {paths, insert, place, bin}; paste_media::
  save_pasted_image writes "Pasted Image <n>.png" where voice-over takes and
  narrations go (voiceover::media_dir: Scratch Disks > Captured Audio and
  Video, else next to the project, else the data directory), trying a
  bounded number of names as voiceover::unique_wav_path does.
  timeline.place's default length moves to commands::full_duration so both
  share it.
- ui-egui: HostHooks::clipboard_media reads the clipboard (ui-egui stays
  OS-independent); menus::invoke turns edit.paste / edit.pasteInsert into a
  media paste when the Timeline or Project panel has focus. egui-winit drops
  the Ctrl+V press when the clipboard holds no text but passes the release,
  so a V release without its press puts the press back. That also makes
  Ctrl+V paste copied clips when the system clipboard is empty.
- filmcraft (desktop): the hook, with arboard (already in the tree through
  egui): copied files first, else an image.

Not covered: the web build, whose browser clipboard works differently.

Tests: paste_media_tests (5: image above the footage with nothing
overwritten and one undo step, tracks added when every track is busy,
Paste Insert, import only, saved image names) and clipboard_paste_ui (4:
an image pasted from a key release with no text, copied files and Paste
Insert, the Project panel imports, copied clips still paste and a plain V
pastes nothing).
2026-10-10 15:34:47 +03:00
Brandon ThomasandClaude Opus 5.5 58f768c472 Merge origin/main into gpu-rendering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:27:57 -04:00
Brandon Thomas 1abacc4d56 Merge pull request #278 from mutombopr/tts-narration
Text to Speech narrations: panel, natural voices (Kokoro-82M in pure Rust), click-to-edit
2026-10-10 08:27:12 -04:00
JordyUSAandClaude Opus 5.5 696f428faf #374: trim preview keeps the waveform in place instead of squeezing it
While an edge was dragged, the Timeline drew the clip at its new length
from the untrimmed item, so draw_waveform spread the clip's whole source
range over the shorter (or longer) box: the waveform squeezed and the
sound under the pointer moved, which made it hard to trim to where the
music starts (#374, item 13). Trim and roll previews now draw from the
item the edit will leave, so every timeline time still covered shows the
same media and the waveform is cut off at the moving edge. Video
thumbnails show the new first frame the same way. Rate Stretch, which
really fits the same media into a new length, still squeezes.

The item update is factored out of trim and ripple_trim_group into
filmcraft_edit::move_edge, which the preview uses too, so preview and
edit agree for speed-changed, reversed and frame-held clips.

Rendered mid-drag against the released result, the music clip's
waveform matched in 15% of pixel columns before and 100% after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:24:37 +07:00
Brandon ThomasandClaude Opus 5.5 c030f78762 Merge origin/main into tts-narration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:13:05 -04:00
Brandon Thomas b47248bbcf Merge pull request #246 from ahmedhosnypro/fix/linux-dev-desktop-icon
linux: install a dev desktop entry so the taskbar shows the app icon
2026-10-10 08:12:47 -04:00
Brandon ThomasandClaude Opus 5.5 be498865c3 Merge origin/main into fix/linux-dev-desktop-icon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:12:41 -04:00
KattandClaude Opus 5.5 de6ab8d239 Media: translate Refresh Changed Media (es, ja, zh-CN)
Portuguese and Ukrainian catalogs only hold menu labels, so they keep the
English name like the other menu-less commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:41:45 +05:30
Brandon Thomas d01440d950 Merge pull request #254 from ahmedhosnypro/fix/alsa-default-fallback-23
Playback: fall back to a working output device when the ALSA default cannot be opened
2026-10-10 08:10:09 -04:00
Brandon Thomas b638f94d7a Merge pull request #442 from cshapiro/fix/windows-upstream-validation
WINCI: Repair HEVC timing and CRLF translation validation
2026-10-10 08:10:03 -04:00
Brandon Thomas 9847a5cbf1 Merge pull request #357 from jnohclee-rgb/dharma/filmcraft-344-help
cli: make --help print usage successfully
2026-10-10 08:09:57 -04:00
Brandon Thomas 7f81d6b063 Merge pull request #390 from txiki739/fix/linux-file-dialog-extensions
#262: file dialogs list extensions in any capitalisation on Linux
2026-10-10 08:09:51 -04:00
MarekSpodBiedry b81792c453 Space out browser downloads so image-sequence exports are not dropped (#378) 2026-10-10 14:06:01 +02:00
MarekSpodBiedry b5965dc568 Let a bin be cleared from its context menu, the trash button, the panel menu and Delete (#587) 2026-10-10 13:59:04 +02:00
Brandon ThomasandClaude Opus 5.5 28b704ba82 #278: zh-cn rows for Text to Speech and Edit Narration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:54:23 -04:00
KattandClaude Opus 5.5 a72d3fe1b9 Media: Automatically refresh growing files works (and catches re-renders)
Settings > Media > Automatically refresh growing files and Refresh
growing files every were listed but not wired. The desktop app now
stamps each file media item's file (size + modification time) on a
worker thread every N seconds and as soon as its window comes back to
the front, and reads changed files again the way import opens them: the
item gets the new MediaInfo (length, frame size) and identity, and the
media pool swaps in a new source (the old one indexes the file as it
was). Not an undo step and a clean project stays clean (bump_view). This
also covers a render written over the file a clip uses (an EffectCraft
or After Effects round trip). A file that can't be opened yet is tried
again. media.refreshChanged runs one look (CLI, MCP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:07:46 +05:30
Brandon Thomas 5a5684484f Merge pull request #517 from mitchmckinstry/feat/drop-import-to-timeline
[feat] files dropped onto the Timeline are placed there, not just imported. Supports preview too
2026-10-10 07:28:19 -04:00
Brandon Thomas 4ab7134a46 Merge pull request #434 from David-T-Campos/fix/share-sequences-across-edit-snapshots
project: share sequences across edit snapshots
2026-10-10 07:28:12 -04:00
Brandon Thomas ef51ef569d Merge pull request #503 from swillunderscore/transitions-select-resize
#430, #224: select, edit, delete and resize transitions
2026-10-10 07:28:04 -04:00
Brandon Thomas ad10c6259c Merge pull request #520 from maccagnit/setparam-choice-names-keyframe
effects.setParam: choices by name, a `keyframe` option, and the keyframe count in the result
2026-10-10 07:27:56 -04:00
Brandon Thomas b962329b0d Merge pull request #515 from Hathor875/feat/clip-link-pairs
clip.link: pair video clips with overlapping audio clips (#507)
2026-10-10 07:27:49 -04:00
Brandon Thomas 409a8d34fa Merge pull request #522 from gyroninja/fix/graphics-drag-undo-step
Essential Graphics: Make a property drag  one undo step
2026-10-10 07:27:43 -04:00
Brandon Thomas 6fe9b5680e Merge pull request #470 from sanyamsingh001/codex/scalar-graph-current-main
Start scalar graph drags at the pointer press position
2026-10-10 07:27:36 -04:00
Brandon Thomas 106ab5e337 Merge pull request #509 from mjohnson7cd/fix-mcp-media-import-schema-499
fix(mcp): media_import schema mirrors file.import (#499)
2026-10-10 07:27:29 -04:00
Brandon Thomas 66122699a7 Merge pull request #465 from MarekSpodBiedry/issue-463
FCP7 XML: keep the source channel of split mono audio tracks (#463)
2026-10-10 07:27:23 -04:00
JordyUSAandClaude Opus 5.5 055e95ab17 Export: FLAC (clean-room encoder; .flac files and FLAC audio in MP4/MOV)
New L0 crate filmcraft-flac, a FLAC encoder written from RFC 9639:
constant / verbatim / fixed / LPC subframes (Levinson-Durbin on Tukey-
windowed autocorrelation, up to order 12), partitioned Rice residuals,
left/side, side/right and mid/side stereo, CRC-8/CRC-16, STREAMINFO with
MD5 (RFC 1321, own implementation), levels 0-8, all lossless.

Export:
- Format::Flac, an audio-only .flac (16/24-bit), streamed one second at
  a time like WAV/AIFF; STREAMINFO is rewritten at the end with frame
  sizes, total samples and MD5. Preset "FLAC 48 kHz 24-bit".
- AudioCodec::Flac in MP4 and QuickTime (fLaC + dfLa), one frame per
  sample with the short last frame timed exactly; the dfLa STREAMINFO
  is replaced before the moov is written (Mp4Writer::set_sample_entry).
  MXF stays PCM.
- AudioSettings::flac_level (default 5); Export panel: FLAC (lossless)
  audio format and Compression Level; file.exportMedia `format: "flac"`,
  `audioCodec`, `flacLevel`. Spanish and Japanese strings.

ffmpeg oracle: every stream decodes bit-exactly with the STREAMINFO MD5
equal to ffmpeg's hash; .flac and FLAC-in-MP4/MOV exports decode to the
same samples as a WAV export of the range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:26:28 +07:00
JordyUSAandClaude Opus 5.5 f91b86dd48 codecs: import FLAC audio from MP4/MOV (pass STREAMINFO from dfLa)
The ISO-BMFF path created the FLAC decoder with no extra data, so every
FLAC track in an MP4 or MOV failed with "flac: missing extra data". The
decoder now gets the STREAMINFO body that `dfLa` carries after its
4-byte block header, as the Matroska path already did.

Regression test `mp4_flac_audio` (an ffmpeg-made MP4 with FLAC audio)
failed before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:26:28 +07:00
Brandon ThomasandClaude Opus 5.5 c9e803ff3b Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:24:39 -04:00
Brandon ThomasandClaude Opus 5.5 cebe4ee9e0 #421: zh-cn rows for the GPU Rendering export strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:24:32 -04:00
Brandon ThomasandClaude Opus 5.5 ac103be552 Merge origin/main into fix-503; Chinese and Japanese rows for the transition strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:21:51 -04:00
Brandon ThomasandClaude Opus 5.5 762344769b Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:21:39 -04:00
Brandon ThomasandClaude Opus 5.5 4304cd9382 Merge origin/main into #421; GPU export opt-in, wgpu error handler, drop panicked renderers
- GpuRendering defaults to Off (opt-in per export) until measured on Windows and macOS;
  docs and ROADMAP say so.
- The shared export device installs an uncaptured-error handler and a device-lost callback
  that log once and retire GPU export for the session (frames go to the CPU) instead of
  wgpu's default panic.
- RendererPool drops a renderer that panicked and retires the pool, so the rest of the
  export renders on the CPU (regression test in gpu_hook_scenarios); the duplicate
  hostile-size test that raced on the global registry is folded into it.
- Rebased onto main's render_plan/transitions: render_export_prepared reads the linear
  accumulator after render_plan; transition (Composite) plans fall back to the CPU.
- es/ja rows for the new Export strings; web_time::Instant in the export pipeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:21:38 -04:00
Brandon ThomasandClaude Opus 5.5 ab227a1853 Timeline file drops: Chinese and Japanese strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:19:37 -04:00
grib_job 1b83994c93 feat: Add Russian (ru) localization (updated) 2026-10-10 14:14:11 +03:00
Brandon ThomasandClaude Opus 5.5 1bf590c73c Timeline file drops: merge origin/main; document the platform cursor exception and mixed-DPI limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:11:18 -04:00
Brandon Thomas e831d0f248 Merge remote-tracking branch 'origin/main' into fix-434 2026-10-10 07:08:57 -04:00
Brandon ThomasandClaude Opus 5.5 68c297b987 Arc<Sequence>: fix the missed &mut sites and closing braces; drop the unrelated key-dispatch test
source_monitor markers and format's audio-stream clamp now detach with Arc::make_mut;
restore the final braces in clip_ops.rs and project/src/lib.rs (cargo fmt);
scripted.rs is back to main (the test covered key dispatch, which this PR doesn't change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:08:56 -04:00
Brandon Thomas 4b172dcede Merge pull request #505 from luniiya/feat/system-appearance
Standardise system appearance modes and FilmCraft theme previews
2026-10-10 07:07:48 -04:00
Brandon ThomasandClaude Opus 5.5 1dc42c7d0d Simplified Chinese rows for the appearance-mode strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:07:40 -04:00
Brandon Thomas 1ace90d191 Merge remote-tracking branch 'origin/main' into fix-505 2026-10-10 07:05:06 -04:00
Brandon Thomas df3df9d352 Merge pull request #550 from hadmark/main
Add Simplified Chinese (zh) translation
2026-10-10 07:04:58 -04:00
Brandon Thomas ac8d62c547 Merge pull request #549 from SilentYeti/nvdec-linux
Linux: NVDEC hardware decoding for H.264 and HEVC
2026-10-10 07:04:53 -04:00
Brandon Thomas 805705398a Merge pull request #445 from LloydNicholson/fix/anamorphic-par
Issue #551: Anamorphic media: honour pixel aspect ratio in placement, monitors and New Sequence From Clip
2026-10-10 07:04:47 -04:00
Brandon Thomas 2160911fc1 Merge pull request #492 from MrCloudy2/fix/h264-crop-size-288
#288: MP4 / MOV pictures are cropped as the file says (H.264 / HEVC cropping, clean aperture)
2026-10-10 07:04:41 -04:00
Brandon Thomas 33bc073743 Merge remote-tracking branch 'origin/main' into fix-505 2026-10-10 07:01:42 -04:00
Brandon ThomasandClaude Opus 5.5 1ffe0cf4e7 #505: merge origin/main; ja/uk rows, colorTheme kept in line, docs
- ja.tsv rows for the new appearance strings (no duplicate Dark/Light);
  uk.tsv gets Next Appearance Mode, which the pt-br menu catalog now has.
- appearance.colorTheme is legacy input but no longer goes stale: it follows the
  mode and theme choices (the theme a fixed mode shows, the dark theme in Auto).
- docs/project-files.md documents appearanceMode / darkTheme / lightTheme.
- #489's menu/settings label test compares with the theme cards' choices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:01:41 -04:00
Brandon ThomasandClaude Opus 5.5 ae852a36da Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:57:46 -04:00
Brandon ThomasandClaude Opus 5.5 664c13efeb Merge origin/main into #278; schema v14, neural-voices off by default, review fixes
- Narrations take schema v14 (main's v13 is audio streams): v13_to_v14, docs updated.
- neural-voices is off by default in the desktop app, like whisper; the model download stays
  an explicit, confirmed job.
- Removed the debug probe2 test module from tts-text.
- project_subset prunes narrations of items it drops.
- unique_wav_path is bounded (99 999 names) and returns an error instead of looping.
- narration.rs uses web_time::Instant (wasm clock test); tts.* commands get the
  "Text to Speech" shortcut category, with es/ja rows for it and Edit Narration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:55:59 -04:00
Brandon Thomas a20ac75ee6 Merge remote-tracking branch 'origin/main' into fix-434 2026-10-10 06:55:52 -04:00
Brandon Thomas 995a8b4bdf Merge remote-tracking branch 'origin/main' into fix-503 2026-10-10 06:55:44 -04:00
Brandon ThomasandClaude Opus 5.5 adcd28b11a chrome_ui test: the menu bar's automation ids (menu.*, from #454) sit in the header
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:51:18 -04:00
Brandon Thomas 5c4a07b148 Merge remote-tracking branch 'origin/main' into fix-505 2026-10-10 06:48:09 -04:00
8e9b805952 #550: merge origin/main; Simplified Chinese as zh-cn with #389's plumbing
- One code everywhere: Language::ZhCn, persisted as `zh-cn`, catalog zh-cn.tsv.
- general.interfaceLanguage accepts and lists zh-cn (Settings > General).
- macOS native menu follows app.language.chinese.
- System Language picks Simplified Chinese only for zh, zh-CN, zh-SG and zh-Hans
  (not zh-TW / zh-HK / zh-MO / zh-Hant).
- Drop the second CJK font loader: cjk.rs already installs the Chinese fallback,
  so switching needs only cjk::available(); without a face the switch is refused
  and a saved zh-cn preference falls back to English at startup, like Japanese.
- Remove the fork's build-zh.yml workflow.
- Tests: Chinese covers every menu label and tl! literal; switching persists or is
  refused without a font; locale mapping. New strings get es/ja/zh rows.

Plumbing from #389 by @demon820308.

Co-authored-by: demon820308 <zwbigi@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:45:45 -04:00
Brandon ThomasandClaude Opus 5.5 8e0a824dcf Merge origin/main into fix-503 (keep both sides: Volume line + transitions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:44:25 -04:00
Brandon Thomas 959c0a5a8b Merge remote-tracking branch 'origin/main' into fix-520
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-10 06:43:01 -04:00
Brandon ThomasandClaude Opus 5.5 dc325d34c1 clip.link: clips without an overlapping partner stay unlinked; track kind worked out once
Merge origin/main (keep both contributor entries).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:38:35 -04:00
Brandon ThomasandClaude Opus 5.5 0414a1d346 #549: NVDEC callback state from Box::into_raw, freed in Drop; setting test accepts NVDEC or VA-API
Session kept the parser's user data in a Box and later wrote through that Box,
which invalidates the raw pointer the callbacks use (Stacked/Tree Borrows).
Rust code now reaches the state only through the same into_raw pointer, never
across the parse call, and Drop frees it after the parser is destroyed.
The setting test accepts either Linux backend's decoder when NVDEC registered,
and the README notes perf.stats names NVDEC for streams VA-API decodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:32:00 -04:00
JordyUSAandClaude Opus 5.5 2e21c44904 Export: never crash on WAV/AIFF over 4 GiB; stream PCM to disk
WAV and AIFF sizes were 32-bit with unchecked arithmetic: past 4 GiB
(24-bit 5.1 at 96 kHz after about 41 minutes) a debug build panicked
and a release build wrote a wrapped, corrupt header. The whole file was
also built in memory first.

- WAV past 4 GiB is written as RF64 (EBU Tech 3306) with a ds64 chunk;
  smaller files stay plain RIFF.
- AIFF has no 64-bit form: a longer one is refused before anything is
  written, naming WAV instead.
- The header is written from the known length, then the samples one
  second at a time, so memory stays flat; progress advances per second.
- Odd-sized data chunks get their pad byte; hostile lengths, channel
  counts and sample rates are errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:31:36 +07:00
Brandon ThomasandClaude Opus 5.5 d0addce05f Graphics drag undo: cargo fmt; UI tests read params through typed accessors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:28:34 -04:00
Brandon ThomasandClaude Opus 5.5 e19eaa760b Merge origin/main into #246; dev desktop entry is opt-in, icons not embedded
- Nothing is written unless FILMCRAFT_DEV_DESKTOP_ENTRY=1 (debug builds no longer write into
  ~/.local/share on every cargo run).
- The 256/512 PNGs are copied from this checkout's assets/app-icon at runtime instead of
  include_bytes! into every Linux binary; a missing icon is logged and skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:22:16 -04:00
Brandon Thomas 4181a50c58 Merge remote-tracking branch 'origin/main' into fix-549 2026-10-10 06:21:46 -04:00
Brandon Thomas fdfbc34c1a Merge remote-tracking branch 'origin/main' into fix-445 2026-10-10 06:20:10 -04:00
Brandon ThomasandClaude Opus 5.5 57bd528769 Merge origin/main into #445; PAR test fixture uses MediaInfo::audio_streams
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:20:09 -04:00
Brandon Thomas e2ebbe715e Merge remote-tracking branch 'origin/main' into fix-246 2026-10-10 06:18:20 -04:00
Brandon ThomasandClaude Opus 5.5 19c756c252 Merge origin/main into #254; prefer pipewire/pulse, cache the fallback, translate notes
- When the default output cannot be opened, probe pipewire/pulse first and raw hw:/plughw:
  last (those take the card exclusively).
- The fallback list is probed only after the default fails, and the pick is cached per host,
  so devices()/configure() no longer open every PCM.
- Status-bar notes go through i18n::t/fmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:18:12 -04:00
Brandon Thomas 40f710ec0a Merge remote-tracking branch 'origin/main' into fix-522 2026-10-10 06:16:09 -04:00
Brandon Thomas c567006057 Merge remote-tracking branch 'origin/main' into fix-470 2026-10-10 06:16:01 -04:00
gyro 81268330e9 Essential Graphics: Make a property drag one undo step 2026-10-10 03:14:40 -07:00
gyro 48e5350d30 Trim Monitor: Make trim drags no longer deadlock
data_mut() and drag_delta() both take egui's non-reentrant context
write-lock; calling the second inside the first froze the app on the
first frame of a drag in the Trim Monitor. The delta is now read
before the lock is taken, and a regression test drags through the
real Trim Monitor.
2026-10-10 03:14:19 -07:00
JordyUSAandClaude Opus 5.5 593515e884 h264enc: CRF level fits its bitrate; stream records its settings
Constant quality has no bitrate cap, but the level was chosen from the
picture size and rate alone: CRF 0 exports of 1080p camera footage
averaged 105 Mb/s (147 Mb/s peak) while signalling High@L4 (25 Mb/s).
The level now allows a worst-case estimate of 3 bits per pixel at
CRF 0, halving every 6 steps, so CRF 0 at 1080p30 signals 5.1 and the
default CRF 23 stays at 4.0.

The user_data_unregistered SEI on the first access unit now lists the
settings x264-style (rc=crf crf=18.0 profile=high level=4.0 ...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:09:05 +07:00
JordyUSAandClaude Opus 5.5 3a46156861 Export: CRF (constant quality) bitrate encoding for H.264
Adds BitrateMode::Crf with ExportSettings::crf (0-51, default 23) and
maps it to the built-in encoder's RateControl::Crf, which export never
used. The Export panel offers "CRF (constant quality)" with a Quality
(CRF) slider in place of the bitrate fields; file.exportMedia takes
`crf` and `bitrateMode: "crf"`. H.265 (hardware only) rejects it, and
VideoToolbox / NVENC decline it so the built-in encoder runs. Settings
saved before this read as CRF 23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:09:05 +07:00
Brandon Thomas e63fecfc06 Merge remote-tracking branch 'origin/main' into fix-254 2026-10-10 06:04:51 -04:00
Brandon ThomasandClaude Opus 5.5 2ff51268f1 Merge origin/main into #442; pin HEVC VUI offsets, drop the CRLF i18n hunk
The i18n CRLF normalisation already landed with #488, so main's version is kept.
abi_tests now asserts the HEVC VUI field offsets (inside the VUI and within
NV_ENC_CONFIG_HEVC) to rule out a binding mismatch behind the reversed timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:04:44 -04:00
Brandon ThomasandClaude Opus 5.5 54f27f1f05 Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:57:23 -04:00
Brandon ThomasandClaude Opus 5.5 e3a00c3a25 Merge origin/main into #357; check --help after Args::parse in cli()
Rebased onto main's cli()/out! refactor: `--help` is checked as a parsed flag, so
`filmcraft-cli export --help` prints the reference too (test extended).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:57:22 -04:00
MrCloudy2andClaude Opus 5.5 059376af1c #260: file dialogs on Linux no longer stop the app
Native file dialogs are requested with a continuation (FilmcraftApp::pick for
commands, pick_ui for panel buttons). On Linux the desktop host shows the rfd
dialog on a worker thread (HostHooks::run_dialog) and the result is applied
when it arrives, so the frame loop, playback and the control channel keep
running and GNOME no longer offers to force-quit the window. One dialog at a
time; a path for a dialog closed meanwhile goes nowhere. macOS / Windows keep
their synchronous pickers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:52:07 +02:00
Brandon Thomas 814287858c Merge pull request #407 from dmatviichuk/i18n-ukrainian
i18n: add Ukrainian (uk) interface translation
2026-10-10 05:50:06 -04:00
Brandon Thomas ba0e66c6d3 Merge remote-tracking branch 'origin/main' into fix-470
# Conflicts:
#	crates/ui-egui/src/panels/effect_controls.rs
2026-10-10 05:49:49 -04:00
Brandon ThomasandClaude Opus 5.5 e0d2c9c73b MCP media_import: test the paths schema and param mapping; update docs/agents.md (#499)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:49:28 -04:00
Brandon Thomas a8e38c4e07 Merge remote-tracking branch 'origin/main' into fix-445 2026-10-10 05:47:31 -04:00
Brandon ThomasandClaude Opus 5.5 72a9a7b7aa #492: clippy is_multiple_of in the oracle test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:47:22 -04:00
Brandon ThomasandClaude Opus 5.5 3cb3dc13aa Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:46:51 -04:00
Brandon ThomasandClaude Opus 5.5 634d6cf359 Merge origin/main into #390; case-insensitive filters on main's translated dialog labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:46:50 -04:00
Brandon Thomas d3dd2fd312 Merge origin/main 2026-10-10 05:45:05 -04:00
Brandon ThomasandClaude Opus 5.5 d8a7b28b2e Merge origin/main into #492; ffmpeg oracle: decode unaligned crops, skip clap cases on ffmpeg without clap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:45:04 -04:00
Brandon Thomas 4e9a450acd Merge remote-tracking branch 'origin/main' into fix-407 2026-10-10 05:44:26 -04:00
MarekSpodBiedry 77becde085 Audio Clip Mixer: click the dB readout to type a volume (#481) 2026-10-10 11:44:16 +02:00
Brandon Thomas 8454634fb8 Merge remote-tracking branch 'origin/main' into fix-509 2026-10-10 05:43:04 -04:00
Brandon Thomas a46b70eac8 Merge remote-tracking branch 'origin/main' into fix-465 2026-10-10 05:42:54 -04:00
Brandon ThomasandClaude Opus 5.5 24f8279327 FCP7: trackindex 1 means channel 0 when the file is split across mono tracks (#463)
A split-stereo left clip exported trackindex 1 and re-imported as full stereo,
losing the left/right split. Treat trackindex 1 as channel 0 when another
audio clip item of the same file in the sequence uses a higher index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:42:52 -04:00
MarekSpodBiedry dcf2d7f423 Reject unknown mcp options instead of silently ignoring them (#476) 2026-10-10 11:42:50 +02:00
MarekSpodBiedry 628d573f1d Keyboard slip: clamp linked clips by one shared delta (#529) 2026-10-10 11:41:12 +02:00
liangforstudyandClaude Opus 5.5 4f8dcdf587 #402: WebM transparency (VP9 alpha in BlockAdditions)
A WebM VP9 track with AlphaMode 1 stores its alpha as a second, independently coded VP9
stream in each block's BlockAdditional (BlockAddID 1). The Matroska reader detected
AlphaMode (has_alpha was true) but never read the additions, so transparent areas
rendered black.

- matroska: BlockGroups' BlockAdditions are parsed; the BlockAdditional with ID 1 of an
  unlaced block is kept as Sample::addition, read with MkvFile::read_sample_addition.
- codecs: a WebM VP9 track with AlphaMode and additions gets an AlphaDecoder
  (webm_alpha.rs): it decodes each sample's alpha frame with a second software VP9
  decoder and attaches its luma plane (full range, converted to the picture's depth)
  as the frame's alpha. After a seek the alpha decoder resynchronises from the nearest
  alpha key frame. Colour pictures are held until their alpha picture is out, since the
  two decoders may return pictures after different calls. A missing or undecodable alpha
  frame leaves that picture opaque; it never fails the colour decode.

Tests: vp9_webm_alpha_layer_matches_libvpx (frames out of order and forward, colour and
alpha exact against ffmpeg's libvpx decode; fails before: no alpha plane; passed 200 of
200 runs with four in parallel), set_alpha unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qq8BK1LmohzE1ga4NgAdWM
2026-10-10 17:40:58 +08:00
Brandon ThomasandClaude Opus 5.5 55d10195fb main: green again after the batch merges
- marker_export test: build the Session with struct-update syntax (clippy field_reassign_with_default, #454)
- es/ja catalogs: Export Markers as CSV… (#454) and the gradient fill labels (#514); drop the duplicate
  "Gradient" row #514 added to es.tsv

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:39:39 -04:00
ReReddo d67583385b project panel: drag in empty space to marquee-select, in List, Icon and Freeform views (#578)
Dragging with the left button from empty space in the Project panel draws a
selection rectangle, and every clip whose row or card it touches is selected,
live. In Icon view the gaps between cards count as empty space too, so a click
there deselects. A plain drag replaces the selection, Shift or Ctrl adds to
it, and Escape cancels and restores the selection the drag began with. The
selection goes through project.select; the rectangle registers
project.marquee.

Synthetic input (control channel, MCP, UI tests) now sends ModifiersChanged
around a pointer press and release that carry modifiers, so a drag that egui
recognises frames after the press still sees Shift / Ctrl, as with real keys.
This also lets automated Shift-drags of the Timeline marquee add to the
selection.

Limits: the rectangle does not select bins (the panel tracks one selected
bin), and in List view, where rows span the full width, it starts in the
empty space below the rows.

Tests: five project_panel_ui tests. The ui-egui suite passes except the two
Windows CRLF failures fixed in #488.
2026-10-10 12:38:47 +03:00
Luniiya 9e3c9c9b65 Merge remote-tracking branch 'upstream/main' into feat/system-appearance
# Conflicts:
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/icons.rs
2026-10-10 11:32:04 +02:00
SilentYetiandClaude Opus 5.5 3df0b9bb36 Merge remote-tracking branch 'origin/main' into nvdec-linux
tests/setting.rs: keep the check against the backend register() reported
(main's fix expects VA-API on Linux, which NVDEC now sits in front of).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:30:22 -07:00
Dmytro MatviichukandCodex 2c2e651eba i18n: add Ukrainian (uk) interface translation (#405)
Translate all 347 Spanish/Portuguese menu keys plus Clip, Audio and Settings.
Add the Ukrainian language command, serialized UI state, native macOS
menu updates, and coverage and Cyrillic glyph tests.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-10 11:26:42 +02:00
Brandon Thomas bd1c043e19 Merge origin/main into fix-465 2026-10-10 05:24:33 -04:00
SilentYetiandClaude Opus 5.5 b3d68fe0ae NVDEC: reject pictures without valid bitstream data before the driver call
The parser's picture parameters were passed to cuvidDecodePicture unread.
Their shared head is now checked first: a null data pointer, zero length, no
slices, or a slice offset past the end of the data is an error, so the
session fails over to the software decoder and the driver never sees the
picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:17:16 -07:00
Brandon Thomas 1201458630 Merge pull request #489 from ReReddo/fix/appearance-theme-name-471
appearance: View > Appearance names the middle theme "Dark", as Settings does
2026-10-10 05:12:53 -04:00
Brandon ThomasandClaude Opus 5.5 63c10831ff Merge origin/main into fix/appearance-theme-name-471
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:12:48 -04:00
Brandon ThomasandClaude Opus 5.5 df7cc97729 Format: rustfmt the clip_ops tests that landed with #561
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:11:32 -04:00
Brandon Thomas 0b92df2288 Merge pull request #569 from MarekSpodBiedry/issue-539
CLI render: report the real reason for an invalid scale instead of no sequence (#539)
2026-10-10 05:11:06 -04:00
Brandon ThomasandClaude Opus 5.5 14d2171282 Merge origin/main into issue-539 (keep both CLI helpers)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:11:01 -04:00
Brandon Thomas f04651adc5 Merge pull request #427 from Tom1tk/windows-av1-oracle-skip
Windows: skip AV1 oracle tests when ffmpeg lacks libsvtav1/libdav1d
2026-10-10 05:03:39 -04:00
Brandon Thomas dfdccd91e1 Merge pull request #504 from sawizzle/docs/installed-release-mcp
docs: register the MCP server from an installed release
2026-10-10 05:03:33 -04:00
Brandon Thomas 701c433a96 Merge pull request #501 from dylanmtaylor/chore/update-actions
chore: bump github actions to fix Node 20 deprecation warnings
2026-10-10 05:03:26 -04:00
Brandon Thomas 53b62a12ea Merge pull request #420 from txiki739/fix/flatpak-source-cli
#346: the from-source Flatpak includes the CLI and MCP server
2026-10-10 05:03:18 -04:00
Brandon Thomas 303b1518f6 Merge pull request #446 from LloydNicholson/fix/mp4-color-from-bitstream
Issue #552: MP4 and Matroska colour from the bitstream when the container has none
2026-10-10 05:03:11 -04:00
Brandon Thomas 11f8ac9718 Merge pull request #486 from MasakatsuFunaki/nvenc-rgba-input
M-HW: NVENC takes the export's RGBA and converts it on the GPU (#30)
2026-10-10 05:03:06 -04:00
Brandon Thomas 606c355644 Merge pull request #475 from MrCloudy2/fix/vaapi-shared-display
#474: VA-API decoders share one display (no 0.3 s open / 70 ms close per decoder on NVIDIA)
2026-10-10 05:03:00 -04:00
Brandon Thomas 325d1aff42 Merge pull request #555 from adurr21/fix/issue-150-light-theme-inconsistencies
Fix Light theme inconsistencies
2026-10-10 05:02:53 -04:00
Brandon Thomas 76ac514c5a Merge pull request #449 from haranishi/i18n-japanese-whole-interface
i18n: Japanese for the whole interface
2026-10-10 05:02:46 -04:00
Brandon Thomas 0c04e194f8 Merge pull request #450 from haranishi/fix-text-tab-width-cjk
Text panel: tab widths follow the drawn label (Japanese tabs overlapped)
2026-10-10 05:02:41 -04:00
Brandon Thomas fc1b9e2930 Merge pull request #488 from ReReddo/fix/i18n-source-scan-crlf
i18n: the tl! source scan in tests passes on CRLF checkouts
2026-10-10 05:02:35 -04:00
Brandon Thomas c83197e4a3 Merge pull request #516 from chuygarcia92/make-missing-clip-list-scrollable
Implement scrollable areas for media dialog
2026-10-10 05:02:30 -04:00
Brandon Thomas 4d3fe1bdb0 Merge pull request #506 from liangforstudy/fix/save-playhead
Project files: each sequence reopens with its playhead where it was left
2026-10-10 05:02:24 -04:00
Brandon Thomas 8e908d0550 Merge pull request #521 from maccagnit/newshape-placement-docs
graphics: shape commands document `seconds` / `track` / `time`, and placement errors name the command that asked
2026-10-10 05:02:17 -04:00
Brandon Thomas a3d2399ae9 Merge pull request #518 from Guilhermeb-Ferrarezi/ui/color-param-eyedropper
ui: add an eyedropper to colour parameters (Ultra Key, Color Key...)
2026-10-10 05:02:10 -04:00
Brandon Thomas c76d7931ca Merge pull request #514 from vbasky/feature/graphic-gradient
Fill graphic shapes and text with a linear gradient
2026-10-10 05:02:04 -04:00
Brandon Thomas f8464f1c96 Merge pull request #511 from liangforstudy/fix/484-enable-flips-each-clip
#484: Enable flips each selected clip on its own
2026-10-10 05:01:58 -04:00
Brandon Thomas 51cfa83ec7 Merge pull request #576 from ReReddo/feat/timecode-scrub-edit
timeline, monitors: drag the current-time timecode to scrub, click to type a time
2026-10-10 05:01:52 -04:00
Brandon Thomas 9f98495728 Merge pull request #487 from DWAA1660/fix/482-transition-drop-preview
Preview transition placement while dragging onto timeline clips (#482)
2026-10-10 05:01:45 -04:00
Brandon Thomas 339198fbdb Merge pull request #472 from ossini/fix/223-audio-in-video-clips
Audio of video clips: volume in Effect Controls + draggable volume line (#223)
2026-10-10 05:01:38 -04:00
Brandon Thomas ef37ad3b40 Merge pull request #275 from carlitosteso3/feat/audio-scrubbing-211
Audio plays while scrubbing (#211)
2026-10-10 05:01:32 -04:00
Brandon Thomas 9a3692f239 Merge pull request #257 from MrCloudy2/feat/edit-point-menu-219
#219: right-clicking a cut opens the edit point menu
2026-10-10 05:01:25 -04:00
Brandon Thomas fed3820692 Merge pull request #494 from sanyamsingh001/codex/time-current-main
Keep frame navigation precise and time arithmetic bounded
2026-10-10 05:01:18 -04:00
Brandon Thomas 751857d715 Merge pull request #464 from MarekSpodBiedry/issue-459
MCP render_frame: max_side 0 means no limit (#459)
2026-10-10 05:01:12 -04:00
Brandon Thomas 8e2df153f4 Merge pull request #556 from LloydNicholson/fix/caption-list-track
Issue #545: captions.list with a track that names no caption track is an error
2026-10-10 05:01:06 -04:00
Brandon Thomas 913b45cb19 Merge pull request #557 from maxm11/issue-536-bin-export
fix(export): Selection as Project exports a selected bin's contents (#536)
2026-10-10 05:00:59 -04:00
Brandon Thomas 2ded373cdb Merge pull request #453 from MrCloudy2/fix/hybrid-replay-keeps-pictures
#447: HybridDecoder fallback replay goes on past samples the software decoder rejects
2026-10-10 05:00:48 -04:00
Brandon Thomas ad0e0ef5d4 Merge pull request #454 from vbasky/feature/marker-csv-export
Export sequence markers to a spreadsheet-safe CSV
2026-10-10 05:00:42 -04:00
Brandon Thomas 204cc16754 Merge pull request #497 from digitaldecaycreations-lgtm/fix/windows-verbatim-pathurl
interchange: fix file:// URLs for Windows verbatim paths (FCP7 XML media offline in Premiere)
2026-10-10 05:00:37 -04:00
Brandon Thomas 629d554e0d Merge pull request #467 from sanyamsingh001/codex/audio-stream-essence
Keep selected audio streams distinct in AAF/OMF exports
2026-10-10 05:00:30 -04:00
Brandon Thomas acf4ca06f7 Merge pull request #419 from txiki739/fix/aaf-metadictionary
#323: AAF files open in readers that load the meta-dictionary
2026-10-10 05:00:24 -04:00
Brandon ThomasandClaude Opus 5.5 0423916667 Marker CSV export test: use egui_kittest's wgpu() builder (with_wgpu_render does not exist in our version)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:51:01 -04:00
Brandon Thomas d92f6b516d Merge remote-tracking branch 'origin/main' into fix-454 2026-10-10 04:51:01 -04:00
Nigel Ihza Firdausy fb41e56195 fix(i18n): prioritize Japanese system font ahead of Chinese fallback (#547) 2026-10-10 15:47:24 +07:00
Brandon Thomas 45936c1d6c Merge pull request #575 from MarekSpodBiedry/issue-548
Slide: keep reversed neighbours' source ranges correct (#548)
2026-10-10 04:44:26 -04:00
Brandon Thomas d55d742026 Merge pull request #574 from MarekSpodBiedry/issue-546
Text panel: word navigation ignores a selection the transcript no longer has (#546)
2026-10-10 04:44:18 -04:00
Brandon Thomas fbb45f794b Merge pull request #573 from MarekSpodBiedry/issue-544
MCC export: end each ancillary packet with its checksum byte so FFmpeg reads the captions (#544)
2026-10-10 04:44:09 -04:00
Brandon Thomas 135f42163d Merge pull request #572 from MarekSpodBiedry/issue-543
masks.addVertex: clamp oversized index on animated paths, reject empty paths instead of panicking (#543)
2026-10-10 04:44:01 -04:00
Brandon Thomas 3db99e6817 Merge pull request #571 from MarekSpodBiedry/issue-541
Audio Gain: measure the peak of the audio stream each clip plays (#541)
2026-10-10 04:43:52 -04:00
Brandon Thomas 58925adc45 Merge pull request #570 from MarekSpodBiedry/issue-540
Parametric Equalizer: use the full EQ so master gain, bands 1/2/4/5 and pass filters reach the audio (#540)
2026-10-10 04:43:44 -04:00
Brandon Thomas 4509e6e217 Merge pull request #566 from MarekSpodBiedry/issue-535
Project Manager: keep every parent of a nested subclip when collecting (#535)
2026-10-10 04:43:35 -04:00
Brandon Thomas 4bb600ee36 Merge pull request #565 from MarekSpodBiedry/issue-534
Forced relink and Replace Footage keep the new file's properties (#534)
2026-10-10 04:43:26 -04:00
Brandon Thomas b5a0e1dd78 Merge pull request #564 from MarekSpodBiedry/issue-532
Queue move: saturate a huge relative offset instead of overflowing (#532)
2026-10-10 04:43:18 -04:00
Brandon Thomas 1dba0532a5 Merge pull request #563 from MarekSpodBiedry/issue-531
Export settings: accept camelCase for nested snake_case fields (#531)
2026-10-10 04:43:08 -04:00
Brandon Thomas 9e2ebd696e Merge pull request #562 from MarekSpodBiedry/issue-530
Frame-held Regular In trim stops at the previous clip instead of overlapping it (#530)
2026-10-10 04:42:59 -04:00
Brandon Thomas de030de569 Merge pull request #561 from MarekSpodBiedry/issue-529
Linked slip: clamp to one shared delta so picture and sound stay aligned (#529)
2026-10-10 04:42:51 -04:00
Brandon Thomas dd28b12f1f Merge pull request #560 from MarekSpodBiedry/issue-528
Trim: extend each selected edit point to the playhead by its own distance (#528)
2026-10-10 04:42:43 -04:00
Brandon Thomas 0e11974976 Merge pull request #559 from MarekSpodBiedry/issue-527
fix(edit): reversed trims use the media handle their playback direction consumes (#527)
2026-10-10 04:42:34 -04:00
Brandon Thomas 764723f439 Merge pull request #558 from MarekSpodBiedry/issue-526
Roll: reversed clips take the cut from the opposite source end (#526)
2026-10-10 04:42:27 -04:00
Brandon Thomas a790ca05ee Merge pull request #568 from MarekSpodBiedry/issue-538
CLI export: forward a lone --start or --end so the engine reports the missing bound (#538)
2026-10-10 04:42:20 -04:00
Brandon Thomas 8a32432f9b Merge pull request #567 from MarekSpodBiedry/issue-537
CLI: a closed stderr no longer panics with exit 101 (#537)
2026-10-10 04:42:12 -04:00
Ikki NishiharaandClaude Sonnet 5.5 c34090f2d2 CFB: bound the DIFAT and FAT chain walks by the file's own size
The DIFAT guard came from the header's sector count and the chain guard from
the FAT length, both of which a file can inflate. A chain cannot have more
sectors than the file holds: bound both walks by that.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:34:16 +09:00
Ikki NishiharaandClaude Sonnet 5.5 c33cf33fe0 AAF: a weak-reference collection with a zero key size can't exhaust memory
An index that declares a key size of 0 made the importer loop `count` times
(a u32 from the file) pushing an empty key each time. Reject a non-empty
collection with key size 0, and require that count * size fits in the index
stream before the loop starts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 17:29:44 +09:00
ReReddo ab9e4e6ceb timeline, monitors: drag the current-time timecode to scrub, click to type a time (#554)
The Timeline's timecode and the Program and Source monitors' current-time
timecodes were display-only. They now share one widget,
widgets::timecode_field, which replaces the unused timecode_label:

- Dragging scrubs that panel's playhead, 1 frame per point (Shift x10), and
  stops at the start (for the Source monitor, the clip's start). The pointer
  is hidden while dragging and put back where it was pressed.
- A click opens a text field with the whole value selected. Enter or clicking
  away goes to the typed time through parse_timecode, so `1000` and `+15`
  work. Escape cancels. An invalid time is reported in the status bar.
  Left/Right on the selected value start from its last digit.

The Timeline and Program monitor use playhead.set, the Source monitor
source.setPlayhead.

Tests: timecode_ui (5 tests). The ui-egui suite passes except the two
Windows CRLF failures fixed in #488.
2026-10-10 10:35:44 +03:00
Max Murphy 5b6e1ce720 fix(export): Selection as Project exports a selected bin's contents
Selecting a nonempty bin reported success with items:[bin] while writing
an empty project: closure() kept the bin id (which names no item) and
project_subset() then pruned everything. Expand a named non-root bin to
the items it holds, as project.delete does, so only real items are kept
and reported. An empty bin now returns an actionable error instead of a
misleading empty destination.
2026-10-10 03:19:38 -04:00
MarekSpodBiedry f684440ef5 Trim: extend each selected edit point to the playhead by its own distance (#528) 2026-10-10 09:16:50 +02:00
MarekSpodBiedry b40964db91 masks.addVertex: clamp oversized index on animated paths, reject empty paths instead of panicking (#543) 2026-10-10 09:16:34 +02:00
MarekSpodBiedry 8337c1656e Text panel: word navigation ignores a selection the transcript no longer has (#546) 2026-10-10 09:16:33 +02:00
MarekSpodBiedry 96ae4c6244 Export settings: accept camelCase for nested snake_case fields (#531) 2026-10-10 09:16:10 +02:00
MarekSpodBiedry d1b83b9c97 Linked slip: clamp to one shared delta so picture and sound stay aligned (#529) 2026-10-10 09:16:01 +02:00
MarekSpodBiedry 769ead74a8 Audio Gain: measure the peak of the audio stream each clip plays (#541) 2026-10-10 09:15:51 +02:00
MarekSpodBiedry 09d5473645 Forced relink and Replace Footage keep the new file's properties (#534) 2026-10-10 09:15:37 +02:00
MarekSpodBiedry 64007e93b6 CLI: a closed stderr no longer panics with exit 101 (#537) 2026-10-10 09:15:24 +02:00
MarekSpodBiedry 544f561587 MCC export: end each ancillary packet with its checksum byte so FFmpeg reads the captions (#544) 2026-10-10 09:15:03 +02:00
MarekSpodBiedry ffbe2622da CLI render: report the real reason for an invalid scale instead of no sequence (#539) 2026-10-10 09:14:50 +02:00
MarekSpodBiedry 948c791db8 Project Manager: keep every parent of a nested subclip when collecting (#535) 2026-10-10 09:14:47 +02:00
MarekSpodBiedry 09f0cf1cc7 Parametric Equalizer: use the full EQ so master gain, bands 1/2/4/5 and pass filters reach the audio (#540) 2026-10-10 09:14:46 +02:00
MarekSpodBiedry d23b33a9fe Frame-held Regular In trim stops at the previous clip instead of overlapping it (#530) 2026-10-10 09:14:41 +02:00
MarekSpodBiedry 7484343012 fix(edit): reversed trims use the media handle their playback direction consumes (#527) 2026-10-10 09:14:40 +02:00
MarekSpodBiedry 1ccb51fb78 Slide: keep reversed neighbours' source ranges correct (#548) 2026-10-10 09:14:35 +02:00
MarekSpodBiedry aff59f15ec Queue move: saturate a huge relative offset instead of overflowing (#532) 2026-10-10 09:14:21 +02:00
MarekSpodBiedry 0c66f5ffd3 Roll: reversed clips take the cut from the opposite source end (#526) 2026-10-10 09:14:03 +02:00
MarekSpodBiedry 8ee37150e4 CLI export: forward a lone --start or --end so the engine reports the missing bound (#538) 2026-10-10 09:13:54 +02:00
AustinandCodex 2c049fe893 ui-egui: fix Light theme chrome inconsistencies (#150)
Route audio controls, meters, effect graphs, timeline controls, caption lanes, and effect badges through theme tokens so Light mode no longer retains dark-only surfaces. Add palette regression coverage and a three-theme mixer harness test while preserving Dark and Medium colors.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-10 03:03:03 -04:00
lloydandClaude Opus 5.5 8175b16a47 captions: captions.list with a track that names no caption track is an error
`captions.list` resolved `track` with track_param and treated an
unresolved selector the same as an omitted one, so a missing numeric id
(or a label past the last track, such as `C3` with two tracks) returned
every caption track with success. It now fails with "no such caption
track", as captions.export, setTrack, setStyle, deleteTrack and add do.
Omitting `track` still lists every track, and valid ids and C-labels
still return just their track.

Test: two caption tracks; each id and each label returns only its own
track; a missing id, `C3`, `C0` and a non-label string are errors.

Fixes #545

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:02:57 +02:00
hadmark 6732ad9c94 Add files via upload 2026-10-10 14:32:49 +08:00
hadmark f067b709de Add Simplified Chinese language support
Add Simplified Chinese to language menu
2026-10-10 14:17:39 +08:00
SilentYetiandClaude Opus 5.5 03a840d28b Linux: NVDEC hardware decoding for H.264 and HEVC
Adds a second Linux decoder backend, crates/platform/src/nvdec, that talks to
NVIDIA's driver directly (libnvcuvid + the CUDA driver, both loaded at run
time). It is registered in front of VA-API: what NVDEC declines still reaches
VA-API, then the software decoders. H.264 8-bit and HEVC 8/10-bit, 4:2:0
progressive.

NVIDIA's parser takes Annex B access units, so there is no host-side DPB.
Pictures are read back as NV12 / P016 through the existing biplanar path and
are bit-exact with the software decoders. The CUDA context is the one
nvenc::device already retains; it gains push/pop and copy-to-host. A seek
recreates the parser and keeps the decoder.

Also fixes tests/setting.rs, which expected the Media Foundation backend name
on Linux, and updates the Hardware decoding tooltip and its Spanish row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:17:11 -07:00
hadmark 9679157fdd Add Simplified Chinese language support
Register Chinese language
2026-10-10 14:14:17 +08:00
hadmark c17013fca9 Add files via upload
Add Chinese translation catalog
2026-10-10 14:08:41 +08:00
SawizzleandClaude Opus 5.5 918a64317d docs: drop the third-party link; the Windows path is the default install folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:50:49 -07:00
Tommaso MaccagniandClaude Opus 5.5 28cde3168d graphics: document shape placement params; placement errors name their command
graphics.newShape, newRectangle, newEllipse and newPolygon already take
`seconds`, `track` and `time` through place_video_clip, as graphics.newText
does. Their parameter docs (what command_list shows) and the table in
docs/graphics.md listed only position, size and clip; they now list all of
them and say that position is the shape's centre.

place_video_clip takes the id of the command that called it for its errors.
It named graphics.newText in every placement error (V3 is not free here),
whether a shape, a file, a template or an upgraded caption had asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:34:24 +02:00
Tommaso MaccagniandClaude Opus 5.5 ecfa560ed3 engine: effects.setParam takes a choice by name and can write a keyframe
A choice parameter takes its option name as well as its index, matched
without case, as transition parameters and graphics.set already do.

"keyframe": true writes a keyframe at `time` and animates the parameter (the
stopwatch and the value in one step, like clipMixer.set). Without it nothing
changes: a parameter that is not animated gets a static value whatever the
time. A parameter that cannot be animated refuses the keyframe.

The result is {"keyframes": n} instead of null, so a caller can tell that a
value sent with a time became a static value (0) and not a keyframe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:32:32 +02:00
MitchandClaude Sonnet 5.5 88c9ec1701 #171: preview where dragged files will land on the Timeline
While files are dragged over the window from the OS, the Timeline shows a
placeholder at the snapped drop point on the row under the cursor, labelled
with the file count. The cursor is polled each frame because the OS sends no
motion events during such a drag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 14:25:58 +11:00
MitchandClaude Sonnet 5.5 b630643ec9 #171: read the OS cursor for drops from the file manager
winit on Windows sends no cursor events while files are dragged in, so the
pointer was stale and the drop was never placed on the timeline. The desktop
app now passes a host hook that reads the cursor from the OS (GetCursorPos),
which the UI maps into window points.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 14:21:49 +11:00
MitchandClaude Sonnet 5.5 a0708ff243 #171: files dropped onto the Timeline are placed there, not just imported
Dropping OS files on the Timeline panel now imports them and places them
(overwrite) at the pointer, snapped, chained end to end. Controlled by
Settings > Timeline > "Place files dropped onto the Timeline directly on
the Timeline" (timeline.dropImportToTimeline, default on). Drops elsewhere
stay a plain bin import.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 14:14:19 +11:00
Guilhermeb-Ferrarezi 3180e3d2f2 ui: format the eyedropper 2026-10-09 23:48:16 -03:00
Guilhermeb-Ferrarezi c2bde2e6fd ui: add an eyedropper to colour parameters
Colour parameters in Effect Controls (the Ultra Key key colour, Color Key, mattes...) get a pipette
button. Armed, a click in the Program monitor fills the parameter with the colour under the pointer
(5x5 average, so a noisy green screen gives its true green); Esc disarms.

The colour is read from a render of the sequence at the playhead with the armed effect switched off,
so an Ultra Key that already removed the green can still pick it. The value is stored the way the
colour picker beside it stores it, through effects.setParam (one undo step).
2026-10-09 23:43:20 -03:00
Krzysztof Cieślik b6c604c104 clip.link: pair video clips with overlapping audio clips (#507)
When linking a selection containing multiple video clips, create one link
per video clip with its overlapping audio clips rather than a single link
for all clips, matching Premiere Pro behavior.
2026-10-10 04:21:06 +02:00
Chuy Garcia f2c42f9864 Implement scrollable areas for media dialog
Added vertical scroll areas for media rows and candidates to improve UI usability.
2026-10-09 20:20:17 -06:00
Vikram Bhaskaran ac83bae223 Blend graphic gradients in linear light and keep per-character fills solid.
Stops were mixed in encoded sRGB, and any run with its own fill turned the whole layer into a solid colour. The ramp now blends in linear light, turns with the layer, and leaves a per-character fill solid.
2026-10-10 13:18:45 +11:00
KitsuneSemCalda 0bc4795083 Add OmaStore manifest 2026-10-09 22:50:29 -03:00
liangforstudyandClaude Opus 5.5 fcaa6bbdf4 #484: Enable flips each selected clip on its own
With one enabled and one disabled clip selected, Clip > Enable (Shift+E) set every
selected clip to the same state: the first press enabled both, the next disabled both.
Premiere flips each clip separately (confirmed by the reporter in #484), so a mixed
selection swaps. Each clip in the selection (with its linked clips) now flips on its
own; a clip named twice in the parameters still flips once.

Test: enable_flips_each_selected_clip (fails before the fix: the mixed press left
both clips enabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qq8BK1LmohzE1ga4NgAdWM
2026-10-10 09:23:38 +08:00
Vikram Bhaskaran d55ebe94e5 Fill graphic shapes and text with a linear gradient.
Appearance › Fill Type paints a ramp across the layer bounds, and the ramp turns with the layer. A per-character fill colour still paints those characters as a solid.
2026-10-10 12:05:03 +11:00
Brandon ThomasandClaude Opus 5.5 7bd7621262 Bump version to 0.5.0 (from 0.4.0) for release; refresh contributors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:11 -04:00
Mara Johnson 71dd56ff8f fix(mcp): media_import schema mirrors file.import (#499) 2026-10-09 20:47:33 -04:00
swillunderscoreandClaude Opus 5.5 f61669d208 #224: dragging a transition's end trims that end only
Adobe's Change transition duration page shows the Trim-In / Trim-Out icon on a transition's end
in the Timeline: the drag trims that end, and the other stays. A centred transition dragged by one
end therefore becomes Custom Start, rather than growing on both sides. The alignment rule ("how
the transition changes length depends on the alignment") stays for typed durations: Effect
Controls' Duration and Set Transition Duration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:20:25 -05:00
Luniiya 77fa159f49 ui: standardise system appearance modes and theme previews 2026-10-10 02:14:55 +02:00
Dylan M. Taylor 75559a8f41 chore: bump github actions to fix Node 20 deprecation warnings 2026-10-09 20:08:27 -04:00
SawizzleandClaude Opus 5.5 1286f135f4 docs: register the MCP server from an installed release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:06:15 -07:00
swillunderscoreandClaude Opus 5.5 efd62f7bda #430, #224: select a transition, edit it in Effect Controls, Delete it, drag its ends or middle
Timeline: clicking a transition selects it (outlined; Shift adds / removes) and opens it in Effect
Controls; Delete removes it. Its ends are edge zones: dragging one changes the duration (a Center
at Cut transition grows on both sides and stays centred, any other keeps its other end), dragging
its middle slides it over the cut (Custom Start); each drag is previewed and committed as one
sequence.setTransition. Double-clicking it opens Set Transition Duration.

Effect Controls for a selected transition: Duration (timecode), Alignment (Center at Cut, Start
at Cut, End at Cut; Custom Start shown when dragged off-centre), Reverse, its own settings
(numbers, angles, choices, colours, points, checkboxes), Reset. Dragging a value is one undo step.

Tests: transitions_ui.rs drives it all over the control channel (select, Effect Controls, the Delete
key, undo, edge and middle drags, the Alignment menu, the dialog, Push's settings), with
FILMCRAFT_UI_SNAPSHOT_DIR snapshots. Spanish for the two new strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:04:21 -05:00
digitaldecaycreations-lgtmandClaude Opus 5.5 5e87784af8 interchange: strip Windows verbatim prefix in file URLs
`path_to_file_url` treated `\\?\C:\x` (the form `canonicalize` returns on
Windows) as a UNC share named `?`, so FCP7 XML exports on Windows wrote
`file://%3F/C:/...` and Premiere opened the media offline. Drop the
`\\?\` prefix (and map `\\?\UNC\server\share` to `//server/share`) before
building the URL. Verified: the exported XML now imports into Premiere
Pro 2026 on Windows with all media online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:20:58 -07:00
sanyamsingh001andCodex a3ad7a8ce7 TIME.FRAME_NAVIGATION: Preserve exact frame steps and bound time arithmetic
Co-authored-by: Codex <codex@openai.com>
2026-10-10 09:00:34 +10:00
swillunderscoreandClaude Opus 5.5 d4d1830d99 #430: transitions can be selected, removed, resized and moved (engine)
- filmcraft_edit::transitions: where an applied transition may sit (over its cut, within the
  clips it joins; a fade to or from nothing keeps its clip edge), its alignment as Effect
  Controls shows it (Center at Cut / Start at Cut / End at Cut / Custom Start), set_span, remove,
  and drag_span for the Timeline (an end changes the duration, the middle slides it).
- EditorState::transition_selection; timeline.select {"transitions": [...]} replaces the clip,
  edit point and caption selections, and a clip selection clears it.
- edit.clear (Delete) removes the selected transitions when no clips are selected;
  sequence.removeTransition removes given or selected ones; Ripple Delete stays clip-only.
- sequence.setTransition also takes duration / frames (following the alignment: Center at Cut and
  Custom Start move both ends, Start at Cut the end, End at Cut the beginning), align, start, and
  merge / begin so a drag is one undo step. Out-of-range times are refused before any frame
  arithmetic, atomically.
- sequence inspection reports each transition's alignment and the transition selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:59:58 -05:00
MrCloudy2andClaude Opus 5.5 e90508f16f #288: MP4 / MOV pictures are cropped as the file says (H.264 / HEVC cropping, clean aperture)
The MP4 source reported the sample entry's size, only swapped for the display rotation, so two
crops were lost: the codec's own (H.264 SPS frame cropping, HEVC conformance window), which the
decoder applies but the reported size and sequences made from the clip didn't, and the clean
aperture (`clap`), parsed but never used. An iPhone clip (1920x1440 coded, 1744x1308 clean
aperture, -90 degree display matrix) showed 1440x1920 instead of 1308x1744.

H.264 / HEVC sizes now come from the stream's parameter sets; the clean aperture is a rectangle
inside that picture (ignored when it makes no sense), reported as the size and cut from every
frame by the GOP cache before the display rotation, through the new VideoFrame::cropped.

Tests: bit-exact against ffmpeg (which applies both crops) for an H.264 SPS crop, an HEVC
conformance window, centred and off-centre clean apertures and a clean aperture with a rotation;
clean apertures that make no sense are ignored without a panic; VideoFrame::cropped on every pixel
format with odd sizes and misaligned corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:50:03 +02:00
DWAA1660 87a01072d0 #482: Preview transition placement while dragging 2026-10-09 16:01:08 -06:00
user.email 5b3d2c2a0b linux: cargo fmt the dev-icon BadExecPath arm
The to_str() refinement landed unformatted: rustfmt wants the long
error arm braced.

🤖 Generated with [Kimi Code](https://github.com/storyteller/kimi-code)
Co-Authored-By: Kimi <noreply@storyteller.dev>
2026-10-10 01:00:30 +03:00
MasakatsuFunakiandClaude 5001d6bf29 M-HW: NVENC takes the export's RGBA and converts it on the GPU (#30)
8-bit NVENC sessions (H.265 Main, hardware H.264) now take the export's RGBA
frames as ABGR input buffers and NVENC converts them to BT.709 limited 4:2:0
on the GPU, with the same codes as rgba_to_yuv420_8. The CPU conversion ran
on the encode thread, where it competed with the render of the next frames
for the thread pool (about 13 ms per 1080p frame instead of 1.5 ms) and held
NVENC back. A driver that refuses RGB input falls back to the CPU conversion
into NV12. HEVC Main 10 is unchanged.

60 s of a 1080p30 project as H.265 in the app: 18.7 s -> 15.0 s (96 -> 120
fps); headless 30.3 s -> 16.8 s.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 23:49:24 +02:00
user.email 4955515bcd linux: dev desktop entry only for source-tree runs; both Exec layers; exact Exec match
Review feedback on #246:

- Gate the install to dev runs: a debug build, or a release run with
  FILMCRAFT_DEV_DESKTOP_ENTRY set (the documented cargo run --release flow);
  never when APPIMAGE or FLATPAK_ID is set — an AppImage mounts at a fresh
  /tmp/.mount_* each launch, so its Exec path changes every time, and packaged
  installs ship their own entry and must not write into the profile.
- Escape Exec for both desktop-entry layers exactly like
  packaging/linux/install.sh: shell layer first (` " $ \ get a backslash),
  then the file-value layer (every backslash doubled), always double-quoted.
  Binary paths with =, % or control characters are rejected instead of written
  (install.sh rejects them for the same reason).
- Compare the Exec path exactly: parse the first Exec argument through both
  layers instead of a substring contains, so /a/filmcraft-old no longer
  counts as ours and a stale entry is rewritten.
- eprintln -> log::warn for the skip message (the logging module landed meanwhile).

🤖 Generated with [Kimi Code](https://github.com/storyteller/kimi-code)
Co-Authored-By: Kimi <noreply@storyteller.dev>
2026-10-10 00:41:44 +03:00
ReReddo 83da21acd1 contributors: credit ReReddo
Add ReReddo's own entry to contributors/people.toml, display name only, as
AGENTS.md asks of contributors. The name is shown once the maintainers'
contributors.py run verifies it.
2026-10-10 00:34:26 +03:00
ReReddo 63fe547f70 i18n: the tl! source scan in tests passes on CRLF checkouts
spanish_translates_every_tl_literal cuts each source file at
"#[cfg(test)]\nmod tests". On a Windows checkout with core.autocrlf=true the
files have CRLF, so the cut never matched. The scan then read the test module,
including its own assertion text, and failed with "use a plain string literal
in tl! and tlf!".

Normalise CRLF to LF when reading the sources. Checkouts with LF endings
(Linux, macOS, CI) are unaffected.

Tests: cargo test -p filmcraft-ui-egui --lib passes (71 tests). The test failed
before this change on a Windows checkout.
2026-10-10 00:31:41 +03:00
ReReddo 73f2727ee4 appearance: View > Appearance names the middle theme "Dark", as Settings does (#471)
View > Appearance called the middle color theme "Medium" while Settings >
Appearance > Color Theme called the same choice "Dark". Use the Settings
name, which is also the stored value and the name in docs/project-files.md.

Only the menu label changes. Command ids (view.theme.dark/medium/light) and
stored values (darkest/dark/light) are as before. In pt-br.tsv the "Medium"
entry becomes "Dark" (Escuro), because that catalog may only hold menu labels.

Adds a settings_ui test that each View > Appearance label equals the matching
Settings dropdown label.

Tests: settings_ui and the i18n tests pass, except
i18n::tests::spanish_translates_every_tl_literal. It fails on Windows
checkouts with core.autocrlf=true (the source scan looks for "\n") and is
unrelated to this change.
2026-10-10 00:30:29 +03:00
liangforstudyandClaude Opus 5.5 72e3eeb5de Project files: each sequence reopens with its playhead where it was left
The engine already keeps one playhead per sequence while a project is open, and the
project file's view already stores each sequence's zoom, scroll and track heights, but
not its playhead: after saving and reopening, every sequence started at zero.

ProjectView gains `playheads` (sequence id -> Tick), written beside the tabs and views
and restored with them when "Restore open sequences when opening projects" is on. Files
written before it have none and open as they did. Values from a file are not trusted:
ids that are not sequences are dropped, playheads are clamped to 0..ProjectView::MAX_PLAYHEAD
and snapped to a frame of their sequence.

Tests: each_sequence_reopens_with_its_playhead_where_it_was_left (fails without the
fix), hostile playheads in a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored,
a deleted sequence's playhead is not written, and the format round trip carries it.

Also adds my contributor credit entry (people.toml).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qq8BK1LmohzE1ga4NgAdWM
2026-10-10 05:22:05 +08:00
MrCloudy2andClaude Opus 5.5 30ff11e669 #474: VA-API decoders share one display instead of opening one each
Every VA-API session opened its own display and terminated it when dropped. With NVIDIA's driver
that is a CUDA context each: 0.2-0.3 s to open, 60-70 ms to terminate and about 100 MB of video
memory, on the thread creating or dropping the decoder (a frame worker at every clip change, the
UI thread when media is removed: a ~0.6 s freeze deleting five played clips, and dropped frames at
clip changes).

Sessions now take the process's shared display (the startup probe's, kept for the life of the
process like the libraries) and make only their own configuration, context and surfaces. Display
is Sync on the strength of libva's Multithreading Guide (va.h): its functions and drivers are
thread-safe, and every session uses only its own VA objects. A session that cannot be made on the
shared display is tried once on a new display, which is shared from then on if it works (a GPU
reset).

On NVIDIA (RTX 3060): deleting five played clips 650-690 ms -> 132-198 ms, dropped frames in 20 s
of playback 2-3 -> 0, peak video memory 1281 -> 1010 MiB. Intel and software unchanged.

Test: tests/vaapi_displays.rs (decoders one after another and six at once on six threads open no
display; pictures bit-exact). The VA-API hardware suite passes on NVIDIA and Intel, and the
concurrency tests 20 times each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:58:47 +02:00
sanyamsingh001andCodex 23f79890fd UI.SCALAR_PRESS_ORIGIN: Start scalar graph drags at the press position
Co-authored-by: Codex <codex@openai.com>
2026-10-10 06:31:59 +10:00
Oskar e9baec0154 #223: audio waveforms follow the clip volume
Like Premiere, the waveform in an audio clip now grows and shrinks with
its Volume level, keyframes included, so dragging the volume line down
visibly lowers the waveform. A bypassed or disabled Volume leaves it as
it was.
2026-10-09 22:28:41 +02:00
Oskar cf694f86b3 #223: volume tooltip keeps one width while dragging
The dB readout next to the pointer resized itself on every value change
and sometimes wrapped into a column of single characters. It is now one
line in a monospace font with a fixed number format, so the box stays
put.
2026-10-09 22:18:03 +02:00
user.email d8ef953f16 Merge branch 'main' into fix/linux-dev-desktop-icon 2026-10-09 23:08:04 +03:00
sanyamsingh001andCodex dd121aeb9c AUDIO.STREAM_ESSENCE: Preserve selected audio streams in AAF/OMF exports
Co-authored-by: Codex <codex@openai.com>
2026-10-10 05:53:14 +10:00
Oskar d6e43bcf2c docs: volume grab kind for ui.timeline.hit, clip_audio_ui tests (#223) 2026-10-09 21:40:02 +02:00
Oskar b4b4ea6317 #223: drag the clip volume line and its keyframes in the timeline
With the Selection or Pen tool the line on audio clips is now a grab
target of its own (Grab::Volume): dragging it up or down changes the
clip's Volume level live, as one undo step. With keyframes it moves the
two keyframes around the pointer, like Premiere. The Pen tool or a
Cmd/Ctrl-click adds a keyframe on the line; keyframes drag in time and
level and right-click deletes them.

A plain click on the line still selects the clip and a right-click
still opens the clip menu, since presses go through the timeline's own
hit-testing.
2026-10-09 21:39:41 +02:00
Oskar 44c462d43d #223: clip volume line follows its keyframes
The white line on audio clips was drawn at the level of the first frame
only, squeezed above the waveform, and not at all with waveforms off.
It now spans the clip under the name on the track fader scale, bends
with Volume keyframes and shows them as diamonds. Tracks showing a
track keyframe lane hide it.
2026-10-09 21:12:48 +02:00
MarekSpodBiedry c25cc5b18b FCP7 XML: keep the source channel of split mono audio tracks (#463) 2026-10-09 21:02:47 +02:00
MarekSpodBiedry 684c0a0d2f MCP render_frame: max_side 0 means no limit (#459) 2026-10-09 20:55:36 +02:00
Oskar 5fbc5cf8e5 #223: keyframe commands can merge a drag into one undo step
effects.setKeyframe and effects.moveKeyframe take merge/begin like
effects.setParam does, and moveKeyframe can set the value on the way
(same as mixer.moveKeyframe). The timeline volume line needs this to
update keyframes live while dragging without flooding the undo history.
2026-10-09 20:47:28 +02:00
Oskar 01a8eb90a6 #223: Properties panel shows the audio of a linked video clip
Same as Effect Controls: with a video clip and its linked audio selected
the Audio section (Level, Pan) now edits the audio clip instead of being
hidden behind Transform and Crop.
2026-10-09 20:44:33 +02:00
Oskar 7a922ab139 #223: Effect Controls shows the audio of a linked video clip
Selecting a video clip with linked selection on also selects its audio,
but the panel only listed the video effects. Now the linked audio clip's
Volume, Channel Volume and Panner follow under an Audio heading, like in
Premiere.
2026-10-09 20:43:22 +02:00
Vikram Bhaskaran c855c50ba9 Export sequence markers to a spreadsheet-safe CSV.
The report is sorted, uses the sequence timecode, escapes formula-like text, and does not change the edit.
2026-10-10 04:37:44 +11:00
MrCloudy2andClaude Opus 5.5 c724bc5566 #447: HybridDecoder fallback replay goes on past samples the software decoder rejects
After a hardware decoder failed, the replay of the run through the software decoder stopped at the
first sample the software decoder rejected (`?` in the replay loop): the pictures it had already
output were dropped, the rest of the run was never fed, the error was returned for the failed
sample even when the software decoder accepted it, and the replay log was not restored. That
happens when the GPU tolerated a damaged sample our decoder rejects and then fails later in the
same run; one picture went missing on a damaged HEVC file through VA-API (Intel).

The replay now behaves like software decoding from the restart point: a rejected sample is skipped
(logged when the hardware had already decoded it); if the failed sample itself is rejected its
error is this call's, and the pictures replayed before it are kept in `carry` for the next output.

Regression test (every platform, no GPU): a stand-in hardware decoder that decodes one damaged
sample the software decoder rejects and fails at that sample or later; every software picture
comes out once and in order, errors are reported where the software decoder reports them, and the
pictures from the next restart point are the software decoder's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:36:54 +02:00
Ikki NishiharaandClaude Sonnet 5.5 a123da6d0d Text panel: tab widths follow the drawn label
The Transcript / Captions / Graphics tabs were laid out at 7 px per
character. That suits Latin text, but Japanese glyphs are about twice as
wide, so with the interface in Japanese the labels ran into each other.

A tab is now as wide as its label is drawn when that is wider than the
old estimate, measured in the semibold the active tab uses so selecting a
tab does not move its neighbours. Latin labels keep their old widths.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 02:19:01 +09:00
Ikki NishiharaandClaude Sonnet 5.5 cf614c1fa3 i18n: Japanese for the whole interface
ja.tsv now has the same 3,466 entries as es.tsv (menus, panels, dialogs,
the Settings pages, the Effects registry, Keyboard Shortcuts, tooltips and
status messages) instead of 89 menu-only entries.

The 46 old rows that came from PhotoCraft/PDFCraft and match no FilmCraft
string are dropped. Five rows that were wrong for a video editor are
fixed: Horizontal/Vertical are 水平/垂直 (every use is geometry, not
writing direction), Orientation is 向き, Type is 種類 and Effect is
エフェクト. The other 38 rows that were already in use are unchanged.

ROADMAP.md and crates/ui-egui/README.md now list Japanese next to Spanish
as covering the whole interface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 02:14:19 +09:00
lloydandClaude Opus 5.5 ff8728af08 Anamorphic media: pixel aspect ratio in placement, monitors and New Sequence From Clip
A 1440x1080 clip with 4:3 pixels showed squeezed: the demuxers read its
pixel aspect ratio, but nothing after import used it. New Sequence From
Clip made a 1440x1080 sequence with square pixels, the Motion transform
sized clips from storage pixels (pillarboxed at 1440 wide in a 1920x1080
sequence), and the monitors drew the frame at its storage aspect.

- project: checked_par / sane_par / par_ratio / conformed_size treat
  file and project ratios as hostile (a zero term, or more than 8:1
  either way, is square). MediaClip::pixel_aspect (Interpret Footage
  wins over the file), Project::source_par (subclips follow the parent,
  nested sequences have their own, graphics and adjustment layers have
  none) and Project::conformed_source_size.
- render: motion_matrix takes the source's ratio and works in square
  display units (anchor in source pixels, position in sequence pixels,
  scale and rotation at the display aspect); Scale to Frame Size fits
  the display size. CPU, GPU plan, adjustment regions and the mask /
  graphics overlays share it.
- engine: New Sequence From Clip, New Sequence from an item and
  multicam sequences copy the clip's ratio; default media scaling and
  Fit / Fill frame use the display size; generators made for a
  non-square sequence get its pixels; transcodes render pixel for
  pixel. clip.interpretFootage takes pixelAspect ([num, den] or "file").
- ui: the Source and Program monitors draw the picture at its display
  aspect; overlays map both axes; Sequence Settings shows the display
  aspect (16:9 for 1440x1080 at 4:3).

Tests: render par_tests (fill, GPU plan parity, Scale to Frame Size,
non-square sequences, rotation, Interpret Footage, hostile ratios),
engine par_tests (New Sequence From Clip, overwrite into 1920x1080,
default scaling, Fit / Fill, Interpret Footage with undo and refused
values, hostile file ratios, generators, and ffmpeg-made MP4 and MKV
files), project unit tests, and the headless par_ui monitor test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:28:56 +02:00
MrCloudy2andClaude Opus 5.5 67ed539fbe #219: translate the edit point menu
The interface became translatable since this branch started. The edit point menu now draws its
labels (and automation labels) in the interface language like the clip menu: the trim types
through i18n::t, Apply Default Transitions and Join Through Edits with tl! (both already in
the Spanish catalog). Spanish entries for the five trim types use the catalog's existing
vocabulary (recorte de rizo, edición de rodillo, recortar), and the trim types are listed with
the clip menu's labels in the test that requires Spanish for every such name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:12:58 +02:00
MrCloudy2andClaude Opus 5.5 f80b6bb002 Merge origin/main into feat/edit-point-menu-219
Conflict in the timeline's right-click handling (crates/ui-egui/src/panels/timeline.rs):
#220 now remembers the right-clicked clip so Unlink keeps only it selected, and this branch
opens the edit point menu on a clip's edge. Both are kept: the clip is remembered on every
right-click, and an edge also stores the edit point menu's target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:12:49 +02:00
Cathy Shapiro 626df5ac86 WINCI: Repair upstream HEVC timing and CRLF translation validation
Delegate HEVC VUI timing to NVENC's configured frame rate instead of the
explicit timing pair reversed by the affected driver. Preserve existing
stream quality/timing assertions and cover integer and fractional rates.
Normalize line endings in the translation test's source scanner so it
excludes test modules in Windows checkouts as well as LF checkouts.

Assisted-by: Codex
2026-10-09 10:59:56 -05:00
lloydandClaude Opus 5.5 d7b7027f42 M8: MP4 and Matroska colour from the bitstream when the container has none
ffmpeg writes MP4 without a `colr` box unless asked (`+write_colr`), and
color_from only read `colr` (or vpcC / apvC), so every such file fell back
to BT.709 limited: an HEVC PQ file was detected as rec709 / hdr false, a
full-range H.264 file as limited range, and BT.601 SD with BT.709
primaries. Decoded frames were right (the decoders read the VUI); the
media info, HDR detection and Interpret Footage defaults were not.
Matroska without a `Colour` element had the same gap.

New `stream_color` reads the colour a codec configuration carries in its
bitstream with the decoders' own parsers: the VUI of the SPS in avcC /
hvcC and the color_config of the AV1 sequence header in av1C. Precedence:
`colr` (nclx / nclc) or Matroska `Colour`, then vpcC / apvC / the
bitstream, field by field (what the container leaves unspecified falls
through), then the existing size defaults (BT.601 matrix for SD, BT.709
otherwise). nclc has no range flag, so the bitstream's range now stands;
an ICC `colr` no longer hides the bitstream's code points. A truncated or
corrupt parameter set gives no colour and the defaults.

Tests build avcC / hvcC / av1C bit by bit: HEVC PQ → rec2100-pq, full-range
H.264 → Full, BT.601 SD (NTSC and PAL) primaries, unspecified VUI keeps the
defaults, colr wins, Matroska without Colour, and every truncation and
single-bit flip of each configuration under catch_unwind. An ffmpeg-oracle
test checks real x264 / x265 files against their `+write_colr` remux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:58:49 +02:00
carlitosteso3andClaude Opus 5.5 2d520d7a2f #211: audio plays while scrubbing
"Play audio while scrubbing in Source and Program Monitors" (audio.scrubAudio,
Shift+S) was saved and toggled but never read. Now, while the playhead moves
with a mouse button down and playback is stopped, an 80 ms grain of the
sequence's sound (4 ms fades at its ends) plays from the new position.

It is detected once per frame in the app loop, so the Timeline ruler, the
monitors' scrub bars and dragged timecodes all scrub audibly. One output
stream stays open for the whole scrub and each move only re-aims it; it closes
shortly after the button is released, when the setting is off, or when
playback starts and takes the device.

Rebased on main, where Source monitor playback (source_playback) arrived:
it owns the audio device while it plays too, so a scrub stream still
lingering when Source playback starts no longer stops its sound on release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:29:23 -05:00
David Campos 89474eaeb5 project: share sequences across edit snapshots (scripted.rs)
Add test for shortcut dispatch in a single input frame.
2026-10-09 19:27:55 +04:00
David Campos 510156ea01 project: share sequences across edit snapshots (fixtures.rs) 2026-10-09 19:27:15 +04:00
David Campos 101d2686ed project: share sequences across edit snapshots (clip_ops.rs) 2026-10-09 19:26:54 +04:00
David Campos e53ac7eff9 project: share sequences across edit snapshots (lib.rs) 2026-10-09 19:26:27 +04:00
David Campos 5b01f8720f project: share sequences across edit snapshots (sequence_extras.rs) 2026-10-09 19:26:06 +04:00
David Campos 5f6c57984a project: share sequences across edit snapshots (lib.rs) 2026-10-09 19:25:46 +04:00
David Campos 7a719fe411 project: share sequences across edit snapshots 2026-10-09 19:25:06 +04:00
Brandon Thomas 7b6c134472 Merge pull request #363 from jerrykimo/fix/chinese-ui-glyphs
Fix Chinese glyph rendering in native timeline and UI labels
2026-10-09 11:02:46 -04:00
Brandon ThomasandClaude Opus 5.5 12f2ea17d6 CJK fonts: Chinese faces after the Japanese ones, so Japanese keeps Japanese glyph forms
The two share code points; putting the Chinese faces first drew Japanese UI text's kanji
with Chinese shapes. Also clears the test's texture deltas (epaint 0.36 panics on
dropping them unapplied).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:02:38 -04:00
Brandon Thomas e9c09d243a Merge remote-tracking branch 'origin/main' into fix-363 2026-10-09 10:56:40 -04:00
Brandon Thomas cb03d34c15 Merge pull request #361 from wisegamesyt-droid/fix-multitrack-audio
feat(media, engine): implement multi-stream container audio support and schema v13
2026-10-09 10:56:24 -04:00
Brandon Thomas e06018e07b Merge remote-tracking branch 'origin/main' into fix-361 2026-10-09 10:53:43 -04:00
Brandon ThomasandClaude Opus 5.5 fc32a19bd1 Proxies with fewer audio streams keep the proxy picture, audio from the original
Rejecting the whole proxy lost proxy playback for every multi-track recording
(and video-only proxies of media with audio); only the audio needs the original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:53:42 -04:00
Brandon Thomas ef6391df90 Merge pull request #428 from storytold/fix-clippy-as-chunks-2
CI: gpu Push test uses as_chunks (clippy on Rust 1.99)
2026-10-09 10:38:46 -04:00
Brandon ThomasandClaude Opus 5.5 1f5ee123b2 CI: gpu Push test uses as_chunks (clippy chunks_exact_to_as_chunks on Rust 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:38:39 -04:00
Brandon Thomas d19e1b2783 Merge remote-tracking branch 'origin/main' into fix-361 2026-10-09 10:34:54 -04:00
Brandon Thomas ade68437d7 Merge pull request #269 from carlitosteso3/feat/new-sequence-dialog
New Sequence… opens the Sequence Settings dialog as New Sequence (stacked on #422)
2026-10-09 10:33:02 -04:00
Brandon ThomasandClaude Opus 5.5 3cc03f1447 Source playback: has_audio() for multi-stream MediaInfo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:22:48 -04:00
carlitosteso3andClaude Opus 5.5 f0e1cfcec5 New Sequence… opens the Sequence Settings dialog as New Sequence
File ▸ New ▸ Sequence… (Cmd+N) and the Project panel's New Item ▸ Sequence…
made a sequence with the default settings at once. They now open the
Sequence Settings dialog (#355) as New Sequence (`open_new`): the General
tab from the default settings, named like file.newSequence would name it,
and a Tracks tab with the number of video and audio tracks. OK runs
file.newSequence with them; drop-frame timecode and Maximum Render Quality,
which the command has no parameter for, follow with sequence.settings when
they aren't the defaults. Making a sequence from a clip, or by dropping one
on an empty timeline, still passes params and stays direct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:22:47 -05:00
carlitosteso3andClaude Opus 5.5 632420a01e Sequence Settings: Sequence Name on the General tab
Follow-up to #355, as suggested in #225: the dialog's General tab gets a
Sequence Name field, filled with the sequence's name when it opens. OK
sends `name` to `sequence.settings` (which already accepts it) only when it
changed, so renaming is part of the dialog's single undo step; a blank name
keeps the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:21:45 -05:00
Tom 1eaeac9b1f Windows: skip AV1 oracle tests when ffmpeg lacks libsvtav1/libdav1d 2026-10-09 15:19:08 +01:00
Brandon Thomas b32d1cd5ed Merge remote-tracking branch 'origin/main' into fix-361 2026-10-09 10:17:58 -04:00
Brandon ThomasandClaude Opus 5.5 850ac4342f CLI test: probe reports audio as audio_streams (schema v13 MediaInfo)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:17:40 -04:00
Brandon Thomas 9c43a0d02f Merge pull request #425 from storytold/pr/353-monitor-export-frame
M3.12: Export frames from Source and Program monitors (#353 by @cshapiro)
2026-10-09 10:14:15 -04:00
Brandon Thomas 81eb842e2e Merge remote-tracking branch 'origin/main' into fix-353 2026-10-09 10:13:59 -04:00
Brandon Thomas a880fa9154 Merge pull request #386 from storybored379/feat/nvenc-export
platform: enable existing NVENC H.264 export on Linux
2026-10-09 10:13:43 -04:00
Brandon Thomas 38f4255735 Merge remote-tracking branch 'origin/main' into fix-386 2026-10-09 10:13:01 -04:00
Brandon ThomasandClaude Opus 5.5 f267a941fb Merge origin/main into feat/nvenc-export
main gained NVENC H.265 Main / Main 10 (#328, #360), export stage timings
(#370) and VA-API decoding (#358). Resolution: main's NVENC session, FFI and
export factory, with this PR's changes ported onto them:
- ffi: the portable Guid (now also for the HEVC GUIDs), NV_ENC_DEVICE_TYPE_CUDA;
- session: device.rs (Direct3D 11 / retained CUDA primary context), the
  Linux libnvidia-encode.so.1 loader, Device::KIND;
- export: factory_with and its tests, the even-size check, the saturating
  max bitrate; H.265 through NVENC stays Windows-only (the factory declines
  it on Linux, where nothing probed or tested it);
- Cargo.toml: main's Linux libloading dependency covers both users;
- docs: README / ROADMAP / architecture now say NVENC H.264 runs on Windows
  and Linux; ADR and platform README keep both addenda / test rows;
- abi_tests: note why the MSVC-generated expectations hold on LP64 Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:12:46 -04:00
Brandon Thomas 5f4e2d7c99 Merge remote-tracking branch 'origin/main' into fix-353
# Conflicts:
#	crates/engine/src/keyboard.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/panels/monitor.rs
2026-10-09 10:11:15 -04:00
Brandon Thomas ab06c110d1 Merge pull request #423 from storytold/pr/338-source-playback
M3.12: Source playback, clip dragging and monitor range editing (#338 by @cshapiro)
2026-10-09 10:10:34 -04:00
Brandon Thomas 560355f3d2 Merge remote-tracking branch 'origin/main' into fix-338 2026-10-09 10:09:57 -04:00
Brandon Thomas 666e891538 Merge pull request #370 from SaymonMaster14/m8/export-stage-timings
Export: stage timings in perf.stats, and render the next batch while encoding
2026-10-09 10:08:49 -04:00
Brandon ThomasandClaude Opus 5.5 9526ba50e3 M3.12: Spanish strings for Source playback controls
#238's i18n landed on main; its catalog-coverage tests need Spanish for
the new Loop marked range checkbox and the Source play/stop command names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:42 -04:00
Brandon ThomasandClaude Opus 5.5 285e81b5da Merge origin/main into m8/export-stage-timings
NVENC export: keep main's Main 10 / Main dispatch and time both colour
conversions (rgbf_to_yuv420_10, rgba_to_yuv420_8) as Stage::Convert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:35 -04:00
Brandon ThomasandClaude Opus 5.5 bb4c6d0bb2 Format: repair out-of-range audio stream data on load instead of refusing the project
A clip with audio_stream >= MAX_AUDIO_STREAMS is reset to stream 0 and media
with more than MAX_AUDIO_STREAMS streams keeps the first ones, each with a
warning: one bad index must never lose a whole project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:10 -04:00
Brandon ThomasandClaude Opus 5.5 701d7c7111 Merge origin/main into multi-stream audio
Keeps #359's contiguous_starts audio timing, now per stream in Mp4Audio /
MkvAudio (starts and preroll in source sample frames). MP4 audio streams and
their decoders are built from the same (track, info) pairs so they stay
index-aligned. Adapts new main code to audio_streams / TrackItem::audio_stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:09 -04:00
txiki739andClaude Opus 5.5 ccaa7212c1 #323: AAF files open in readers that load the meta-dictionary
The Root's MetaDictionary was written with no properties. pyaaf2 loads
its ClassDefinitions and TypeDefinitions sets to merge them with its
built-in model and refused every file: "cannot find index stream:
ClassDefinitions-3 index".

The MetaDictionary now has both sets (pids 0x0003, 0x0004, keyed by the
definition AUID 0x0005), empty: they only carry extensions to the
baseline model and everything FilmCraft writes is baseline. An empty
strong-reference set also writes its real key size (16 for AUIDs, 32
for MobIDs) instead of 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:06:49 +02:00
Brandon Thomas e196de8f64 Merge remote-tracking branch 'origin/main' into fix-338
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/commands.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/README.md
#	crates/ui-egui/src/panels/monitor.rs
2026-10-09 10:06:01 -04:00
Brandon Thomas 95230b9072 Merge pull request #360 from SaymonMaster14/m3/nvenc-hevc-main10
Windows: NVENC H.265 Main 10 HDR (PQ / HLG) export
2026-10-09 10:05:10 -04:00
Brandon ThomasandClaude Opus 5.5 c9e2bfcac0 Merge origin/main into m3/nvenc-hevc-main10
Export panel: the HEVC profile labels go through tl! with Spanish entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:04:58 -04:00
Brandon Thomas 95e585d989 Merge pull request #418 from storytold/pr/337-windows-validation
WINCI: Fix Windows paths and portable validation (#337 by @cshapiro)
2026-10-09 10:04:57 -04:00
Brandon Thomas d84fdc1268 Merge remote-tracking branch 'origin/main' into fix-337 2026-10-09 10:04:46 -04:00
Brandon Thomas 9a61c7610b Merge pull request #308 from WavJaby/perf/shared-transitions
perf(gpu): composite Push transitions on the shared GPU path
2026-10-09 10:04:24 -04:00
Brandon ThomasandClaude Opus 5.5 faa4bf953d Render plan: restore the detailed module doc and add the Push / PlanStep paragraph
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:03:38 -04:00
Brandon Thomas a6ac2d7eff Merge remote-tracking branch 'origin/main' into fix-308
# Conflicts:
#	crates/ui-egui/src/perf.rs
2026-10-09 10:01:25 -04:00
Brandon ThomasandClaude Opus 5.5 afd5a6b347 M3: NVENC Main 10 review fixes: saturating plane sizes, as_chunks for clippy
encode / encode_10 compute the plane sizes they check with saturating_mul;
fill_p010, rgbf_to_yuv420_10 and the HDR export test use as_chunks instead of
chunks_exact with a constant size (clippy::chunks_exact_to_as_chunks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:01:08 -04:00
Brandon Thomas 3931562101 Merge pull request #238 from txiki739/i18n-spanish-panels
i18n: Spanish for the whole interface, catalog files and a remembered language
2026-10-09 10:00:38 -04:00
Brandon ThomasandClaude Opus 5.5 a66be150e3 WINCI: ignore the mixer throughput benchmark only where it times wall clock
On Unix the benchmark measures the test thread's CPU time, so suite load
does not move it and it keeps running. Without a per-thread CPU clock
(Windows) it falls back to wall time, which is load-dependent and flaky on
shared CI runners; ignore it only there and document how to run it.
The GPU effect-source hunks were dropped in the merge in favour of #376's
fs_fx_source, now on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:59:45 -04:00
Brandon Thomas 156535a6e4 Merge origin/main into i18n-spanish-panels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	crates/codecs/tests/mpeg_oracle.rs
2026-10-09 09:59:28 -04:00
Brandon Thomas efa89a7d2d Merge remote-tracking branch 'origin/main' into fix-337
# Conflicts:
#	crates/gpu/src/fx_tests.rs
2026-10-09 09:59:26 -04:00
Brandon Thomas 4d3a814968 Merge pull request #376 from sanyamsingh001/codex/gpu-effect-source
GPU.EFFECT_SOURCE: preserve exact texel centers for effect inputs
2026-10-09 09:59:15 -04:00
Brandon Thomas ef46f64a28 Merge pull request #375 from WavJaby/perf/web-yuv-frames
perf(web): feed WebCodecs YUV planes to the shared compositor
2026-10-09 09:58:35 -04:00
Brandon Thomas 1dea07a85a Merge remote-tracking branch 'origin/main' into fix-376 2026-10-09 09:58:16 -04:00
Brandon Thomas 9c24f4419c Merge pull request #368 from sanyamsingh001/codex/linux-validation-baseline
LINUX.VALIDATION: honor preset shortcuts and retain every MPEG oracle timestamp
2026-10-09 09:58:10 -04:00
Brandon ThomasandClaude Opus 5.5 183884fd34 web: WebCodecs busy check honours STALE_MS; checked row offsets in plane()
A copyTo that never settles kept copying > 0 and bypassed the stale timeout,
wedging the session; now busy requires recent output. plane() computes each
row's start/end with checked arithmetic and bounds it by the validated end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:58:06 -04:00
Brandon Thomas 6893b19314 Merge remote-tracking branch 'origin/main' into fix-368
# Conflicts:
#	contributors/people.toml
2026-10-09 09:57:52 -04:00
Brandon ThomasandClaude Opus 5.5 de1320dc88 Merge origin/main into i18n-spanish-panels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:57:17 -04:00
Brandon ThomasandClaude Opus 5.5 82cb6fbc74 i18n: Spanish for strings added on main (Sequence Settings, keyframe navigator, export)
Translate the new Sequence Settings dialog (labels, sections, tabs,
values, hints), the shared keyframe navigator's tooltips, the
non-macOS tool and mask hints, Include Alpha Channel and the export
status. Update the hardware decoding description to main's wording.
Automation ids and labels stay English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:57:13 -04:00
Brandon Thomas 67634241c8 Merge remote-tracking branch 'origin/main' into fix-375 2026-10-09 09:56:15 -04:00
Brandon Thomas 0d2c21163c Merge pull request #299 from dcarnelutti/export/trim-pool-when-done
export: free the frame pool when an export ends
2026-10-09 09:56:07 -04:00
Brandon ThomasandClaude Opus 5.5 79a3b623fe Pool trim: free only the float shelf, and only after a standalone export
pool::trim() from Pipeline::drop flushed the whole process-wide pool after every
render-preview segment and proxy, and threw away playback's U8/U16 planes too.
Now Pipeline::drop calls pool::trim_f32() only when !settings.part_of_batch.
PoolStats gains f32_idle_bytes so the tests can check the float shelf alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:55:47 -04:00
Brandon Thomas 584f742bf4 Merge pull request #247 from switch87/readme-gentoo
README: install on Gentoo from the ::snakebyte overlay
2026-10-09 09:55:23 -04:00
Brandon ThomasandClaude Opus 5.5 7050055a58 README: move the Gentoo overlay into Downloads > Linux
The Gentoo paragraph sat after the build-from-source and control-protocol
notes; it belongs with the other Linux install options. Mark it clearly as
community-maintained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:54:46 -04:00
Brandon Thomas c21c86089f Merge remote-tracking branch 'origin/main' into fix-247
# Conflicts:
#	contributors/people.toml
2026-10-09 09:52:32 -04:00
Brandon Thomas 6ebc21f093 Merge remote-tracking branch 'origin/main' into fix-360
# Conflicts:
#	README.md
#	ROADMAP.md
#	crates/platform/README.md
2026-10-09 09:46:48 -04:00
Brandon Thomas 18f5bad7a8 Merge pull request #328 from SaymonMaster14/m2/nvenc-hevc
Windows: NVENC H.265 (HEVC) Main hardware encoding for Export
2026-10-09 09:46:13 -04:00
Brandon Thomas 97a8eaffec Merge remote-tracking branch 'origin/main' into fix-299
# Conflicts:
#	docs/control-protocol.md
2026-10-09 09:45:27 -04:00
Brandon Thomas 66a74afc38 Merge pull request #365 from DWAA1660/fix/windows-dx12-startup
Fix #179: leave OpenGL out of the Windows wgpu instance (Vulkan | DX12 by default)
2026-10-09 09:45:03 -04:00
Brandon ThomasandClaude Opus 5.5 b3f2a89944 Windows: default to Vulkan | DX12 | Metal (drop only GL) instead of forcing DX12
Forcing DX12 compiles shaders through FXC, which rejects fx.wgsl (silent CPU
fallback), and leaves machines without DX12 with no backend. Leaving GL out is
what avoids the AMD atio6axx.dll crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:44:20 -04:00
Brandon Thomas aff136ff67 Merge remote-tracking branch 'origin/main' into fix-365
# Conflicts:
#	apps/filmcraft/src/main.rs
2026-10-09 09:44:20 -04:00
Brandon ThomasandClaude Opus 5.5 cc5e3c1065 Revert shortcut-preset changes in favour of #337's approach
The maintainers chose #337's handling of Ctrl/Cmd shortcut collisions off
macOS (drop_control_collisions / audit_for / builtin_for), which audits the
collisions and keeps documented winners rather than keeping the first listed
binding. This reverts crates/engine/src/shortcuts.rs and its tests to main
so the two approaches don't overlap; the MPEG-oracle ffprobe-JSON change and
the frame-pool test rewrite from this PR are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:40:15 -04:00
Brandon Thomas 1b4b6fbffa Merge remote-tracking branch 'origin/main' into fix-368
# Conflicts:
#	contributors/people.toml
2026-10-09 09:40:15 -04:00
Brandon Thomas 1879d472e1 Merge pull request #417 from storytold/fix-clippy-as-chunks
CI: gpu test uses as_chunks (clippy on Rust 1.99)
2026-10-09 09:38:52 -04:00
Brandon ThomasandClaude Opus 5.5 7a29eb4dff CI: gpu test uses as_chunks (clippy chunks_exact_to_as_chunks on Rust 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:38:45 -04:00
txiki739andClaude Opus 5.5 17921200d7 #346: the from-source Flatpak includes the CLI and MCP server
The release bundle manifest installs filmcraft and filmcraft-cli; the
from-source manifest built and installed only the desktop app, so a
Flatpak built from source had no headless CLI or stdio MCP server.

It now builds -p filmcraft -p filmcraft-cli and installs
/app/bin/filmcraft-cli. The default command stays filmcraft; the CLI
runs with `flatpak run --command=filmcraft-cli ai.storyteller.filmcraft`
(the manifest header and docs/releasing.md show it). Runtime, SDK and
finish-args are unchanged.

packaging-lint now also checks that both manifests install the same
programs, {filmcraft, filmcraft-cli}; it fails on the old manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:37:09 +02:00
Brandon Thomas 570d06d5bd Merge remote-tracking branch 'origin/main' into fix-328
# Conflicts:
#	Cargo.lock
#	README.md
#	ROADMAP.md
#	crates/platform/Cargo.toml
#	crates/platform/README.md
#	docs/architecture.md
2026-10-09 09:34:12 -04:00
Brandon ThomasandClaude Opus 5.5 c497b648f5 Merge origin/main into i18n-spanish-panels
Resolve conflicts: keep main's macOS/other hint text and mask vertex
tooltip variants (each wrapped in tl!), take main's shared keyframe_nav
helper and translate its tooltips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:34:01 -04:00
Brandon ThomasandClaude Opus 5.5 33f3656071 #262: drop new apps/filmcraft/README.md; explain file filters in module docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:33:38 -04:00
Brandon Thomas 45e951ff61 Merge remote-tracking branch 'origin/main' into fix-390
# Conflicts:
#	apps/filmcraft/src/main.rs
2026-10-09 09:33:32 -04:00
Manuel CamachoandClaude Opus 5.5 7b73c8e887 GPU3d: GPU export rendering is Auto by default (#30)
Merlin's effects-heavy bench scene (GPU3c) settles it: with layers and
effects the GPU compositor exports 2.4x faster at 1080p (13.0 -> 5.0-5.6 s,
737 -> 238 CPU ms/frame) and 3.2x at 4K (38.7-39.3 -> 12.0 s, 2955 -> 775
CPU ms/frame); a single plain clip takes the same time either way
(decoding / encoding bound). Every Auto row rendered all its frames on the
GPU, without fallbacks. The CPU reference renderer stays the fallback for
anything the GPU stage does not run.

Trade-off: the GPU matches the CPU within the compositor's parity
tolerance, not bit for bit, so an export's bytes can depend on the
machine's GPU; Off keeps them byte-reproducible (the determinism tests
register no GPU renderer and are unaffected).

- settings: GpuRendering::Auto is the #[default]; ExportSettings::default.
- test: the default, settings saved without the field, and "off".
- docs/performance.md: GPU3 results (1080p and 4K).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:31:57 -07:00
Brandon Thomas d63df6882b Merge pull request #256 from wstock/fix/245-ctrl-chords-control
control: Ctrl+ chords through ui.key fire shortcuts off macOS (#245)
2026-10-09 09:31:39 -04:00
Brandon Thomas a739ffea84 Merge remote-tracking branch 'origin/main' into fix-256
# Conflicts:
#	crates/ui-egui/tests/keyboard_ui.rs
#	docs/control-protocol.md
2026-10-09 09:29:27 -04:00
Brandon Thomas e5214ab7eb Merge pull request #355 from swillunderscore/sequence-settings-dialog
Sequence ▸ Sequence Settings… opens Premiere's dialog (#172, #287)
2026-10-09 09:28:59 -04:00
Brandon Thomas afe35e4c41 Merge pull request #240 from WavJaby/fix/web-import-collision
fix(web): preserve distinct same-name media imports
2026-10-09 09:28:53 -04:00
Brandon Thomas de0299bd1b Merge remote-tracking branch 'origin/main' into fix-240
# Conflicts:
#	apps/filmcraft-web/tests/smoke.mjs
2026-10-09 09:27:59 -04:00
Brandon Thomas 96f76bac2a Merge remote-tracking branch 'origin/main' into fix-355
# Conflicts:
#	contributors/people.toml
2026-10-09 09:27:39 -04:00
Brandon Thomas c2f2018707 Merge pull request #359 from IppSec/fix/236-audio-crackle
#236: audio in MKV and OBS-remuxed MP4 plays without crackling
2026-10-09 09:27:34 -04:00
Brandon Thomas 6e80bab6b8 Merge remote-tracking branch 'origin/main' into fix-359
# Conflicts:
#	contributors/people.toml
2026-10-09 09:26:08 -04:00
Brandon Thomas 0bae537ad0 Merge pull request #300 from dcarnelutti/platform/warm-hevc-probe
platform: ask for the hardware HEVC encoder in the background at startup
2026-10-09 09:25:32 -04:00
Brandon Thomas 22d2046c0a Merge pull request #270 from Tom1tk/windows-modifier-hints
Windows: platform modifier names in status-bar and mask hints
2026-10-09 09:25:26 -04:00
Brandon Thomas 9736b2a6ba Merge pull request #264 from carlitosteso3/fix/recovery-retry-197
#197, #193: web recovery retries failed snapshot writes and keeps skipped snapshots dirty
2026-10-09 09:25:20 -04:00
Brandon Thomas 48e0b65110 Merge pull request #237 from wstock/fix/230-wasm-clock
#230: jobs time themselves with web_time, so Create Proxies and mask tracking don't panic on the web
2026-10-09 09:25:14 -04:00
Brandon Thomas 202d5f061c Merge pull request #242 from wstock/fix/160-png-alpha
export: keep alpha in PNG/TIFF sequences (Include Alpha Channel) (#160)
2026-10-09 09:25:08 -04:00
Brandon Thomas 5cca829c39 Merge pull request #369 from storybored379/fix/mcp-cancel-numbered-movies
MCP: cancelling a movie export keeps numbered files beside it
2026-10-09 09:25:03 -04:00
Brandon Thomas 0409fdccf4 Merge pull request #372 from storybored379/fix/graphic-duration-placement
graphics: keep duration separate from timeline placement
2026-10-09 09:24:58 -04:00
Brandon Thomas d1f491823c Merge pull request #373 from storybored379/fix/audio-gain-result
mixer: report only the audio gains actually applied
2026-10-09 09:24:51 -04:00
Brandon Thomas bf0f2bd8ff Merge pull request #305 from Pathos0925/fix/296-mono-loudness
#296: Auto-Match measures a mono clip as one channel
2026-10-09 09:24:45 -04:00
Brandon Thomas bf21ee86c2 Merge pull request #282 from Tom1tk/windows-export-paths
Windows: export paths expand ~, accept trailing \ and verbatim paths
2026-10-09 09:24:38 -04:00
Brandon Thomas 996ccf72fd Merge pull request #349 from rossaai/rossa/embedding-hooks
Embedding: set_context on the web app, header and status bar flags, status bar colour token
2026-10-09 09:24:31 -04:00
Brandon Thomas 7cf322f19d Merge pull request #319 from WavJaby/fix/yuv-code-range
fix(gpu): normalize YUV code ranges independently of texture scaling
2026-10-09 09:24:25 -04:00
Brandon Thomas 275abb3193 Merge pull request #385 from SaymonMaster14/m4c/background-decoders
Thumbnails decode with small decoders that are not kept (#55)
2026-10-09 09:24:19 -04:00
Brandon Thomas cae8eb9f4f Merge pull request #381 from SaymonMaster14/m4/memory-stress
Decode caches: navigation bursts no longer keep every clip's decoder and frames (#55)
2026-10-09 09:24:13 -04:00
Brandon Thomas ae79acad8a Merge pull request #241 from WavJaby/fix/import-bin
fix(import): use the open Project bin as destination
2026-10-09 09:24:07 -04:00
Brandon Thomas 16ba523625 Merge pull request #253 from wstock/fix/244-project-delete-named-bins
engine: project.delete takes explicit bin ids without a Project-panel selection (#244)
2026-10-09 09:24:02 -04:00
Brandon Thomas 20ece5313e Merge pull request #261 from wstock/fix/243-delete-key
shortcuts: Delete clears the selected timeline clips (#243)
2026-10-09 09:23:56 -04:00
Brandon Thomas e7fe8f46b7 Merge pull request #250 from MrCloudy2/fix/unlink-keeps-clicked-clip-220
#220: Unlink leaves only the right-clicked clip selected
2026-10-09 09:23:49 -04:00
Brandon Thomas 5d69487925 Merge pull request #307 from justjohn12345/pr/bugfix
Keyframes: Properties gets Premiere's navigator, Effect Controls a time ruler
2026-10-09 09:23:44 -04:00
Brandon Thomas 354bb86332 Merge pull request #304 from justjohn12345/pr/monitor-follows-drags
Program monitor: the picture follows a value that is dragged
2026-10-09 09:23:37 -04:00
Brandon Thomas 55b6c0a916 Merge pull request #221 from carlitosteso3/fix/playhead-controls-208
Go to In / Go to Out move to the sequence start / end without marks (#208)
2026-10-09 09:23:31 -04:00
Brandon Thomas 929e05d996 Merge pull request #303 from MarekSpodBiedry/issue-139
Issue #139: portable.txt next to the exe keeps the data directory beside it
2026-10-09 09:23:25 -04:00
Brandon Thomas 0f3343cc4a Merge pull request #277 from midasdf/fix/vertical-japanese-punctuation
M10: use Japanese vertical forms and origins for titles
2026-10-09 09:23:20 -04:00
Brandon Thomas 298391574e Merge pull request #289 from midasdf/fix/vertical-grapheme-cells
M10: keep combining marks in one vertical text cell
2026-10-09 09:23:14 -04:00
Brandon Thomas e919d3f80b Merge pull request #249 from switch87/desktop-log-file
Desktop: write log records to standard error and a rotating log file
2026-10-09 09:23:08 -04:00
Brandon Thomas d16f9adb0a Merge pull request #387 from txiki739/fix/linux-tar-installer
#214: install script for the Linux tarball
2026-10-09 09:23:02 -04:00
Brandon Thomas bfa819a448 Merge pull request #358 from JordyUSA/vaapi-h264-decode
HW5: Linux VA-API hardware decoding for H.264 and HEVC (Main / Main 10)
2026-10-09 09:22:56 -04:00
Brandon Thomas 3e6043c46a Merge pull request #263 from XusBadia/dmg-background
macOS DMG: branded window background and icon layout
2026-10-09 09:20:31 -04:00
Manuel Camacho 1aa6ff6514 GPU3c: effects-heavy export bench scene (#30) 2026-10-09 06:18:18 -07:00
Manuel Camacho 5ee4faf2d2 GPU7: Lumetri GPU op matches fn lumetri exactly (test); doc comments restored (#30)
Add CPU parity test verifying FxOp::Lumetri::apply matches original fn lumetri
exactly (0.00e0 sample difference) across all parity cases. Restore doc comments
on fn on and fn grade_space.

🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
2026-10-09 06:18:18 -07:00
Manuel Camacho 029e9bb040 GPU7: Lumetri Color (basic, creative, vignette) on the GPU compositor (#30)
Port the common slice of Lumetri Color (Basic Correction, Creative without
sharpen, and Vignette) to the GPU effect stage (FxOp::Lumetri, OP_LUMETRI = 31).

Lumetri instances with unsupported sections (input LUT, Look / look LUT,
sharpen, curves, color wheels, HSL secondary, or HDR grading) fall back to CPU.

Parity tests pass within max relative difference 4.28e-7 and 0.000% flips.

🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
2026-10-09 06:18:18 -07:00
Manuel CamachoandClaude Opus 5.5 83e6fe7eda GPU6: invert alpha keeps the CPU's 1e-6 threshold (#30)
The previous commit raised the GPU's un-premultiply threshold for Invert
(alpha channel) to 1e-4, which the CPU reference does not use (it is
1e-6): alphas between the two inverted differently. The 6 % flipped
pixels came from the bilinear leak the same commit fixed in load4; with
it, the CPU's threshold passes gpu_effects_match_cpu_exactly (and the
gpu and golden suites, serially).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:18:18 -07:00
Manuel Camacho f2fefeeb4b GPU6: GPU effects match the CPU on AMD (gamma, extract, invert, color replace) (#30)
Fix subpixel bilinear interpolation leakage during 1:1 source texture
sampling in composite.wgsl load4, and guard against divide-by-zero /
subpixel noise amplification in OP_INVERT_ALPHA in fx.wgsl.

All fx_tests::gpu_effects_match_cpu_exactly test cases now pass on AMD
(Radeon RX 7900, Mesa 26.2.2, Vulkan) within EXACT (1e-4) and 0.000% flips.

🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
2026-10-09 06:18:17 -07:00
Manuel Camacho ab334b2adf GPU5: Video Limiter on the GPU compositor (#30)
Move Video Limiter from CPU (crates/render/src/vfx/color.rs) to the GPU
compositor (filmcraft_render::gpufx::FxOp, crates/gpu/src/fx.rs, crates/gpu/src/fx.wgsl).
Parity matches CPU reference within 9.28e-6 across all axis modes, clip levels,
compressions, and gamut warning color.

🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
2026-10-09 06:18:17 -07:00
Manuel Camacho d2b608605e GPU4: Vignette on the GPU compositor (#30)
Add Vignette effect to the GPU effect stage (render::gpufx, gpu::fx,
fx.wgsl). Evaluated parameters are shared between the CPU reference and
GPU compute passes. Replaces old CPU vignette body in vfx::color with
gpufx::FxOp::eval and gpufx::FxOp::apply.

Gates: fmt, clippy, test -p filmcraft-render, test -p filmcraft-gpu,
layers, wasm all verified.

🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
2026-10-09 06:18:17 -07:00
Manuel CamachoandClaude Opus 5.5 91056340ba GPU3b: pooled GPU export renderers, one-submission read-back (#30)
GPU export rendering was ~20 % slower in wall-clock time than CPU
rendering: one renderer behind a mutex made the export workers queue.

- Export workers share a pool of up to four renderers on one wgpu device
  (one per worker was no faster: the read-back then contends for memory
  bandwidth).
- render_export_prepared composites into the linear accumulator and
  copies it to a reused staging buffer in one submission (no sRGB resolve
  pass); read_accumulator shares the read-back, which now returns None
  instead of stale pooled values when a row is missing.
- f16_to_f32_fast: same result as f16_to_f32 for all 65536 inputs (test).
- Timing counters (export::export_lock_wait, gpu::export_renderer::timing)
  printed by the export bench.

RX 7900, same build, Off / Auto / Off: H.264 + AAC 4.67 / 4.71 / 4.76 s,
ProRes 422 4.20 / 4.21 / 4.21 s; CPU per frame 287-292 -> 277 (H.264),
316-317 -> 300 (ProRes). Was 4.86 -> 5.78 s. Parity, not faster: on this
single-clip scene decoding and encoding dominate (rendering is ~15 of
~290 CPU ms per frame), so GPU rendering stays Off by default.

Gates: fmt, clippy (gpu, export, ui-egui), layers, assets; gpu
export_pipeline, golden and export tests pass. Pre-existing on the
previous commit: fx_tests::gpu_effects_match_cpu_exactly fails on this
AMD GPU, and the gpu unit tests segfault in the driver when run in
parallel (pass serially apart from that test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:18:17 -07:00
Manuel Camacho ba403212f6 GPU3: GPU export rendering off by default until it is faster (#30)
Set GpuRendering default to Off in ExportSettings so exports use the CPU
reference renderer by default until GPU export achieves lower wall-clock time.

🤖 Written by agy (Antigravity Agent, Gemini 3.8 Flash)
2026-10-09 06:18:17 -07:00
Manuel Camacho 2b0f263418 GPU3: render exports on the GPU compositor (#30)
Add off-screen GPU export renderer (filmcraft-gpu::ExportRenderer) reading back
the linear float accumulator as premultiplied RGBA f32, plug it into the
export pipeline via FrameRenderer hook and GpuRendering setting, register
in desktop app and CLI, and record export.gpu_frames statistics.

🤖 Completed by agy (Antigravity Agent, Gemini 3.8 Flash) based on draft by Merlin
2026-10-09 06:18:17 -07:00
Brandon Thomas 9c24778039 Merge pull request #399 from ItzBloxy/fix/259-trim-hit-testing
#259: the edge you press is the edge you trim
2026-10-09 08:17:17 -04:00
Brandon Thomas 6a240ca701 Merge pull request #391 from txiki739/fix/export-menu-paths
#382: File > Export entries work from the menus
2026-10-09 08:17:12 -04:00
Brandon Thomas 6a1145eb42 Merge pull request #324 from mjohnson7cd/fix/301-nonstandard-fps-stepping
fix: frame stepping broken for non-standard frame rates (#301)
2026-10-09 08:17:06 -04:00
Brandon Thomas 28235e25b8 Merge pull request #394 from txiki739/fix/reverse-source-frames
#318: a reversed clip shows exactly its source range
2026-10-09 08:17:01 -04:00
Brandon Thomas 7605a7a7ce Merge pull request #392 from txiki739/fix/fcp7-subframe-spans
#340: FCP7 XML clip source span always matches its record span
2026-10-09 08:16:56 -04:00
Brandon Thomas 475b30d793 Merge pull request #393 from MarekSpodBiedry/issue-379
#379: preset export and import go through the host, so the web build no longer traps
2026-10-09 08:16:51 -04:00
Brandon Thomas fee418e2b6 Merge pull request #252 from WavJaby/fix/workspace-lints
chore(lints): satisfy strict workspace Clippy on Rust 1.95
2026-10-09 08:16:46 -04:00
Brandon Thomas d51f6b1b7d Merge pull request #362 from JordyUSA/fix/341-export-message
#341: CLI export message names the file written, not the requested path
2026-10-09 08:16:41 -04:00
Brandon Thomas 0f409f3fb1 Merge pull request #297 from mjohnson7cd/fix/285-cli-broken-pipe
fix: filmcraft-cli exits quietly on broken pipe (#285)
2026-10-09 08:16:36 -04:00
BloxyandClaude Opus 5.5 7401f5038b contributors: credit @ItzBloxy
My own entry for the About window: display name "Bloxy".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:17:08 +02:00
BloxyandClaude Opus 5.5 af2b4601c8 #259: caption edges trim from where they were pressed
Caption blocks chose move / trim in / trim out where egui recognised the drag, about 9 px in
from a press 2 px inside the edge and past the 5 px edge zone, so they moved instead of
trimming. They now decide at the press origin and measure the drag from it. UI test that
failed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:46:35 +02:00
BloxyandClaude Opus 5.5 42cc47e7db #259: the timeline shows the edge a press will grab
Hovering a clip edge draws the edit-point bracket at half strength on the edge a press would
grab: red for a ripple; yellow for a trim, roll, rate stretch or remix; on both sides of the
cut for a roll. It follows the tool and the modifiers, from the same grab_at as the press.
The bracket drawing is shared with the selected edit points (trim_monitor::paint_bracket).

An ignored test renders the four cases at a demo cut to PNGs for review:
cargo test -p filmcraft-ui-egui --test timeline_trim_ui -- --ignored hover_bracket_screenshots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:40:07 +02:00
txiki739andClaude Opus 5.5 dfd67b2774 #318: a reversed clip shows exactly its source range
A reversed clip's source time is the instant just before its mirrored
position (source_out - rel - 1 tick). The MP4 reader rounds a request to
the nearest unit of the track timescale before picking the frame at or
before it, so one tick below a frame boundary landed on the next frame:
a clip trimmed to source frames 10-29 played 30 down to 11.

video_source_time snaps a reversed clip's video request down to the
start of the media frame that contains it (clamped to the source In and
the unsnapped time). Both places that ask a source for a clip's picture
use it: media_frame and the plan path. source_time_at is unchanged for
audio and nested sequences, and forward clips and frame holds keep
their request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:28:24 +02:00
BloxyandClaude Opus 5.5 4eb34ae6c7 #259: the edge you press is the edge you trim
Pressing a clip edge and dragging inward selected and moved the clip: the press was hit-tested
only once egui recognised a drag, 6 pt later, by when the pointer had left the 7 px edge zone.
And at a cut the left clip's Out edge claimed both sides, so the right clip's In edge could not
be grabbed.

What a press grabs is now worked out on the frame the button goes down (timeline_hit::grab_at,
shared by the cursor, the press and ui.timeline.hit) and acted on by both the click and the
drag. Inside a clip beats outside it, so the side of a cut picks the clip; zero-width clips
have no edges. Trims, rolls, rate stretches and remixes move by how far the pointer moved since
the press, and a moved clip stays under the point it was grabbed by. ui.timeline.hit takes
`modifiers` and reports `kind`, the trim a press would start.

Unit tests for the zones and tools; headless UI tests that drag a pixel or a few per frame at
60 fps (before: the clip moved, the wrong clip was trimmed, a wobbly click selected the clip, a
moved clip lagged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:27:48 +02:00
txiki739andClaude Opus 5.5 43e3d26823 #218: System Language follows the operating system's language
Settings > General > Interface Language gains System Language
(`system`), now the default: a new installation shows the first of the
user's preferred languages that the interface has (es-419, pt_BR.UTF-8,
ja-JP...; any Portuguese gets pt-br), otherwise English. A language
already chosen, or saved in preferences.json, is kept.

The host supplies the preferred languages through
HostHooks::system_languages: sys-locale on the desktop (LANGUAGE,
LC_ALL, LC_MESSAGES, LANG on Linux; the OS lists on macOS/Windows) and
navigator.languages on the web. Without the hook (tests, headless),
System Language is English, so tests do not depend on the machine's
locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:06:43 +02:00
MarekSpodBiedry 55e43e6147 #379: preset export and import go through the host, so the web build no longer traps 2026-10-09 10:45:28 +02:00
txiki739andClaude Opus 5.5 d39692af63 #340: FCP7 XML clip source span always matches its record span
write_clip rounded start, end, in and out to frames independently, so
a clip placed by seconds at 23.976 (start 0, in 20 s, 3 s long) was
written as start 0 / end 72 / in 480 / out 551: 71 source frames for
72 record frames.

At normal speed and on a freeze frame the source span is the record
span, so out is now in + (end - start). Speed-changed clips keep their
independently rounded out. The master duration is raised to out when
rounding pushes out past it; pproTicksIn/Out still carry exact ticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:37:12 +02:00
txiki739andClaude Opus 5.5 a076e50553 #382: File > Export entries work from the menus
A menu entry runs its command with no parameters, so every File >
Export entry whose command needs a `path` failed with "need `path`",
from the egui menu bar and the native macOS menu alike.

- Media… opens the Export mode, like Cmd+M (Premiere's File > Export >
  Media); with a `path` it still exports directly.
- EDL, Final Cut Pro XML, FCPXML, OpenTimelineIO, Avid Log Exchange and
  Selection as FilmCraft Project ask the host's save panel for the
  destination, suggest the sequence's (or project's) name with the
  right extension, then export. "Selection" suggests "<project>
  selection.fcproj" so it does not offer to overwrite the open project.
  Cancelling writes nothing and reports no error.

Disabled entries (no sequence, no selection) still fail before any
dialog opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:27:36 +02:00
txiki739andClaude Opus 5.5 5f3682fe7c #262: file dialogs list extensions in any capitalisation on Linux
rfd hands filter extensions to the XDG portal, GTK and Zenity as
case-sensitive `*.ext` globs, so `CLIP.MP4` and `clip.Mp4` were hidden.
`file_filters::extensions` keeps the given extensions first (the save
dialogs' default suffix) and, outside Windows and macOS, adds one
bracket pattern per extension (`mp4` -> `[mM][pP]4`), the form the
portal's FileChooser docs recommend.

Every filtered dialog uses it: import, relink, Open file, Open
Project, Save Project and Save As.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:06:35 +02:00
txiki739andClaude Opus 5.5 ae09ba904c #214: install script for the Linux tarball
The tarball now ships install.sh. It installs filmcraft, filmcraft-cli,
the desktop entry, icons, MIME type, metainfo and docs under ~/.local
without sudo; --prefix takes any absolute path (sudo ./install.sh
--prefix /usr/local for all users). Running it again updates.

The desktop entry's Exec/TryExec point at the installed binary's
absolute path, escaped per the Desktop Entry spec, so the menu entry
works without ~/.local/bin on PATH. The script checks the bundle is
complete before writing, refuses /, prefixes inside the extracted
bundle and characters launchers cannot express (=, %, control
characters), and treats the cache refresh tools as optional.

packaging/linux/test_install.py covers install and update, launching
through gio, bad arguments, an incomplete bundle, --help and a real
package.sh tarball build; Packaging lint runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:54:20 +02:00
Davi AndreiandClaude Sonnet 5.5 b485e2c180 Thumbnails decode with small decoders that are not kept
Right after importing many 4K clips the Project panel and the timeline ask
every clip for a thumbnail. Up to six frame workers decoded them at once,
each with a frame-threaded software H.264 decoder (16 threads and 18
pictures in flight here, about 0.5 GB at 4K), and the decoders stayed: 42
4K clips with hardware decoding Off took the process past 6 GB during import.

Thumbnail jobs (item frames at the Media Browser / thumbnail priorities) now
run under filmcraft_media::cancel::with_background: H.264 decoders made for
them use 2 threads (thread_limited), parallelism comes from the workers
decoding different files, and the decoders of caches only thumbnails used
are released after each thumbnail (the clip served last keeps its own). A
monitor or playback request on such a cache replaces the small decoder with
a full one. Sequence frames are never background (the scopes ask for the
program frame at 40 while playing).

42 4K clips (Windows), peak private bytes: hardware Off 6.1-6.8 GB (killed)
-> 3.1-3.2 GB; hardware Auto 2.7-2.8 GB (#381) -> 2.3-2.4 GB. Thumbnails of
12 / 42 clips ready in 0.6 / 2.2 s instead of 2.2 s / not finishing.
Example crates/ui-egui/examples/thumb_burst.rs reproduces the burst.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:32:43 -03:00
Davi AndreiandClaude Sonnet 5.5 0c64e41b53 Decode caches: navigation bursts no longer keep every clip's decoder and frames (#55)
A cache asked for a frame within IDLE (3 s) counted as in use, and the caps
of #41 (4 live decoders, 1 GiB of frames) only bound caches idle for longer,
so moving the playhead across 42 4K clips kept 42 hardware decoders (6.5 GB
of VRAM) and up to 3.9 GB of frames. Now only caches touched within RECENT
(500 ms) are protected: caches idle 0.5-3 s give up decoders beyond
HARD_DECODERS (8) and frames beyond the shared budget, least recently used
first; the 3 s rule down to 4 decoders is unchanged. store() refreshes
last_used.

Measured with a 42-clip 4K reproduction (Windows, Media Foundation): peak
private bytes 6.6 -> 4.0 GB, frame cache 3.9 -> 1.1 GB, live decoders
42 -> 8-14, GPU memory 6.5 -> 4.3-4.6 GB, 0 dropped frames before and after.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:13:42 -03:00
WavJaby 6893cb4f03 perf(web): feed native YUV frames to the shared compositor 2026-10-09 13:47:06 +08:00
sanyamsingh001andCodex 892003f3d4 GPU.EFFECT_SOURCE: preserve exact texel centers for effect inputs
Draw effect sources from working pixel positions and integer decimation.
Cover identity, alpha inversion and box minification without changing tolerances.

Co-authored-by: Codex <codex@openai.com>
2026-10-09 15:44:39 +10:00
Xus BadiaandClaude Opus 5.5 10b71eec85 macOS DMG: branded window background and icon layout
The DMG opened as a plain Finder window. It now shows the app icon's figure on
the FilmCraft colour, with FilmCraft.app and the Applications link side by side.
Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201,
storytold/vectorcraft#493).

- packaging/macos/dmg/: background.svg (source; text outlined, no paper grain,
  the figure an <image> of assets/app-icon), background.tiff (1x + 2x HiDPI,
  16-colour palette, Deflate, sRGB; ~106 KB)
  and dmg-layout.DS_Store (Finder view settings: window, icon size 128,
  positions, background).
- generate.py writes the TIFF and the layout on any OS, byte-for-byte
  reproducible, with nothing from the machine that ran it (no paths, users,
  disk names or volume UUIDs).
- package.sh copies them into the staging folder; the image is still built with
  hdiutil makehybrid. Nothing extra runs in the signing job.
- The volume name drops the version: the layout finds the background through
  an alias that includes the volume name. The DMG file name keeps the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:43:43 +02:00
storybored379andOpenAI Codex 7264594494 mixer: report only the audio gains actually applied
Fixes #343.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-08 21:35:58 -07:00
Davi AndreiandClaude Sonnet 5.5 9510e26584 Export: render the next batch while the current one is encoded
A step now renders batch k+1 on the rayon pool while the calling thread
encodes, muxes and mixes the audio of batch k. The file is byte-identical
with or without overlap. A prefetch that hits pending media is dropped and
rendered again, cancel frees it, a render panic becomes an error, and wasm
never overlaps. perf.stats export.stages gains waitMs.

Memory: up to 2 x batch x frame bytes of finished frames are in flight, so
the batch is capped to a 512 MiB budget (1080p keeps 16, 4K SDR 8, 4K HDR 2
frames). Measured peak RSS vs before: 1080p +67..+158 MB; 4K SDR -535..-927
MB; 4K HDR about -2.6 GB (5.2 -> 2.6 GB). Wall time 1.06-1.16x faster.

Known pre-existing web issue, untouched: AudioOut::pull advances before the
pending check, so a retried audio group is lost.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:26:41 -03:00
Davi AndreiandClaude Opus 5.5 1996f319c3 Export: stage wall times in perf.stats (export.stages)
filmcraft_export::Stage, stage_times(), note_stage() and timed(): the wall
time of setup, loudness, render, encode (and its RGB->YUV convert part),
audio, mux and finish, summed per process like export.hardware. Converters
in the H.264 / ProRes / NVENC / VideoToolbox encoders are timed as Convert.
Two clock reads per stage per batch; the exported file does not change
(determinism tests). Test: an export grows every stage counter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:26:09 -03:00
storybored379andOpenAI Codex 3971b2666f graphics: keep duration separate from timeline placement
Fixes #326.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-08 21:08:47 -07:00
sanyamsingh001andCodex 0e0f0485da LINUX.VALIDATION: honor preset shortcuts and retain MPEG oracle frames
Resolve inherited physical key collisions when loading built-in presets,
while preserving established explicit winners and deliberate custom conflicts.
Read every ffprobe display-frame timestamp and register the requested credit.
Verify exact frame-plane reuse without depending on concurrent global metrics.

Co-authored-by: Codex <codex@openai.com>
2026-10-09 14:06:37 +10:00
storybored379andClaude Opus 5.5 78053191ce MCP: cancelling a movie export keeps numbered files beside it
remove_partial treated any `<stem><digits>.<ext>` file as a still of an image sequence, so
cancelling an export to `clip.mp4` also deleted a `clip1.mp4` written in the same second (or
during the export). Only PNG/TIFF/BMP sequence exports write numbered stills, and their frame
numbers have at least three digits (`image_sequence_path`); match only those.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:05:31 -07:00
storybored379andOpenAI Codex ea79751a5c platform: enable existing NVENC H.264 export on Linux
Runtime-load the NVIDIA encode and CUDA driver libraries, retaining the primary context through session destruction. Preserve the Windows Direct3D path and default-off selection, and test driver rejection and deterministic software fallback.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-08 20:40:01 -07:00
Cathy Shapiro ac145dbaa8 M3.12: Dismiss Export Frame when clicking outside
Cancel the export draft when clicking outside the dialog, including top menus and mode tabs, while allowing the background control to receive the click. Keep the dialog's own format and depth dropdowns interactive.

Cover dismissal for both monitors, mode switching, dropdown selection, and background monitor clicks in a UI regression. No frame is exported when clicking away.

Assisted-by: Codex
2026-10-08 21:57:30 -05:00
JordyUSAandClaude Opus 5.5 9e8ad1d6a2 #341: CLI export message names the file written, not the requested path
When the format changes the extension (APV into `out.mp4` writes
`out.mp4.mov`), `filmcraft-cli export` said "exported out.mp4" on stderr
while stdout JSON and the filesystem had `out.mp4.mov`. The message now
takes the path(s) from the export result: `path` of a direct export, each
item's `path` with --queue; the requested path only when the result names
none. Unit test for the helper; end-to-end test (direct and queued APV
export) that failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:53:28 +07:00
Davi AndreiandClaude Sonnet 5.5 dbae11b843 Windows: NVENC H.265 Main 10 HDR (PQ / HLG) export
HDR sequences export as real 10-bit HEVC Main 10 through NVENC where the GPU
reports 10-bit HEVC: float BT.2020 R'G'B' -> rgbf_to_yuv420_10 -> P010 input;
VUI BT.2020 + PQ/HLG; HDR10 mastering-display / content-light as SEI on every
IDR (PQ); colr/mdcv/clli in the sample entry. New register_hdr_probe /
hdr_available in the export crate; macOS and GPUs without 10-bit keep
tone-mapping HDR sequences to 8-bit SDR HEVC. ABI asserts extended and
re-verified against nvEncodeAPI.h 12.1 (0 drift). 8-bit SDR HEVC unchanged;
the two #328 tests that asserted HDR is declined now expect Main 10.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:38:14 -03:00
Idiotic f146d7ba9f feat(media,engine): implement multi-stream container audio support and schema v13
- Update MediaInfo to store all discovered audio streams in audio_streams

- Retain independent stream metadata and decoder state for MP4/MOV and Matroska/WebM

- Update audio_placement_specs to allocate successive tracks via ensure_audio_tracks

- Isolate clip rendering to read assigned audio stream rather than replaying stream 0

- Bump project schema to v13 with backwards compatibility for legacy v12 projects

Resolves #312

Fixes #200

Fixes #315
2026-10-08 22:36:45 -04:00
ippsecandClaude Opus 5.5 c43d1090c1 contributors: credit IppSec by GitHub profile name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:30:49 -04:00
ippsecandClaude Opus 5.5 d51945c807 #236: audio in MKV and OBS-remuxed MP4 plays without crackling
Matroska timestamps are whole milliseconds and a 1024-sample AAC frame at 48 kHz lasts
21.33 ms, so frames are stamped at 0, 1008, 2064, 3072… samples; OBS's remux to MP4 carries
that rounding into the sample durations (1008, 1008, 1056…). The MKV and MP4 readers put each
decoded packet at its own timestamp, overwriting the end of one packet and leaving a hole of
silence after another: a click every few packets, crackling throughout playback and export.

Packets of codecs with a fixed frame length (AAC, MPEG audio, AC-3) now run on from one
another and resynchronise to the timestamp only across gaps of more than half a packet, the
rule the MPEG-TS/PS reader already uses. Laced Matroska frames without a duration (which
repeat the block's timestamp) follow on too, and MKV Opus uses the same helper. MP4 audio
packet starts are kept in samples instead of track timescale units.

Regression tests mux our own AAC into MKV (millisecond stamps, plain and laced, a real gap)
and into an MP4 with OBS-remux durations: block-by-block playback and seeks must equal the
packets decoded back to back (all fail before the fix). An ffmpeg oracle test plays
ffmpeg-made MKVs with AAC, MP3, MP2 and AC-3 and requires them as smooth as ffmpeg's decode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:29:23 -04:00
swillunderscoreandClaude Opus 5.5 57d8990765 contributors: display name for @swillunderscore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:19:21 -05:00
jnohclee-rgb 0bfea22e1b cli: make --help print usage successfully
Fixes #344. Handle the leading help flag before command parsing and decoder registration; cover output and exit status with a native CLI regression.
2026-10-09 10:14:06 +08:00
Cathy Shapiro 6c0e3ecc39 M3.12: Match Export Frame timecode to its monitor
Display the captured frame with the same frame rate and drop-frame timecode format as Source or Program, instead of decimal seconds. Keep the captured timecode fixed while the export dialog is open.

Add a UI regression comparing both monitor labels at standard and NTSC rates, with Program drop-frame formatting and playhead movement after capture.

Assisted-by: Codex
2026-10-08 21:11:44 -05:00
JordyUSAandClaude Opus 5.5 e741267125 HW5: Linux VA-API HEVC hardware decoding, Main and Main 10 (#30)
HEVC through VA-API the same way as H.264: the software HEVC decoder's
parsers and DPB (now generic over the picture type in filmcraft-hevc:
POC, reference picture sets, reference lists, output order, RASL
handling) drive the stateless hardware. Main, Main 10 and Main Still
Picture, 4:2:0 8/10-bit (P010 read-back); scaling lists, tiles, WPP,
dependent slices, weighted prediction. Missing references and range
extensions go to the software decoder.

Tests: hardware parity on HEVC Main / Main 10 incl. open GOP, scaling
lists, slices+WPP, weighted prediction, tskip+AMP, 1080p/2160p (streamed
comparison to stay within memory); HEVC front end against a recording
stand-in on every OS; ABI checks extended to va_dec_hevc.h. Benchmark on
Iris Xe: 4K HEVC 203 -> 10 ms CPU per frame, 4K Main 10 212 -> 18 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:00:30 +07:00
swillunderscoreandClaude Opus 5.5 87bb39f5f5 #172: Sequence Settings… opens Premiere's dialog (General, Color Management, VR Properties)
The menu item ran sequence.settings with no parameters and changed nothing. It now opens the
dialog: Timebase, Frame Size with "Scale motion effects proportionally" (on by default), the
timecode Display Format (drop-frame / non-drop-frame for 29.97, 59.94, 119.88), Channel Format,
Sample Rate, Maximum Render Quality, and the Color Management tab, applied in one undo step; OK
with nothing changed adds no undo step. Settings FilmCraft has no equivalent for yet (Editing
Mode, Pixel Aspect Ratio, Fields, Frames / Feet + Frames, Number of Channels, audio Display
Format, Video Previews, Maximum Bit Depth, VR Properties) are shown greyed out with their value
and a "Not supported in FilmCraft yet" hint; Composite in Linear Color is shown on (the
compositor is always linear). Every control has an automation id.

sequence_ui.rs: two tests driving the dialog over the control channel, and
FILMCRAFT_UI_SNAPSHOT_DIR support like the other UI tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:58:37 -05:00
swillunderscoreandClaude Opus 5.5 1f6f7d4c7d #172: sequence.settings scales Motion with the frame size and takes timecode, render quality and colour
- filmcraft_edit::frame_size::scale_motion: Sequence Settings' "Scale motion effects
  proportionally when changing frame size". Every video clip's Motion Position maps through
  p' = c1 + k(p - c0) and Scale / Scale Width multiply by k = min(W1/W0, H1/H0), static values
  and keyframes: a plain proportional scale for the same aspect, and on an aspect change the old
  frame fits inside the new one, centred. Automatic positions stay automatic, Scale to Frame
  Size clips keep their Scale (the renderer refits them), Anchor Point and Rotation stay.
- sequence.settings: new dropFrame, maxRenderQuality, workingSpace, wideGamut, autoToneMap and
  scaleMotion (default false, so existing callers are unchanged) params, in the same undo step;
  returns {"scaledClips": n}. Drop-frame on a rate without it is refused atomically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:58:37 -05:00
jerrykimo 34cf6005d0 ui: Render Chinese document names with font fallbacks 2026-10-08 20:50:09 -05:00
Cathy Shapiro 9f0ed51b49 M3.12: Export frames from Source and Program monitors
Wire both camera buttons to captured-monitor frame export with original media at full resolution. Add a movable settings dialog with format/depth selection, a visible destination folder, Browse, overwrite confirmation, and optional project import.

Reuse existing 8-bit still encoding; add JPEG and direct float-to-16-bit PNG/TIFF encoding. Cover monitor routing, captured frame time, proxy bypass, hostile parameters, depth, import, cancellation, overwrite, and fractional header movement with synthetic regressions.

Assisted-by: Codex
2026-10-08 20:46:12 -05:00
JordyUSAandClaude Opus 5.5 3496e553e7 HW5: Linux VA-API H.264 hardware decoding (#30)
Stateless VA-API decoding with FilmCraft's own H.264 host side: the
software decoder's SPS/PPS/slice-header parsers and its DPB (now generic
over the picture type in filmcraft-h264) drive the GPU, so reference
marking, reference lists and output order are shared and the pictures are
bit-exact with the software decoder. libva is loaded at run time; no libva
or driver means software decoding as before. frame_num gaps (open-GOP
seeks) get the software decoder's stand-in frames. HybridDecoder keeps the
transparent software fallback.

Tests: hardware parity on 7 H.264 streams incl. 1080p/2160p, seeks,
flushes, forced failures, fuzzing, concurrency; front end against a
recording stand-in on every OS; ABI checks against va.h. Docs: platform
README, ADR 0001 addendum, architecture, performance (H.264 2160p CPU per
frame 417 -> 11 ms on Iris Xe), roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:35:57 +07:00
Davi AndreiandClaude Opus 5.5 a8f91e3ffe M2: H.265 stays in the Main tier (pick the level)
Left to choose, NVENC answered a peak bitrate above its level's Main tier
limit with the High tier: 1080p30 at 12/18 Mbit/s was written as level 4 High
tier, 2160p30/60 at 40/60 Mbit/s as 5.0/5.1 High tier, which many hardware
decoders refuse. The level is now the lowest whose Main tier limits hold the
picture size, sample rate and peak bitrate (H.265 Table A.8), e.g. 1080p30 at
18 Mbit/s is level 4.1 Main tier. Unit test of the table; hardware test that
the hvcC says Main tier at the expected level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:23:25 -03:00
Idiotic 78ed3edd84 feat(engine): dynamically expand audio tracks on multi-clip placement 2026-10-08 21:11:12 -04:00
rossaaiandClaude Opus 5.5 0506a01f29 UI: the status bar fills with the header_bg token
The status bar was a fixed #1c1c1c, dark on the Light and Medium themes
and on a theme an embedding app sets through FilmcraftApp::tokens. It now
takes the header bar's colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:57:43 -05:00
rossaaiandClaude Opus 5.5 1e9f37864b UI: UiState::show_header and show_status_bar, on by default
An app that embeds FilmCraft can hide the header bar and the status bar
and give the panels the whole window. Saved UI state without the fields
keeps both shown. With the status bar hidden, a finished preview render
still plays: the job watching moved out of job_status into watch_jobs,
which runs every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:57:43 -05:00
rossaaiandClaude Opus 5.5 83501964f6 Web: public set_context for a host that runs its own WebApp
post and repaint wake the egui context that start stores in a private
thread-local. An app that embeds FilmCraft and builds WebApp in its own
eframe runner had no way to set it, so posted actions and window.filmcraft
requests waited for the next input event. start now calls the same
set_context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:57:43 -05:00
Cathy Shapiro 8a7275c821 M3.12: Source playback and monitor range editing
Play source video and audio, drag marked ranges as video, audio, or a linked pair, and route Source commands to clip marks and markers. Add shared draggable In/Out handles and range movement, preserving scrubber priority and explicit-only Program marks.

Include original code-drawn controls, documentation, attribution, and regression coverage for playback, placement, routing, undo, cancellation, and hostile parameters.

Assisted-by: Codex
2026-10-08 18:58:46 -05:00
Cathy Shapiro 26d972ce01 WINCI: Merge current upstream 0.4.0 release and packaging
Preserve the Windows validation fixes and original #282 commit while bringing the contribution up to date with upstream main.

Assisted-by: Codex
2026-10-08 18:58:11 -05:00
DWAA1660andClaude Opus 5.5 8d6461705e Fix #179: default Windows startup to DirectX 12
eframe's default wgpu instance enables PRIMARY | GL on Windows. Creating
the OpenGL backend can crash inside AMD's atio6axx.dll before the window
appears, so the app flashes in Task Manager and exits. When WGPU_BACKEND
is not set, the Windows instance now enables only DirectX 12; an explicit
WGPU_BACKEND is still honoured. macOS, Linux and web are unchanged.

Same fix as storytold/designcraft#168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD
2026-10-08 17:52:30 -06:00
Cathy Shapiro 0b34c076ce WINCI: Windows path handling and portable validation
Normalize explicit relink prefixes, compare native paths in portable tests, close fixture sessions before directory moves, and preserve exact GPU effect-source pixel centres. Keep the hardware-dependent mixer benchmark opt-in with its 4x target unchanged.

Shortcut portability changes reused from storytold/filmcraft#37 (bd944ee and f2b36fe). The existing export fix from #282 remains in its original parent commit.

Assisted-by: Codex
2026-10-08 18:28:32 -05:00
mjohnson7cd 7048a7905a fix: frame stepping broken for non-standard frame rates (#301)
tick_of() used floor division (div_euclid) which truncated the frame
duration for non-standard rates like 37.516 fps (9379/250) where the
frame duration is 6770871094.999 ticks, not an integer. This caused
frame_at(tick_of(f)) to return f-1 instead of f, making step forward
stall at frame 0 and step back jump backward by 2+ frames.

Changed tick_of() to use ceiling division so the round-trip is exact
for all integer frame indices. Ceiling is correct because the frame
duration is always > 1 tick, so floor(ceil(f*D) / D) == f for every
integer f. frame_duration() remains floor division (unchanged) since
many callers do end - frame_duration() to get the previous frame.

All 11 standard broadcast rates are unaffected since their frame
durations are already exact integers (ceil == floor there).

Added regression tests:
- exotic_frame_rate_roundtrip: round-trip over -20k..20k for 5
  non-standard rates, snap invariant, step-back via frame_duration,
  forward/backward stepping simulation
- exotic_frame_rate_durations: frame_duration is floor for exotic,
  exact for standard
- exotic_frame_tick_roundtrip proptest: arbitrary FrameRate::new(num,
  den) round-trip

Thanks to @justjohn12345 for catching that round (not ceil) still
fails for ~50% of frames starting at f=938.
2026-10-08 18:37:41 -04:00
Oldrich333andClaude Opus 5.5 44248c060f CLI: a closed stdout no longer panics filmcraft-cli, and the work still finishes (#285)
`filmcraft-cli commands | head -c 10` panicked with "failed printing to stdout: Broken pipe
(os error 32)". Output now goes through `outln!` / `out!`. When stdout fails, later output is
dropped but the program does not stop: the rest of `exec`, `run` and `import` (later script
lines, `--save`, `--save-as`) still runs and the exit status still reports a failing command.
Exiting at the first failed print would report success while skipping the save, e.g.
`filmcraft-cli run edits.jsonl --project p.fcproj --save | head -1`. A closed pipe is not an
error; any other stdout write error is reported and makes the exit status 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 17:09:53 -04:00
WavJaby 4d74065aa6 fix(gpu): separate YUV code ranges from texture scaling
- Normalize full-range 8-bit and limited-range high-depth YUV
- Gate code ranges and CPU/GPU parity for inline/prepared uploads
- Existing main GPU effects/frame-pool failures reproduced
- Full workspace and app lifecycle checks remain unrun
2026-10-09 03:57:01 +08:00
Davi AndreiandClaude Opus 5.5 29b454ac17 M2: review fixes for NVENC H.265
- register() asks the HEVC probe on a thread of its own (the first probe is
  about half a second: D3D11 device + NVENC session), so the first draw of
  Export's format list does not wait for it; tests/nvenc_hevc_warm.rs.
- A machine with no HEVC encoder is not a declined hardware attempt: the
  factory returns None without counting `declined`.
- HEVC tests skip only when the probe says there is no HEVC encoder; on a
  machine that has one, a configuration NVENC refuses fails the test.
- Wording: NVENC is FilmCraft's only HEVC encoder on Windows (Windows itself
  ships others).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:48:43 -03:00
Davi AndreiandClaude Sonnet 5.5 deebe2e5b2 Windows: NVENC H.265 (HEVC) Main hardware encoding for Export
One NVENC session, ring and NV12 path now serve H.264 and HEVC (Codec enum,
Profile::HevcMain). HEVC Main 8-bit 4:2:0 SDR BT.709, MP4/MOV hvc1, parameter
sets only in an hvcC built from the SPS the encoder wrote. The H.265 format is
available through a cached NVENC probe; choosing it is the opt-in, declines are
counted errors. ABI asserts regenerated from nvEncodeAPI.h 12.1 (no drift) and
extended for HEVC; tests, docs and roadmap updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 16:28:50 -03:00
WavJaby 944d581cde perf(gpu): composite Push transitions on the shared GPU path 2026-10-09 01:41:43 +08:00
Pathos0925andClaude Fable 5.1 24cde5e9fe #296: Auto-Match measures a mono clip as one channel
Loudness Auto-Match metered every clip as the stereo pair clip_signal
plays, so a mono source (or a mono pick from Modify > Audio Channels /
Breakout to Mono), copied onto both sides, read 3.01 LU louder than the
file and got 3 dB too little gain. BS.1770 and ffmpeg's ebur128 measure
a mono programme as one channel; now so does clip_loudness, through a new
render::audio::clip_is_mono. Stereo and wider sources are unchanged, as
are the export loudness pass and the Meters panel (they measure the real
output mix).

Regression test: the same speech as a 1-channel WAV and as dual-mono
stereo measure 3.01 LU apart, the mono clip agrees with a one-channel
meter over the file within 0.1 LU, and a mono pick counts as mono.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 12:09:15 -05:00
justjohn12345andClaude Opus 5.5 a6b21fe7cb Keyframes: Properties gets Premiere's navigator, Effect Controls a time ruler
Properties panel: the diamond on a row was the stopwatch (effects.toggleAnimation),
so clicking it on a keyframe dropped every keyframe and kept the value at the
playhead. It is now Add/Remove Keyframe (effects.addKeyframe), as in Premiere:
it turns animation on with a keyframe at the playhead, adds one there, or removes
the one there, and the value falls back to what the remaining keyframes give in
Properties, Effect Controls and the Program monitor. An animated row shows the
navigator (previous / diamond / next); the diamond is filled only while the
playhead is on a keyframe. Crop's diamonds work too (the effect is applied first).

Effect Controls: the keyframe lane has a time ruler (ticks and sequence timecode
over the clip's stretch of the sequence) with the playhead's handle on it; click
or drag the ruler to move the playhead. The playhead is drawn only while it is on
the clip. Both panels share one navigator widget, with automation ids
<panel>.<effect>.<param>.addKeyframe / .prevKeyframe / .nextKeyframe, plus
effectControls.ruler and effectControls.playhead.

Tests: engine add/remove at the playhead with undo, redo and bad parameters;
headless UI tests of the navigator, Crop and the ruler; ruler labels and limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:06:25 -04:00
justjohn12345andClaude Opus 5.5 d09e95663c Program monitor: the picture follows a value that is dragged
While a value was dragged in Effect Controls or Properties, the Program monitor
kept the picture from before the drag and only caught up when the mouse rested.

Two causes:

- Every step of a drag is a new project revision, so the frame the monitor asks
  for is never ready on the refresh that asks for it, and the stand-in lookup
  only knew frames of the same revision. The monitor now remembers the frames it
  asked for on its latest paused refreshes and, when neither the frame due nor a
  cached frame just before it is ready, shows the newest of those that is. A
  backward or fast scrub had the same gap and gets the same stand-in. Playback
  is unchanged.
- A frame still rendering was cancelled when the same frame was asked for at a
  newer revision. With frames slower than a refresh (CPU effects) each one was
  thrown away for the next. Such a job now finishes; a request for a different
  frame of the view (scrubbing) still cancels it.

perf.stats gains `monitor`: the frame and revision of the picture the Program
monitor shows, against the playhead's frame and the project's revision.

Tests (headless, perf_ui): a parameter changed on every UI pass and a backward
scrub leave the picture at most one step behind; a frame being rendered survives
a request for the same frame at a newer revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:06:13 -04:00
MarekSpodBiedry 502c9a33a7 Issue #139: portable.txt next to the exe keeps the data directory beside it 2026-10-08 18:58:41 +02:00
Dario CarneluttiandClaude Sonnet 5.5 389cfefe92 platform: ask for the hardware HEVC encoder in the background at startup
`Format::Hevc` is available only where a hardware HEVC session can be created, and
`hardware_encode::hevc_available` finds out by creating a small one on the first question. That
question comes from the first draw of Export mode's format list, on the UI thread, and the session
takes about 0.1 s (88-100 ms in seven of eight fresh processes on an Apple M1, 222 ms in the first):
the app stalls for that long the first time Export is opened.

`register()` now starts the probe on a thread of its own (`warm_hevc_probe`, once however many times
`register` is called). Whoever asks while it runs waits for the same `OnceLock` answer, no longer than
asking first; a thread that cannot start, or a probe that panics (caught), leaves the question to the
first caller as before. `hevc_probed()` says whether the answer exists, for diagnostics and tests.

Test: `tests/hevc_probe.rs` registers, never calls `hevc_available`, and waits for `hevc_probed()` (it
fails after 60 s without the call in `register`).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 10:17:58 -06:00
Dario CarneluttiandClaude Sonnet 5.5 10879f385e export: free the frame pool when an export ends
The export pipeline hands the float image of every frame it converts back to
`filmcraft_frame::pool` (#184), which keeps up to 320 MiB of them, and the decoders' plane shelves
hold up to 192 MiB of each type. An export's images are the size of its frames, which nothing else
asks for, so a finished export left them idle until the next one.

`pool::trim()` frees every idle buffer. `Pipeline` calls it when it is dropped, one place for every
way an export ends (done, failed, cancelled, dropped half way by a host that steps it, as the web
build does) and for every caller of `export` / `Exporter` (previews, proxies and AAF/OMF mixdowns
share the pipeline). A job still rendering at that moment allocates a few images again: the pool is
only a cache.

Measured with a synthetic 3 s 1080p24 matte exported as PNG frames three times in one process
(Apple M1, `footprint`, 10 s after the last export): 575 MB without the trim, the pool holding
332 MB idle, and 258 MB with it. macOS returns the freed pages over a few seconds, not at once.

Tests: `crates/frame/tests/pool_trim.rs` (every shelf emptied, the pool works afterwards) and
`crates/export/tests/pool_trim.rs` (an image sequence, a finished and a cancelled video, an export
that could not start and a stepped export dropped half way each leave nothing idle; without the
trim they fail).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 10:13:07 -06:00
midasdfandCodex d77728008f Keep grapheme clusters together in vertical text
Co-Authored-By: Codex <noreply@openai.com>
2026-10-09 00:40:01 +09:00
Tom 2b843d3d81 Windows: export paths expand ~, accept trailing \ and verbatim paths
- expand_home uses std_home_dir (USERPROFILE on Windows) instead of
  HOME only; the Export panel reuses the engine function.
- std_home_dir prefers USERPROFILE on Windows, where Git Bash sets
  HOME to a Unix-style path.
- On Windows, / in an export path becomes \: after a verbatim
  prefix (what canonicalize returns) / is not a separator, giving
  os error 123.
- One is_folder check for output_path and export.queue.add, so a
  trailing \ no longer numbers several queued sequences.
2026-10-08 15:57:33 +01:00
Manuel CamachoandClaude Opus 5.5 abd62eefa9 Cargo.lock: tts crates at 0.4.0 after the version bump
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:38:19 -07:00
Manuel CamachoandClaude Opus 5.5 036daaaf12 Merge upstream main (0.4.0 version bump) into tts-narration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:38:10 -07:00
midasdfandCodex d67d225da5 M10: use Japanese vertical forms and origins for titles
Co-Authored-By: Codex <noreply@openai.com>
2026-10-08 23:20:38 +09:00
Brandon ThomasandClaude Opus 5.5 5231852443 Bump version to 0.4.0 (from 0.3.0) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:01:39 -04:00
Manuel CamachoandClaude Opus 5.5 beebdbcd39 M7.12: natural Text to Speech voices (Kokoro-82M in pure Rust)
- filmcraft-tts (feature kokoro): Kokoro-82M on candle, implemented from
  the StyleTTS 2 / iSTFTNet / ALBERT papers, config.json and the
  checkpoint's tensor shapes; official .pth read with candle's restricted
  pickle reader; voice packs from their zip. Neural Voice: tts-text
  phonemize -> Kokoro per chunk -> pauses -> pitch via audio-dsp.
- catalog: 9 US English voices (model card grade C+ or better) + CMUdict,
  every file pinned to a revision and SHA-256 (336 MB).
- speech: generic download_files (same verified downloader).
- engine: tts.render (background synthesis into Session::tts_cache),
  tts.downloadVoices (background job, cancellable), voices list with
  package state; feature neural-voices (default on in the desktop app).
- ui: natural/basic voice groups, consent box (size, sources, licences),
  download progress + Cancel, synthesis never blocks the window;
  ui.set {tts:{...}} for agents.

Measured: Whisper-base WER on 50 original sentences 2.1-3.6 % for all 9
voices (built-in robotic voice 96.6 %); 2.2-2.3x real time on CPU.
Gates: fmt, clippy, layers, assets, wasm pass; tests pass except
shortcuts_tests::assign_reassigns_conflicts_and_undoes (fails on main)
and the frame pool test (flaky under load, passes alone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:52:30 -07:00
Manuel Camacho 6367fc1444 M7.11: tts-text: keep ? and ! for intonation
Fix round from Claude's P2b review. The normalizer's terminator run now picks
the strongest terminator (? over ! over .) and carries it through a new
backwards-compatible Token::SentenceEndWith(char) (SentenceEnd still means
"."); phonemize attaches ? and ! to the sentence's last word so Kokoro can
shape intonation: "Ready?" -> "ɹˈɛdɪ?", "Go!" -> "ɡˈO!", "Really?!" -> ?.

Also converts the remaining direct indexes in non-test code to
get()/get_mut()/iterators (letter_to_sound slices, the tion/sion and
first-vowel stress writes, the scanner's char access).

New cases: nine terminator tests in normalize (incl. "Is it 1999?",
"Call 555-0134!", "Wait... what?", "Are you there?!") and nine phonemize
assertions (Ready?, Go!, Done., No!!, Are you there?!, ...). All
SentenceEnd-matching tests updated.

Gates: fmt, clippy -p filmcraft-tts-text --all-targets -D warnings, tests
(37 suites), xtask layers, xtask wasm - all green.

🤖 Written by Merlin (Hermes Agent, profile merlin)
2026-10-08 06:52:30 -07:00
Manuel Camacho 82d411c7e2 M7.11: tts-text: English pronunciation (CMUdict + letter-to-sound) for Kokoro
New module phonemize in filmcraft-tts-text: normalize(script) -> words ->
Kokoro phoneme symbols, the interface Claude's Kokoro code (P3) calls. Public
API per the brief's contract: Lexicon (CMUdict parser, 16 MiB / 500 000-entry
caps, malformed lines skipped and counted), Overrides (case-insensitive,
raw /phonemes/ or respellings, capped at 1 000 entries of 200 chars),
Chunk::Phonemes/Pause with MAX_CHUNK_CHARS = 400, phonemize(), and SYMBOLS —
the exact character set emitted (ARPAbet→Kokoro table from the brief, stress
marks ˈ/ˌ before the vowel, function words unstressed, punctuation attached).

Lookup order: overrides → dictionary (first pronunciation) → morphology
(-s/-'s with voicing, -ed, -ing, -er, -ly, un-, re-) → letter-to-sound rules
written from scratch (tion/sion, ture/sure, ough, igh, digraphs, silent e,
soft c/g, doubled consonants, final le). Hyphenated words split per part.

Tests: CMUdict parse table (alternates, comments, malformed counting, caps),
46 exact phonemize cases, 36 letter-to-sound cases, chunk splitting at
commas ≤ 400 chars, a symbol-set test over all normalizer inputs plus
hostile ones, and catch_unwind hostiles (huge, empty, emoji, RTL, NUL,
10 000-letter word, 1 001 overrides). Optional measured-accuracy test
(FILMCRAFT_CMUDICT): held-out CMUdict words → 9.1% word accuracy, 37.9%
phoneme error rate (229 words, 2026-10-08). CMUdict is downloaded with the
voices at run time, never committed.

Gates: fmt, clippy -p filmcraft-tts-text --all-targets -D warnings, tests
(37 suites), xtask layers, xtask wasm (44 crates) — all green.

🤖 Written by Merlin (Hermes Agent, profile merlin)
2026-10-08 06:52:30 -07:00
Manuel Camacho a38ddcbda4 M7.10: tts-text: text normalizer for narration (English)
New L2 crate filmcraft-tts-text: normalize(script, lang) -> Vec<Token> for the
Text to Speech text front end (P2a). English only (EnUs/EnGb), per the amended
brief. Handles cardinals to 999 999 999 999, negatives, decimals, ordinals,
money ($, €, £), percent, years (1100-2099), clock times, numeric and
abbreviated dates, phone-like digit groups, Mr./Mrs./Dr./St./Mt./vs./etc./e.g./
i.e./a.m./p.m., spelled acronyms, &/+@#/, and [pause Ns|ms] markers capped at
10 s. Pure Rust, deps serde + thiserror only, wasm-clean.

Never crashes (AGENTS.md §0): >1 MiB is NormalizeError::TooLong, oversized and
leading-zero numbers are read digit by digit, malformed pause markers become
plain text, all slicing is get()/boundary-safe, and a catch_unwind suite covers
empty/whitespace/oversized/10k-digit/$$$$/mixed-script/emoji/RTL/combining/NUL
inputs plus determinism.

Fix round (Claude review): a '.' after a number now ends the sentence (year
rule and money/decimal follow-through), so "in 1999." reads as a year with a
SentenceEnd and "$1,234,567." gets its final SentenceEnd; new table cases for
sentence-final numbers and the year rule under punctuation (., ,, !, ()); the
remaining direct indexes in non-test code converted to get()/first()/last().

Gates: fmt, clippy -p filmcraft-tts-text --all-targets -D warnings, tests
(25 suites, ~140 table cases), cargo xtask layers, cargo xtask wasm all green.
cargo xtask ci not run this round (Claude runs it when merging); it stops on a
pre-existing filmcraft-engine shortcuts test failure unrelated to this crate.

🤖 Written by Merlin (Hermes Agent, profile merlin)
2026-10-08 06:52:30 -07:00
Manuel CamachoandClaude Opus 5.5 66ba5bbb3c M7.9: Text to Speech narrations (panel, tts.* commands, built-in voice)
Write what a narrator says, pick a voice, pitch and pace, and add it to
the timeline. Selecting a narration clip loads it back into the panel;
Save regenerates it in place as one undo step, keeping the clip's start
and length (longer speech kept whole in the file, shorter speech padded
with silence).

- filmcraft-tts (L2): Voice trait, voice catalogue, pause markers
  ([pause 1s]), and an original formant synthesizer (Basic Female/Male;
  robotic placeholder until the neural voices land).
- project: Narration + Project::narrations (schema v13, no-op migration).
- engine: tts.voices/preview/create/edit/inspect; narrations never
  overwrite clips (free targeted track, free track, else a new track).
- ui: Text to Speech panel beside Properties, preview playback,
  click-to-edit, automation ids.

Gates: fmt, clippy -D warnings, layers, assets and wasm (rustup
toolchain) pass. Tests: all pass except three that fail identically on
main (61238bf) on this machine: shortcuts_tests::assign_reassigns_
conflicts_and_undoes, gpu fx_tests::gpu_effects_match_cpu_exactly, and
the frame pool test, which passes when run alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:52:23 -07:00
207b179428 README Downloads: where to get it first, then builds with Windows before macOS (#271)
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:50:42 -04:00
Tom 0bf83a27c0 Windows: platform modifier names in status-bar and mask hints
The Selection and Zoom status-bar hints and the mask-vertex tooltip
named Mac keys (Opt, Cmd) on every platform. Show Alt/Ctrl on
Windows and Linux, matching the keys the code actually reads
(egui `alt` / `command`). macOS text is unchanged.
2026-10-08 14:47:40 +01:00
86a531ad22 Release: FreeBSD, Flatpak (x86_64 + aarch64) and AppImage zsync; README Downloads (#267)
- release.yml: new `flatpak` jobs (x86_64 on ubuntu-24.04, aarch64 on ubuntu-24.04-arm) that
  repackage the Linux tarball as filmcraft-<v>-linux-<arch>.flatpak, and a `freebsd` job that
  builds filmcraft-<v>-freebsd-x86_64.tar.gz in a FreeBSD 14.3 VM; both feed the draft release.
- AppImages embed gh-releases-zsync update information and ship a .zsync beside them; the
  Linux job checks both.
- Jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) no longer use the `release`
  environment, so a dispatch on a branch dry-runs them; the signing jobs and the draft-release
  job stay in the environment, so nothing is published from a branch.
- packaging/linux/flatpak-bundle.sh + bundle manifest (same runtime and finish-args as the
  Flathub manifest; packaging-lint checks they agree), packaging/freebsd/package.sh (with a
  --dry-run that packaging-lint runs), and a nightly FreeBSD build workflow.
- README: Downloads section listing every build; docs/releasing.md updated.

Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:41:42 -04:00
wstockandClaude Opus 5.5 06abca1bb9 shortcuts: Delete clears the selected timeline clips (#243)
Windows and Linux keyboards' Delete key is the forward-delete key. In
FilmCraft Default it was bound only to Project ▸ Clear (application-
wide), so with the Timeline focused it ran project.delete, which does
nothing without a Project-panel selection: the selected clips stayed.
Backspace (the Mac "delete" key) already cleared them.

Add a Timeline-panel binding of Delete to edit.clear, as Premiere has
it; in the Project panel and application-wide Delete still runs
Project ▸ Clear.

Tests: shortcuts_tests::delete_clears_timeline_clips_and_project_items
(Delete resolves to edit.clear with the Timeline focused and to
project.delete otherwise, macOS and Windows) and
keyboard_ui::delete_key_clears_the_selected_timeline_clip (headless app:
D selects a clip, Delete removes it, undo restores it). Both failed
before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:14:52 +00:00
Brandon ThomasandClaude Opus 5.5 08735f3103 Bump version to 0.3.0 (from 0.2.1) for release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:12:49 -04:00
wstockandClaude Fable 5.1 32764392f1 control: Ctrl+ chords through ui.key fire shortcuts off macOS (#245)
Bindings are written Cmd+… and parse to `command`; egui's matching then
requires `command`. A physical Ctrl press off macOS carries it
(egui-winit sets command = ctrl there), but ui.key built its event from
parse_shortcut, which mapped "Ctrl" to `ctrl` alone, so an injected
Ctrl+Z matched nothing while Cmd+Z worked. ui.click's `ctrl` modifier
had the same gap.

Off macOS "Ctrl" and "Cmd" are one key (the engine's Chord::effective
already says so), so parse_shortcut maps both to `command` there, and
the control channel synthesises presses the way the OS reports them:
a press of either carries both flags. macOS is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 11:08:19 +00:00
Ahmed HosnyandKimi 9bf6595ccd Playback: fall back to a working output device when the ALSA default cannot be opened
On a machine whose ALSA default PCM is misconfigured (missing 99-pipewire-default.conf symlink, #23/#106) the host default could not be opened: playback ran without sound and without any user-facing message. The device picker now falls back to the first output device that can open, logs why, and a new defaulted AudioOut::note() surfaces the note in the status bar (first frame and when playback starts); a machine with no device at all reports it too.

🤖 Generated with [Kimi Code](https://github.com/storyteller/kimi-code)
Co-Authored-By: Kimi <noreply@storyteller.dev>
2026-10-08 13:59:59 +03:00
wstockandClaude Fable 5.1 acf56bcd27 engine: project.delete takes explicit bin ids without a Project-panel selection (#244)
A caller that names its targets does not need a selection: enabled_for
re-runs the enablement check with the named ids standing in for it. But
named_items only recognised project items, and project.delete's `items`
take bin ids too (the Project panel's selection holds both), so
{"items":[<bin ids>]} with nothing selected was still "not available
right now: select an item in the Project panel".

Count a named bin as a target, except the project's own top bin, which
no command acts on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 10:54:42 +00:00
WavJaby 92c6aa2b63 chore(lints): preserve behavior in workspace clippy cleanup
- Move conditional no-op match arms into guards

- Preserve renderer predicate order with De Morgan's law

- Strict clippy, fmt, layers, assets and WASM pass on Rust 1.95

- Clean engine runs match main: 379 pass, 10 fail, 3 ignored

- Full CI is not green; upstream baseline failures remain
2026-10-08 18:22:27 +08:00
Gert Pellin 2d6c4d3a3d Desktop: write log records to standard error and a rotating log file
The desktop app installed no logger, so every log::warn!/log::error! in
the workspace was discarded: GPU compositor fallbacks, audio output
problems, a panicked frame job or audio mixer, container warnings. A
launch from a desktop menu has no terminal either, so there was nothing
to attach to a bug report.

apps/filmcraft/src/logging.rs (no new dependency):
- standard error plus <data dir>/Logs/filmcraft.log, next to the crash
  logs (--data-dir / FILMCRAFT_DATA_DIR respected, Help > Reveal Log Files
  opens the folder); each launch rotates the previous log to
  filmcraft.1.log and .2.log, so the log of a run that crashed survives
  the next start; the file is capped at 16 MiB
- records logged before the data directory is known are buffered
  (bounded) and written once the file is attached
- default levels: info for filmcraft*, warn for everything else;
  RUST_LOG replaces them with env_logger-style directives
- never panics: an unusable directory or a failed write leaves standard
  error as the only sink; a poisoned lock is tolerated
- the realtime audio callbacks do not log; a record holds the lock for
  one write of an already formatted line

The logger is installed first in main (before the panic hook) and the
file is opened after argument parsing (--help, --version and usage
errors leave no file). The start-up eprintln!s in main (auto-save
unavailable, open/recovery errors, no window) now go through the logger.
Documented in docs/contributing.md (environment variables, Logs).
2026-10-08 11:37:02 +02:00
MrCloudy2andClaude Opus 5.5 fcdcc07359 #219: right-clicking a cut opens the edit point menu
Right-clicking a clip's edge on the timeline now selects that edit point and
opens Premiere's edit point menu instead of the clip menu: Ripple Trim In,
Ripple Trim Out, Roll Edit, Trim In and Trim Out (each re-selects the cut with
that trim type, greyed out when the needed side has no clip), Apply Default
Transitions, and Join Through Edits. The body of a clip still opens the clip
menu.

Join Through Edits from this menu joins only the right-clicked cut (and its
linked partner): `sequence.joinThroughEdits` takes a new `cut: [left, right]`
parameter, since `clips: [id]` also matched any other through edit touching
the same piece.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:19 +02:00
Gert Pellin d018fb9abb Contributors: credit switch87 as Gert Pellin 2026-10-08 11:27:34 +02:00
Gert Pellin 7a042cd72f README: install on Gentoo from the ::snakebyte overlay 2026-10-08 11:25:54 +02:00
MrCloudy2andClaude Opus 5.5 bee690f050 #220: Unlink leaves only the right-clicked clip selected
Unlinking from the timeline clip menu cleared the link, but the picture and
its sound stayed selected together, so dragging the picture still moved the
sound and Unlink looked like it did nothing. The clip that was right-clicked
is now the only one selected after Unlink, and can be dragged away straight
away. Link leaves the selection as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:53:54 +02:00
Ahmed HosnyandKimi 58ce158ed0 contributors: credit ahhosnyas (people.ahmedhosnypro) as Ahmed Hosny
Own entry in contributors/people.toml for the About window, per AGENTS.md.

🤖 Generated with [Kimi Code](https://github.com/storyteller/kimi-code)
Co-Authored-By: Kimi <noreply@storyteller.dev>
2026-10-08 11:39:09 +03:00
Ahmed Hosny f14d8b7ec9 Merge branch 'main' into fix/linux-dev-desktop-icon 2026-10-08 11:37:31 +03:00
Ahmed Hosny 538a329935 linux: install a dev desktop entry so the taskbar shows the app icon
A dev run (cargo run) has no installed .desktop entry or hicolor icon for
the app id the window sets (ai.storyteller.filmcraft), so KDE (Wayland and
X11) falls back to the generic Wayland icon in the taskpanel and the
window topbar. Packaging installs both; development did not.

At startup on Linux, when no ai.storyteller.filmcraft.desktop exists in
any XDG data dir, install one into $XDG_DATA_HOME/~/.local/share
(applications/) plus the 256/512 hicolor PNGs, with Exec pointing at the
running binary. A packaged entry is never overridden; failures print a
warning and the app carries on (AGENTS.md §0).

🤖 Generated with [Kimi Code](https://github.com/storyteller/kimi-code)
Co-Authored-By: Kimi <noreply@storyteller.dev>
2026-10-08 11:37:31 +03:00
wstockandClaude Fable 5.1 6068bfc589 export: keep alpha in PNG/TIFF sequences with Include Alpha Channel (#160)
The export pipeline flattened every frame over black
(`over_black_rgba8`), so a PNG sequence of a sequence with transparency
came out on a black background, while Export Frame already wrote straight
RGBA. `ExportSettings.alpha` (serde default false, Premiere's default)
makes the pipeline emit straight sRGB RGBA for PNG and TIFF sequences and
`encode_still` keep the channel in TIFF; BMP and video formats ignore it.
Exposed as the `alpha` flat parameter of file.exportMedia and an
"Include Alpha Channel" checkbox in Export mode's Video section, shown
for PNG and TIFF only.

Fixes #160

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 08:31:37 +00:00
WavJaby aa66dd9c8d fix(import): use the open Project bin as destination
Resolve the shown Project bin for file-dialog and dropped imports. Capture the browser destination before asynchronous media reads so navigation cannot redirect an in-flight batch.
2026-10-08 16:27:48 +08:00
WavJaby 3e39952e0b fix(web): preserve distinct same-name media imports
Allocate an unused virtual path for every browser import so equal-size media cannot replace existing sources. Add an OPFS recovery regression for issue #194.
2026-10-08 16:18:07 +08:00
wstockandClaude Fable 5.1 0e129e2811 #230: jobs time themselves with web_time, so Create Proxies and mask tracking don't panic on the web
The web build runs every job synchronously, and four job bodies (proxies,
mask tracking, video and audio previews, Project Manager) still called
std::time::Instant::now() for their "Done in N s" report — which panics
with "time not implemented on this platform" on wasm32 and took the whole
app down. They now use web_time::Instant like the rest of the engine
(std::time::Instant on native targets). autosave imports its clock from
web_time too, and a regression test scans the crate's non-test sources
for std::time::Instant so the next copy-pasted job fails cargo test
instead of the web app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 07:45:37 +00:00
txiki739andClaude Opus 5.5 4af4765278 Merge storytold/main into i18n-spanish-panels
Brazilian Portuguese (#176) moves to the catalog system as i18n/pt-br.tsv (the
menu entries unchanged) and joins the general.interfaceLanguage preference. The
new UI text from main is wrapped and translated into Spanish: the credits
window, Text Properties, H.265 export rows, Clip Speed / Duration, Color Matte
dialogs, the voice-over discard button, the time-left suffix and the audio
output status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:59:32 +02:00
Claude 7fbb223707 #197, #193: web recovery retries failed snapshot writes and keeps skipped snapshots dirty
The OPFS recovery snapshot (apps/filmcraft-web) had two problems:

- #197: opfs::write never reported failures, and Autosave recorded the
  revision as snapshotted before the write ran. A failed write was only
  retried after the next edit.
- #193: the first tick of a clean session wrote {"dirty": false} to the
  meta. Starting with ?norecover or ?fresh therefore discarded a
  snapshot that was never reopened.

The decision of when to snapshot or mark clean moves to a pure module,
recovery_policy, tested natively in tests/recovery_policy.rs. Failed
writes (snapshot or clean meta) are retried after the 5 s interval
without another edit, and the meta is only marked clean to retire a
snapshot this session wrote. opfs::write_then reports whether a queued
write landed (coalesced writes report the newest one's result).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HrAqZmrrDLRpxBJatxvaJ
2026-10-08 04:37:53 +00:00
carlitosteso3andClaude Opus 5.5 7392a69d0f #208: Go to In / Go to Out move to the sequence start / end without marks
The Program monitor's Go to In and Go to Out buttons (Shift+I / Shift+O) did
nothing when the sequence had no In / Out mark. Like Premiere (and like the
Source monitor already did), they now go to the start / end of the sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:43:49 -05:00
Brandon ThomasandClaude Opus 5.5 61238bf8fc Release CI: macOS job on macos-15-xlarge
Custom macOS larger runners in a runner group do not pick up jobs; the
built-in M2 Pro label does. Only release.yml uses it, and the
actions_macos_xl budget caps the monthly spend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:57 -04:00
Brandon ThomasandClaude Opus 5.5 08b769a92a Release CI: macOS job on the org macos-release runner
The standard macOS runners are shared with all CI and queue for an hour or
more. The macos-release runner (GitHub-hosted M2 Pro) is in a runner group
usable only by release.yml on the release branch, with a monthly spend cap
on the actions_macos_xl SKU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:54:54 -04:00
Brandon Thomas 5812509d3b Merge pull request #213 from carlitosteso3/fix/speed-dialog-undo-201
Speed/Duration opens its dialog; dragging an effect parameter is one undo step (#201)
2026-10-07 21:25:58 -04:00
Brandon ThomasandClaude Opus 5.5 ee8f886332 #201: each press of a drag begins its own undo step
A drag value changes only once the mouse moves, after the press frame, so sending `begin`
only on the press frame merged two drags of the same parameter into one undo step.
Remember the press that began a step by its start time instead; unit test drives two drags
through `run` with a real egui context (fails on the press-frame version).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:25:44 -04:00
carlitosteso3andClaude Opus 5.5 70aa661586 #201: Speed/Duration opens its dialog; dragging an effect parameter is one undo step
- The clip menu's Speed/Duration… applied 50% at once, and Cmd+R / Clip ▸
  Speed/Duration… ran it at 100% with no dialog. It now opens Clip Speed /
  Duration (speed, the resulting duration, Reverse Speed, Ripple Edit, Time
  Interpolation), seeded from the selected clip.
- effects.setParam took one undo step per frame of a drag, so Undo after
  dragging Time Remapping's speed (or any Effect Controls value) crept back in
  small steps. It takes `merge` / `begin` like the mixer and masks, and Effect
  Controls sends them while the mouse button is down: a drag is one step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:25:44 -04:00
Brandon ThomasandClaude Opus 5.5 87c4d1516e About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#215)
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle)

Help ▸ About FilmCraft gets tabs About · Contributors · Models, following the craftrules standard
(standards/contributors.md): contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as static
tables (nothing read at run time; the web build has them too). Each contributor line: @username,
opt-in display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last commit.
Sortable by every column; alphabetical ignores case and the leading @. Tabs and credits controls
register automation ids (about.tab.*, about.credits.*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors docs: craftrules is two levels up (../../craftrules)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section

build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: regenerate (verified people.toml entries)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:16:52 -04:00
Brandon Thomas c337dcce01 Merge pull request #210 from cirocueto2013-wq/fix/hostile-input-bounds
fix: validate hostile project, export and MXF inputs
2026-10-07 20:53:11 -04:00
Brandon ThomasandClaude Opus 5.5 de70468ab6 #210 review: bound MXF reads by the file, keep legacy projects opening, allow 16K
- mxf: read_pcm bounds a request by the sample frames the file actually stores (plus 16M
  samples of zero padding) instead of a fixed 16,777,216-sample cap, so whole-clip reads of
  long stereo MXF (transcription) work again; PCM/AES3 chunk buffers are clamped to the source
  length instead of a 256 MiB cap; decode_pcm's output is bounded by its input, so its
  fixed cap goes (the AES3 >8-channel refusal stays; a request is also bounded by the source
  length divided by the channel count). read_sample keeps only the source-length bound.
- mxf: index_entries no longer fails open() past 1,048,576 entries (the sparse BTreeMap
  already bounds memory by real entries); long recordings open.
- project/format: new Sequence/Track/CaptionTrack::check_bounds() hold only the hostile-value
  bounds (settings, marks, overflowing time ranges); project load and export ranges use it, so
  projects that break only structural rules (overlap, zero duration) still open and export.
  check() = check_bounds() + the structural rules, as before.
- project: frame-size cap raised to 268,435,456 pixels, 32768 per side (16K, 16384x8192).
- engine: checked_u32_p accepts integer-valued floats (1920.0); renderable_sequence returns
  an EngineError (new Session::try_render_program_at, used by automation render) instead of
  eprintln.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:52:53 -04:00
cirocueto2013-wqandCodex 07d85b0f02 fix: validate hostile project, export and MXF inputs
Validate staged speed edits before adopting their timeline state.

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 20:38:43 -04:00
Brandon Thomas a847671dbb Merge pull request #212 from storytold/test-188
#188: regression test — a copied Graphic clip pastes at the playhead
2026-10-07 20:37:34 -04:00
c57541674d #188: regression test — a copied Graphic clip pastes at the playhead
Engine test from #205 (the shortcut half of that PR is superseded by #204).

Co-Authored-By: carlitosteso3 <carlitosteso3@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:36:21 -04:00
5ee3ff64e9 Web: Link Media Locate… links the chosen file to the selected missing clip (#111) (#174)
* Web: Link Media Locate… links the chosen file to the selected missing clip (#111)

Link Media ▸ Locate…, `media.attachProxies` and `media.reconnectFullRes` all
need the chosen file's path back synchronously so they can call the engine
with `item` + `path`. The existing `pick_files` hook imports every chosen
file as a new project item, so on the web build (where the browser picker is
asynchronous and the hook returned `Vec::new()`) the command ran without a
path and the file silently landed as a new project item instead of being
relinked. On native the same hook worked because rfd returns paths
synchronously.

Add a separate `pick_file_for_relink` hook that returns the chosen file's
virtual path synchronously. Native hosts open an rfd dialog; the web host
registers the file in `/files/<name>` via a pending slot and runs
`media.relink` itself when the picker resolves. The three call sites prefer
the new hook and fall back to `pick_files` when the host has not installed
it (so the import flow used by every other dialog stays unchanged).

Fixes #111

Co-Authored-By: ninso112 <ninso112@pm.me>

* Co-ship: Luna161 ack'd and reviewed fix/111-link-media-locate

Reviewed the pick_file_for_relink hook routing, the take_files relink path,
and the regression test. Looks correct end to end; ready for upstream review.

Co-Authored-By: ninso112 <ninso112@pm.me>

* #111: relink hint names its command; no pending state after a cancelled picker

- RelinkHint carries the command (media.relink / attachProxies / reconnectFullRes), so the
  web host no longer runs media.relink for Attach Proxies and Reconnect Full Resolution.
- The web picker hands the hint to its own callback instead of a thread-local slot, so a
  cancelled picker can't turn the next ordinary import into a relink.
- Replace the hook-only unit tests with a UI test that drives Link Media ▸ Locate….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: ninso112 <ninso112@pm.me>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:35:58 -04:00
Brandon Thomas 4f98721cab Merge pull request #209 from cirocueto2013-wq/fix/cpal-voiceover-recovery
fix: support integer CPAL devices and recover voice-over/audio failures
2026-10-07 20:35:54 -04:00
Brandon Thomas a4d65d3263 Merge pull request #204 from DWAA1660/fix/199-clipboard-shortcuts
#199: make Ctrl+C / Ctrl+X / Ctrl+V work on Windows and Linux
2026-10-07 20:35:50 -04:00
Brandon Thomas 6d904c1b37 Merge pull request #192 from cirocueto2013-wq/fix/editing-workflow-stability
Stabilize project lifecycle, timeline, audio and export workflows
2026-10-07 20:35:46 -04:00
Brandon Thomas d4ca3594ec Merge pull request #203 from carlitosteso3/fix/ripple-razor-snap-164
Ripple delete names the sync-locked track in the way; playhead snaps to cuts (#164, items 2, 3 and snap)
2026-10-07 20:35:42 -04:00
Brandon Thomas cd7395ec7c Merge pull request #207 from carlitosteso3/fix/ripple-trim-targets-164
Q/W ripple trim only the selected clips' tracks (#164)
2026-10-07 20:35:37 -04:00
carlitosteso3andClaude Opus 5.5 41659ae769 #164: Q/W ripple trim only the selected clips' tracks
With clips selected under the playhead, Ripple Trim Previous/Next Edit to
Playhead (and the plain trims) now trim only their tracks and linked partners,
like Add Edit; with none selected they use the targeted tracks as before. All
tracks are targeted by default, so W on a V1 clip also cut the music on A2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:50:39 -05:00
carlitosteso3andClaude Opus 5.5 e7e96b247f #164: ripple delete says which sync-locked track blocks it; playhead snaps to cuts
- A refused ripple delete / ripple trim now names the sync-locked track whose
  clip is in the way and how to proceed, instead of a generic "would break sync"
  (the edit is refused the same way Premiere refuses it).
- Dragging the playhead no longer snaps onto its own position (it stuck and moved
  in SNAP_PX jumps), snaps on the initial press too, and "Snap playhead in
  Timeline when Snap is enabled" is on by default, as in Premiere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:47:21 -05:00
txiki739andClaude Opus 5.5 c7ddd4f314 tests: shortcut conflict test compares against the platform's own baseline
On Windows and Linux the macOS Control key folds into the primary modifier, so
the default set already reports conflicts there (documented in shortcuts.rs).
The test now checks that assigning adds exactly one conflict and that undo
restores the original set, on every platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:41:48 +02:00
txiki739andClaude Opus 5.5 3d67c4b73d tests: the MPEG oracle reads ffprobe's best-effort timestamps
Recent ffprobe versions report PTS=N/A for a program-stream picture whose
timestamp is inferred, so the oracle dropped that frame and compared 29 of 30.
best_effort_timestamp keeps the inferred time and all 30 frames are compared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:41:48 +02:00
txiki739andClaude Opus 5.5 b6cdc9dd7a i18n: Spanish for the whole interface, catalogs and a remembered language
Translations move from Rust tables to one catalog per language
(crates/ui-egui/src/i18n/<code>.tsv), looked up through HashMaps. UI code
wraps its strings in tl!("…") and tlf!("…{name}", name); names from
registries (effects and parameters, settings, commands, panels, workspaces,
export and effect presets) are translated where they are drawn, so command
ids, automation ids and preference values stay English.

Spanish now covers menus, panels, dialogs, the Settings pages, the Effects and
Effect Controls registry, the Keyboard Shortcuts dialog and status messages
(about 3,350 entries). Tests fail when a tl!/tlf! literal, a menu label or a
registry name has no Spanish entry, and check catalog syntax, duplicates,
placeholders and ellipses. Searches match the Spanish label as well as the
English source. Window titles keep their English egui ids.

The language is stored in the general.interfaceLanguage preference (also in
Settings > General) and restored on startup; a failed preferences write still
switches the interface and says it could not be saved. Template values are
inserted literally, so a file name containing braces is not re-substituted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:41:48 +02:00
cirocueto2013-wqandCodex 7151d329d6 fix: support integer CPAL devices and recover voice-over/audio failures
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 20:33:33 -03:00
DWAA1660andClaude Opus 5.5 f4221cd1b5 #199: make Ctrl+C / Ctrl+X / Ctrl+V work on Windows and Linux
egui-winit reports the clipboard shortcuts as Event::Copy / Cut / Paste
instead of key presses, so the shortcut bindings never matched them.
Outside text fields, add the key press each clipboard event stands for
before matching bindings, and put copied clip names on the system
clipboard so Ctrl+V is delivered right after a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQNunPf8nMeAQi6M92RT68
2026-10-07 16:57:38 -06:00
cirocueto2013-wqandCodex 1fd30aa29d engine: bound subclip media resolution and reject cycles
Co-authored-by: Codex <noreply@openai.com>
2026-10-07 19:56:58 -03:00
Brandon Thomas a4536bcfed Merge pull request #180 from SaymonMaster14/feat/windows-nvenc-h264
Windows: NVENC H.264 hardware encoding for Export (opt-in)
2026-10-07 18:17:05 -04:00
Brandon ThomasandClaude Opus 5.5 853f03b1aa M-HW: NVENC on top of the shared hardware-encoding setting (#183); never-crash fixes
Merge origin/main into the NVENC branch. #183 already provides the
HardwareEncoding enum, ExportSettings.hardware_encoding, the
hardwareEncoding export param and the Export > Video > Hardware Encoding
checkbox, so the duplicates from this branch are dropped and NVENC uses
them. filmcraft-export is now an all-platform dependency of
filmcraft-platform (VideoToolbox and NVENC both register encoders).

Never-crash fixes in the NVENC backend:
- submit() rejects a driver input pitch narrower than a row (and unlocks
  the buffer); fill_nv12 uses get_mut instead of slicing rows.
- composition offsets use saturating math and i32::try_from; frame-rate,
  size and timestamp casts are checked; sizes above u16::MAX decline.
- Annex B scanning and NAL type checks use get()/first().
- A failed nvEncOpenEncodeSessionEx that returns a handle destroys it.

ffi.rs carries NVIDIA's MIT notice for nvEncodeAPI.h, listed in
ATTRIBUTION.md under a new "Third-party source code" section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:16:12 -04:00
Brandon Thomas 71483312b6 Merge pull request #162 from SaymonMaster14/feat/windows-vp9-av1-hwdecode
Windows: VP9 + AV1 hardware decode (Media Foundation / DXVA)

# Conflicts:
#	ROADMAP.md
#	crates/platform/README.md
2026-10-07 17:58:12 -04:00
Brandon Thomas 13f71e3111 Merge pull request #175 from Luna161/fix/110-import-refreshes-offline
Engine: file.import refreshes the offline-missing list (#110)
2026-10-07 17:56:50 -04:00
Brandon ThomasandClaude Opus 5.5 4e4efcef12 M-relink: file.import also drops the cached slate of media it brought back (#110)
refresh() cleared the Missing badge, but the media pool kept the
MEDIA NOT FOUND slate cached under the item, so the monitors kept
showing it. The regression test now checks the rendered frame too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:56:33 -04:00
Brandon Thomas a4b92f1bec Merge remote-tracking branch 'origin/main' into pr-175 2026-10-07 17:54:19 -04:00
Brandon Thomas 1fe80344c7 Merge pull request #195 from midasdf/fix/vertical-type-tool-flyout
M10: choose vertical titles from the Type tool flyout
2026-10-07 17:53:53 -04:00
Brandon ThomasandClaude Opus 5.5 bd5387dff6 Merge main into #195: vertical type on top of point/paragraph text (#166)
The Type-tool click now goes through new_text_action; Vertical Type swaps
in graphics.newVerticalText and never draws a paragraph box (vertical text
is point text). Drops the dated ROADMAP log line (the maintainers keep the log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:53:45 -04:00
Brandon ThomasandClaude Opus 5.5 6ce2038a00 M-HW: keep the Windows-only annexb/biplanar helpers warning-free on other systems (#141)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:50:20 -04:00
Brandon Thomas fcf1c45583 Merge pull request #141 from SaymonMaster14/feat/windows-media-foundation-hwdecode
Windows: Media Foundation/D3D11 hardware H.264 + HEVC decode

# Conflicts:
#	README.md
#	ROADMAP.md
#	crates/platform/Cargo.toml
#	crates/platform/src/lib.rs
#	docs/architecture.md
#	docs/performance.md
2026-10-07 17:48:06 -04:00
Brandon Thomas b5f197567b Merge pull request #186 from dcarnelutti/platform/videotoolbox-hevc-encode
platform: H.265 (HEVC) export with VideoToolbox

# Conflicts:
#	crates/export/src/settings_tests.rs
#	crates/ui-egui/tests/export_ui.rs
2026-10-07 17:47:10 -04:00
Brandon Thomas 84fcbeb1b9 Merge pull request #183 from dcarnelutti/platform/videotoolbox-h264-encode
platform: opt-in hardware H.264 encoding with VideoToolbox

# Conflicts:
#	crates/engine/src/export_tests.rs
2026-10-07 17:46:57 -04:00
Brandon Thomas d18bc04023 Merge pull request #184 from dcarnelutti/render/overlay-bounds-and-buffer-reuse
render: composite only what overlays cover, and reuse the float images
2026-10-07 17:46:38 -04:00
Brandon Thomas fed7201fcf Merge pull request #185 from dcarnelutti/export/progress-eta
export: show the estimated time left of a render next to its percentage
2026-10-07 17:46:37 -04:00
Brandon Thomas cbf181bd1f Merge pull request #176 from RicSchonfelder/i18n-pt-br
i18n: add Brazilian Portuguese (pt-br) UI translation
2026-10-07 17:46:33 -04:00
Brandon Thomas ce5d365c78 Merge pull request #165 from carlitosteso3/fix/media-browser-probe-157
Media Browser: probing a file doesn't read it whole (#157)

# Conflicts:
#	crates/engine/src/media_pool.rs
2026-10-07 17:46:32 -04:00
Brandon Thomas d01e1b893d Merge pull request #145 from carlitosteso3/fix/color-matte-29
Color Matte: pick the color on creation and change it later (#29)
2026-10-07 17:46:23 -04:00
Brandon Thomas c86847ff72 Merge pull request #155 from carlitosteso3/fix/playback-stalled-audio-136
Playback: a stalled audio device no longer freezes the playhead (#136)
2026-10-07 17:46:22 -04:00
Brandon Thomas 0810b403ce Merge pull request #148 from EthanSuquet/fix/h264-rc-slow-start
h264enc: 1-pass bitrate modes no longer starve the opening second
2026-10-07 17:46:21 -04:00
Brandon Thomas 8b3f118878 Merge pull request #173 from carlitosteso3/fix/shortcut-modifiers-164
Shortcut recorder accepts modifier chords; Add Edit cuts only selected clips (#164, items 1 and 5)
2026-10-07 17:46:20 -04:00
Brandon Thomas b4c65bdf8a Merge pull request #189 from Luna161/fix/168-project-select-all
Engine: project.selectAll and project.deselectAll for the Project panel (#168)
2026-10-07 17:46:19 -04:00
Brandon Thomas 47f75df15e Merge pull request #182 from justjohn12345/pr/layer-box-visibility
Graphics: only selected layers have a box, and a hidden layer has none
2026-10-07 17:44:08 -04:00
Brandon Thomas 91a36a4a3f Merge pull request #166 from justjohn12345/pr/text-box-modes
Graphics: point text and paragraph text, as Premiere has them
2026-10-07 17:44:07 -04:00
Brandon Thomas a3667ad26e Merge pull request #190 from ajsongpilot/fix/new-from-file-anchor
Graphics: New Layer from File anchors the picture at its own centre
2026-10-07 17:44:06 -04:00
midasdf b7beca9bb9 M10: choose vertical titles from the Type tool flyout 2026-10-08 06:13:41 +09:00
AJ DepewandClaude Opus 5.5 57069293fb Graphics: New Layer from File anchors the picture at its own centre
graphics.newFromFile resolved the Motion anchor with the sequence frame size
instead of the imported picture's size, so a picture smaller than the frame
got its anchor at the frame centre (e.g. 960,540 for a 4x2 image). Setting
Motion ▸ Position then moved the wrong point and the picture landed far from
where it was asked to go. place_video_clip now resolves auto points with the
source's own size (Project::source_size), falling back to the frame size.

The new-layer-from-file test checks the anchor is the picture's centre; it
fails before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:35:41 -06:00
06bc7ef9ec Engine: project.selectAll and project.deselectAll for the Project panel (#168)
Root cause: the only Select All was `edit.selectAll`, which fills the
timeline clip selection (`state.selection`). Nothing filled the Project
panel's selection (`state.project_selection`), so with 161 imported
items the user had to click each one.

Fix:
- `project_panel::commands()` gets `project.selectAll` (selects every
  item in the project) and `project.deselectAll` (clears the Project
  panel selection). They are regular journalled commands with no menu
  entry and no shortcut of their own, so they add no shortcut conflicts.
- `handle_shortcuts` (ui-egui) remaps a fired `edit.selectAll` /
  `edit.deselectAll` to the `project.*` command when the Project panel
  has focus. Cmd+A / Cmd+Shift+A keep acting on timeline clips
  everywhere else, and `edit.selectAll` behaves exactly as before.

Out of scope: no menu entry. The Edit ▸ Select All menu item still
calls `edit.selectAll` directly, because menu clicks don't go through
the focus remap. The regression test checks the engine commands but not
the UI keyboard remap.

Regression test: project_panel_tests::select_all_selects_every_project_item
(fails without the fix: unknown command).

Co-Authored-By: ninso112 <ninso112@pm.me>
Co-Authored-By: Luna161 <Rosenkranzrene@gmail.com>
2026-10-07 20:21:49 +00:00
Dario CarneluttiandClaude Sonnet 5.5 790b3a0697 platform: H.265 (HEVC) export with VideoToolbox
Adds `Format::Hevc` (Export > Format > H.265 (HEVC); `"format": "hevc"` in
`file.exportMedia`): HEVC Main, 8-bit 4:2:0, in MP4 (`hvc1`) or QuickTime, with AAC audio,
encoded by the macOS hardware encoder. There is no built-in H.265 encoder (one in pure Rust
is a project of its own, and x265 is GPL), so the format exists only where the OS has one.

- `videotoolbox_encode`: the session wrapper creates HEVC sessions too (`VtProfile::HevcMain`;
  the profile also says which codec). Keyframes are the HEVC random access points (NAL types
  16 to 21); the parameter sets are VPS / SPS / PPS; the `hvcC` record in the MP4 sample
  entry is the one VideoToolbox writes (sample description extension atoms), so profile,
  level and flags are the encoder's own. `VtEncoder::uses_hardware()` asks VideoToolbox
  whether the session runs on a hardware encoder (a test checks H.264 and HEVC), so a
  requirement dropped by accident could never become a silent software encode.
- `hardware_encode`: one adapter for both codecs. Choosing the format is the opt-in (the
  `hardware_encoding` setting is for H.264). `hevc_available()` (one small hardware session,
  created once) is registered with the new `filmcraft_export::register_format_probe`, so
  `available(Format::Hevc)`, the format list of Export mode and `exportFormats` follow the
  machine. `Format::has_builtin_encoder` says which formats need such a probe.
- `export`: `Format::is_h26x` where H.264 and H.265 behave alike (MP4 or QuickTime, AAC, size
  estimate, summary line). Two-pass VBR is refused up front ("H.265 export has no two-pass
  mode") and HDR sequences export as SDR, because there is no encoder to fall back to.
- Export mode: H.265 shows H.264's multiplexer, audio, metadata and bitrate controls, without
  profile, level, two-pass or the hardware checkbox, and says "Encoder: Hardware".

Apple M1 (8 cores), 1080p25, a 9:29 timeline (camera clip, ProRes 4444 overlays, AAC,
loudness): hardware H.265 at 10 Mb/s 195 s, hardware H.264 at 20 Mb/s 204 s (CPU 1029 s and
1042 s; the same H.264 export took 193 s and 200 s on other runs). It is not faster: the codec
that compresses further is the more expensive one (raw encoders: 166 and 189 frames/s), and
the export is bound by the CPU compositor, not by the media engine.

Quality per bit, 30 s of the same timeline against a ProRes 422 HQ export (average PSNR over
the three planes, SSIM): 20 Mb/s 46.88 dB / 0.9902 (H.264) and 46.91 dB / 0.9903 (H.265);
10 Mb/s 44.96 / 0.9863 and 45.09 / 0.9863; 6 Mb/s 43.72 / 0.9833 and 44.31 / 0.9843; 4 Mb/s
42.91 / 0.9807 and 43.64 / 0.9825; 3 Mb/s 42.37 / 0.9786 and 43.05 / 0.9807. The same picture
at 10 Mb/s and above; at 3 to 6 Mb/s H.265 needs 22 to 31 % less bitrate for the same PSNR.
Both formats therefore keep the same default bitrate. Details in docs/performance.md (HW3).

Tests: tests/hardware_encode.rs gets the HEVC counterparts of the H.264 ones (what is taken
and declined, a round trip through our HEVC decoder with luma PSNR above 30 dB, keyframes, MP4
and QuickTime exports with AAC, ffmpeg / ffprobe reading `hevc` / `Main` / `hvc1` /
`yuv420p` / BT.709, exact sizes, hostile configurations, encoders dropped at any point, the
format list agreeing with the probe, sessions on the hardware encoder); settings_tests.rs
checks names, ids, defaults, the summary, two-pass refusal and the probe; export_ui.rs the
controls H.265 shows and hides.

Stacked on the hardware H.264 encoder (the first commit of this branch).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:00:08 -06:00
Dario CarneluttiandClaude Sonnet 5.5 283ea80a62 export: show the estimated time left of a render next to its percentage
Export mode, the status bar and the Progress panel showed "Encoding 37%" and nothing about how
long the rest would take. They now say "37% · 2:05 left".

- `Progress::eta()` (the new `pace` module) is the time left at the job's speed over the last
  15 seconds. Each call is also a reading, so the UI polling on every frame it draws the job
  feeds it, and none of the places that advance `done` had to change. A trailing window follows
  the real speed changes of an export (an intro, a banner, plain camera footage) where an
  average since the start lags; a stall makes the estimate grow instead of freezing it; a long
  idle start (the loudness pass) is not counted as the job's speed; nothing is shown until a
  second of readings has progress, and nothing once the job is done or finished.
- `format_eta`: `45 s`, `2:05`, `1:02:05`, rounded up.
- Shown in the status bar ("Exporting... 37% · 2:05 left", and the `status.job.progress`
  automation label), in the Export queue (on its own line: the column is narrow) and in the
  Progress panel. The engine adds `etaSeconds` (null while unknown) to every job of `jobs.list`
  and to the encoding items of `export.queue.list`; docs/agents.md says so.

Measured on a whole 9:29 export (193 s, Apple M1), asking every 3 s: the first estimate comes
after 6 s; from 30 s on, the predicted end of the export is on average 1.1 s early and never
more than 14.5 s off (7.5 % of the run). The status bar read "9% · 3:00 left" at 21 s and
"97% · 5 s left" at 184 s.

Tests: `pace` (steady job, speed change, stall, idle start, count going back, bounded history,
extreme values, a whole `Progress`), `format_eta`, the engine (`etaSeconds` of jobs and queue
items), and export_ui (status bar label and queue item).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:48:52 -06:00
Dario CarneluttiandClaude Sonnet 5.5 d2ab33ba76 render: composite only what overlays cover, and reuse the float images
The CPU compositor, which is what export runs, works on premultiplied linear f32 images,
33 MB each at 1080p, and every layer was one of them. A profile of a 9:29 export with a
hardware H.264 encoder (so the encoder is no longer the bottleneck) put 61-64 % of the busy
CPU in compositing, 12 % in zeroing and copying memory, 8-11 % each in the float -> 8-bit
and RGBA -> Y'CbCr conversions and 4-7 % in decoding the ProRes overlays: a banner that is
transparent except for its bottom 100 rows still cost a whole conversion and a whole mix,
and the bottom layer paid for a zero-filled canvas and a pass that mixes it over that canvas.

Three changes, each of which gives the bits the general path gives:

- The canvas is allocated when a layer needs it, and an opaque first layer (a Y'CbCr frame
  without an alpha plane, opacity 100 %, Normal blend, the size of the output) becomes the
  canvas instead of being mixed over zeros.
- A clip of plain media whose frame has an alpha plane (a ProRes 4444 banner) is converted
  and mixed only inside the rectangle where its alpha is not zero: `VideoFrame::alpha_region`,
  `to_linear_f32_region` and `blend::composite_at` (`composite` is `composite_at` at the
  origin; Dissolve's pattern follows the pixel's place in the output). A clip with effects,
  opacity masks, frame blending, colour management or a picture that is not placed one to
  one on the output takes the general path, as before.
- `filmcraft_frame::pool` keeps idle f32 images (320 MiB at most). `take_f32_overwritten`
  hands one back with its old contents, which the conversions overwrite whole (so no
  zero-fill), and the export pipeline gives each frame's image back after the 8-bit
  conversion.

Apple M1 (8 cores), a machine that was not idle, so the two builds alternate and the
median is reported:

- Synthetic 1080p frame (`cargo run --release -p filmcraft-render --example frame_bench`,
  5 rounds): camera only 8.9 -> 6.5 ms, camera + banner 16.2 -> 7.8 ms, same picture checksum.
- A real project (1080p25 camera clip, ProRes 4444 banner and subscribe bar), 30 s with the
  banner exported with the built-in encoder, which is deterministic: 46.4 -> 36.7 s, and
  both exports are the same file (same MD5), so every frame of the real pipeline matches.
- The same project with a hardware encoder, 3 rounds: 30 s with the banner 17.4 -> 11.2 s
  (CPU 99 -> 56 s), 30 s of the camera alone 11.4 -> 8.8 s, 20 s with the subscribe bar
  11.1 -> 7.0 s.

Tests: `render/src/region_tests.rs` compares each shortcut with the general path bit for bit
(an opaque bottom layer, banners over and under a camera, an 8-bit sticker, every blend mode,
several opacities, moved, scaled and effect clips, a bigger source, reduced-resolution
rendering), each render twice so the second starts from recycled buffers, and checks which
layers take a shortcut; the frame tests check that a region is the crop of the whole
conversion, that `alpha_region` is the box around what is not transparent, and that a buffer
full of NaN never leaks into a conversion, for every pixel format. Breaking a shortcut on
purpose (a region one pixel off, an opaque layer taken as the canvas at half opacity, a
conversion that skips the alpha channel) makes them fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:35:20 -06:00
Dario CarneluttiandClaude Sonnet 5.5 4cab6bc5da platform: opt-in hardware H.264 encoding with VideoToolbox
Export encodes H.264 on the CPU with the built-in encoder, which is most of the time
of an export (about two thirds of the busy CPU in a profile of a 20 s export). On
macOS the media engine can do it: a VideoToolbox compression session behind
`filmcraft_export::VideoEncoder`, registered by `filmcraft_platform::register()`.

- `ExportSettings::hardware_encoding` (`HardwareEncoding::Off` | `Auto`; Export > Video >
  Hardware Encoding, `"hardwareEncoding": "off|auto"` in `file.exportMedia`, a boolean is
  understood too), off by default: the built-in encoder's output is byte-identical on
  every machine, a hardware encoder's is not.
- `videotoolbox_encode` (FFI, same containment as the decoder: SAFETY comments, no
  panic across the callback, safe API) and `hardware_encode` (safe adapter and factory).
- The factory declines, so the built-in encoder is used, for other formats, MXF, two-pass
  VBR, HDR, odd sizes, non-square pixels and any configuration VideoToolbox cannot create a
  hardware session for. An encoder that fails mid-export stops it with an error: unlike a
  decoder, it cannot hand a half-written stream to another one.
- No B-frames: on real 1080p footage the quality is the same with and without them
  (+-0.3 dB at equal bitrate) and the bitrate lands closer to the target, and without
  them every frame is presented at its decode time (no composition offsets, no edit list).
- `export::register_encoder` ignores a factory registered twice; `encoder_registered`.

Apple M1, 8 cores, single runs: a 9:29 timeline (camera clip, ProRes 4444 overlays with
alpha, AAC, loudness) exports in 311 s instead of 793 s (2.5x faster); its densest 134 s
in 84 s instead of 395 s (4.7x). The outputs compare at SSIM 0.990 / PSNR 46.5 dB. The encoder
alone runs 200 fps at 1080p (docs/performance.md).

Tests (tests/hardware_encode.rs): what is taken and declined, a round trip through our
software decoder, an export that decodes in our decoder and in ffmpeg, the fallback for
everything hardware declines, exact output sizes, an empty range, hostile configurations
and encoders dropped at any point; the engine's `hardwareEncoding` parameter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:34:51 -06:00
Brandon ThomasandClaude Opus 5.5 86f3375775 Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#177)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:45:32 -04:00
Davi Andrei a7b51aa313 nvenc: fix a doc path 2026-10-07 15:42:38 -03:00
Davi AndreiandClaude Sonnet 5.5 11b199dbcd Docs: NVENC H.264 hardware encoding
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMorBHwct72udS2LiC3ZHp
2026-10-07 15:42:28 -03:00
Davi AndreiandClaude Sonnet 5.5 6db932a9ba Windows: NVENC H.264 hardware encoding for Export (work in progress, #30)
NVENC through nvEncodeAPI64.dll (API 12.1, FFI layout checked against the C header by generated
tests), registered as an Export encoder in front of the software one; Export > Hardware encoding
(off by default). Declines two-pass VBR, HDR, MXF, unsupported sizes; B-frames, IDR/GOP, VUI, avcC.
Tests: decode with our decoder (PSNR), pipeline export vs software, decline paths.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMorBHwct72udS2LiC3ZHp
2026-10-07 15:41:02 -03:00
RicSchonfelder 9a4a85d8e1 i18n: Brazilian Portuguese (pt-br) joins Edit > Language
Language::PtBr is persisted as pt-br and translated through a new
PORTUGUESE table with the same 347 menu labels as SPANISH, so every
translated key still resolves to a real menu item. The new
app.language.portuguese command checks, switches and restores the
language like the other three, and the native menu maps it back.
Cargo.lock is untouched.
2026-10-07 15:26:26 -03:00
Luna161andninso112 16cc1c808c Engine: file.import refreshes the offline-missing list (#110)
file.import created new items but never rebuilt Session::offline.missing.
The UI reads that list to drive the Program slate and the Project
panel 'Media missing' badge, so any re-imported item the project
already listed as missing stayed listed as missing until on_open ran
again (project reopen) or the caller manually relinked.

Add a single crate::relink::refresh(s) call at the end of file.import,
matching the call on_open already makes after a project opens. Closes
the slate/badge symptom without requiring reopen or per-item relink.

The deeper SharedSource cache eviction issue the reporter observed
with media.autoRelink is out of scope here; a follow-up should call
MediaPool::remove for items whose path now resolves before re-insert.

Co-Authored-By: ninso112 <ninso112@pm.me>
Co-Authored-By: Luna161 <Rosenkranzrene@gmail.com>
2026-10-07 18:20:23 +00:00
carlitosteso3andClaude Opus 5.5 d47e794437 sequence.addEdit: selected clips under the playhead are cut, and only they (#164)
Add Edit always cut every targeted track, so with one clip selected it cut
all tracks. As in Premiere, selected clips under the playhead are now cut
and nothing else; with none there, the targeted tracks are, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:37:50 -05:00
carlitosteso3andClaude Opus 5.5 98c022e0b0 shortcuts: recording a chord skips the modifier key presses (#164)
egui 0.36 reports pressing Shift, Ctrl, Alt or the Windows / Cmd key as a
key event of its own, before the key they modify. The Keyboard Shortcuts
recorder took that first press and refused it ("AltLeft can't be used as a
shortcut"), so no chord with a modifier could be recorded. Modifier keys on
their own are now skipped; the chord is recorded with the key they modify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:37:49 -05:00
justjohn12345andClaude Opus 5.5 b6281f968f Graphics: only selected layers have a box, and a hidden layer has none
Every layer of the selected graphic was outlined on the Program monitor,
so a box stayed after its layer was deselected, and a layer whose
visibility was off kept its box and could still be clicked and dragged.

As in Premiere Pro 26: a layer has a box only while it is selected (or
typed into), and a layer whose visibility is off is not on the monitor
at all: no box, no handles, no click target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:24:43 -04:00
carlitosteso3andClaude Opus 5.5 a54bf13ebc Merge upstream main into fix/color-matte-29 (keep both tests at the end of clip_ops_tests.rs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:02:28 -05:00
carlitosteso3andClaude Opus 5.5 dd404568ae media browser: probing a file doesn't read it whole (#157)
Looking at a folder probes each visible file for its properties. A format
no streaming reader takes (AVI, which isn't supported at all, and WAV,
MP3 and the other byte-opened formats) fell through open_reader's
whole-file path: a 290 MB AVI took 518 MB of memory and a full read just
to report that it isn't supported, a 247 MB WAV 500 MB.

- filmcraft-media: open_reader_within(.., max_whole) refuses, after the
  sniffing head, a file no reader opener takes when it is larger than
  max_whole. open_reader is open_reader_within with no limit.
- engine: MediaPool::probe_file uses it with PROBE_WHOLE_FILE_MAX (64 MiB);
  media_browser::probe calls probe_file. Import and playback still use
  open_file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:21:53 -05:00
Davi AndreiandClaude Sonnet 5.5 79cb1d0f8d Windows: VP9 + AV1 hardware decode through Media Foundation / DXVA (#30)
Extends the Windows backend (#141) to VP9 (profiles 0 and 2) and AV1 (main, 8/10-bit), 4:2:0.

- codecs::hw: FrameStreamInfo (vpcC / av1C): profile, bit depth, chroma, picture size and colour as
  the software decoders report them (vp9_color / av1_color are now shared with them), random-access
  samples, in-band format changes; StreamInfo lets HybridDecoder handle NAL and frame-coded streams.
- platform: media_foundation::stream (what each codec needs: DXVA profile, MFT request, input
  format), MfDecoder generalised; VP9 frames / AV1 temporal units go in as they are; the MFT's
  declared picture size (display aperture) is checked against the bitstream's.
- Declined up front: VP9 profiles 1/3, AV1 profiles 1/2, 4:2:2/4:4:4, mono, 12-bit, no codec
  extension, no DXVA profile. A key frame of another size or a different sequence header falls back
  to the software decoder.
- Tests: bit-exact parity (360p / 1080p / 2160p, seeks, resets, flush, hidden frames), forced
  failure, damaged samples and av1C, format change fallback, WebM / Matroska through the media stack.
- Bench: AV1 fixtures with libaom when ffmpeg lacks SVT-AV1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMorBHwct72udS2LiC3ZHp
2026-10-07 12:39:45 -03:00
Brandon Thomas 8fcad73c92 Merge pull request #159 from storytold/fix/90-web-api-rejects-errors
Web: window.filmcraft rejects with Error objects everywhere (#90)
2026-10-07 10:57:54 -04:00
Brandon ThomasandClaude Opus 5.5 b82825acb8 Web: window.filmcraft rejects with Error objects everywhere (#90)
readFile (missing path), importUrl and openProject (HTTP errors) and openProject (neither a File
nor a URL) rejected with plain strings, so e.message was undefined and e instanceof Error false,
unlike execute / request. Their promises now go through as_error, which wraps a string in an Error
and passes an Error through unchanged.

Checked in Chrome against the web build: all four cases reject with an Error carrying the message
("/files/nope.mp4: not found", "…: HTTP 404", "need a File or URL").

Fixes #90

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:57:48 -04:00
Brandon Thomas 3561e856a6 Merge pull request #158 from storytold/fix/107-flatpak-media-access
Packaging: the Flatpak can read a project's media wherever it lives (#107)
2026-10-07 10:53:01 -04:00
Brandon ThomasandClaude Opus 5.5 20ddf9c49e Packaging: the Flatpak can read a project's media wherever it lives (#107)
The manifest granted only xdg-pictures, on the premise that the portals make filesystem access
unnecessary. But a project refers to its media by absolute path and the document portal exposes
only the project file, so a project in ~/Videos or ~/Music opened with all its media missing.
Grant the home directory and the usual external-drive mount points (/media, /run/media, /mnt), as
other video editors on Flathub do; nothing system-wide.

Fixes #107

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:50:24 -04:00
Brandon Thomas 5695626216 Merge pull request #156 from storytold/fix/docs-85-86-87-88-99
Docs: releasing guide, CLI export options, README and CLAUDE.md drift (#85, #86, #87, #88, #99)
2026-10-07 10:49:14 -04:00
Brandon ThomasandClaude Opus 5.5 fa67dfa372 Docs: releasing guide, CLI export options, README and CLAUDE.md drift (#85, #86, #87, #88, #99)
- docs/releasing.md (#99): release.yml points readers to it twice and it didn't exist. Written from
  the workflow and packaging scripts as they are (macOS, Windows x64/x86/arm64, Linux x86_64/aarch64
  with the glibc 2.35 floor, web; draft release, secrets).
- filmcraft-cli help (#86): export lists --scale, --quality, --no-audio and --queue, and apv among
  the formats.
- README (#87): more than 650 engine commands (671 today), not about 135.
- README (#88): the web line says the browser app ships with every release and links docs/web.md.
- CLAUDE.md (#85): the Illustrator-class sibling is ../vectorcraft (formerly DrawCraft).

Fixes #85
Fixes #86
Fixes #87
Fixes #88
Fixes #99

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:49:08 -04:00
Brandon Thomas 3186a754f2 Merge pull request #151 from carlitosteso3/fix/transcripts-89-97-98
Transcripts: disabled, not failing, in builds without speech-to-text (#89, #97, #98)
2026-10-07 10:44:59 -04:00
Brandon ThomasandClaude Opus 5.5 a066748354 Merge main: the transcript checks landed in #146; keep Transcribe Sequence and the docs
#146 landed the same transcript.generate / downloadModel / import changes, so this branch keeps
main's versions of those files and adds what #146 missed: Transcribe Sequence (sequence.transcribe)
is disabled like transcript.generate in a build without speech-to-text, and docs/transcripts.md
describes the disabled state. A test asserts both directions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:44:47 -04:00
Brandon Thomas 2647a2d9f8 Merge remote-tracking branch 'origin/main' into pr151
# Conflicts:
#	crates/engine/src/settings_tests.rs
#	crates/engine/src/transcript_tests.rs
2026-10-07 10:44:47 -04:00
carlitosteso3andClaude Opus 5.5 0da0a3430a playback: a stalled audio device no longer freezes the playhead (#136)
The playhead follows the audio clock (frames the output device consumed).
An output stream can open without error and then never call back (ALSA
with a busy or misconfigured device): the clock stayed at 0 and Play did
nothing, with no message.

advance_playback now notes when the audio clock last moved; if it stands
still for AUDIO_STALL_S (1 s) while playing, playback continues from where
the audio got to on the wall clock, the stream is stopped, a warning is
logged and the status bar says the audio output is not responding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:42:42 -05:00
Brandon Thomas 3628bbe744 Merge pull request #154 from storytold/fix/91-92-bridge-blocking-export
Bridge: a blocking export runs as an app job, so it neither times out nor freezes the app (#91, #92)
2026-10-07 10:42:35 -04:00
Brandon ThomasandClaude Opus 5.5 bd12dcae81 Bridge: a blocking export runs as an app job, so it neither times out nor freezes the app (#91, #92)
file.exportMedia with wait: true was forwarded to the app unchanged. The app then encoded on its UI
thread: it showed no progress and answered no other control request until the encode ended, and the
control server's 60 s reply limit reported "timeout" for any longer export while it went on and
succeeded.

BridgeClient::execute now starts the export without wait and polls jobs.list until the job
finishes, returning the same {job, path, result} (or the export's error). filmcraft-cli --bridge
exec and MCP bridge mode both use it, and MCP command_run in bridge mode now gets the same progress
notifications and cancellation as headless mode.

Tested against a fake app (wait: true never reaches it, one export started, result and error
returned) and end to end against the desktop app: a blocking demo export returned its 642-frame
result with exit 0 while ui.inspect was answered in ~0.05 s throughout.

Fixes #91
Fixes #92

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:42:28 -04:00
carlitosteso3andClaude Opus 5.5 b8fa786cad transcripts: disabled, not failing, in builds without speech-to-text (#89, #97, #98)
- transcript.generate is enabled only with a recogniser: one installed in
  Session::transcriber, or the build's own (feature `whisper`). Otherwise
  describe, command_list enabled_only and the menu report it disabled with
  "speech-to-text is not available in this build" as the reason (#97).
  Transcribe Sequence, which runs it, follows.
- transcript.downloadModel is enabled only with `speech-download` (#98).
- file.import with Automatically transcribe clips on no longer skips
  transcription silently when it can't run: the disabled command's reason
  lands in the import's errors as "transcription: ..." (#89).
- docs/transcripts.md describes the disabled state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:14:05 -05:00
Brandon Thomas a273a95ad2 Merge pull request #147 from storytold/fix/93-rpm-glibc-floor
Packaging: the rpm requires glibc >= 2.35, as the deb does (#93)
2026-10-07 10:07:56 -04:00
Brandon ThomasandClaude Opus 5.5 a50f6162ba Packaging: the rpm requires glibc >= 2.35, as the deb does (#93)
The binaries are built on Ubuntu 22.04 (glibc 2.35). The deb says libc6 (>= 2.35), but the rpm
named no glibc floor, so dnf installed it on the RHEL 9 family (glibc 2.34), where both binaries
then failed with "GLIBC_2.35 not found". Now dnf refuses the install there.

Fixes #93

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:06:42 -04:00
Brandon Thomas be5b34b383 Merge pull request #146 from storytold/fix/97-98-89-transcripts-without-speech
Transcripts: builds without speech-to-text say so (#97, #98, #89)
2026-10-07 10:05:02 -04:00
Brandon ThomasandClaude Opus 5.5 621240f761 Transcripts: builds without speech-to-text say so (#97, #98, #89)
- transcript.generate is enabled only with an installed transcriber or the whisper feature; it
  reported enabled and then always failed with "speech-to-text is not available in this build".
- transcript.downloadModel is enabled only with the speech-download feature.
- An import with Automatically transcribe clips (all imported) on reports a
  "transcription: speech-to-text is not available in this build …" error instead of importing
  silently untranscribed.

Fixes #97
Fixes #98
Fixes #89

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:04:55 -04:00
carlitosteso3andClaude Opus 5.5 56eaea2fa3 Color Matte: pick the color on creation and change it later (#29)
- File ▸ New ▸ Color Matte… (and Project ▸ New Item) opens a New Color Matte
  dialog: color picker, name, width, height and duration. Called with params
  (CLI, control, MCP) it still creates the matte directly.
- project.matteColor {item?, color}: change a Color Matte's color (the
  selected one by default); every clip of it follows and it undoes.
  Double-clicking a Color Matte in the Project panel opens its color picker,
  as in Premiere.
- media pool: a generator's cache key now holds its parameters, so a matte
  whose color changes (or comes back with undo) is generated again instead of
  the pool serving the first one forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:58:01 -05:00
Brandon Thomas e975621bbd Merge pull request #144 from storytold/fix/114-render-tools-read-only
MCP: render_frame and render_preview leave the playhead where it was (#114)
2026-10-07 09:55:32 -04:00
Brandon ThomasandClaude Opus 5.5 65c6b7365d MCP: render_frame and render_preview leave the playhead where it was (#114)
Both tools are annotated readOnlyHint, but a call with `seconds` moved the active sequence's
playhead (and, with Selection Follows Playhead on, the selection) to that time, in headless and
bridge mode alike.

- headless: Session::render_program_at renders the frame at `seconds` (frame-snapped, as the
  playhead would be) without touching the session.
- bridge: the Program monitor screenshot needs the live playhead there, so the tool records the
  playhead and selection, takes the screenshot and puts both back (the selection only if it changed,
  since selecting also leaves trim mode).

Regression test: rendering at 5 s and 8 s with Selection Follows Playhead on leaves playhead and
selection unchanged and still renders the requested frame (it moved the playhead to 8 s before).

Fixes #114

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:55:24 -04:00
justjohn12345andClaude Opus 5.5 8407f233c1 Graphics: point text and paragraph text, as Premiere has them
A text layer is now one of two types, each with Premiere Pro 26's handles:

- Point text (Type tool click): every handle scales the layer about its
  anchor point, both axes alike; the font size is untouched.
- Paragraph text (Type tool drag): the dragged rectangle is a box the text
  wraps in. Handles move that side or corner of the box, the opposite side
  stays, the text re-wraps at the same size. Lines below the box are not
  shown and the bottom-right handle turns into a red plus.
- The anchor point can be dragged with the Selection tool; the layer stays.
  It is picked before anything under it, so a shape dragged by its exact
  centre moves its anchor.
- Properties > Text > wrench opens Text Properties, where Text Layer Type
  switches a layer between the two (graphics.setTextType); the text stays
  where it is.

Shapes keep stretching away from their opposite side.

New: text layer parameter box_height, graphics.newText `box`, and
textType / box / overflow / anchor / scale in graphics.list. A layer saved
before a parameter existed can now be given it with graphics.set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:54:26 -04:00
Brandon Thomas 50f8d12dd9 Merge pull request #143 from storytold/fix/125-oracle-sequence-fixture-race
Tests: image-sequence oracle fixtures are written atomically (#125)
2026-10-07 09:49:41 -04:00
Brandon ThomasandClaude Opus 5.5 ba411138d4 Tests: image-sequence oracle fixtures are written atomically (#125)
make_sequence had ffmpeg write numbered stills straight into the shared seq_<ext> directory and
treated it as ready once frames 1 and 12 existed. Two tests generating it at once (png_sequence_exact
and missing_frames_hold_the_previous_frame) read each other's half-written PNGs; one that fails to
decode is held as missing, so frame 6 equalled frame 3. Six parallel processes on a fresh fixture
dir failed 18 of 120 runs before, 0 of 120 after.

ffmpeg now writes into a testkit temp_path directory that is renamed into place (with a .complete
marker) only when all twelve frames are there; the gap copy is private to its test.

Fixes #125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:49:35 -04:00
Brandon Thomas 0dfad0b321 Merge pull request #142 from storytold/fix/66-hostile-edit-list-subclips
Hardening: hostile MP4 edit lists and cyclic subclip chains (#66)
2026-10-07 09:46:03 -04:00
Brandon ThomasandClaude Opus 5.5 0dd66d8aa3 Hardening: hostile MP4 edit lists and cyclic subclip chains (#66)
- codecs: a resampled MP4 audio read scales the edit-list offset to the source rate in i128 and
  saturates the window arithmetic; a hostile media_time overflowed i64.
- engine: media_duration follows a subclip's parent chain in a loop bounded at 16 hops instead of
  recursing; a cyclic or very deep chain in a damaged project overflowed the stack.

Each has a regression test that failed before (overflow panic / stack overflow).

Fixes #66

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:44:45 -04:00
EthanSuquetandClaude Opus 5.5 68173aa82a h264enc: 1-pass bitrate modes no longer starve the opening second (#74)
The 1-pass ABR rate factor counts one second of virtual inter frames so the
first I frame gets a fair share of the budget. That history was priced once,
at the first frame, with the model's initial coefficient guess, which is
several times too high on ordinary content, and then only faded out. Every
VBR/CBR export therefore coded its first second 6-9 dB below the rest.

A virtual frame now costs what the P and B frames coded so far cost on
average (the old guess only until the first of them), re-estimated per frame.
On the synthetic 640x360p60 test at 2 Mb/s, 0.1-0.5 s goes from 25.0 dB to
32.1 dB against 33 dB after the first second, at the same bitrate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:41:14 -04:00
Davi AndreiandClaude Sonnet 5.5 932cdb77f5 Docs: Windows hardware decoding (platform README, ADR 0001, architecture, performance, roadmap)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMorBHwct72udS2LiC3ZHp
2026-10-07 10:07:40 -03:00
Davi AndreiandClaude Sonnet 5.5 5e440a6607 Windows: Media Foundation / Direct3D 11 hardware H.264 + HEVC decode (#30)
New backend in crates/platform (media_foundation/): a Direct3D-aware decoder MFT is driven at the
level of single access units (Annex B), given the process's D3D11 video device through an
IMFDXGIDeviceManager so it decodes with DXVA, and its NV12 / P010 textures are read back through a
staging texture into planar Yuv8 / Yuv16. H.264 Baseline/Main/High and HEVC Main / Main 10, 4:2:0.

- Hardware is verified: non-D3D-aware or async MFTs, streams the GPU's DXVA decoder does not list,
  and system-memory output (Microsoft's MFT software fallback) are declined or fail the stream so
  HybridDecoder continues with our decoder.
- mfplat.dll is loaded at run time so Windows N editions still start.
- register() / registered() / hardware_decoder_for() cover Windows; perf.stats reports the backend.
- Tests (Windows): bit-exact parity with the software decoders at 640x360, 1080p and 2160p
  (resets, seeks, flush), forced mid-stream failure, damaged input, concurrency, repeated init.
- Bench: runs on Windows (ffmpeg on PATH, process/thread CPU clocks), HEVC Main 10 2160p row,
  hardware counters per row.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMorBHwct72udS2LiC3ZHp
2026-10-07 10:07:40 -03:00
Brandon Thomas 94a22d7a83 Merge pull request #130 from punkhop/fix/premiere-ui-type
Theme: interface type closer to Premiere's
2026-10-07 08:33:06 -04:00
Brandon Thomas 6a02e92004 Merge remote-tracking branch 'origin/main' into pr-130 2026-10-07 08:27:41 -04:00
Brandon ThomasandClaude Opus 5.5 736760752b Theme: keep the interface in Inter; Source Sans 3 is an Adobe font (AGENTS.md §1.1)
The timecode, colour and menu changes stay; the interface face from craft-fonts is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:27:41 -04:00
Brandon Thomas 6262126056 Merge pull request #105 from mweinbach/feat/apv-codec
Codecs: add clean-room APV (RFC 9924) decoder, encoder, container support, and export presets
2026-10-07 07:10:17 -04:00
Brandon Thomas cd3fe20ef9 Merge remote-tracking branch 'origin/main' into apv-105 2026-10-07 07:09:54 -04:00
Brandon Thomas 94cda9dca4 Merge pull request #116 from justjohn12345/pr/nested-interchange
Nested sequences: interchange (AAF compositions, OMF rendered sound, EDL reel AX)
2026-10-07 07:09:39 -04:00
Brandon Thomas 16475596d3 Merge remote-tracking branch 'origin/main' into nest-116
# Conflicts:
#	crates/engine/src/commands.rs
2026-10-07 07:09:20 -04:00
Brandon Thomas d4e438e932 Merge remote-tracking branch 'origin/main' into apv-105 2026-10-07 07:08:58 -04:00
Brandon Thomas 9c026067a9 Merge pull request #132 from punkhop/fix/playback-rate-conversion
Audio: play a sequence on a device at another rate without phase jumps
2026-10-07 07:08:32 -04:00
Brandon Thomas 7916cfb80e Merge pull request #131 from punkhop/fix/audio-plays-ahead
Audio: mix playback 200 ms ahead so a busy Mac doesn't drop sound
2026-10-07 07:08:29 -04:00
Brandon Thomas 566164cf7d Merge pull request #129 from carlitosteso3/fix/he-aac-108
HE-AAC plays at its output rate; AAC export caps the bitrate (#108, parts 1–2)
2026-10-07 07:08:25 -04:00
Brandon Thomas b4caf39f5f Merge pull request #117 from justjohn12345/pr/add-tracks-dialog
Sequence: Add Tracks… opens Premiere's dialog (amount, placement, track type)
2026-10-07 07:08:21 -04:00
Brandon ThomasandClaude Opus 5.5 3f3d6ab45b Audio: the channel test lets the mixer get ahead before playing, so a busy runner doesn't flake it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:34:20 -04:00
Brandon ThomasandClaude Opus 5.5 b94c81b5d1 HE-AAC: probing reads at most one access unit's worth of bytes per sample
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:34:19 -04:00
Brandon ThomasandClaude Opus 5.5 a1d245e517 Sequence: the Add Tracks dialog's OK check cannot overflow on huge counts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:34:16 -04:00
Brandon Thomas 005745d9ec Merge remote-tracking branch 'origin/main' into apv-105
# Conflicts:
#	crates/codecs/src/mp4.rs
2026-10-07 06:01:14 -04:00
Brandon ThomasandClaude Opus 5.5 d3c3b0f6e8 NEST: hostile project data cannot panic a nest's length, oversize its mix or multiply AAF needs
A negative clip duration no longer panics the clamp when a nest follows its contents; a nested
sequence's claimed sample rate is clamped before it sizes the mix buffer; audio_needs lists each
nested sequence once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:00:36 -04:00
Brandon Thomas 19d2f15148 Merge remote-tracking branch 'origin/main' into nest-116
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panels/timeline.rs
2026-10-07 06:00:36 -04:00
Brandon Thomas a4c4d38550 Merge pull request #123 from Luskish/fix/control-close-on-malformed-input
Control: close the connection on input that isn't a request (#119)
2026-10-07 05:57:55 -04:00
Brandon Thomas 0232c53404 Merge pull request #124 from punkhop/fix/ripple-trim-l-cut-head
Ripple trim: a shorter head after an L cut keeps the earlier sound in place
2026-10-07 05:57:51 -04:00
Brandon Thomas a481f05e26 Merge pull request #104 from punkhop/fix/ripple-trim-j-cut-head
Ripple trim: a shorter head on a J-cut take is no longer refused
2026-10-07 05:57:46 -04:00
Brandon Thomas 13e5591bb2 Merge pull request #121 from punkhop/fix/user-workspaces
Window ▸ Workspaces: save your own workspaces, like Premiere
2026-10-07 05:57:42 -04:00
Brandon Thomas 7811da4ec0 Merge pull request #122 from coygeek/docs/add-star-history
docs: add star history chart to README
2026-10-07 05:57:38 -04:00
Brandon Thomas ce9d43a014 Merge pull request #128 from punkhop/fix/crisp-line-icons
Icons: draw straight lines on whole device pixels
2026-10-07 05:57:34 -04:00
Brandon Thomas 98979fdf74 Merge pull request #127 from carlitosteso3/i18n-spanish
i18n: Spanish interface language (Español)
2026-10-07 05:57:31 -04:00
Brandon Thomas dc16c0e219 Merge pull request #126 from carlitosteso3/fix/packaging-metadata
Packaging: describe FilmCraft, not PhotoCraft, in package metadata (#94, #100)
2026-10-07 05:57:27 -04:00
Brandon Thomas 631235e8b2 Merge pull request #120 from justjohn12345/pr/timeline-scrolling
Timeline: the wheel scrolls as in Premiere, and the view cannot be lost
2026-10-07 05:57:23 -04:00
Brandon Thomas 2bfcd512c2 Merge pull request #79 from justjohn12345/pr/layer-corner-scaling
graphics: all eight layer handles scale towards the pointer
2026-10-07 05:57:19 -04:00
Brandon Thomas aa1143ea74 Merge pull request #109 from dcarnelutti/gpu/alpha-plane-upload
gpu: upload and composite the alpha plane of Y'CbCr frames
2026-10-07 05:57:15 -04:00
Brandon Thomas 36d8114d2f Merge pull request #118 from HEOJUNFO/fix/probe-pixfmt-from-config
codecs: MP4/MOV pixel format and alpha come from hvcC, avcC and the ProRes frame header (#103)
2026-10-07 05:57:10 -04:00
Brandon Thomas 79057f9c36 Merge pull request #102 from EthanSuquet/fix/mp4-duration-edit-list
codecs: an MP4/MOV clip lasts as long as its edit list plays it
2026-10-07 05:57:06 -04:00
Brandon Thomas cb69b55e56 Merge pull request #101 from EthanSuquet/fix/h264enc-corrupt-frames
h264enc: strict ffmpeg oracle that sees damaged slices (#74)
2026-10-07 05:57:01 -04:00
Brandon Thomas 7fcbaeab5e Merge pull request #83 from EthanSuquet/fix/webm-frame-lookup-rounding
codecs: look video frames up by the nearest timestamp unit (WebM at 30 fps drops every third frame)
2026-10-07 05:56:56 -04:00
Brandon Thomas 205e4bf68e Merge pull request #96 from punkhop/fix/move-onto-occupied-spot
timeline.move: report the clips an overwrite move covers
2026-10-07 05:56:52 -04:00
Brandon Thomas b8cc3925c3 Merge pull request #68 from punkhop/fix/clear-deletes-a-bin
project: Clear deletes a bin it is given, with everything in it
2026-10-07 05:56:48 -04:00
Brandon Thomas 6a0fca1cd1 Merge pull request #64 from punkhop/fix/xml-master-clip-keeps-its-bin
interchange: an FCP7 master clip lands in its bin when a sequence used it first
2026-10-07 05:56:44 -04:00
Brandon Thomas 05a731b309 Merge pull request #77 from justjohn12345/pr/test-fixes
Tests: fix seed_sweep overflow and the speed-dependent export progress test
2026-10-07 05:56:39 -04:00
Brandon ThomasandClaude Opus 5.5 df3b4ecbde Control: a connection's slot is freed even if its thread panics or can't start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:56:23 -04:00
Peet GuercioandClaude Opus 5.5 ea68e14a39 Merge fix/audio-plays-ahead: the resampler runs inside playback_mix
Problem: both branches change start_audio, so the nightly updater stops on the conflict.
Solution: keep play-ahead's structure; the rate converter lives in playback_mix (now FnMut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:52:42 -07:00
Peet GuercioandClaude Opus 5.5 a4ff06f4ad Audio: play a sequence on a device at another rate without phase jumps
Problem: with the device at another rate than the sequence (44.1 kHz
device, 48 kHz sequence), each buffer truncated its source start (phase
lost every block: a buzz), took the nearest sample (aliasing), and asked
the mixer for overlapping spans, so the mixer's graph cache missed every
buffer and rebuilt effect state.

Solution: audio-dsp StreamResampler interpolates each output sample from
its absolute position (exact rational), bit-identical however the stream
is cut, and pulls the source as one contiguous stream. start_audio uses
it when rates differ and restarts it when the rate or layout changes.

Context: tests cover block-size invariance at four rate pairs, a 1 kHz
tone within 3e-3, contiguous pulls, seeks and hostile input. Same lines
as #131; whichever lands second needs a small rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:35:29 -07:00
Peet Guercio bf00c29b14 Merge remote-tracking branch 'origin/fix/ripple-trim-j-cut-head' into fix/ripple-trim-l-cut-head
# Conflicts:
#	crates/edit/src/lib.rs
#	crates/edit/src/tests.rs
#	crates/engine/src/split_edit_ripple_trim_tests.rs
2026-10-06 23:56:32 -07:00
Peet GuercioandClaude Opus 5.5 bb5d7ca0a6 Audio: mix playback 200 ms ahead so a busy Mac doesn't drop sound
Problem: playback decoded and mixed every ~10 ms device buffer inside the
cpal callback (AAC decode, effects, opening a clip's file at a cut, rayon,
locks). On a loaded Mac any stall missed the device deadline: static and
dropouts, different on every play.

Solution: play_ahead mixes on its own thread into a lock-free ring kept
200 ms ahead; the callback only copies. An underrun plays silence, is
counted, and the mixer skips the same span so sound stays locked to the
picture. Any device width plays the channels it shares with the mix.
perf.stats reports playback.audio (callbacks, underruns, missingFrames,
minLeadMs). The program mix moved to playback_mix so bench_audio can play a
real project through a simulated 48 kHz device and count dropouts.

Context: on a real 22-sequence project, load average 38-58, 15 s per cut:
mixing in the callback overran 8-170 times, mixing ahead 0 (ring never
below 149 ms). The web build keeps mixing in its callback (no threads).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:47:47 -07:00
carlitosteso3andClaude Opus 5.5 463a59c1b4 codecs: HE-AAC plays at its output rate; AAC export caps the bitrate (#108)
- aac: the decoder notes SBR data in fill elements (EXT_SBR_DATA /
  EXT_SBR_DATA_CRC), so implicitly signalled HE-AAC is recognised;
  Decoder::sbr() covers explicit signalling too.
- codecs: aac_output_rate() gives an AAC stream's output rate (the
  extension rate, or twice a <= 24 kHz core when SBR data is present); the
  MP4 reader reports it instead of the core rate, so sequences made from an
  HE-AAC clip get 44.1 kHz instead of 22.05 kHz. Our decoder now also takes
  explicitly signalled HE-AAC (it decodes the AAC-LC core) and, when the
  stream rate is twice the core rate, interpolates the core to it with a
  streaming half-band upsampler. The SBR high band is not reconstructed yet.
- export: the AAC bitrate is capped at 6 bits per sample and channel
  (ISO/IEC 14496-3 frame limit) instead of failing with "bitrate out of
  range" (320 kbps stereo at 22.05 kHz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:28:30 -05:00
carlitosteso3andClaude Opus 5.5 96e9a00997 packaging: describe FilmCraft, not PhotoCraft, in package metadata (#94, #100)
The Linux, macOS, web and Windows packaging still carried PhotoCraft's
image-editor metadata.

- nfpm.yaml (#94): deb/rpm summary and description describe the video
  editor; filmcraft-cli is a command runner, MCP server and exporter;
  Debian section video instead of graphics.
- AppStream (#100): AudioVideo/Video/AudioVideoEditing categories, matching
  the desktop entry; video keywords; the screenshot is the README hero image
  (the old filmcraft-demo.jpg URL was a 404).
- macOS Info.plist.in (#100): export the .fcproj project type (JSON) instead
  of PhotoCraft's .pcraft; app category video; drop the PSB and QOI imported
  types, which FilmCraft does not read.
- filmcraft.wxs header comment: the MSI declares no file associations.
- web hosting guide: iframe title "FilmCraft video editor".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:06:08 -05:00
carlitosteso3andClaude Opus 5.5 ed535180cb i18n: Spanish interface language
Add Español next to English and 日本語 (Edit ▸ Language ▸ Español, also in the
native menu bar). SPANISH covers every menu, submenu and menu item label
(347 entries, Latin American Spanish); names, formats and percentages stay
as they are. A new test checks that every entry is a current menu label, so
a renamed command cannot leave a dead entry behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:06:02 -05:00
Peet GuercioandClaude Opus 5.5 066dcf5b98 Theme: menu items every 24 px, as in Premiere
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:02:13 -07:00
Peet GuercioandClaude Opus 5.5 f9ca62f67e Theme: interface type closer to Premiere's
Measured from Premiere screenshots at 1x: panel tabs and tooltips are a
humanist sans at 12 px; the big timecode is regular weight, 76 x 10 px
for 00:00:16:05, blue #4096f3; menus are the system's, 13 pt, #dedede.

- The interface face is Source Sans 3 (OFL), the closest open face to
  Premiere's, read from craft-fonts (CRAFT_FONTS_DIR) like the Japanese
  faces. Inter stays right behind it for symbols it lacks, and is the
  face when the app is built without craft-fonts. The web build embeds
  it too.
- The timecode over the timeline, under the Source and Program monitors
  and in the Reference Monitor is regular weight at 16 (Source Sans 3's
  width for Premiere's), in the timecode blue #4096f3 (also the scrubby
  value blue).
- The menu bar's menus and submenus are 13 pt in #dedede.

Tests: with craft-fonts, every weight of the interface text is Source
Sans 3 with Inter behind it; without, the interface keeps Inter; Japanese
still renders from craft-fonts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:48:51 -07:00
Peet GuercioandClaude Opus 5.5 3a48a99c94 Icons: draw straight lines on whole device pixels
The panel menu mark (≡) on the active tab was three 1.5 pt lines placed
at fractional positions, so at 1x they straddled pixels and anti-aliasing
made the gaps look uneven (3 px then 4 px). Every line icon had the same
half-pixel blur.

The icon pen now draws strokes a whole number of device pixels wide and
snaps every horizontal or vertical segment (and any that runs less than
a pixel sideways) onto the pixel grid, rounding from the icon's centre
the same way on both sides, so equal steps on the 16 grid stay equal on
screen at 1x and 2x. The tab's menu mark is now the Hamburger icon drawn
by the pen instead of hand-drawn lines.

Tests: every icon at 1x and 2x at four sizes off the pixel grid has only
whole-pixel straight lines; the menu mark's two gaps are equal at 1x,
1.5x and 2x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:40:55 -07:00
Coy Geek 8d5acae231 docs: add star history chart to README
Show FilmCraft GitHub star growth with a linked Star History chart at the bottom of the README. Preserve the date view and top-left legend.
2026-10-06 19:33:01 -07:00
LuskishandClaude Opus 5.5 023127e146 Control: close the connection on input that isn't a request (#119)
A line that is not a JSON object with a string `method` (an HTTP request
line, other non-JSON text, a JSON array, invalid UTF-8, a line over 4 MiB)
now gets one error reply and the connection is closed, so a browser's
cross-origin fetch can't smuggle a command in its body. Connections are
capped at 16. Same transport as LightCraft (storytold/lightcraft#129).

Fixes #119.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:32:13 -04:00
Peet GuercioandClaude Opus 5.5 480b9e7655 Ripple trim: an L cut's sound stays only when its clip ends at the cut
A linked clip that spans the cut while its partner reaches past it too is
no L cut, and still refuses, like the same clip unlinked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:30:22 -07:00
Peet GuercioandClaude Opus 5.5 77d3b41a5c Ripple trim: a shorter head after an L cut keeps the earlier sound in place
A ripple trim that shortens a clip's head was refused when the previous
clip's linked sound ran on past the cut on a sync-locked track (an L cut).
That clip never moves, so its sound stays put and the L cut still ends
where it did. Unlinked material across the cut (a music bed) still refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:21:38 -07:00
Peet GuercioandClaude Opus 5.5 fc5fcd3322 feat(ui): save your own workspaces, like Premiere
Problem: Window > Workspaces only had the nine built-in layouts; a layout
you arranged could not be kept, renamed or brought back.

Solution: one alphabetical list of built-in and user workspaces, plus
Save Changes to this Workspace, Save as New Workspace..., Reset to Saved
Layout and Edit Workspaces... (rename, delete; built-ins restore instead
of delete). Saved in workspaces.json beside preferences.json, so an older
build rewriting preferences never erases them. The workspace in use
reopens at launch; ui.set and window.workspace.<name> switch to user
workspaces like built-ins.

Context: tests in crates/ui-egui/tests/workspaces_ui.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:18:02 -07:00
justjohn12345andClaude Opus 5.5 0bcfcfff16 Timeline: the wheel scrolls as in Premiere, and the view cannot be lost
Three problems with scrolling the Timeline:

1. Cmd + wheel zoomed, like Option + wheel. egui turns Cmd + wheel into a
   zoom before the panel sees it, and the panel only knew "zoom" and "scroll
   sideways".
2. The plain wheel scrolled sideways; the tracks could only be scrolled up
   and down with Shift held.
3. The view could be scrolled without limit past the end of the sequence (39
   minutes past a 26 second one). There the zoom scroll bar's thumb sat at
   the right end and stayed there when dragged: the bar's range grew with the
   scroll position, so the view moved but the thumb did not.

Now, as Premiere Pro 26.5.2 does on macOS (checked in the app):
- wheel: the tracks under the pointer, up and down (video tracks above the
  divider, audio tracks below);
- Cmd + wheel: the Timeline, sideways;
- Option + wheel: zoom about the pointer.
The existing setting Timeline Mouse Scrolling ("Vertical" by default) is
wired up: "Horizontal" swaps the first two. A sideways gesture (trackpad
swipe, tilt wheel, Shift + wheel) always scrolls sideways and a pinch zooms.
The wheel is read from its events, with their own modifiers.

The Timeline ends ten minutes past the end of the sequence, where Premiere's
view stops too, and the view is kept inside it however it is moved (wheel,
scroll bar, zoom, a script; not-a-number positions are reset).

The zoom scroll bar spans that extent. Its thumb is as wide as the share in
view, never narrower than 34 points, and its travel covers the whole scroll
range: it reaches both ends of the bar and stays under the pointer while
dragged (the drag follows the pointer's position, not a sum of steps). A
click or press beside the thumb brings the thumb there. The handles at its
ends zoom as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:14:00 -04:00
justjohn12345andClaude Opus 5.5 f6d78f7e0c Sequence: the Add Tracks dialog's fields span its width
The placement and track type lists had a fixed width, narrower than the
dialog, which left an empty band down its right side. Each row is now a label
in a column as wide as the widest label and a list that takes the rest of the
row, up to the group's right edge, as in Premiere's dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:45:38 -04:00
heojunfoandClaude Opus 5.5 f761a299a7 codecs: MP4/MOV pixel format and alpha come from hvcC, avcC and the ProRes frame header (#103)
The probe guessed the pixel format from the codec profile alone: HEVC
reported nothing, H.264 High 4:2:2 had no bit depth, and ProRes 4444
claimed alpha because of its fourcc.

- HEVC: chroma format and bit depth from hvcC.
- H.264: from the avcC High-profile extension (ISO/IEC 14496-15 §5.3.3),
  else from the first SPS; unknown (empty) if neither can be read.
- ProRes: the first frame header gives the chroma format and alpha
  (alpha_channel_type), as the first DNx frame header already does.

Checked against ffprobe on ffmpeg-made HEVC 4:2:0/4:2:2 8/10-bit,
H.264 4:2:0/4:2:2 8/10-bit and ProRes 422 HQ / 4444 with and without
alpha. Unit tests cover hvcC, avcC (extension and SPS fallback) and
ProRes 4444 with and without alpha.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 10:44:38 +09:00
justjohn12345andClaude Opus 5.5 13a0da9c2b Sequence: Add Tracks… opens Premiere's dialog (amount, placement, track type)
Sequence ▸ Add Tracks… added one video track at once: no dialog, and no way
in the interface to add an audio track.

It now opens the dialog Premiere Pro 26.5.2 shows, checked in the app:
- "Add video tracks": Amount, Placement.
- "Add audio tracks": Amount, Placement, Track type (Standard, 5.1, Adaptive,
  Mono).
- "Add audio submix tracks": Amount, Placement, Track type (Stereo, 5.1,
  Adaptive, Mono); its placement is off while the sequence has no submix
  track.
Placement is "Before First Track" or after one of the tracks, after the last
by default. The defaults are Premiere's: one video and one audio track, no
submix track. OK is one undo step.

`sequence.addTracks` takes the same choices as params (`videoAfter`,
`audioAfter`, `submixAfter`, `audioType`, `submix`, `submixType`) and returns
the new tracks' ids. Called without a place it still appends, and without
amounts it still adds one video track, so scripts keep working; only the menu
item opens the dialog. Tracks are numbered by their place, as in Premiere: a
track added after Video 1 is the new Video 2 and default names above it move
up, while a name the user gave is kept.

Amounts were not checked before (any number of tracks was created). At most
99 tracks are added at a time and a sequence has at most 999 of a kind; a
wrong amount, place or type is an error that changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:39:03 -04:00
justjohn12345andClaude Opus 5.5 1ebc1440e7 NEST11: the architecture doc says a plain nest's layers go into the frame plan
docs/architecture.md still listed every nested sequence among the things
rendered on the CPU and handed to the GPU as one image. Since NEST11 that
holds only for nests that are not plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:53:53 -04:00
justjohn12345andClaude Opus 5.5 e13151d87b NEST10: the captions doc says nested captions are part of the nest's picture
docs/captions.md still said nested sequences do not burn in their own
captions. Since NEST10 the captions of a nested sequence are drawn wherever
the nest is shown, in the Program monitor and in exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:53:53 -04:00
justjohn12345andClaude Opus 5.5 9b31f4eb84 NEST15: nested sequences in AAF and OMF, the way Premiere Pro writes them
A nested sequence was left out of both formats as a gap, with a report entry
telling the user to "render and replace" it first (a command FilmCraft does
not have). An AAF with nested compositions was read with gaps in their place.

What Premiere Pro 26.5.2 does, read off files it exported for a sequence with
a nest:
- AAF: the nested sequence is a composition mob of its own, and its clips are
  source clips that point at it.
- OMF: the nested sequence's sound is rendered into the document as one clip
  named after the sequence.

Export
- AAF: a nested sequence is written as a composition mob (no usage code; the
  exported sequence stays the top-level one). A clip of it is a source clip
  at that mob's first slot of the clip's kind, its start in that slot's edit
  units. Nests in nests are followed; a nest used twice is written once. With
  embedded or consolidated audio, the media of the clips inside a nest is
  prepared with the rest (`NestNeeds::Inside`).
- OMF: `audio_needs` lists the range of the nested sequence each nest clip
  plays (`NestNeeds::Render`); the engine mixes it through the export audio
  pipeline with what is on the clip, and it is written as media. Without that
  essence the nest is a gap and the report names the sequence.

Import (AAF)
- A source clip that points at a composition becomes a clip of a nested
  sequence; the composition is read once.
- Premiere Pro's layout is read: all video tracks in one slot (a nested scope
  with a segment per track; before, only the last segment was read, so a
  Premiere AAF came in with one video track), and "top-level" tagged on nested
  compositions too (top-level are those no other composition uses).
- A composition without a frame size takes that of the first picture it
  shows, not of the first picture in the file.

A sequence that claims to be inside itself is a gap with a report entry in
both directions; the walks are bounded (16 levels).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:53:34 -04:00
justjohn12345andClaude Opus 5.5 12585410b3 NEST15: a nested sequence is reel AX in an EDL, in every reel mode
The export report already said a nested sequence is written "as a single
source (reel AX)", but the reel came from the reel mode: with the default
(file name) a nest called "Nested Sequence 02" got reel NESTED_S. A nested
sequence has no tape and no file, so its reel is now AX whatever the mode.

Premiere Pro 26.5.2 writes the same for a nest (seen in an EDL it exported):
one event from reel AX, the nested sequence's name in "FROM CLIP NAME", and
the nested sequence's own time as source timecode. A test pins that event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:53:19 -04:00
justjohn12345andClaude Opus 5.5 cdd9836705 NEST13: the panel menu closes on a click elsewhere and on Escape
Found while looking at the tab menu in the running app: once open, a panel
menu stayed open until one of its entries was chosen. It was meant to close on
a click elsewhere, but the test for that needed the press and the release of
the click in the same frame, which a real mouse never delivers. A right-click
on a tab now opens the menu, so the stuck menu was easy to run into.

The menu now remembers the frame it opened in (the click that opens it is not
a click elsewhere) and closes on any later click outside it, or on Escape. A
right-click on another tab moves it to that tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:29:41 -04:00
justjohn12345andClaude Opus 5.5 eeec772671 NEST14: a project reopens with its sequence tabs and their views
Premiere Pro 26.5.2 reopens a project with the same sequence tabs, in the
same order, the same one shown, each at the zoom it was left at. FilmCraft
opened every project on its first sequence; the "Restore open sequences when
opening projects" preference existed and nothing read it.

A saved project file now has a `view` beside `project`: the open sequences in
tab order, the active one, and each sequence's view from NEST12. It is not
part of the project: no undo step, no "edited" mark, and no schema bump (older
builds ignore it). `file.open` restores it when the preference is on.

Nothing in the view is trusted. Ids that are not sequences of the project and
repeats are dropped, numbers are clamped, and a view that cannot be read is
ignored instead of failing the open. A view with no open sequence leaves the
project on its first sequence, so a project saved by a script or the CLI does
not open on an empty Timeline.

Opening or recovering a project now tells the frontend which sequence is
shown, so the Timeline takes that sequence's view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:23:19 -04:00
justjohn12345andClaude Opus 5.5 6cff0f0b5d NEST13: sequence tabs get Premiere's menu, drag to reorder and a list for tabs that do not fit
Checked in Premiere Pro 26.5.2: a right-click on a sequence tab that is not
shown shows it and opens the panel menu, which has "Close Panel", "Close Other
Timeline Panels" and "Reveal Sequence in Project"; a dragged tab follows the
pointer and its neighbours make room.

- A right-click on any tab shows it and opens its panel menu.
- The Timeline's menu gains "Close Other Timeline Panels" (new command
  `sequence.closeOthers`); its entries have automation ids
  (`panel.menu.Timeline.close`, `.closeOthers`, `.revealSequence`).
- A sequence tab dragged sideways changes places with a neighbour when the
  pointer passes that neighbour's middle (new command `sequence.moveTab`).
- Tabs that do not fit: the shown tab is always in the strip (tabs before it
  are left out until it fits), and the chevrons at the right end open a list
  of every tab instead of stepping to the next one. Ids `timeline.tabs.more`
  and `timeline.tabs.list.<sequence>`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:23:07 -04:00
justjohn12345andClaude Opus 5.5 6aefdef661 NEST12: every sequence keeps its own Timeline zoom, scroll and track heights
The Timeline had one view for all sequence tabs, and showing another sequence
fitted it to the panel every time. In Premiere Pro 26.5.2 each sequence tab
has its own zoom and its own track heights (expanding the tracks of one
sequence leaves the others as they were).

Each sequence now has a `SequenceView` (zoom, horizontal scroll, the vertical
scroll of the video and audio halves, video and audio track height) in
`EditorState::timeline_views`. The panel's view is the active sequence's: it
is put away when another sequence is shown and taken back when the sequence is
shown again, also when a tab is closed. A sequence shown for the first time is
fitted, at the default track heights. A view changed in the session (a
command, the control channel) is taken over by the panel. The view of a
deleted sequence is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:21:19 -04:00
justjohn12345andClaude Opus 5.5 5e730ab020 NEST11: the frame plan draws a plain nest's layers itself; a nest's sound clip gets a waveform
Frame plan. A nest was always handed to the compositor as one image rendered
on the CPU. A plain nest (same frame size and colour settings as the sequence
it is in, no effects, masks, opacity, blend mode or motion on the clip, every
layer inside it blended normally, not a multicam angle) now contributes its own
layers to the plan, with its captions, so the GPU composites them directly.
Nests in nests are followed up to the nesting depth limit. Anything that is
not plain is planned as one image as before.

Waveforms. Premiere draws a waveform on the sound clip of a nest. The Timeline
now mixes the nested sequence in the background (under `catch_unwind`), keeps
one peak per 256 samples, and mixes again when the sequence's sound changes,
showing the old waveform until the new one is ready.

Tests: a plain nest is planned as its own layers and looks the same as the
reference render, also nested twice and over another layer; five kinds of nest
that are not plain are still drawn right; a nest's sound clip gets a waveform
that follows its sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:52:28 -04:00
justjohn12345andClaude Opus 5.5 a8b803c7a9 NEST10: a nest's sound follows its speed, direction and effects; its captions are part of its picture
Sound. A nest's audio went around the path that clips of media take, so speed,
reverse and the clip's audio effects did nothing to it, and a nested sequence
at another sample rate was mixed at the outer rate without converting. The
nested mix is now read through the same path as a media source (`effected`
takes a reader instead of a `MediaSource`): it is mixed at the nested
sequence's own rate, resampled to the outer one, and then goes through speed,
reverse and the clip's effects like any clip. The nesting depth limit still
applies.

Captions. Premiere Pro 26.5.2 draws the captions of a nested sequence in the
sequence it is nested in, as part of the nest's picture. A nested sequence is
now rendered with its captions whether or not the outer sequence shows its
own, and the preview cache key of a nest covers its caption tracks.

Tests: a nest sounds as its sequence does, keeps pitch and timing at another
sample rate, changes with speed and reverse like a clip, and starts later when
trimmed in; speed, reverse and frame hold pick the right frame of the nested
sequence; opacity and a Crop effect apply to the nest's whole picture; nested
captions are drawn and outer ones only when asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:52:20 -04:00
justjohn12345andClaude Opus 5.5 642d3b2800 NEST9: a nest of another size or frame rate, and Fit / Fill frame in the clip menu
A nested sequence whose frame size differs from the sequence it is in comes in
centred at its own size with Scale 100, as in Premiere Pro 26.5.2 (a 1280x720
nest in a 1920x1080 sequence). The clip context menu gains Premiere's "Fit to
frame" and "Fill frame" next to "Scale to Frame Size".

Tests pin the behaviour: a smaller nest sits in the middle, a larger one is
cropped by the frame, a nest at another frame rate shows its sequence at the
same moment, Fit and Fill frame set Scale to 150 for that pair of sizes, and
Scale to Frame Size fills the frame and leaves Scale alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:50:27 -04:00
justjohn12345andClaude Opus 5.5 5ce9fbb48a NEST8: Multi-Camera submenu in the clip context menu
Enable (ticked when on), Flatten, and the cameras of the selected nested
sequence clip to switch to; greyed out when no selected clip is a nested
sequence. The commands already existed in the Clip menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:25:01 -04:00
justjohn12345andClaude Opus 5.5 4164fa2e7c NEST6: Reveal in Project and Reveal Sequence in Project
clip.revealInProject (clip context menu) selects the clip's source in the
Project panel and shows it there: the panel comes forward with the search
cleared, the list opens the bins on the way, and the icon and freeform views go
into the bin that holds the item. sequence.revealInProject (Timeline tab menu)
does the same for the sequence being edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:24:41 -04:00
justjohn12345andClaude Opus 5.5 598268721a NEST5: a sequence in the Source Monitor is marked and edited in like a clip
Mark In / Mark Out on a sequence loaded in the Source Monitor did nothing: the
marks were only stored for media. They are now the sequence's own In and Out, so
Insert and Overwrite edit in the marked part (as a nest, or as its clips with
the nest toggle off).

Tests also pin Match Frame on a nest: it loads the nested sequence at the frame
the nest shows, through a trim and a speed change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:23:45 -04:00
justjohn12345andClaude Opus 5.5 559a8b7543 NEST4: the nest toggle edits a sequence in as a nest or as its clips
The Timeline's "Insert and overwrite sequences as nests or individual clips"
button was drawn always on and did nothing. It now toggles
(sequence.nestSequences). Off, a sequence is edited in as its clips, as observed
in Premiere Pro 26.5.2: the clips come with their transitions and links, the
source's tracks that hold clips go to consecutive tracks from the destination
track up, and missing tracks are added. A sequence can then go into itself as
plain clips. Multi-camera sources stay one clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:23:17 -04:00
justjohn12345 642cfb6bdf Merge branch 'pr/nested-safety' into pr/nested-editing 2026-10-06 19:10:58 -04:00
justjohn12345 29fd297045 Merge branch 'pr/sequence-tabs' into pr/nested-editing 2026-10-06 19:10:58 -04:00
justjohn12345andClaude Opus 5.5 ca489f9fce NEST2: Nest and Make Subsequence as Premiere Pro 26.5.2 does them
Observed in the app on 2026-10-06 (about 35 test nests):

- The nest is a linked picture + sound pair only when the selected sound is on
  one track and that track is free for the nest's length. With sound on several
  tracks, or another clip in the way on its track, the nest is picture only: the
  selected sound stays in the sequence (unlinked) and a copy with its own ids
  goes into the nest. How many video tracks the selection uses does not matter.
- Where the picture goes. With L the lowest selected video track and `a` the
  number of the highest audio track holding selected sound, it takes the lowest
  track T from L up such that T is free for the nest's whole length, and so is
  every video track numbered from T up to a - 1 that the selection had clips on.
  If none will do, a new track is added. Before, the nest could land on top of
  an unselected clip.
- In the new sequence, picture tracks keep their positions up to the highest one
  used. Sound tracks are A1 followed by the other tracks used, in order
  (A1 + A3 become A1, A2; A3 alone becomes an empty A1 and A2).
- After Nest nothing is selected in the timeline and the new sequence is
  selected in the Project panel; clip.nest returns the nest's clips.
- Make Subsequence loads its result in the Source Monitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:23:01 -04:00
mweinbach 15787f7fe8 Codecs: add clean-room APV (RFC 9924) decoder, encoder, container support, and export presets 2026-10-06 17:24:34 -04:00
justjohn12345andClaude Opus 5.5 fc557a1c10 NEST3: a nest follows its contents
A nest keeps its length when its sequence changes. Tests now pin what already
held: it trims out to reveal added material and no further, shows and plays
nothing past shortened contents, and the outer sequence shows edits made inside.

New: the Timeline hatches the part of a nest past the end of its contents, as
Premiere does (Project::nest_overhang, automation id timeline.clip.<id>.empty).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:23:38 -04:00
justjohn12345andClaude Opus 5.5 094ea74bde NEST2: Nest keeps transitions, track names and channel layouts
Nest… copied only the clips: transitions between them were lost and the new
sequence's tracks had default names and stereo layouts. Transitions whose clips
are all nested now go into the nest with them, and the tracks are laid out like
the parent's (shared with Make Subsequence). Clips on locked tracks are left out
instead of being duplicated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:23:38 -04:00
justjohn12345andClaude Opus 5.5 29d9d0ac5a NEST1: a sequence can never contain itself
A sequence placed inside itself (directly or through another nest) crashed the
app: nested audio mixing and the frame plan's multi-camera shortcut recursed
until the stack overflowed.

- render: the audio mixer and the multi-camera shortcut follow nests to the same
  depth as the picture (MAX_NEST_DEPTH), then give silence / fall back.
- project: Project::nest_cycle finds a sequence that contains itself.
- engine: every edit that would create one is refused, whichever command asks
  (place, Source insert/overwrite, paste, ...). A project opened with a cycle
  already in it can still be edited and repaired.

Regression tests: both crashes (stack overflow before), the refusals, repair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:23:38 -04:00
Peet GuercioandClaude Opus 5.5 7f77bdfd3a Ripple trim: a head trim grabbed by its early sound uses the picture's cut
Sync-locked tracks rippled from the grabbed member's start, so grabbing a
J cut's early sound checked and shifted from before the picture's cut and
was refused. For a head trim the cut is the latest member's start. Adds the
test only the new stretch catches: a clip starting at the cut on a
sync-locked track, which the old stretch let fall out of sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:22:13 -07:00
Peet GuercioandClaude Opus 5.5 27124080bf Ripple trim: a shorter head closes the stretch after the cut
A ripple trim that shortens a clip's head was refused as a sync-lock
conflict whenever a sync-locked track held material just BEFORE the cut,
because the check looked at the stretch before it. With a J cut that is
the outgoing clip's sound on the other audio track, so the J-cut take
could never lose its head. The stretch that closes is the one just after
the cut; the clip's own linked partners stay the caller's to trim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:16:39 -07:00
Dario CarneluttiandClaude Sonnet 5.5 1b92ea210b gpu: upload and composite the alpha plane of Y'CbCr frames
The GPU compositor uploaded only the Y, Cb and Cr planes of a Yuv8/Yuv16 frame and
the shader returned alpha 1.0 for them, so a ProRes 4444 overlay (banner, lower third,
outro) was drawn opaque in the Program monitor and covered the footage under it.
Exports were correct because they use the CPU compositor.

Frames with an alpha plane now upload it as a fourth texture (R8Unorm, or R16Float
converted like the other planes), bound at binding 5 of the layer and blend layouts.
A uniform slot (p3.y) carries the alpha scale (0 = no alpha plane); the shader reads
the alpha at full resolution and premultiplies the linear colour by it.

Adds gpu_draws_yuv_alpha_plane_like_the_cpu: an alpha ramp over an opaque layer at
8, 10 and 12 bits, GPU against the CPU compositor. It fails without the change
(p99 diff 190/255) and passes with it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 15:04:48 -06:00
justjohn12345andClaude Opus 5.5 3f7fd8252f Timeline: tab menu says Close Panel, as Premiere's does; test finds the nest by its sequence
Checked in Premiere Pro 26.5.2: the tab's menu item is "Close Panel" and it
closes that sequence's tab. Premiere also leaves nothing selected after Nest,
so the tabs test no longer relies on the selection to find the nest clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:46:18 -04:00
EthanSuquetandClaude Opus 5.5 1f2f20a700 codecs: an MP4/MOV clip lasts as long as its edit list plays it
The clip duration came from the media duration (`mdhd`), which runs on
the decode timeline. ffmpeg / x264 MP4s with B-frames skip the reorder
delay with an edit, so every one imported a frame too long (a 120-frame
file became 121 frames, the last a held repeat). A video track delayed
by a leading empty edit was a frame short, and its last frame could not
be reached.

Use the sum of the track's edits (movie timescale, what `tkhd` records)
when there is an edit list. Fragmented files, and edit lists that add up
to nothing or overflow a Tick, keep the media duration.

Tests: a unit test with crafted edit lists (skip, empty edit, hostile
sizes) and an ffmpeg oracle test (libx264 with B-frames: 60 frames,
was 61).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:37:06 -04:00
Peet GuercioandClaude Opus 5.5 dfaf31bfec Timeline: timeline.move reports the clips an overwrite move covers
An overwrite move shortens, splits or removes whatever already sits where a
clip lands, as Premiere's drag does, but the reply only named the clips it
moved: a script moving clips one at a time never learned that an earlier move
had cut one it had not reached yet. The reply now lists each covered clip
under `overwritten` with its length before and after and the ids what is
left of it lives on as. The edit itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:19:04 -07:00
EthanSuquetandClaude Opus 5.5 3eae70b11e h264enc: strict ffmpeg oracle that sees damaged slices (#74)
ffmpeg prints "corrupt decoded frame" as a warning, so the oracle's
`-v error -ec 0` decode let a stream with a whole slice missing pass
silently. Switching that decode to `-v warning` is not enough: with
`-ec 0`, ffmpeg 7.1/8.1 flags every picture that has more than one
slice, libx264's multi-slice streams included, while the pixels are
unchanged. That is the 119/120 in #74; the encoder's bitstream is not
at fault (VideoToolbox decodes the same streams bit-identically, and
ffmpeg with strict error detection reports nothing).

The oracle now decodes with ffmpeg's default concealment, `-v warning`
and `-err_detect +crccheck+bitstream+buffer+explode`, fails on any
output or a non-zero exit, and still compares every pixel with the
encoder's reconstruction. A negative control drops and truncates
slices (CAVLC and CABAC, 3 slices) and checks the oracle reports each
one; it fails with the old flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:02:53 -04:00
EthanSuquetandClaude Opus 5.5 2b49201e84 codecs: video frames are looked up by the nearest timestamp unit, so a 1 ms WebM shows every frame
Problem (#73): a 30 fps WebM played and exported with every third frame
missing and the frame before it shown twice (20 unique frames a second).
The same happens at 29.97, 60, 59.94, 24 and 23.976 fps, and with MP4
files whose video track has a 1 ms timescale. 25 and 50 fps were fine.

Cause: Matroska stores block timestamps in TimestampScale units (usually
1 ms), rounded to the nearest unit, so at 30 fps frame 2 (66.67 ms) is
stored as 67. MkvSource::video_frame converted the requested time to track
units with a floor (66), and the lookup (last sample with pts <= target)
returned frame 1. Every frame whose timestamp was rounded up was skipped.
Mp4Source::video_at, and the web build's WebCodecs MP4 path, floored the
same way against the track timescale; normal MP4 timescales hold frame
times exactly, so only coarse (1000) timescales were hit.

Fix: new Tick::to_rational_round (nearest unit, halves up, total for zero
and negative timebases), used for the video lookup target and the
catch-up threshold in mkv.rs, mp4.rs and webcodecs.rs. A requested time
now matches a frame whose stored timestamp is within half a unit of it,
the same precision the timestamps have. Truncating muxers (stamps rounded
down) still work. Times on exact timebases (90 kHz, 15360, 30000/1001…)
convert exactly as before.

Test: crates/codecs/src/rounded_pts_tests.rs builds synthetic Motion-JPEG
streams in the test (each picture shows its index as black/white blocks),
muxed as WebM with 1 ms timestamps and as MOV with a 1000 timescale, at
30, 29.97, 60, 59.94, 24, 23.976 and 25 fps with nearest and truncated
stamps. Requesting every frame's start over 3 s must show every frame
exactly once, and every frame's midpoint must show that frame. Before the
fix both tests failed ("30 fps, Nearest ms stamps: 30 of 90 frames never
shown [2, 5, 8, 11]…, 30 shown twice [1, 4, 7, 10]…"). The time crate
tests cover the rounding, ties and hostile timebases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:16:37 -04:00
Brandon Thomas 771b614ad4 Merge pull request #81 from storytold/pr/craft-fonts
Fonts: use craft-fonts as an optional build input (Japanese UI and title fallback)
2026-10-06 15:08:06 -04:00
Brandon ThomasandClaude Opus 5.5 7f8e102b13 Fonts: the Japanese interface uses craft-fonts first; absolute CRAFT_FONTS_DIR
The merged Japanese interface (#38) looked only for a system font. When
FilmCraft is built with craft-fonts, theme::install already appends the
Japanese faces to every font family, so switching to Japanese uses them
(no system scan) and works on the web too; otherwise it falls back to an
installed system font as before. Docs use an absolute CRAFT_FONTS_DIR
(build.rs runs in crates/text, so a relative path resolves from there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:26:14 -04:00
justjohn12345andClaude Opus 5.5 b0aeabdb8d graphics: all eight layer handles scale towards the pointer
A corner handle scales both axes alike with the opposite corner held in place;
the edge handles (top, right, bottom, left) are now grabbable and stretch only
their own axis with the opposite edge held in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:17:29 -04:00
justjohn12345andClaude Opus 5.5 c80d23fd2e graphics: a corner handle scales the layer away from the opposite corner
Dragging a corner handle of a graphic layer in the Program monitor scaled about
the layer's anchor, so a text layer (anchor at its bottom-left) grew up and to
the right whichever corner was dragged. The opposite corner now stays in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:52:20 -04:00
justjohn12345andClaude Opus 5.5 ea491ef467 automation: the export progress test no longer depends on machine speed
A short export can finish inside one 100 ms poll, which left a single progress
notification and no time to answer the ping. The test now tries longer exports
until one outlasts a few polls, and cancels a longer one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:51:43 -04:00
justjohn12345andClaude Opus 5.5 10b007cea9 Timeline: sequence tabs, Premiere-style clip menu, Nest name dialog
- The Timeline panel shows one tab per open sequence (x closes the active one);
  closing a sequence keeps the panel, and opening one restores a closed panel.
- Double-clicking a nested sequence clip opens it in its own tab.
- The clip context menu follows Premiere's grouping and adds Paste/Remove
  Attributes, Ungroup, Synchronize, Merge Clips, Make Subsequence, Reveal Nested
  Sequence and the frame hold / field options; right-click selects the clip.
- Nest... asks for the name (Nested Sequence Name) and numbers the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:51:43 -04:00
justjohn12345andClaude Opus 5.5 fb824c6fb0 mpeg2v: seed_sweep derives its seeds without overflowing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:51:43 -04:00
Brandon ThomasandClaude Opus 5.5 9953befe28 Fonts: use craft-fonts as an optional build input
crates/text/build.rs embeds the fonts of a storytold/craft-fonts checkout
named by CRAFT_FONTS_DIR as filmcraft_text::fonts::CRAFT_FONTS (native:
all of them; wasm32: only BIZ UDPGothic Regular). Unset, CRAFT_FONTS is
empty and nothing changes. A bad CRAFT_FONTS_DIR is a warning, or an
error with CRAFT_FONTS_REQUIRED=1. Never a Cargo dependency, no network.

- UI: the Japanese faces are the last fallback of every egui font family
  (BIZ UDPGothic first, Bold first in the semibold/medium families).
- Text engine: registered after the bundled faces; fallback_for picks
  them after the bundled faces and before system faces (Mincho for serif
  text, Gothic otherwise, nearest weight).
- Tests: Japanese glyphs (no tofu) in the UI and title paths, skipped
  without craft-fonts; the theme works with CRAFT_FONTS empty.
- Release: every build job checks out craft-fonts at a pinned commit and
  builds with CRAFT_FONTS_DIR + CRAFT_FONTS_REQUIRED; packages carry
  OFL-<family>.txt. NOTICE, ATTRIBUTION.md (optional build input), AGENTS.md,
  CLAUDE.md, README and docs/contributing.md describe it.

FilmCraft main bundles no Japanese font, so no font files move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:40:29 -04:00
Brandon Thomas a2f6ba8b44 Merge pull request #72 from storytold/pr/docs-hw-decode-correction
Docs: hardware decode correction; log the 2026-10-06 community batch
2026-10-06 11:39:47 -04:00
Brandon ThomasandClaude Opus 5.5 83481c146f Docs: hardware decode was only active in the desktop app since #41; log the 2026-10-06 community batch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:39:41 -04:00
Brandon Thomas 5c8471c9f4 Merge pull request #41 from justjohn12345/pr/decode-memory
MEM1–MEM4 + HW1: clip memory on a timeline from 12 GB to 1.9 GB; hardware decode actually on
2026-10-06 11:38:03 -04:00
Brandon Thomas 37361900a9 Merge pull request #38 from midasdf/feat/japanese-interface-language
i18n: Japanese interface language (uses an installed system font)
2026-10-06 11:37:57 -04:00
Brandon Thomas e89feeac4d Merge pull request #58 from punkhop/fix/speed-ripples-linked-clips-once
edit: a speed change with ripple moves later clips once
2026-10-06 11:37:52 -04:00
Brandon Thomas 555bfe7c18 Merge pull request #57 from punkhop/fix/ripple-delete-split-edit
edit: ripple delete closes one gap for a clip with a split edit
2026-10-06 11:37:47 -04:00
Brandon Thomas 5dd09386f6 Merge pull request #56 from punkhop/fix/ripple-trim-keeps-split-edit
edit: a ripple trim takes a later split edit along
2026-10-06 11:37:42 -04:00
Brandon Thomas 475c294678 Merge pull request #45 from punkhop/fix/move-linked-partners
engine: timeline.move takes linked partners along
2026-10-06 11:37:38 -04:00
Brandon Thomas 51026bd2b2 Merge pull request #60 from punkhop/fix/place-on-a-sound-track
engine: timeline.place on an audio track places the sound there
2026-10-06 11:37:32 -04:00
Brandon Thomas d886d2f322 Merge pull request #44 from punkhop/fix/unknown-track-is-an-error
engine: naming a track that does not exist is an error
2026-10-06 11:37:28 -04:00
Brandon Thomas 39ef62607d Merge pull request #47 from punkhop/fix/app-help-and-unknown-flags
filmcraft: --help prints usage and an unknown option is an error
2026-10-06 11:37:22 -04:00
Brandon Thomas 8dfa8fff4c Merge pull request #52 from punkhop/fix/explicit-targets-enable
engine: a command whose targets are named in the parameters does not need a selection
2026-10-06 11:37:16 -04:00
Brandon Thomas 8a795324f4 Merge pull request #50 from punkhop/fix/replace-reports-short-media
engine: Replace With Clip reports clips that run past the new media
2026-10-06 11:37:11 -04:00
Brandon Thomas eb3ca3e811 Merge pull request #49 from punkhop/fix/replace-from-bin-keep-source-in
engine: clip.replaceFromBin can keep the clip's source In
2026-10-06 11:37:05 -04:00
Brandon Thomas 950879ef10 Merge pull request #59 from punkhop/fix/inspect-reports-reverse
engine: sequence.inspect reports a reversed clip
2026-10-06 11:36:58 -04:00
Brandon Thomas 7377def476 Merge pull request #48 from punkhop/fix/sequence-inspect-readback
engine: sequence.inspect reports clip ends, gain and marker comments
2026-10-06 11:36:53 -04:00
Brandon Thomas 5bc07026c7 Merge pull request #43 from punkhop/fix/import-auto-points
engine: a project saved after an interchange import opens again
2026-10-06 11:36:46 -04:00
Brandon Thomas a3b077877f Merge pull request #51 from punkhop/fix/interchange-export-reports-dropped-graphics
interchange: an export names every title it cannot carry
2026-10-06 11:36:41 -04:00
Brandon Thomas a407099019 Merge pull request #46 from punkhop/fix/export-interchange-unknown-format
engine: file.exportInterchange refuses an unknown format
2026-10-06 11:36:36 -04:00
Brandon Thomas bccc3dd031 Merge pull request #42 from punkhop/fix/mp4-audio-thumps
codecs: resampled MP4 audio reads apply the edit list once
2026-10-06 11:36:31 -04:00
Brandon Thomas bd40764756 Merge pull request #69 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 10:10:16 -04:00
Brandon ThomasandClaude Opus 5.5 e6d693cc24 Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:06 -04:00
Brandon ThomasandClaude Opus 5.5 ac906666d9 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:38 -04:00
Brandon ThomasandClaude Opus 5.5 6a9fdce2ef codecs: released decoders are dropped off the cache lock; dirty-pool bit-exact test
trim_decoders dropped the decoders it took from idle caches while the requesting cache's lock was held. Dropping a decoder can block (a VideoToolbox session waits for its frames in flight), so the render path paid that latency under the lock. trim_decoders now returns the released decoders and frame_late keeps them in a local declared before its lock guard, so they drop after the guard on every return path; the cap is enforced exactly as before.

New tests/pool_bitexact.rs: primes the frame pool with 0xAA-filled plane buffers, then decodes an H.264 fixture into them; every frame must match ffmpeg bit for bit, and the pool must report the reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:16:09 -04:00
punkhop 64d41b266a project: Clear counts each item and bin once
A bin inside another named bin goes with it and is not counted again;
an item named and also inside a named bin is counted once. The reply's
counts now match what went. The params line stays {"items":[id]?} so
a caller naming targets keeps the same shape as before.
2026-10-06 06:10:56 -07:00
punkhop 8e867cb8c9 project: Clear deletes a bin it is given, with everything in it
Problem: no command could delete a bin. `project.delete` takes item
ids, and a bin's id passed to it answered ok and changed nothing, so a
bin emptied after an XML import (every clip moved to new bins) stayed
in the project for good.

Solution: `project.delete` also accepts bin ids in `items`. A bin
named there is taken out of the tree with its sub-bins, and every item
inside goes the way a named item goes (out of the project, off every
timeline). The project's own top bin is never removed. The reply now
says how many items and bins went. One undo step, as before.

Test: clear_deletes_a_bin_with_everything_in_it (fails before: the
bins stay).
2026-10-06 06:06:34 -07:00
Brandon ThomasandClaude Opus 5.5 47c6b03681 filmcraft: --help, --version and argument errors show a message box on Windows
Windows release builds have no console (windows_subsystem = "windows", added on main after this branch), so --help printed nothing and an unknown option or a bad FILMCRAFT_CONTROL_PORT quit with exit code 2 and no visible message. The text is still printed, and on Windows release builds also shown in a message box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:05:59 -04:00
Brandon Thomas 39d81aee14 Merge remote-tracking branch 'origin/main' into pr-47-fix 2026-10-06 08:46:53 -04:00
Brandon ThomasandClaude Opus 5.5 2f49131f64 edit: speed changes refuse NaN and never overflow the timeline
set_speed_group accepted NaN (speed <= 0.0 is false for NaN), and a tiny speed (1e-7) saturated the new duration to i64::MAX, after which the ripple shift overflowed (panic in debug, wraparound in release). Non-finite speeds are refused, the new duration is capped at what the timeline can hold, and the ripple shift uses checked arithmetic; a refused edit changes nothing (it works on a copy). Regression test with hostile speeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:46:30 -04:00
Peet GuercioandClaude Opus 5.5 56cc93f4ea interchange: an FCP7 master clip lands in its bin when a sequence used it first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:42:03 -07:00
midasdfandClaude Opus 5.5 bab130c358 i18n: Japanese interface language using an installed system font
Edit > Language switches English/Japanese and persists in UI state
(`app.language.english` / `app.language.japanese`, reachable through the
control channel). Core native and in-window menu labels are translated;
untranslated labels stay English. The in-window header fits the document
title beside the localized menus.

No font is bundled. Japanese text uses a Japanese font already installed on
the system (Hiragino, Yu Gothic, Meiryo, Noto Sans CJK JP, Source Han Sans,
IPA, … or any installed face covering the menus' characters), added to every
theme font family as the last fallback. Without one, switching to Japanese is
refused with a message and a saved Japanese setting falls back to English
(the web build cannot read system fonts). Titles already fall back to
installed fonts through the text engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:41:17 -04:00
Brandon Thomas 61ab6f5c7d Merge pull request #63 from storytold/pr/release-0.2.1
Release: FilmCraft v0.2.1
2026-10-06 08:01:17 -04:00
Brandon ThomasandClaude Opus 5.5 e659671bdf Release: FilmCraft v0.2.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank, and the shortcut description was wrong. Release builds no longer open a console window behind the app. Export can no longer panic (filmcraft-export never-crash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:01:04 -04:00
Brandon Thomas bc1523a33f Merge pull request #62 from storytold/pr/never-crash-export
Never crash: filmcraft-export without expect()/unreachable!()
2026-10-06 07:58:18 -04:00
Brandon ThomasandClaude Opus 5.5 136424dc93 Never crash: filmcraft-export without expect()/unreachable!()
The five 'mux created' / 'audio' expects in the stepped exporter now return
an ExportError::Encode instead of panicking if the writer or audio pipeline
is missing, and the non-stepped dispatch returns Unsupported instead of
unreachable!(). The crate root gets the never-crash deny attribute, so every
FilmCraft production crate now carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:58:07 -04:00
Brandon Thomas 6bf2756cde Merge pull request #61 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:54:51 -04:00
Brandon ThomasandClaude Opus 5.5 c60d3f9c4c release.yml: explicit if for the wasm-bindgen install (shellcheck SC2015)
packaging-lint has failed on main since this step was added; same behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:53:46 -04:00
Brandon ThomasandClaude Opus 5.5 7fee797ee7 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what FilmCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:51:02 -04:00
Brandon Thomas 508a5a933f Merge pull request #28 from storybored379/pr/mcp-conventions
MCP: tool annotations, core tools, resources, export progress and cancellation
2026-10-06 07:27:58 -04:00
Brandon Thomas 5012fbd598 Merge pull request #19 from storybored379/pr/deterministic-export
Export: same file on every machine (fixed mux interleave, H.264 slices by frame size)
2026-10-06 07:27:44 -04:00
Brandon Thomas 1f7e4f802e Merge pull request #39 from storybored379/fix/ttml-timing
Captions: report invalid TTML timing parameters on import
2026-10-06 07:27:28 -04:00
punkhopandClaude Opus 5.5 a8e4c815eb edit: ripple delete closes one gap for a clip with a split edit
Problem: `edit.rippleDelete` on a clip whose linked sound has a
different edge than its picture (a J or an L cut) treated the two
ranges as two gaps and closed both on every affected track. With two or
more clips after it the later ones were moved up twice and the edit was
refused ("items overlap"). With one clip after it the delete went
through, and sequence markers after it were moved by both gaps (one
that landed inside the second gap was removed).

Solution: one set of stretches closes on every track: the time the
deleted clips covered, less whatever stays on the tracks that lose a
clip. Those tracks move up by the same amount, into time that is free
on all of them, so later clips stay in sync and nothing is overwritten.
For a J or an L cut that is the picture's range. Straight cuts close as
before, also with the sounds on alternating tracks. A track that is
locked loses nothing and does not count. `ripple_delete_items` returns
the stretches it closed and the command ripples the markers by those,
so the two cannot disagree.

Things to know. A gap can stay on one track when a clip that stays on
another of the tracks covers part of the deleted time: a sound shorter
than its picture with other sound right up against it, or unlinked
clips on different tracks deleted in one call. When nothing can close
at all the edit is refused and nothing changes; some such calls went
through before, moving clips by a gap another track did not have. A
selection wholly on locked tracks is refused as locked; before, it
deleted nothing and still shifted the sync-locked tracks.

Tests: an edit-crate test (a split on both sides, two neighbours,
clips apart in time, sounds on alternating tracks, a short sound with
and without neighbours, a locked track, the refusal); engine tests
through `edit.rippleDelete` for a J cut with a marker, undo and redo,
an L cut, two neighbours with a split between them, two neighbours with
sounds on alternating tracks, and a straight cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:23:27 -07:00
punkhopandClaude Opus 5.5 4a0ab32480 edit: a ripple trim takes a later split edit along
Problem: `timeline.trim` with `mode: "ripple"` on the clip before a
split edit moved the later picture and left its linked sound behind.
With a J cut (the next clip's sound starts before its picture, on a
sync-locked track where the trimmed clip has no partner) a sound that
led its picture by 12 frames led by 20 after lengthening by 8, with no
error. Shortening was refused as a sync-lock conflict, because the
early sound sits in the stretch that closes.

Solution: in `ripple_trim_group`, a clip that starts before the cut,
reaches past it and is linked to a clip that shifts now shifts with it,
on the tracks that ripple (the members' tracks and sync-locked tracks).
It no longer counts as material in the way of a shortening. When it has
no room to follow (another clip right before it, or the sequence start)
the trim is refused with the sync-lock error and nothing changes (an
overlap on any track that ripples now reports that error instead of
the generic "items overlap"). A
linked clip that ends before the cut is left alone, as before, and so
are the trimmed clips' own partners. A transition into a follower from
a clip that stays behind would be left over a gap; it is removed.

Tests: an edit-crate test for longer, shorter, both refusals, the
clip that ends before the cut, the In edge, the trimmed clip's own
partners and the transition; engine tests through `timeline.trim` for
longer and shorter with undo and redo, and for both refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:23:27 -07:00
punkhopandClaude Opus 5.5 4df7e75085 engine: timeline.place on an audio track places the sound there
Problem: `timeline.place {item, track: "A3"}` (an audio track given as
`track`). For an item with picture and sound it put a video item on the
audio track A3 and a second, sound clip on the default audio track. For
a sound-only item it ignored `track` and placed on the default audio
track.

Solution: `track` is where the picture goes and `audioTrack` where the
sound goes. An audio track given as `track` now places the sound alone,
on that track. What cannot be meant is a parameter error that says why,
and changes nothing: an audio track as `track` together with
`audioTrack`; a video track as `audioTrack`; an audio track for an item
with no sound; a video track alone for an item with no picture. Calls
that name a video track as `track` and an audio track as `audioTrack`
(the timeline panel's drop does) are unchanged. The parameter hint and
`docs/agents.md` say so.

Tests: track_params_tests.rs places a clip with picture and sound and a
sound-only item on audio tracks by name and by id, checks each refusal
for its error kind, its text, an untouched project and undo history,
and checks the usual forms still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:14:24 -07:00
punkhopandClaude Opus 5.5 f06bfc4648 edit: a speed change with ripple moves later clips once
Problem: `clip.speedDuration` with `ripple: true` on a clip that has
linked sound set the speed on the picture and then on its sound, and
each one rippled the later material on its own. Slower (50%) moved the
later clips by twice the change and left a double gap. Faster (200%)
was refused ("items overlap") when more than one clip followed: the
second ripple pulled the farther clips onto the nearer ones.

Solution: `set_speed_group` changes a clip and its linked partners on
other tracks as one edit and ripples once, on the members' tracks and
on sync-locked tracks. The command groups the selected clips by link
(one clip per track in a group); clips that are not linked, or linked
clips of one track, still change one after the other. When the members'
lengths change by different amounts (a split edit), later material
moves by the largest change, so no member is overlapped and the tracks
stay in sync; the shorter member is followed by a gap. The group edit
works on a copy, so a failure changes nothing. `set_speed` keeps its
signature and behaviour for one clip.

Tests: an edit-crate test (slower, faster, a split edit both ways, two
clips of one track and a missing clip refused with nothing changed);
engine tests through `clip.speedDuration` for slower with undo and
redo, faster, two clips in one call, no ripple, picture and sound both
named with Linked Selection off, linked clips of one track, a split
edit, and a clip that is not in the sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:14:24 -07:00
punkhopandClaude Opus 5.5 7eb4f1fc7b engine: sequence.inspect reports a reversed clip
Problem: a clip set to play backward (`clip.speedDuration` with
`reverse: true`) read back from `sequence.inspect` exactly like a
forward clip: same `speed`, same source range, nothing that said it was
reversed. An agent could set it and not confirm it.

Solution: clips gain `reverse` (bool) in `sequence.inspect`, named as
in the project file and in the command. Additive; existing keys are
unchanged. `docs/agents.md` lists it and says what it means for the
source range.

Tests: sequence_inspect_tests.rs reverses a demo clip through the
command and checks that only that clip reports `reverse: true`, and
none after undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:45:21 -07:00
punkhopandClaude Opus 5.5 ff8aa8749d engine: a project saved after an interchange import opens again
Problem: a Final Cut 7 XML whose Basic Motion filter sets only `scale`
makes the importer add the Motion effect with its defaults, where
`position` and `anchor` are the NaN "auto" sentinel. Placing a clip
resolves those with `resolve_auto_points`; the interchange import did
not (any format: the clips are built with the effect defaults).
serde_json writes NaN as `null`, and the reader then refused the file:
"project file is damaged: invalid type: null, expected f64". The project
could be saved but never opened again.

Solution:
- project: a point parameter's value (`ParamValue::Vec2`) reads a `null`
  coordinate back as NaN, so "auto" survives a save. Only there: `Vec2`
  itself is unchanged, and a `null` in a mask vertex or any other point
  is still a damaged file.
- project: `Project::resolve_placed_auto_points` resolves the points of
  the clips in chosen sequences. Points measured in the frame use the
  sequence size. `anchor` is measured in the clip's source
  (`Project::source_size`, a subclip through its parent, as the renderer
  resolves it) and is resolved only for sources the caller says have
  their final size.
- engine: the import resolves in the edits it already makes, so the undo
  steps are what they were. In "Import ...": everything but the anchors
  of file media, whose size is only what the document says so far (an
  EDL says nothing and the importer assumes the sequence size). In each
  "Link Media": the anchors of that file's clips, from the probed size.
  A clip whose file is not found keeps an "auto" anchor, which the
  renderer centres in whatever size the media has, as before this
  change.
- engine: a later Link Media (relink.rs `apply`, behind `media.relink`
  and `media.autoRelink`) resolves those waiting anchors in its own undo
  step, when it has read the file. Nothing else resolves them: a file
  that merely comes back at its old path, or a forced relink that does
  not read the file, leaves the anchor "auto" (still rendered centred).

Why no schema step and no repair on load: the file's shape is not
changed. Files with `null` points were written by every build before
this one, at the current schema version, so a migration (keyed on the
version) cannot select them, and a bump would only make every new
project unreadable to those builds. They open now and their points are
"auto", exactly as in the session that wrote them; resolving them on
load would mean guessing the size of media that was never linked.
docs/project-files.md describes `null` points.

Tests: project
`point_params_keep_auto_through_json_and_no_other_point_reads_null` and
`placed_auto_points_resolve_once_the_source_size_is_final`; format
`auto_points_written_as_null_open_again`; engine
(interchange_auto_points_tests.rs): import with the media missing, save,
reopen, undo, redo; a linked file whose real size differs from the
declared and the sequence size (undo of Link Media and of the import,
redo); an EDL clip smaller than its sequence; import with the media
missing, save, reopen, relink (anchor becomes a number, undo, redo, a
second relink leaves it). All fail without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:28:54 -07:00
punkhopandClaude Opus 5.5 8f74d18829 engine: timeline.move takes linked partners along
Behaviour change to the default: with Linked Selection on (the default),
`timeline.move` now also moves the linked partners of the listed clips.
A partner lands on its own track in the same mode as the listed clip, so
a caller that lists only a picture clip can now overwrite audio at the
destination time. `"linked": false` gives the old behaviour (only the
listed clips move); the timeline panel passes it, so dragging is
unchanged.

Problem: `timeline.move {"moves": [{clip, track, time}]}` moved only the
listed clip. Its linked sound stayed where it was, out of sync, although
Linked Selection was on and `timeline.trim`, slide and select all act on
linked partners. Separately, a `time` near i64::MAX overflowed the tick
addition in the edit (a panic in debug builds, a wrapped position in
release).

Solution: a linked partner that is not itself in `moves` moves by the
same time offset and stays on its own track. A partner that is listed is
moved once, to where the list says. Two kinds of partner are left
exactly as they are: one on a locked track (the listed clip still moves,
as it did before), and one whose position would not change, which is the
case when the listed clip only changes track. Re-placing such a partner
would drop the transitions at its edges and report a move that did not
happen. A new optional `linked` parameter overrides the toggle for one
call. The command returns `{"moved": [ids]}`, the clips that moved.

No clip is moved before the sequence start. When one would be (a split
edit whose sound starts earlier, or several listed clips asked too far
left), all clips of the call land later by the same amount, so the
spacing asked for is kept. This replaces the per-clip clamp for a call
that lists several clips with a time below zero; a single clip still
lands at zero. The offsets are summed in i128 and checked on the way
back: a start past Tick::MAX is a parameter error and nothing moves.

Tests (timeline_move_tests.rs): partners follow, undo / redo, both
listed, `linked: false`, toggle off, `linked: true`; a locked partner; a
picture moved to another track at the same time leaves its sound and the
sound's crossfade untouched; the common stop at the sequence start with
a split edit and with two listed clips; hostile times (i64::MAX,
i64::MIN, junk). All fail without the change, the hostile one with the
overflow panic. docs/control-protocol.md and docs/agents.md describe the
default, the `linked` parameter and the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:24:23 -07:00
punkhopandClaude Opus 5.5 6de389c75a engine: a command whose targets are named in the parameters does not need a selection
Problem: a command gated on the selection refused to run although the
caller named what to act on. `clip.replaceFromBin {clips, item}` answered
"select a clip in the Project panel"; `edit.clear`, `edit.rippleDelete`,
`edit.pasteAttributes`, `clip.enable`, `clip.link`, `clip.speedDuration`
and `clip.audioGain` with `clips` answered "no clips selected". A script
or agent had to change the user's selection first. The cause is the order
in Session::execute: `enabled()` runs before the parameters are read, and
it only knows the selection.

Fix: Session::execute asks enablement per call. When `enabled()` passes,
nothing changes. When it fails and the parameters name targets under a
key the command documents as ids (`clips` / `clip`: clips of the active
sequence, `items` / `item`: project items), the same `enabled()` runs
once more with those targets standing in for the selection, and the
selections are put back before the command runs. So only the selection
condition is answered by the named targets: no sequence, an empty
clipboard or the wrong kind of clip still disable the command, ids of
nothing do not count, and a command that does not document the key
(`edit.cut`) still needs a selection. This works for every command
because the predicates already resolve their targets the way `run` does
with empty parameters. `is_enabled` (menus, `engine.commands`) is
unchanged.

`clip.audioGain` already read `clips`; its parameter doc now says so.

Tests (explicit_targets_tests.rs): the commands above with and without
parameters, undo/redo, the selections left untouched, menu enablement
unchanged, and the cases that must still be refused. Four of the five
fail without the change in execute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:05:12 -07:00
punkhopandClaude Opus 5.5 4aa3f79953 interchange: FCP7 XML import reports a clip item whose file is not defined
Problem: a clip item whose `<file id="...">` reference has no definition
anywhere in the document was dropped without a word, so a sequence could
come back one clip short with an empty import report. FilmCraft's own
export writes titles and adjustment layers this way.

Solution: the importer adds a warning that names the clip item: `clip
"Title" refers to a file the document does not define; skipped`. Nothing
else about the import changes.

Test: `fcp7_import_reports_a_clip_item_whose_file_is_not_defined`
(FilmCraft's own export, a hand-written document, and a document whose
files are all defined).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:02:27 -07:00
punkhopandClaude Opus 5.5 6899f15e51 interchange: an export names every title it cannot carry
Problem: exporting a sequence with a graphic (title) clip to Final Cut
Pro 7 XML returned a report that listed other losses and said nothing
about the title. The XML holds a clip item for it that points at a file
the document never defines, and importing the XML gives the sequence
back without the title. OTIO (a clip with a missing media reference,
read back as offline media) and EDL (an ordinary event) were silent in
the same way; FCPXML said "clip without media skipped" and AAF "graphics,
titles and adjustment layers are not exported" without saying which clip.
Adjustment layers take the same paths.

Solution: every exporter names each graphic clip and adjustment layer
it cannot carry, one warning per clip, with what was written instead:
`graphic clip "Title" at frame 48 has no Final Cut Pro 7 XML equivalent:
it is written as a clip item without media and is not read back on
import`. One helper (`common::uncarried_clip`) builds the name for all
formats. What is written is not changed, byte for byte.

Why only a warning: FCP7 XML has a Text generator, but this crate's
importer does not read one (unknown generators become Black Video), and
a title here is a stack of text and shape layers with per-character
styles, pins and roll settings that the generator's few parameters
cannot hold. Writing one would need a new import path and would still
lose most of a title, so the honest result for now is to say what
happens. crates/interchange/README.md has the table per format.

Tests: crates/interchange/tests/uncarried_clips.rs (each format names
the title and the layer once; two titles are two lines; nothing is said
without such clips; a re-import gives back what the report says), and
the engine's `interchange_export_reports_the_title_it_cannot_carry`
through `file.exportInterchange`. The two naming tests fail without the
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:01:34 -07:00
punkhopandClaude Opus 5.5 5bf2203bb8 codecs: resampled MP4 audio reads apply the edit list once
Problem: a clip with AAC audio in MP4 whose sample rate differs from the
sequence's (48 kHz clip in a 44.1 kHz sequence) exported with loud
low-frequency thumps, several times full scale, recurring through the clip.
Every exported container was affected (AAC clips them into thumps, PCM
clips at full scale); a WAV export had a single one at the clip's start.

Cause: Mp4Source::audio decodes a window starting at s0, the requested
presentation sample minus the edit-list offset, then resamples it. The
resampler derived its sub-sample phase from s0, so the edit-list offset
(-1024 samples of AAC priming) was applied a second time: the read
position started about 1024 samples before the window, was clamped to
index 0 with a large negative fraction, and linear interpolation
extrapolated a ramp over the first ~940 samples of every request. Export
asks for audio one batch of video frames at a time, so the ramp repeated
at each request. Sources at the sequence rate take the copy path and
were unaffected, as were tracks without an edit list.

Fix: take the phase from the presentation sample the window starts at
(before the edit-list offset is subtracted).

Test: aac_mp4_resampled_reads_follow_the_edit_list
(crates/codecs/tests/mp4_oracle.rs, with `require_ffmpeg!` and the
shared fixture helper as docs/testing.md asks of new oracle tests) reads
an ffmpeg AAC fixture at 44.1 kHz in frame-sized pieces and compares it
with the 48 kHz decode interpolated at the same times (peak was 7.35
against a source level of 0.15 before the fix). docs/testing.md lists it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:29:26 -07:00
punkhopandClaude Opus 5.5 d3ee1dfd22 engine: Replace With Clip reports clips that run past the new media
Problem: when the replacement is shorter than the clip's slot, the clip
silently runs past the end of its media and the renderer holds the last
frame. Nothing in the command's result says so, so a script or agent
that swaps a take cannot tell.

Solution: the result of the three Replace With Clip commands
(`clip.replaceFromSource`, `clip.replaceFromSourceMatchFrame`,
`clip.replaceFromBin`) gains `short`: one `{clip, shortByFrames}` per
replaced clip that now plays past the end of its media, in sequence
frames and never more than the clip's own length. It is empty when none
does; stills and other sources without an end, and held frames, are
never short. The edit itself is not changed, and the parameter docs are
untouched (results are described in docs/control-protocol.md).

Test: `replace_with_clip_reports_clips_that_run_past_the_new_media`
(From Bin with a 12-frame subclip, undo / redo, From Source Monitor,
Match Frame, the cases that are not short, and an In past the end of the
new media capped at the clip's length).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:28:20 -07:00
punkhopandClaude Opus 5.5 0e3aebee5d engine: clip.replaceFromBin can keep the clip's source In
Problem: `clip.replaceFromBin` always restarts the clip at the new
item's In point (or its start). That matches Premiere's Replace With
Clip > From Bin, but a script or agent swapping one take for another has
no way to keep the clip's place in the media.

Solution: the default is unchanged, and so is its result. An optional
`keepSourceIn` (default false) keeps each clip's current source In. A
subclip that restricts trims has no media outside its range, so there a
kept In outside the range is moved to the subclip's first or last frame;
with `keepSourceIn` the result gains `sourceInClamped`, the clips this
was done for. The parameter doc lists the new parameter only (a result
description in it would take the command out of the Keyboard Shortcuts
list, which reads the doc to see whether a command needs parameters).

Tests: `replace_from_bin_keeps_the_source_in_on_request` (default and
its result, keepSourceIn, undo / redo, the disabled case, subclips before,
after and around the In, an unrestricted subclip, junk values), and
`list_searches_labels_and_keys` now asserts the three Replace With Clip
commands are bindable. docs/control-protocol.md describes the commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:25:50 -07:00
punkhopandClaude Opus 5.5 22229df105 filmcraft: --help prints usage and an unknown option is an error
Problem: `filmcraft --help` opened the app. Every argument the parser
did not know was taken as a file to open, so `--help`, `-h` or a
mistyped flag started a window (and tried to import "--help" as media).
`--control` with a missing or invalid port, and an invalid
`FILMCRAFT_CONTROL_PORT`, started the app without the control server and
said nothing, which leaves a script waiting for a server that never
comes.

Solution: argument parsing moves into its own module (`args.rs`,
`args::parse`), which runs before anything else starts. `--help` / `-h`
print the usage (the options the app parses: --control, --demo, --empty,
--recover, --no-recover, --data-dir, --version) and exit 0. An unknown
option, or `--control` / `--data-dir` without a usable value, prints the
error and the usage on stderr and exits 2. The whole line is checked, so
`--version --bogus` is an error too. A `FILMCRAFT_CONTROL_PORT` that is
not a port is the same error as a bad `--control` (exit 2), for the same
reason; an empty value counts as unset, and the value is not looked at
when `--control` is given or for `--help` / `--version`. `--` ends the
options, so a file whose name starts with a dash can still be opened; a
lone `-` and the `-psn_...` argument macOS adds to app launches are not
treated as options.

Tests (in args.rs): `help_and_version_do_not_start_the_app`,
`unknown_options_are_errors`,
`a_bad_port_in_the_environment_is_an_error_like_a_bad_control_flag`,
`known_options_and_files_parse_as_before`. docs/contributing.md
mentions `--help` and the errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:21:56 -07:00
punkhopandClaude Opus 5.5 285da5c66b engine: file.exportInterchange refuses an unknown format
Problem: `file.exportInterchange {"format": "<anything unknown>"}` wrote
FCP7 XML and returned ok, so a typo or an unsupported format produced a
file of the wrong kind under the name that was asked for.

Solution: only a missing `format` means FCP7 XML. A given format must be
one of the six documented names (edl, xml, fcpxml, otio, aaf, omf: each
format's extension, in either case). Anything else, including a value
that is not a string, is a parameter error that lists exactly those
names, and nothing is written. No other spelling is accepted (".xml",
"fcpxmld", "omfi"): none was accepted before either, they silently
became XML. The parameter doc now lists aaf and omf, which the command
already took, and the default; docs/architecture.md says the same.

Test: `export_interchange_refuses_an_unknown_format`, next to the
interchange round-trip test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:20:02 -07:00
punkhopandClaude Opus 5.5 51ecd70763 engine: sequence.inspect reports clip ends, gain and marker comments
Problem: `sequence.inspect` is how an agent verifies an edit, but it
left out where a clip ends in the sequence and in its media, a clip's
audio gain, and the comment of a marker, so those edits could not be
checked from the readback.

Solution: each clip also reports `end`, `endFrame`, `sourceOut` (which
depends on speed, so it cannot be derived from `sourceIn` + `duration`)
and `gainDb`; each sequence marker also reports `comment`. Every
existing key stays. docs/agents.md lists what the readback holds.

Test: `sequence_inspect_reports_clip_ends_gain_and_marker_comments`
(sequence_inspect_tests.rs), on a clip at 200 %, a clip with gain and a
marker with a comment made through the commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:17:52 -07:00
punkhopandClaude Opus 5.5 9d3975a697 engine: naming a track that does not exist is an error
Problem: `timeline.place {"audioTrack": "A2"}` on a sequence that has
only A1 placed the clip on A1, overwrote what was there and returned ok.
`track_p` returned `None` both for "no track asked for" and for "that
track does not exist", so callers fell back to the source-patched track.
It also took any first letter other than V as an audio track, accepted
track ids that are not in the sequence, and `split_at(1)` panicked on an
empty or non-ASCII name.

Solution: `track_p` returns `Result<Option<TrackId>>` and takes the
command id, like the other fallible parameter helpers. An absent (or
null) parameter is `Ok(None)` and keeps every default as it was. A name
or id the active sequence does not have is a parameter error
(`EngineError::BadParams`): "invalid parameters for `timeline.place`: no
track A2 in this sequence (`audioTrack`)". All callers propagate it:
timeline.place (`track`, `audioTrack`), timeline.move, timeline.razor,
timeline.setTrack, timeline.setTargeting, sequence.closeGap,
sequence.deleteTrack, sequence.deleteTracks, the go-to-gap commands,
sequence.transcribe and the multicam sync reference track.

Not changed: a `timeline.move` entry without a `clip`, `track` or `time`
is still skipped, as before; only a track that was named and is missing
is an error.

Test: `naming_a_missing_track_is_an_error_not_a_default` in
track_params_tests.rs (missing and hostile names, the error kind, nothing
edited, no undo step, existing tracks by name and id still resolve).
docs/agents.md notes the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:10:19 -07:00
justjohn12345andClaude Opus 5.5 4f3e119ac2 MEM4: decode into the planes of evicted frames
With MEM1-MEM3 twelve clips on a timeline still took 5.1 GB, only 2.5 GB of
it live: each decoded picture allocated three planes, each eviction freed
them, and the system allocator kept the freed pages.

filmcraft_frame::pool keeps the planes of frames the GOP caches evict (up to
192 MiB per sample type; a frame something else still holds is left alone).
The H.264 decoder, through a plane allocator hook that keeps the crate free
of dependencies, and the VideoToolbox path take their output planes from it.

perf.stats reports decode.planePoolMB and decode.planesReused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:57:16 -04:00
Brandon ThomasandClaude Opus 5.5 5d1165d06b MCP: cancelled exports delete only their own files
remove_partial removed every '<stem>[digits].<any ext>' file in the output folder modified since the export started, so cancelling an export to trailer.mp4 also deleted trailer.aep or trailer2.psd if they were saved during the export. It now removes only the output file, numbered stills with the output's extension, and the .srt/.vtt caption sidecar. Regression test with decoy files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:54:24 -04:00
justjohn12345andClaude Opus 5.5 b999b69f07 HW1: register the hardware decoders in every desktop run
The desktop app called filmcraft_platform::register() as an argument of
log::info!. A log macro does not evaluate its arguments while no logger takes
its level, and the app installs none, so VideoToolbox was never registered:
perf.stats showed hardware decoding enabled with 0 sessions, 0 declined and
every frame decoded in software. The CLI and the bench register directly and
were not affected.

Registration is now a statement of its own, with a start-up test that runs
without a logger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:42:28 -04:00
justjohn12345andClaude Opus 5.5 4486652f07 MEM3: one frame budget shared by all sources
Each GOP cache keeps up to 384 MB of decoded frames (at least 64), and
nothing bounded the total: twelve clips on a timeline held 3.8 GB of frames
nobody was looking at, and no perf.stats counter showed it.

The pool now counts the frames of every cache against a 1 GiB budget. Over
it, caches idle for 3 s give up frames, least recently used cache first.
Caches in use keep their own budget, since a frame evicted before it is
shown costs a re-decode from the keyframe.

perf.stats reports decode.cacheMB and decode.cacheBudgetMB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:48 -04:00
justjohn12345andClaude Opus 5.5 517b6b041b MEM2: idle sources give up their decoders beyond a cap
Every source kept its decoder for good once it had decoded a frame, with its
reference pictures and the pictures its frame threads hold in flight: about
290 MB per H.264 clip, 3.5 GB for twelve clips on a timeline.

The GOP caches now share a pool. Beyond four live decoders, the least
recently used caches that have been idle for 3 s drop theirs and make a new
one when asked again (a restart at the sync sample, as after a seek). Caches
in use keep their decoder whatever the count, so a composite of many layers
never restarts decoders per frame. Other caches are only try-locked, so two
caches trimming each other cannot deadlock.

perf.stats reports decode.liveDecoders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:48 -04:00
justjohn12345andClaude Opus 5.5 ac8279d293 MEM1: desktop reads media files in place
The desktop host had no Services::reader, so MediaPool::open_file read every
clip whole and kept its bytes for as long as the source stayed open: 2.9 GB
for twelve clips on a timeline (malloc_history, 15 allocations in open_file).

FsServices::reader now returns a FileReader that keeps the file open and
serves positional reads, so MP4/MOV, Matroska and the other reader openers
load the index and then single samples, as the web host already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:48 -04:00
Brandon Thomas ada55eb625 Merge pull request #35 from storytold/pr/gpu-effects
GPU2: standard effects on the GPU compositor (#30)
2026-10-05 16:27:05 -04:00
Brandon ThomasandClaude Opus 5.5 adfd9a66de docs: point agents to the shared test corpora (#36)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:17 -04:00
Brandon ThomasandClaude Opus 5.5 e9a0c49d15 Docs: hardware decode on macOS and 31 GPU effects landed (#30)
ROADMAP honest assessment, Where we are lacking and Running now; README status and codec note. 35 crates (platform).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:50:45 -04:00
Brandon ThomasandClaude Opus 5.5 4aea06e2ae GPU2: standard effects on the GPU compositor (#30)
A media clip whose enabled standard effects all have a GPU implementation
plans as a GPU layer carrying them (render::plan::LayerFx): parameters are
evaluated on the CPU at the frame's time (render::gpufx::FxOp::eval, shared
with the CPU path so both clamp hostile values identically), the source is
drawn into an Rgba32Float working image at the size the CPU decodes it, the
effects run as WGSL compute passes (gpu::fx, fx.wgsl) with the CPU
reference's math, and the result is placed with Motion, opacity and blend
mode as before. Layers without effects keep the single draw.

GPU effects: Brightness & Contrast, ProcAmp, Tint, Black & White, Color
Balance, Leave Color, Change to Color, Color Pass, Color Replace, Channel
Mix, ASC CDL, Gamma Correction, Levels, Extract, Invert, Posterize, Alpha
Adjust, Gaussian / Camera / Directional Blur (+ legacy aliases), Sharpen,
Unsharp Mask, Crop, Edge Feather, Transform, H/V Flip, Mirror, Offset.
Masked effects, opacity masks, non-finite parameters and minifying
Transform/Crop zooms stay on the CPU. Devices without compute shaders run
the effects on the CPU inside the compositor.

Also fixes a debug-build overflow in Image::transformed for non-finite maps.
Parity tests (exact f32 checks and composited plans), a golden test that
plans effect chains as GPU layers, and the stack3-fx playback benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:50:20 -04:00
Brandon Thomas e9a01dbf38 Merge pull request #33 from storytold/pr/hw-decode-videotoolbox
HW1: platform crate + VideoToolbox H.264/HEVC hardware decode (#30)
2026-10-05 07:26:47 -04:00
Brandon Thomas 2e8df0541a Merge pull request #34 from storytold/pr/honest-assessment
Docs: honest parity assessment (ROADMAP, README, agent docs)
2026-10-05 06:32:58 -04:00
Brandon ThomasandClaude Opus 5.5 3d4e6d77bb Docs: honest parity assessment in ROADMAP, README and agent docs
ROADMAP now reports two numbers: the feature checklist (~87%, what exists)
and ready for real work (~50-60%, an honest estimate). Adds a by-dimension
assessment with evidence, a prioritised "Where we are lacking" list (hardware
acceleration, a measured parity tool, real-world media corpus, workflow
acceptance tests, Windows/Linux runtime CI, delivery codecs, plugins, AI, web),
and where we're going. Fixes stale rows (M5 effect count, Running now) and logs
2026-10-05.

README: the stale "45%" status replaced with both numbers and the biggest
gaps; the codec note now lists what imports today and what's missing.

docs/agents.md: agents read the honest assessment first, prefer its gaps,
and report progress as measured vs estimated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:04:09 -04:00
Brandon Thomas 98792afd1c Merge pull request #32 from storytold/pr/gpu-blend-modes
GPU1: blend modes on the GPU compositor (#30)
2026-10-05 05:28:35 -04:00
Brandon ThomasandClaude Opus 5.5 03f5439d9a HW1: plane views borrow the pixel-buffer lock guard (#30)
PlaneView's slices were tied to the pixel buffer's lifetime, not the lock's, so nothing stopped a later edit from reading plane memory after CVPixelBufferUnlockBaseAddress. They now borrow the Locked guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:53:28 -04:00
Brandon ThomasandClaude Opus 5.5 c3ec0e85d3 HW1: platform crate + VideoToolbox H.264/HEVC hardware decode (#30)
- New crates/platform (filmcraft-platform, L5): OS media FFI only. The one
  crate allowed `unsafe` (own [lints] with unsafe_code = "deny",
  #[allow(unsafe_code)] only on the videotoolbox module, SAFETY comment on
  every block, safe Result-returning public API). AGENTS.md §0.3 names the
  exception; docs/adr/0001-platform-ffi.md records why and the rules.
- VideoToolbox decoder for avcC / hvcC, 8/10-bit 4:2:0 and 4:2:2: session from
  the sample entry's parameter sets with hardware required, asynchronous
  decode (2 in flight), biplanar CVPixelBuffer copied to planar Yuv8/Yuv16,
  reorder buffer of the stream's own depth, RASL skipped after a starting CRA.
  Output callback under catch_unwind.
- HybridDecoder: transparent mid-stream fallback to the software decoder
  (replays samples since the last restart point, drops already-returned
  pictures). New codecs::software_video_decoder (built-in factories only) and
  codecs::hw (switch, counters, NalStreamInfo shared with software decoders;
  vui_color / sar_par helpers now used by both).
- Settings ▸ Playback ▸ Hardware decoding: Auto / Off (engine applies it to
  the codecs switch); desktop app, CLI/MCP and bench call register().
- perf.stats decode.framesDecoded and decode.hardware counters.
- Tests: bit-exact parity with the software decoders (H.264 High, HEVC Main
  open GOP, Main 10; resets, reseeks, flushes), forced-failure fallback,
  mutation fuzz of samples and parameter sets, cross-platform fallback with a
  stand-in decoder, setting Off, HEVC 4:2:2 vs ffmpeg.
- Bench --hw auto|off; results in docs/performance.md (4K CPU/frame
  ~86-119 ms -> ~4 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:53:28 -04:00
Brandon ThomasandClaude Opus 5.5 6102a3794a GPU1: blend modes on the GPU compositor (#30)
All 27 blend modes now composite on the GPU instead of sending the
whole frame to the CPU.

- render::plan: PlanLayer carries its Blend; plan_frame no longer falls
  back to a CPU frame for non-Normal modes. Media, graphic clips,
  multicam angles and CPU-rendered layer images (standard effects) keep
  their clip's mode; inside a transition the mode is Normal, as in the
  CPU reference. execute_cpu composites with each layer's mode.
- gpu: Normal keeps the single fixed-function pass. Dissolve also uses
  fixed-function "over": the CPU's 64-bit pixel hash is reproduced in
  WGSL on u32 pairs (pattern bit-exact). The other 25 modes copy the
  accumulator region under the layer's quad into a backdrop texture and
  composite in fs_blend with the CPU math (sRGB-encoded straight colour,
  W3C formulas, back to linear, same premultiplied mix and clamping).
- Tests: per-mode GPU-vs-CPU parity (gpu crate: overlapping transformed
  layers, partial opacity/alpha, empty/partial backdrop; golden: every
  mode through plan_frame incl. an effect layer, blended clips in a
  cross dissolve), exact-value edge cases (black/white, Divide by 0,
  Dodge/Burn at 0 and 1, zero alpha) within 4.9e-4, Dissolve pattern
  exact, off-output layers, quad bounds.
- Bench: new stack3-blend playback scenario (V2 Screen, V3 Overlay);
  CPU ms/frame 151-214 -> 86-89, Full 19/173 and 138/54 -> 191/1, 192/0.
- Docs: architecture §5, gpu crate docs, testing, performance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:25:16 -04:00
storybored379andOpenAI Codex 9c06722119 Captions: report invalid TTML timing parameters on import
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-04 21:48:25 -07:00
storybored379andClaude Opus 5.5 cad16825e3 MCP: progress notifications and cancellation for blocking exports
command_run file.exportMedia with wait: true (headless) runs as the
background export job and uses the MCP progress and cancellation utilities:

- with _meta.progressToken, notifications/progress reports frames done /
  total with the job status, at most every 100 ms (none without a token);
- notifications/cancelled stops the job at the next batch (jobs.cancel),
  deletes the partial output and sends no response;
- the session lock is only held for short polls, so ping, doc_inspect,
  jobs.list etc. are answered while it encodes.

Without wait, and in bridge mode, nothing changes. Test
export_progress_and_cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:51:38 -07:00
storybored379andClaude Opus 5.5 8ee7658303 MCP: filmcraft://document and filmcraft://commands resources, with MCP 2026-07-28 cache hints
- resources/list and resources/read serve the project (tree + active
  sequence, as doc_inspect) and the command catalog (as command_list) as
  JSON, for clients that attach context from resources.
- Results are built with rmcp's types, so resultType follows the negotiated
  revision; for 2026-07-28 clients (current Claude Code) they also carry
  ttlMs and cacheScope, like the generated tools/list. The resource list is
  fixed (10 min), document reads are never cached (0).
- get_info now declares the resources capability; the instructions text
  moves to a const, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:51:28 -07:00
storybored379andClaude Opus 5.5 0f70015b93 MCP: tool titles and annotations, command_batch / doc_inspect / render_preview, strict arguments
- Every tool has a title and readOnly / destructive / idempotent / openWorld
  hints, set in its #[tool] attribute, so clients can run the read-only
  tools without asking and ask before edits.
- New tools: command_batch (several commands, stop at the first error by
  default), doc_inspect (project tree + active sequence in one call) and
  render_preview (= render_frame).
- An unknown tool argument is a -32602 error naming it and the accepted
  ones, instead of being ignored.
- A line that is not JSON gets a -32700 reply (id null) instead of being
  dropped without an answer: serve_io filters lines in front of rmcp.
- A panicking command no longer poisons the session lock for the rest of the
  session ("session lock poisoned"); it is reported as "internal error: …"
  (AGENTS.md §0.6).
- docs/agents.md § Conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:51:21 -07:00
storybored379andClaude Opus 5.5 a55d20c454 export: same file on every machine (fixed mux interleave, H.264 slices by frame size)
Exporting the same project twice could give different files, because two
export parameters followed the number of cores the process sees:

- The muxer got one chunk per render batch, and a batch is one frame per
  rayon thread. The chunk layout (stsc/stco, PCM packet sizes) changed with
  the core count; the samples themselves did not.
- The H.264 encoder used one slice per core (capped at one per four MB rows),
  so the bitstream and every decoded picture changed with the core count.

On a host whose CPU quota changes (containers, power limits), two exports in a
row differed. Now packets are muxed in fixed groups of 16 output frames
(INTERLEAVE), each followed by its audio, whatever the batch size, and H.264
exports use one slice per four macroblock rows. Hosts with 17 or more threads
already used these values, so their 1080p output does not change.

Tests: determinism_tests (fail on main): same bytes for ProRes, H.264 and
Motion-JPEG at 2/3/5/16 cores and 1/7 frames per step; 64 slices for a
4096-line H.264 export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:32:49 -07:00
777 changed files with 162567 additions and 9147 deletions

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# Builds FilmCraft on FreeBSD, inside a FreeBSD VM on a Linux runner (GitHub has no FreeBSD
# runners), then runs packaging/freebsd/package.sh, the script the release job runs, and checks
# the tarball. A broken FreeBSD build or package fails here before it can fail a release. Nothing
# is uploaded and no secrets are used. (The test suite isn't run here yet: the ffmpeg-oracle and
# golden-image tests need tools and a GPU the VM doesn't have.)
name: FreeBSD
# The VM build takes an hour or more, so it runs nightly on main rather than on every push or
# dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
on:
schedule:
- cron: "37 2 * * *"
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + package)
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
# The same packages as release.yml's freebsd job: winit/wgpu (X11, Wayland, Vulkan,
# Mesa), the file dialogs (GTK 3), fonts, and audio (cpal over ALSA: alsa-lib).
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "*"
rustc --version
export CARGO_TARGET_DIR=/var/tmp/filmcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$'
+2 -32
View File
@@ -19,35 +19,5 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/filmcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.filmcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.filmcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.filmcraft.yml')); assert m['id'] == 'ai.storyteller.filmcraft' and m['command'] == 'filmcraft'"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
+23 -266
View File
@@ -1,9 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "FilmCraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
name: Release
on:
@@ -17,270 +11,33 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
FILMCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### FilmCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$FILMCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
env:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$FILMCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libasound2-dev \
desktop-file-utils appstream python3-yaml
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$FILMCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/filmcraft$|metainfo|applications'
ldd target/release/filmcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.filmcraft.yml')); assert m['id'] == 'ai.storyteller.filmcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen crate version exactly (WASM_BINDGEN in xtask/src/main.rs).
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$FILMCRAFT_VERSION"
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="FilmCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: filmcraft
app-title: FilmCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+35
View File
@@ -0,0 +1,35 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: filmcraft
app-dir: FilmCraft
+39 -1
View File
@@ -19,7 +19,12 @@ panic path, and fix a crash before building on top of it. The cross-app standard
fallback that is truly correct (never one that silently corrupts a project). An unfinished feature
returns an "unsupported" error. The only exception is a call that is provably infallible from local
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace).
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
video decoding through VideoToolbox, Media Foundation / Direct3D 11, VA-API and NVDEC; hardware encoding) and does nothing else. It
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).
4. **Every input-derived number is hostile.** Media files, project and interchange files, presets, fonts,
CLI / MCP / control-channel parameters and UI state are untrusted. Index and slice with `get()` (or
validate bounds once, up front, for a hot loop); slice strings only at char boundaries; use
@@ -87,6 +92,13 @@ reproduce an image (for example, point lists traced from someone else's icon).
need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other
non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub
and other logos stay out; draw a generic icon instead).
9. **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** Don't commit new font files (the small Latin UI fonts already in `assets/fonts/` stay). A
font FilmCraft needs is added to craft-fonts, which the app reads through the optional build input
`CRAFT_FONTS_DIR=<absolute path of a craft-fonts checkout>` (`crates/text/build.rs` embeds the fonts in its
`fonts/manifest.txt` as `filmcraft_text::fonts::CRAFT_FONTS`; unset, it is empty and the app uses its
bundled and system fonts). Never add craft-fonts to a `Cargo.toml`. Code using `CRAFT_FONTS` must work
when it is empty. Standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
## 2. Clean-room code
@@ -115,6 +127,13 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
as PhotoCraft does with `cargo xtask corpus`.
## See also
- [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/contributing.md](docs/contributing.md): setup, gates, commits, how to add things
@@ -123,3 +142,22 @@ a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`
- [docs/agents.md](docs/agents.md): driving FilmCraft over MCP / the control channel, and the agent work loop
- [docs/control-protocol.md](docs/control-protocol.md): control-channel method reference
- [ATTRIBUTION.md](ATTRIBUTION.md): asset index
- [ROADMAP.md](ROADMAP.md), [docs/target-app-parity.md](docs/target-app-parity.md), [docs/gaps.md](docs/gaps.md): stage, parity with Premiere Pro and the ranked work list (craftrules `standards/progress-docs.md`)
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
+32
View File
@@ -9,6 +9,11 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| Asset | Author | Source | Licence |
|---|---|---|---|
| `crates/ui-egui/src/i18n/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/pt-br.tsv` | FilmCraft contributors | Original work: clean-room Brazilian Portuguese interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 |
| `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`) |
@@ -26,6 +31,8 @@ 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 |
| `assets/app-icon/filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; canonical vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: FilmCraft macOS DMG window background, derived from the FilmCraft app icon (`assets/app-icon/filmcraft.svg`); rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
| `assets/app-icon/filmcraft-small.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; lighter vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft-1024.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; 1024 px render | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft-macos-512.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; macOS runtime Dock icon (512 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
@@ -99,6 +106,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 |
| Source and Program range braces and trim hover cues | `crates/ui-egui/src/panels/monitor.rs` and `source_range.rs`: original generic vector shapes by FilmCraft contributors, MIT OR Apache-2.0; no third-party artwork |
| 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` |
| Built-in graphics templates (Lower Third – Slab / Rule, Ticker – Crawl, Title – Centered / Boxed, End Credits – Roll, Callout – Pointer / Tag) and their placeholder text | `crates/project/src/gtemplate.rs` (`builtin_templates`): original designs defined in code with the bundled Inter font; thumbnails are rendered at runtime, no template, image or font files |
@@ -106,6 +114,21 @@ is traced or derived from Adobe artwork.
| Lumetri Presets (Effects panel: Cinematic, Film Emulation, Monochrome, Technical) and their preview picture | `crates/render/src/lumetri_presets.rs`: Lumetri parameter values chosen by contributors, descriptive names, and a procedural preview picture (dusk sky, ridges, lake, grey ramp, colour chips) |
| Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork |
## Optional build input: craft-fonts
These fonts are **not files in this repository**. Builds made with the optional build input
`CRAFT_FONTS_DIR` (all official releases; see [docs/contributing.md](docs/contributing.md#building-with-craft-fonts))
embed them from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), where each file,
its author, pinned upstream source and licence text are listed in its
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). Release packages
carry each font's licence as `OFL-<family>.txt`.
| Font | Use in FilmCraft | Licence |
|---|---|---|
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 |
| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
## First-party brand marks
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and
@@ -124,6 +147,15 @@ remove them (AGENTS.md §1.8).
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
## Third-party source code
Source files that are transcribed from third-party code rather than written from scratch. Each keeps
the original copyright and permission notice in its header.
| File | Author | Source | Licence |
|---|---|---|---|
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures (including `NV_ENC_CONFIG_HEVC` and its bit-field masks, `NV_ENC_PIC_PARAMS_HEVC`, `NV_ENC_SEI_PAYLOAD`, `NV_ENC_TIME_CODE`), function table, codec / profile GUIDs (H.264, HEVC Main, HEVC Main 10) and constants (`NV_ENC_LEVEL_HEVC_*`, tier, CU size, `NV_ENC_BUFFER_FORMAT_YUV420_10BIT`, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, BT.2020 / PQ / HLG VUI codes) transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
## Downloaded at runtime
These files are never in the repository or the app bundle. FilmCraft downloads them into the
+4 -3
View File
@@ -1,18 +1,19 @@
# FilmCraft — instructions for agents
FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor targeting Adobe Premiere Pro parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat) and `../drawcraft` (Illustrator), with the same conventions.
FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor targeting Adobe Premiere Pro parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat) and `../vectorcraft` (Illustrator; formerly DrawCraft), with the same conventions.
## Start every session here
`plan/` is maintainer-local (gitignored, not in the repo). Each step names the public equivalent to use if you don't have it.
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md).
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md), then [`docs/gaps.md`](docs/gaps.md) (ranked shortfalls) and [`docs/roadmap.md`](docs/roadmap.md) (current focus).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Public: [`docs/architecture.md`](docs/architecture.md), [`docs/contributing.md`](docs/contributing.md), [`docs/testing.md`](docs/testing.md). Visual/behaviour reference: `plan/premiere/` (local only, never committed).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7; public summary: [`docs/agents.md`](docs/agents.md) §5–7). Don't stop to ask unless it lists the decision as the user's.
## Non-negotiables
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.**
- **Never crash** ([`AGENTS.md`](AGENTS.md) §0, standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)). People depend on FilmCraft; a crash loses their work, so this outranks feature work. No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` or `unsafe` outside tests: return `Result<T, E>` and propagate with `?` (`ok_or`, `let … else`, `if let`). Treat every number from media, project files, presets or CLI/MCP/control params as hostile: `get()` instead of `[i]`, checked/saturating math, no division by possibly-zero values, capped allocations, bounded recursion. `lock().unwrap_or_else(PoisonError::into_inner)`; background jobs run under `catch_unwind`. The panic hook + UI `catch_unwind` (`crates/ui-egui/src/crash.rs`) are the last-resort guard, not a licence. Every crash fix gets a regression test that panicked before. In particular (§1): never use Adobe iconography, images or any other Adobe asset. Every asset must be openly licensed (OSS / public domain / Creative Commons, or original work by a contributor) and must have a `<file>.attribution` sidecar plus an entry in `ATTRIBUTION.md`. `cargo xtask assets` enforces this.
- **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo (no new font files here; [`AGENTS.md`](AGENTS.md) §1.9, standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Build with them: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run -p filmcraft` (`crates/text/build.rs` embeds them as `filmcraft_text::fonts::CRAFT_FONTS`, used for Japanese in the UI and titles). Unset, `CRAFT_FONTS` is empty and everything must still build, test and run; never add craft-fonts to a `Cargo.toml`. Tests on its glyphs skip when it is empty, so run the gates with and without `CRAFT_FONTS_DIR` when you touch fonts.
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, LUTs, fonts. Never copy GPL/LGPL/AGPL code (FFmpeg, x264, x265, MLT, Kdenlive, Shotcut, Olive, LAME…). ffmpeg/ffprobe run only as external test oracles / fixture generators — never linked or shipped.
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (planned; not created yet), optional, behind traits.
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
- **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math.
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id.
Generated
+302 -46
View File
@@ -992,6 +992,16 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "cpu-time"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1001,6 +1011,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1655,28 +1674,40 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"arboard",
"chrono",
"cpal",
"eframe",
"egui",
"filmcraft-engine",
"filmcraft-export",
"filmcraft-gpu",
"filmcraft-platform",
"filmcraft-project",
"filmcraft-render",
"filmcraft-time",
"filmcraft-ui-egui",
"glob",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"pollster 0.4.0",
"rfd",
"serde_json",
"sys-locale",
"winit",
"winresource",
"zbus",
]
[[package]]
name = "filmcraft-aac"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1686,22 +1717,32 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-apv"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-audio-dsp"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1717,7 +1758,7 @@ dependencies = [
[[package]]
name = "filmcraft-av1"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1726,16 +1767,23 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-avi"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-bitstream"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"proptest",
]
[[package]]
name = "filmcraft-captions"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1748,19 +1796,25 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.2.0"
version = "0.7.2"
[[package]]
name = "filmcraft-cli"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
"filmcraft-engine",
"filmcraft-export",
"filmcraft-gpu",
"filmcraft-media",
"filmcraft-mpegts",
"filmcraft-mxf",
"filmcraft-ogg",
"filmcraft-platform",
"filmcraft-project",
"filmcraft-render",
"filmcraft-testkit",
"filmcraft-time",
"serde_json",
"tokio",
@@ -1768,11 +1822,13 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
"filmcraft-apv",
"filmcraft-av1",
"filmcraft-avi",
"filmcraft-bitstream",
"filmcraft-color",
"filmcraft-dnx",
@@ -1782,6 +1838,7 @@ dependencies = [
"filmcraft-isobmff",
"filmcraft-matroska",
"filmcraft-media",
"filmcraft-mpa",
"filmcraft-mpeg2v",
"filmcraft-mpegts",
"filmcraft-mxf",
@@ -1790,9 +1847,12 @@ dependencies = [
"filmcraft-prores",
"filmcraft-testkit",
"filmcraft-time",
"filmcraft-vp8",
"filmcraft-vp9",
"image",
"log",
"rayon",
"serde_json",
"symphonia",
"thiserror 2.0.21",
"web-time",
@@ -1800,15 +1860,24 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"proptest",
"serde",
]
[[package]]
name = "filmcraft-dng"
version = "0.7.2"
dependencies = [
"filmcraft-dng",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-dnx"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1818,8 +1887,9 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-geom",
"filmcraft-media",
"filmcraft-project",
"filmcraft-time",
@@ -1830,18 +1900,20 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-dng",
"filmcraft-edit",
"filmcraft-export",
"filmcraft-format",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-interchange",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-mxf",
"filmcraft-project",
@@ -1851,6 +1923,7 @@ dependencies = [
"filmcraft-testkit",
"filmcraft-text",
"filmcraft-time",
"filmcraft-tts",
"image",
"log",
"serde",
@@ -1861,13 +1934,15 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
"filmcraft-audio-dsp",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-dnx",
"filmcraft-flac",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-h264enc",
@@ -1888,13 +1963,23 @@ dependencies = [
"web-time",
]
[[package]]
name = "filmcraft-flac"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-format"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-media",
"filmcraft-project",
"filmcraft-time",
"log",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -1902,7 +1987,7 @@ dependencies = [
[[package]]
name = "filmcraft-frame"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -1912,7 +1997,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"proptest",
"serde",
@@ -1920,7 +2005,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1937,15 +2022,17 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"filmcraft-export",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
"filmcraft-time",
"image",
"log",
"pollster 0.4.0",
"rayon",
@@ -1954,7 +2041,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1964,7 +2051,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1974,7 +2061,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1984,7 +2071,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2001,7 +2088,7 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2012,7 +2099,7 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
"serde_json",
@@ -2020,20 +2107,32 @@ dependencies = [
[[package]]
name = "filmcraft-media"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"filmcraft-dng",
"filmcraft-frame",
"filmcraft-testkit",
"filmcraft-time",
"image",
"png",
"rayon",
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-mpa"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-mpeg2v"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2042,7 +2141,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2050,28 +2149,54 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-platform"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-export",
"filmcraft-frame",
"filmcraft-h264",
"filmcraft-hevc",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
"filmcraft-testkit",
"filmcraft-time",
"libloading",
"log",
"objc2-core-foundation",
"objc2-core-media",
"objc2-core-video",
"objc2-video-toolbox",
"windows 0.62.2",
]
[[package]]
name = "filmcraft-project"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2085,7 +2210,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2095,7 +2220,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2115,7 +2240,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"serde",
@@ -2124,7 +2249,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"candle-core",
"candle-nn",
@@ -2142,34 +2267,59 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-color",
"harfrust",
"skrifa",
"unicode-bidi",
"unicode-linebreak",
"unicode-segmentation",
]
[[package]]
name = "filmcraft-time"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"proptest",
"serde",
]
[[package]]
name = "filmcraft-ui-egui"
version = "0.2.0"
name = "filmcraft-tts"
version = "0.7.2"
dependencies = [
"candle-core",
"candle-nn",
"filmcraft-audio-dsp",
"filmcraft-tts-text",
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "filmcraft-tts-text"
version = "0.7.2"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-ui-egui"
version = "0.7.2"
dependencies = [
"cpu-time",
"craft-ui",
"eframe",
"egui",
"egui_kittest",
@@ -2178,16 +2328,19 @@ dependencies = [
"filmcraft-color",
"filmcraft-edit",
"filmcraft-engine",
"filmcraft-export",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-gpu",
"filmcraft-interchange",
"filmcraft-media",
"filmcraft-platform",
"filmcraft-project",
"filmcraft-render",
"filmcraft-scopes",
"filmcraft-text",
"filmcraft-time",
"filmcraft-tts",
"image",
"log",
"pollster 0.4.0",
@@ -2195,12 +2348,21 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"toml 1.1.6+spec-1.1.0",
"unicode-bidi",
"web-time",
]
[[package]]
name = "filmcraft-vp8"
version = "0.7.2"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-vp9"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2209,7 +2371,7 @@ dependencies = [
[[package]]
name = "filmcraft-web"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"eframe",
"egui",
@@ -2767,6 +2929,12 @@ dependencies = [
"vello_common",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "glow"
version = "0.17.0"
@@ -3129,6 +3297,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -3779,7 +3966,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"libc",
"objc2 0.6.4",
]
@@ -3820,6 +4009,37 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-media"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"dispatch2",
"objc2 0.6.4",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-core-video",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
"objc2-metal 0.3.2",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3992,6 +4212,23 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-video-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05bf9a3c14831a7d9641b0d81d87dd913ee238a012b2fde27db5a84b56f5df3e"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-media",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-metal 0.3.2",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -4004,6 +4241,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -5683,6 +5930,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "sysctl"
version = "0.6.0"
@@ -7140,7 +7396,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.7.2"
dependencies = [
"filmcraft-testkit",
"serde_json",
+24 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.7.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -17,20 +17,26 @@ filmcraft-geom = { path = "crates/geom" }
filmcraft-color = { path = "crates/color" }
filmcraft-bitstream = { path = "crates/bitstream" }
filmcraft-isobmff = { path = "crates/isobmff" }
filmcraft-avi = { path = "crates/avi" }
filmcraft-matroska = { path = "crates/matroska" }
filmcraft-mxf = { path = "crates/mxf" }
filmcraft-cfb = { path = "crates/cfb" }
filmcraft-mpegts = { path = "crates/mpegts" }
filmcraft-mpeg2v = { path = "crates/mpeg2v" }
filmcraft-ac3 = { path = "crates/ac3" }
filmcraft-mpa = { path = "crates/mpa" }
filmcraft-ogg = { path = "crates/ogg" }
filmcraft-h264 = { path = "crates/h264" }
filmcraft-hevc = { path = "crates/hevc" }
filmcraft-vp8 = { path = "crates/vp8" }
filmcraft-vp9 = { path = "crates/vp9" }
filmcraft-prores = { path = "crates/prores" }
filmcraft-dnx = { path = "crates/dnx" }
filmcraft-apv = { path = "crates/apv" }
filmcraft-dng = { path = "crates/dng" }
filmcraft-av1 = { path = "crates/av1" }
filmcraft-aac = { path = "crates/aac" }
filmcraft-flac = { path = "crates/flac" }
filmcraft-opus = { path = "crates/opus" }
filmcraft-h264enc = { path = "crates/h264enc" }
filmcraft-audio-dsp = { path = "crates/audio-dsp" }
@@ -43,6 +49,8 @@ filmcraft-interchange = { path = "crates/interchange" }
filmcraft-text = { path = "crates/text" }
filmcraft-captions = { path = "crates/captions" }
filmcraft-speech = { path = "crates/speech" }
filmcraft-tts = { path = "crates/tts" }
filmcraft-tts-text = { path = "crates/tts-text" }
filmcraft-render = { path = "crates/render" }
filmcraft-gpu = { path = "crates/gpu" }
filmcraft-scopes = { path = "crates/scopes" }
@@ -51,6 +59,7 @@ filmcraft-format = { path = "crates/format" }
filmcraft-engine = { path = "crates/engine" }
filmcraft-ui-egui = { path = "crates/ui-egui" }
filmcraft-automation = { path = "crates/automation" }
filmcraft-platform = { path = "crates/platform" }
filmcraft-testkit = { path = "crates/testkit" }
# foundation
@@ -63,6 +72,8 @@ proptest = "1"
roxmltree = "0.21"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
@@ -100,12 +111,21 @@ opt-level = 3
[profile.dev.package.filmcraft-dnx]
opt-level = 3
[profile.dev.package.filmcraft-apv]
opt-level = 3
[profile.dev.package.filmcraft-dng]
opt-level = 3
[profile.dev.package.filmcraft-av1]
opt-level = 3
[profile.dev.package.filmcraft-aac]
opt-level = 3
[profile.dev.package.filmcraft-flac]
opt-level = 3
# Audio effects: realtime-factor tests and long convolution / spectral runs.
[profile.dev.package.filmcraft-audio-dsp]
opt-level = 3
@@ -116,6 +136,9 @@ opt-level = 3
[profile.dev.package.filmcraft-opus]
opt-level = 3
[profile.dev.package.filmcraft-vp8]
opt-level = 3
[profile.dev.package.filmcraft-vp9]
opt-level = 3
+3
View File
@@ -15,4 +15,7 @@ list (author, source, license) is in ATTRIBUTION.md.
Inter font (assets/fonts/Inter-*.ttf) — OFL-1.1. See assets/fonts/OFL-Inter.txt.
Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-NotoSerif.txt.
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
+124 -12
View File
@@ -59,6 +59,7 @@
<a href="#interchange">Interchange</a> ·
<a href="#built-for-agents">Agents</a> ·
<a href="#get-started">Get started</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#documentation">Docs</a> ·
<a href="#status">Status</a>
</p>
@@ -82,7 +83,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
- **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out.
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
@@ -136,7 +137,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter, and each animated parameter opens into **value and velocity graphs** with draggable influence handles.
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
<p align="center">
@@ -174,6 +175,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
| **HEVC / H.265** | ✓ | | Main and Main 10, bit-exact on 41 streams (tiles, WPP, PCM, long-term references), about 225 fps at 1080p |
| **Apple ProRes** | ✓ | ✓ | Decodes 422 Proxy to 4444 XQ; export writes 422 HQ |
| **AAC-LC** | ✓ | ✓ | |
| **VP8** | ✓ | | Every coding tool, bitstream versions 0–3, hidden altref frames, alpha layer of transparent WebM; bit-exact with libvpx on its published VP8 test vectors; in WebM/Matroska and MP4 |
| **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 |
| **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 |
| **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska |
@@ -182,9 +184,10 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
| **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own |
| **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks |
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet |
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF |
MXF and hardware decode are next ([roadmap](ROADMAP.md)).
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
<br>
@@ -196,7 +199,7 @@ MXF and hardware decode are next ([roadmap](ROADMAP.md)).
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes cleanly in ffmpeg with error concealment switched off.
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on.
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
- **Background jobs** with progress and cancel, so you keep editing while it renders.
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
@@ -220,7 +223,7 @@ Import merges the document's bins, media and sequences into your project as one
## Built for agents
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are about 135 engine commands so far, with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
```jsonc
// over the control channel (JSON lines on TCP) or as MCP tool calls
@@ -238,7 +241,7 @@ The trailer and the grades in these screenshots were built exactly this way, by
## Everywhere
- **Native** on macOS, Windows and Linux, with a native macOS menu bar.
- **The web:** every crate up to the engine compiles to `wasm32`; the browser front end is next.
- **The web:** the same engine and UI run in the browser via WebAssembly (`apps/filmcraft-web`, see [docs/web.md](docs/web.md)); every release ships it as `filmcraft-web-<version>.zip`.
- **Swappable UI.** The interface is one crate (`ui-egui`) over the engine, so a different front end can replace it without touching editing logic.
<br>
@@ -252,6 +255,13 @@ cargo run --release -p filmcraft-cli -- commands # list every engine c
cargo run --release -p filmcraft-cli -- mcp # MCP server (headless)
```
Japanese text in the interface and in titles comes from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (release builds always include it; without it FilmCraft uses its own and the system's fonts):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft
```
The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft).
## Documentation
@@ -266,11 +276,29 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
| [docs/control-protocol.md](docs/control-protocol.md) | Control-channel and MCP reference |
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
| [ROADMAP.md](ROADMAP.md) | Milestones and estimates |
| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log |
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus |
| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension |
## Status
FilmCraft is young and moving fast: roughly 45% of the way to Premiere Pro parity, with editing, trimming, colour, keyframes, titles, captions, mixing, codecs and export working today. [ROADMAP.md](ROADMAP.md) tracks every milestone with estimates.
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2):
- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
The biggest gaps today:
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
- **AI features.** Few so far; speech to text is optional and off by default.
Bug reports with real footage are the most useful thing you can send us: [open an issue](https://github.com/storytold/filmcraft/issues) or tell us in [Discord](https://discord.gg/artcraft).
## Architecture
@@ -278,7 +306,7 @@ A layered Cargo workspace:
| Layer | Crates |
|---|---|
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `bitstream` |
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `avi`, `bitstream` |
| Foundations | `time`, `geom`, `color`, `frame`, `media`, `text`, `project`, `audio-dsp` |
| Editing and interchange | `edit`, `codecs`, `captions`, `format` (project files), `interchange` |
| Rendering and output | `render`, `gpu`, `export` |
@@ -288,6 +316,84 @@ A layered Cargo workspace:
Nothing below the front ends depends on a UI toolkit or OS API. `cargo xtask ci` checks formatting, lints, tests, the layering rules, asset attribution and the wasm build.
## Downloads
**New to FilmCraft?** Download it from the [FilmCraft page on getartcraft.com](https://getartcraft.com/apps/filmcraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/filmcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/filmcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `filmcraft-<ver>-windows-x64.msi` | `filmcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `filmcraft-<ver>-windows-arm64.msi` | `filmcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `filmcraft-<ver>-windows-x86.msi` | `filmcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only Vulkan and DirectX 12 by
default, not OpenGL. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
PowerShell, from the folder containing the executable:
```powershell
$env:WGPU_BACKEND = "vulkan"
& .\filmcraft.exe
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
```
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `filmcraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `filmcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `filmcraft-<ver>-linux-x86_64.AppImage` | `filmcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `filmcraft-<ver>-linux-x86_64.flatpak` | `filmcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `filmcraft-<ver>-linux-x86_64.deb` | `filmcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `filmcraft-<ver>-linux-x86_64.rpm` | `filmcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack and run `./install.sh` |
RISC-V (riscv64): `filmcraft-<ver>-linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04
or Debian 13).
The tarball installer places FilmCraft, its command-line tool and desktop integration in
`~/.local`, without administrator permissions. For all users, run
`sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation.
**Gentoo (community-maintained):** the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay)
packages the Linux release as `media-video/filmcraft-bin`. It is maintained by the community, not by the
FilmCraft team, so report packaging problems to the overlay:
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'media-video/filmcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/filmcraft
emerge --ask media-video/filmcraft-bin
```
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `filmcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `filmcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
## The Crafting Apps
FilmCraft is one of the **Crafting Apps**: free, open-source creative tools from the
@@ -300,7 +406,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | **Video editing, color and sound · you are here** | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
@@ -336,7 +442,9 @@ FilmCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APAC
Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds made with
[craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) also embed its fonts
(OFL-1.1), listed in its [ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
@@ -355,3 +463,7 @@ Forks and modified versions must remove them.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/filmcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Ffilmcraft&type=date&legend=top-left)
+116 -61
View File
@@ -1,81 +1,124 @@
# FilmCraft Roadmap
Progress toward feature parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths.
- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained.
**Last updated:** 2026-10-02 · **Overall parity:** ~87% (measured scorecard below) · **Code:** 34 crates, 1605 tests
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
## Parity scorecard
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
Measured against Premiere Pro 26.5 on this machine. Menu items: the native menu bar dump, minus
Adobe-cloud-only items (Team Projects, Productions, Firefly, Stock, Dynamic Link, account/help pages),
matched against `filmcraft-cli commands` and the UI command table. Effects: the Effects panel tree.
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
[docs/gaps.md](docs/gaps.md) (the ranked work list: **agents, read it before choosing work**) and
[docs/roadmap.md](docs/roadmap.md) (milestones and current focus).
| Area | Weight | Measured | Coverage |
|---|---|---|---|
| Editing, timeline, trimming, multicam | 20% | core edit algebra, trim modes, sync, multicam, menu long tail, Scene Edit Detection, Automate to Sequence, Premiere default keyboard (143 new commands; 39 cloud/absent-panel keys skipped) | ~86% |
| Menus / commands | (cross-check) | ~325 of 344 in-scope menu items | ~95% |
| Effects and transitions | 12% | video effects 93/93 (+Legacy, Obsolete), audio effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3; some approximations (Warp Stabilizer 2-D, Morph Cut, Auto Reframe) | ~92% |
| Media I/O and codecs | 12% | H.264, HEVC, VP9, AV1, ProRes, DNx, MJPEG, MPEG-2/MPEG-1 (4:2:2 incl. IMX/XDCAM), Opus, AAC, AC-3, MP2; MP4/MOV/MKV/WebM, MXF (incl. MPEG-2), MPEG TS/PS (AVCHD, broadcast, DVD), Ogg, image sequences, BWF; no E-AC-3, camera raw, HW decode | ~90% |
| Panels and UI fidelity | 12% | all main panels incl. Metadata, Timecode, Events, Progress, Reference Monitor, full Lumetri Scopes, audio effect editors; Project panel List/Icon/Freeform with view presets and hover scrub; Media Browser with Favorites/navigation/ingest | ~85% |
| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% |
| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% |
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; 8K and AV1 not yet real-time on a loaded machine | ~62% |
| **Weighted total** | | | **~87%** |
## Headline numbers
## Estimate to parity
Measured throughput in the last work block (2026-10-01 night → 10-02): five Opus 5.5 agents in
parallel for ~4.5 wall-clock hours (~18 agent-hours including integration) moved parity by ~4 points
(≈4–5 agent-hours per point), on a machine that was heavily overloaded (load 150–300 on 14 cores) and
once ran out of disk. The remaining points are a long tail (each effect, dialog and preference page is
small, but there are many), so the estimate applies a 1.3–1.5× tail factor.
| | Opus 5.5 agent-hours | Wall-clock (4–5 parallel agents + integrator) |
| | Value | How |
|---|---|---|
| Feature parity by checklist (~19 points left) | ~90–125 | **~20–32 h** |
| Robust on real-world material (codec edge cases, 4K/8K performance, pro workflows) | +120–200 | **+30–50 h** |
| **Total to "100% and better"** | **~210–325** | **~50–82 h (≈2–3.5 days, 24/7)** |
| **Feature breadth** | **~86%** | Does each Premiere feature exist? Menus 92% (measured: label diff against Premiere 26.5.2's menu dump), effects / transitions / audio effects 100% (measured by tests), other areas estimated |
| **Ready for real work** | **~56%** (51–61%) | How close FilmCraft is to replacing Premiere for an editor on real projects: depth, correctness, file compatibility, speed, stability, ecosystem. Estimated depth per area, combined by a [written formula](docs/target-app-parity.md#three-readiness-numbers) |
| **Mainstream practitioner** | **~55%** | A typical professional editor's weekly work only, discounted ×0.90 interaction, ×0.92 stability, ×0.88 file exchange with Premiere users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| **Essentials user** | **~60%** | Import, cut, title, music, export at default settings, discounted ×0.88 launch / stability, ×0.90 discoverability, ×0.95 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| **To beta** | **~400–700 Opus 5.5 agent-hours** | Estimated, calibrated from this repo's PR history |
| **To full parity** | **~1,550–2,750 agent-hours** | ~70% parallelizes; plugins, AI models, native-speaker review and some hardware need a human |
| Code (measured) | 45 crates, ~343k lines of Rust, 2,520 tests, 268 merged PRs, 69 authors | `git`, `gh`, `grep` |
The 2026-10-02 block moved parity ~62% → ~72% with six agents in ~6 wall-clock hours, despite the
disk filling twice; the remaining work is mostly panels (scopes, Metadata, Media Browser), export
presets/queue, codecs (MXF, image sequences, hardware decode), performance and graphics templates.
Two numbers, because they answer different questions. The gap between them is the work that
matters most now: breadth is nearly done, depth is not.
Limits on speed: CPU and disk on one machine (more than ~5 agents slows everyone down: each worktree
build is 6–7 GB and a full `cargo xtask ci` takes 20–60 min under load), and a single integrator
merging, resolving conflicts (e.g. two agents bumping the project schema) and re-running CI. With one
agent and no parallelism, multiply wall-clock by ~3–4.
### Readiness by audience
Not reachable clean-room and locally: **Generative Extend** (needs a large video-generation model).
**Enhance Speech** and **Auto Reframe** are feasible only with openly licensed models we can ship.
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~56% | ~1,450–2,600 (~70% parallelizes) | Plugin hosting, AI models, localization incl. RTL / Devanagari, camera RAW and delivery codecs, `.prproj`, hardware acceleration, UI fidelity |
| Mainstream practitioner | ~55% | ~500–850 (~65% parallelizes) | `.prproj` import (60–120), UI fidelity (120–200), stability and PR CI (40–80), 10-bit / 4:2:2 camera decode (30–50), playback / hardware (60–100), import and export gaps (60–100) |
| Essentials user | ~60% | ~180–350 (~60% parallelizes) | Launch crashes on Windows / Intel / older macOS (40–80), discoverability and timeline friction (60–110), playback lag (30–60), common import gaps (20–40), audio devices (15–30) |
## Milestones
Method and calibration: [docs/target-app-parity.md](docs/target-app-parity.md#three-readiness-numbers).
Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining agent-hours.
## By dimension
| # | Milestone | Status | Done | Remaining | Est. |
|---|---|---|---|---|---|
| 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 + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 39 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, work area bar, Production/Search panels) | 6–10 |
| M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 |
| M5 | Effects, keyframes, GPU | 🟡 | ~60 CPU effects, 30 transitions, keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Full ~150-effect catalogue, WGSL parity for all effects and masks in the live GPU path, Warp Stabilizer, Morph Cut | 20–30 |
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 |
| 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 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 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, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
| 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), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, MPEG-2 decoder, 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; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, 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; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 |
| M12–M16 | Multicam, web (WASM), platform, long tail | 🟡 | Multicam: audio sync (GCC-PHAT, sub-sample), Merge Clips, multi-camera source sequences, Multi-Camera view with live switching (1–9); L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), scene detection, auto reframe, ~800 remaining commands and dialogs, performance hardening | 35–55 |
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (editing, effects, colour, audio, graphics, export settings) | ~66% | 350–620 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
| UI / UX fidelity | ~60% | 120–200 | [ui-parity.md](docs/ui-parity.md) |
| File formats (containers, `.prproj`, interchange, captions, stills) | ~60% | 140–240 | [file-format-parity.md](docs/file-format-parity.md) |
| Codecs | ~60% | 150–250 | [codec-parity.md](docs/codec-parity.md) |
| Hardware acceleration | ~45% | 120–200 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization | ~35% | 150–230 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ~55% | 60–120 | [performance.md](docs/performance.md) |
| Stability | ~45% | 60–120 | [gaps.md G4](docs/gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests) |
| Platforms (macOS, Windows, Linux, FreeBSD, web) | ~60% | 50–90 | [gaps.md G9](docs/gaps.md#g9-windows-and-linux-at-run-time) |
| Plugins and ecosystem | ~5% | 200–400 | [gaps.md G11](docs/gaps.md#g11-no-plugin-hosting) |
| AI features | ~25% | 150–300 | [gaps.md G12](docs/gaps.md#g12-ai-features) |
## Running now
## Features
- Nothing; next: remaining Premiere menu items (Settings categories, Scene Edit Detection, Normalize Mix Track, OMF/AAF, Find), AV1 single-thread re-measure on an idle machine
| Area | Breadth | Ready | Hours |
|---|---|---|---|
| Editing, timeline, trimming, multicam | ~90% | ~70% | 80–140 |
| Effects and transitions | 100% (measured) | ~60% | 80–140 |
| Media import and codecs | ~75% | ~60% | 150–250 |
| Audio | ~88% | ~65% | 50–90 |
| Panels and UI fidelity | ~88% | ~60% | 120–200 |
| Colour | ~90% | ~70% | 40–70 |
| Graphics, titles, captions | ~85% | ~65% | 50–90 |
| Export and delivery | ~80% | ~65% | 60–110 |
| Project files and interchange | ~70% | ~45% | 90–160 |
| Preferences and project management | ~80% | ~65% | 20–40 |
## Log
Evidence for every row: [docs/target-app-parity.md](docs/target-app-parity.md#by-feature-area).
## Languages
Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings):
| Language | Status | Strings |
|---|---|---|
| English | full | 100% |
| Simplified Chinese | partial (whole interface; engine errors English) | 99.6% |
| Spanish | partial (whole interface) | 99.9% |
| Hindi | none (no Devanagari shaping in the interface) | 0% |
| Arabic | none (no RTL interface) | 0% |
| French | partial (whole interface; native review welcome) | ~100% |
| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% |
| Indonesian | partial (whole interface; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | menus only (native-reviewed) | 10.6% |
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
## Upcoming
Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
1. **No launch crashes, and CI on pull requests** (macOS 12, Intel UHD, Windows 11 reports; no workflow runs the tests today). 40–80 h.
2. **Open Premiere's `.prproj`**, the beta blocker. 60–120 h.
3. **10-bit / 4:2:2 camera media without hardware** (H.264 High 10 / 4:2:2, HEVC 4:2:2; #626). 30–50 h.
4. **Measure fidelity against Premiere** and add `cargo xtask parity`. 50–90 h.
5. **Real-world media corpus and workflow acceptance tests.** 40–70 h.
6. **Hardware acceleration, continued** (#30): zero-copy upload, more effects on the GPU, Quick Sync / AMD encoders, VideoToolbox Main 10. 120–200 h.
7. **UI fidelity:** Program monitor transform handles, Effect Controls, docking. 120–200 h.
8. **Plugin hosting** (VST3 / AU, then OpenFX). Owner decision needed. 200–400 h.
## Progress log
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
- **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575).
- **2026-10-09:** release 0.5.0. NVENC H.265 export on Windows, Main and Main 10 HDR (#328, #360); NVENC H.264 export (#386); VA-API H.264 / HEVC decode (#358); Source monitor playback (#423); multi-stream container audio, schema v13 (#361); New Sequence and Sequence Settings dialogs (#269, #355); Spanish for the panels (#238); background decoders and memory stress tests (#385, #381).
- **2026-10-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping.
- **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed.
- **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged.
- **2026-10-08:** H.265 (HEVC Main, 8-bit 4:2:0, SDR) export on Windows through NVENC (#30): the NVENC session, ring and NV12 path of the H.264 encoder now serve both codecs (the H.264 export is byte-for-byte unchanged); the format is available where the GPU has an HEVC encoder, choosing it is the opt-in, and `hvcC` is built from the SPS the encoder wrote. ffprobe reads `hevc` / `Main` / `hvc1` / `yuv420p` / BT.709; our own HEVC decoder decodes it at 48.9 dB luma PSNR (RTX 5060). Main 10 and HDR remain.
- **2026-10-08 (#285):** `filmcraft-cli` no longer panics with "Broken pipe" when its stdout is closed early (`filmcraft-cli commands | head`): later output is dropped, the rest of the work (script lines, `--save`, `--save-as`) still runs, and the exit status still reports failures. Other stdout write errors are reported and make the exit status 1. No change to the checklist or estimates.
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
- **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8).
- **2026-10-04 (later):** interchange + MXF export (M11.4/M6.6): own compound-file container (`crates/cfb`), AAF Edit Protocol import/export (embedded/linked/consolidated audio, video mixdown, handles, breakout to mono), OMF 2.0 export (Bento), MXF OP1a/OP-Atom writer (DNxHR/ProRes/H.264/PCM) as export formats. 1605 tests.
@@ -116,3 +159,15 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
- **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.
- **2026-09-30 (late morning):** keyframe value/velocity graphs; xtask gates; H.264 MP4 export; Lumetri curves, wheels, looks, HSL secondary.
- **2026-09-30 (early morning):** H.264 decoder, ProRes, AAC; GPU compositor; MCP; Premiere 26 visual fidelity pass.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (~55%) and essentials user (~60%); ready for real work ~55% -> ~56% (formula written out, same inputs); beta distance ~19 points |
| 2026-10-10 | minor | Stage banner links the core-workflow gate (all six workflows pass on macOS; stays alpha) |
| 2026-10-10 | major | Restructured to the craftrules progress-docs shape: stage banner, headline numbers, dimension / feature / language tables, upcoming; re-measured against Premiere 26.5.2 (breadth ~87 → ~86%, ready ~50–60% → ~55%); honest assessment and scorecard moved to docs/target-app-parity.md, "Where we are lacking" to docs/gaps.md, milestones to docs/roadmap.md, interface localization to docs/localization-parity.md |
| 2026-10-08 | minor | Log entries for HEVC export, Source playback and range dragging |
| 2026-10-05 | major | Honest assessment added: checklist ~87% vs ready for real work ~50–60% |
+9 -1
View File
@@ -19,12 +19,20 @@ whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]
[dependencies]
filmcraft-engine = { workspace = true }
filmcraft-export = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-time = { workspace = true }
filmcraft-gpu = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-codecs = { workspace = true }
filmcraft-platform = { workspace = true }
filmcraft-mxf = { workspace = true }
filmcraft-mpegts = { workspace = true }
filmcraft-ogg = { workspace = true }
filmcraft-time = { workspace = true }
filmcraft-automation = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+40
View File
@@ -19,6 +19,7 @@ const VALUED: &[&str] = &[
"--end",
"--data-dir",
"--settings",
"--gpu-rendering",
];
#[derive(Debug, Default)]
@@ -60,6 +61,17 @@ impl Args {
self.opts.iter().any(|(n, _)| n == k)
}
/// A version query: `--version` anywhere, or `-V` in the subcommand position, so global
/// options may come first (`--demo --version`, `--compact -V`).
pub fn wants_version(&self) -> bool {
self.flag("--version") || self.pos(0) == Some("-V")
}
/// The first option that is not in `allowed`, if any.
pub fn unknown_opt(&self, allowed: &[&str]) -> Option<&str> {
self.opts.iter().map(|(n, _)| n.as_str()).find(|n| !allowed.contains(n))
}
/// Command params from positionals `from..`: one JSON object, or `key=value` pairs
/// (dotted keys nest: `color.r=1`).
pub fn params_from(&self, from: usize) -> Result<Value, String> {
@@ -135,9 +147,37 @@ mod tests {
assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}}));
}
#[test]
fn version_after_global_options() {
for s in ["--version", "-V", "--demo --version", "--compact -V", "--data-dir d --version", "export --version"] {
assert!(args(s).wants_version(), "{s}");
}
for s in ["version", "--data-dir -V", "exec x -V", "-- --version"] {
assert!(!args(s).wants_version(), "{s}");
}
}
#[test]
fn unknown_opt_reports_first_unlisted_option() {
let allowed = ["--bridge", "--project"];
assert_eq!(args("mcp --automation-read-root X").unknown_opt(&allowed), Some("--automation-read-root"));
assert_eq!(args("mcp --bridge=a --typo=1").unknown_opt(&allowed), Some("--typo"));
assert_eq!(args("mcp --project p --bridge a").unknown_opt(&allowed), None);
}
#[test]
fn double_dash_ends_options() {
let a = args("import -- --weird-name.mov a.mov");
assert_eq!(a.positionals, ["import", "--weird-name.mov", "a.mov"]);
}
#[test]
fn gpu_rendering_takes_a_space_separated_value() {
let a = args("export --gpu-rendering off out.wav --start 0 --end 0.1");
assert_eq!(a.positionals, ["export", "out.wav"]);
assert_eq!(a.opt("--gpu-rendering"), Some("off"));
let b = args("export out.wav --gpu-rendering auto");
assert_eq!(b.positionals, ["export", "out.wav"]);
assert_eq!(b.opt("--gpu-rendering"), Some("auto"));
}
}
+334 -29
View File
@@ -4,6 +4,32 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
/// `println!` that doesn't panic when stdout is closed (`filmcraft-cli commands | head`) with
/// "failed printing to stdout: Broken pipe (os error 32)". Once stdout has failed, later output is
/// dropped and the work goes on: see `stdout_failed`.
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if !$crate::stdout_gone() {
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}
}};
}
/// `print!` counterpart of `outln!`.
macro_rules! out {
($($arg:tt)*) => {{
use std::io::Write as _;
if !$crate::stdout_gone() {
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}
}};
}
mod args;
mod probe;
@@ -11,6 +37,28 @@ use args::{Args, parse_value};
use filmcraft_automation::BridgeClient;
use filmcraft_engine::Session;
use serde_json::{Value, json};
use std::sync::atomic::{AtomicBool, Ordering};
/// Set once a write to stdout has failed; later output is dropped.
static STDOUT_GONE: AtomicBool = AtomicBool::new(false);
/// Set when stdout failed for a reason other than a closed reader: the exit status becomes 1.
static STDOUT_ERROR: AtomicBool = AtomicBool::new(false);
fn stdout_gone() -> bool {
STDOUT_GONE.load(Ordering::Relaxed)
}
/// stdout went away. The program doesn't stop there: the rest of `exec`/`run`/`import` (later
/// script lines, `--save`, `--save-as`) still runs and the exit status still tells how it went, so
/// `exec … --save | head -1` keeps the edit. A reader that stopped early (a closed pipe) is not an
/// error, as in ripgrep; any other write error is reported and makes the exit status 1.
fn stdout_failed(e: std::io::Error) {
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
return;
}
diag(format_args!("filmcraft-cli: can't write to stdout: {e}"));
STDOUT_ERROR.store(true, Ordering::Relaxed);
}
const HELP: &str = "\
filmcraft-cli: drive FilmCraft from the shell (headless engine, or the live app with --bridge)
@@ -27,14 +75,22 @@ SUBCOMMANDS
inspect [project|sequence] project tree or active sequence as JSON (default: both)
import <file>... import media into the project
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
[--gpu-rendering auto|off]
[--scale f] [--quality 0-100] [--no-audio] [--queue] [--allow-offline]
export the active sequence and wait for it to finish: with an
export preset (`export --list-presets`; built-in or the user's), or
a format (h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed
from the extension); --range entire|inOut|workArea, or a custom
range in seconds; --settings is ExportSettings JSON merged over the
preset; --queue adds to the export queue and runs it instead
preset; --scale renders at a fraction of the frame size (0.5 =
half); --quality 0-100 for the formats that take one; --no-audio
leaves the sound out; --queue adds to the export queue and runs it
instead. Offline media (missing, made offline, unreadable) renders
as placeholder frames: the export then lists it in `offlineMedia`,
warns on stderr and exits 1, unless --allow-offline accepts it
export --list-presets [query] list export presets (name, category, format) as JSON
render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG
render --seconds S --out f.png [--scale 0.5] [--allow-offline]
render one Program frame to PNG (offline media: as for export)
probe <media> [--image-sequence]
media info as JSON, with MXF / Ogg / BWF details; with
--image-sequence <media> is the first numbered still of a sequence
@@ -60,16 +116,27 @@ VALUES
Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=.
EXIT STATUS
0 success · 1 a command failed · 2 usage error
0 success · 1 a command failed, or export/render drew offline media without --allow-offline
· 2 usage error
";
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
/// reader is gone, which would turn a usage error, a failure or a finished export into a crash.
fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) {
let _ = writeln!(w, "{msg}");
}
fn diag(msg: std::fmt::Arguments) {
write_diag(&mut std::io::stderr(), msg);
}
fn usage(msg: impl std::fmt::Display) -> ! {
eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.");
diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."));
std::process::exit(2)
}
fn fail(msg: impl std::fmt::Display) -> ! {
eprintln!("filmcraft-cli: {msg}");
diag(format_args!("filmcraft-cli: {msg}"));
std::process::exit(1)
}
@@ -105,7 +172,8 @@ impl Backend {
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
match self {
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await.map_err(|e| e.to_string()),
// a blocking export runs as an app job polled by the client (#91, #92)
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
}
}
@@ -129,7 +197,7 @@ impl Backend {
fn print(a: &Args, v: &Value) {
let s = if a.flag("--compact") { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) };
println!("{}", s.unwrap_or_default());
outln!("{}", s.unwrap_or_default());
}
/// Export format from the output file's extension.
@@ -145,21 +213,76 @@ fn format_for(path: &str) -> Option<&'static str> {
"tif" | "tiff" => "tiff",
"bmp" => "bmp",
"aif" | "aiff" => "aiff",
"m4a" | "aac" => "aac",
_ => return None,
})
}
/// OS hardware video decoders (VideoToolbox on macOS, Media Foundation on Windows) in front of our
/// own, as in the desktop app. Also what `mcp` and the headless commands decode with.
/// The GPU export frame renderer (filmcraft-gpu's off-screen compositor behind filmcraft-export's
/// frame-renderer hook): exports with GPU rendering Auto composite on the GPU and fall back to the
/// CPU reference renderer wherever it cannot.
struct GpuFrameRenderer(filmcraft_gpu::ExportRenderer);
impl filmcraft_export::FrameRenderer for GpuFrameRenderer {
fn render(
&mut self,
project: &filmcraft_project::Project,
seq: filmcraft_project::ItemId,
t: filmcraft_time::Tick,
opts: filmcraft_render::RenderOptions,
sources: &dyn filmcraft_render::SourceProvider,
) -> Option<filmcraft_render::Image> {
self.0.render(project, seq, t, opts, sources)
}
}
fn register_gpu_frame_renderer() {
filmcraft_export::register_frame_renderer(|| {
filmcraft_gpu::ExportRenderer::new().map(|r| Box::new(GpuFrameRenderer(r)) as Box<dyn filmcraft_export::FrameRenderer>)
});
}
fn register_hardware_decoders() -> filmcraft_platform::Availability {
filmcraft_platform::register()
}
/// `path` made absolute against the working directory, as the project stores it. Unlike
/// `canonicalize`, `std::path::absolute` keeps symlinks and adds no Windows verbatim `\\?\`
/// prefix (#850). A path it cannot resolve is kept as given.
fn absolute(path: &str) -> String {
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
}
#[tokio::main]
async fn main() {
if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) {
println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
cli().await;
if STDOUT_ERROR.load(Ordering::Relaxed) {
std::process::exit(1);
}
}
async fn cli() {
// `std::env::args` panics on an argument that is not Unicode (a Linux file name in Latin-1)
let a = Args::parse(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()));
// `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference:
// the parser takes it as a flag option, so it never reaches the subcommand match.
if a.flag("--help") {
out!("{HELP}");
return;
}
let a = Args::parse(std::env::args().skip(1));
// `--version` anywhere and `-V` as the subcommand, also after global options (`--demo --version`, #710).
if a.wants_version() {
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
return;
}
register_hardware_decoders();
register_gpu_frame_renderer();
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
match cmd {
"help" | "--help" | "-h" => print!("{HELP}"),
"version" => println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
"help" | "-h" => out!("{HELP}"),
"version" => outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
"probe" => {
let path = a.pos(1).unwrap_or_else(|| usage("probe <media> [--image-sequence]"));
match probe::probe(path, a.flag("--image-sequence")) {
@@ -177,11 +300,11 @@ async fn main() {
src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(f))).unwrap_or_else(|e| fail(e));
}
let dt = t0.elapsed().as_secs_f64();
println!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
outln!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
let t1 = std::time::Instant::now();
let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(n / 2))).unwrap_or_else(|e| fail(e));
let (w, h, _) = f.to_linear_f32_decimated(2);
println!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
outln!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
}
"commands" | "describe" => {
let mut b = Backend::open(&a);
@@ -207,7 +330,7 @@ async fn main() {
} else {
for c in &list {
let s = |k: &str| c[k].as_str().unwrap_or("").to_string();
println!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
outln!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
}
}
}
@@ -234,9 +357,9 @@ async fn main() {
let v: Value = serde_json::from_str(line).unwrap_or_else(|e| usage(format!("line {}: {e}", n + 1)));
let id = v["id"].as_str().unwrap_or_else(|| usage(format!("line {}: missing \"id\"", n + 1)));
match b.exec(id, v.get("params").cloned().unwrap_or(json!({}))).await {
Ok(r) => println!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
Ok(r) => outln!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
Err(e) => {
println!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
outln!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
failed += 1;
if !a.flag("--keep-going") {
std::process::exit(1);
@@ -262,8 +385,7 @@ async fn main() {
print(&a, &v);
}
"import" => {
let paths: Vec<String> =
a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect();
let paths: Vec<String> = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect();
if paths.is_empty() {
usage("import <file>...");
}
@@ -306,17 +428,16 @@ async fn main() {
if let Some(r) = a.opt("--range") {
p["range"] = json!(r);
}
if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) {
p["range"] = json!("custom");
p["startSeconds"] = parse_value(s0);
p["endSeconds"] = parse_value(s1);
}
set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end"));
if let Some(js) = a.opt("--settings") {
p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}")));
}
if a.flag("--no-audio") {
p["audio"] = json!(false);
}
if let Some(v) = a.opt("--gpu-rendering") {
p["gpuRendering"] = json!(v); // off | auto (validated by the export command)
}
let mut b = Backend::open(&a);
let t0 = std::time::Instant::now();
let r = if a.flag("--queue") {
@@ -332,7 +453,9 @@ async fn main() {
match r {
Ok(v) => {
print(&a, &v);
eprintln!("exported {out} in {:.1}s", t0.elapsed().as_secs_f64());
let written = written_paths(&v, out).join(", ");
check_offline(&a, &offline_media(&v), &format!("export: {written}"));
diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64()));
}
Err(e) => fail(format!("export: {e}")),
}
@@ -347,15 +470,20 @@ async fn main() {
let out = a.opt("--out").unwrap_or("frame.png");
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
let t0 = std::time::Instant::now();
let Some(img) = s.render_program(scale) else { fail("no sequence") };
let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
let dt = t0.elapsed();
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
let offline: Vec<Value> = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect();
check_offline(&a, &offline, &format!("render: {out}"));
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
}
"mcp" => {
if let Some(opt) = a.unknown_opt(&["--bridge", "--project", "--demo", "--data-dir"]) {
usage(format!("unknown mcp option `{opt}`"));
}
let server = match a.opt("--bridge") {
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)),
None => match Backend::open(&a) {
Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s),
Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"),
@@ -369,8 +497,185 @@ async fn main() {
}
}
/// The files an export wrote, from its result: `path` of a direct export, or each queued item's
/// `path` (the format may have changed the extension: `out.mp4` exported as APV is `out.mp4.mov`).
/// `requested` when the result names none.
fn written_paths(result: &Value, requested: &str) -> Vec<String> {
let paths: Vec<String> = match result.get("path").and_then(Value::as_str) {
Some(p) => vec![p.to_string()],
None => result
.get("items")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|i| i.get("path").and_then(Value::as_str))
.map(str::to_string)
.collect(),
};
if paths.is_empty() { vec![requested.to_string()] } else { paths }
}
/// The offline items an export result reports (`offlineMedia` of a direct export, or of each
/// queued item), once each.
fn offline_media(result: &Value) -> Vec<Value> {
let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten());
let mut out: Vec<Value> = Vec::new();
for o in lists.filter_map(|r| r.get("offlineMedia").and_then(Value::as_array)).flatten() {
if !out.iter().any(|x| x.get("item") == o.get("item")) {
out.push(o.clone());
}
}
out
}
/// One stderr line per offline item that rendered as placeholder frames (#1054).
fn offline_warning(offline: &[Value]) -> String {
let s = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let mut msg = format!("filmcraft-cli: warning: {} offline media item(s) rendered as placeholder frames:", offline.len());
for o in offline {
let id = o.get("item").and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_default();
msg.push_str(&format!("\n item {id} {} ({}): {}", s(o, "name"), s(o, "status"), s(o, "path")));
}
msg
}
/// Warn about offline media on stderr; without `--allow-offline` that fails the run (exit 1), after
/// the output (`what`) was written, so scripts don't take placeholder frames for the real picture.
fn check_offline(a: &Args, offline: &[Value], what: &str) {
if offline.is_empty() {
return;
}
diag(format_args!("{}", offline_warning(offline)));
if !a.flag("--allow-offline") {
fail(format!("{what} has placeholder frames for offline media; relink it (exec media.relink …) or pass --allow-offline"));
}
}
/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be
/// rendered (no sequence, bad scale).
fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec<filmcraft_engine::media_pool::OfflineMedia>), String> {
s.try_render_program_with_offline_at(scale, s.playhead()).map_err(|e| e.to_string())
}
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
/// instead of the export silently running over the whole sequence.
fn set_custom_bounds(p: &mut Value, start: Option<&str>, end: Option<&str>) {
if start.is_none() && end.is_none() {
return;
}
p["range"] = json!("custom");
if let Some(s0) = start {
p["startSeconds"] = parse_value(s0);
}
if let Some(s1) = end {
p["endSeconds"] = parse_value(s1);
}
}
#[cfg(test)]
mod format_tests {
#[test]
fn render_reports_the_real_reason() {
let mut s = filmcraft_engine::Session::default();
assert!(super::render_at_playhead(&s, 0.5).unwrap_err().contains("sequence"));
s.execute("file.newSequence", serde_json::json!({"width": 16, "height": 16})).unwrap();
for scale in [0.0, -1.0, f32::NAN] {
let e = super::render_at_playhead(&s, scale).unwrap_err();
assert!(e.contains("finite and positive"), "{e}");
}
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8);
}
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
/// decoders at start-up.
#[test]
fn startup_registers_the_hardware_decoders() {
let hardware = super::register_hardware_decoders();
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
}
#[test]
fn a_failing_diagnostic_stream_does_not_panic() {
struct Closed;
impl std::io::Write for Closed {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
}
super::write_diag(&mut Closed, format_args!("exported {}", "a.wav"));
let mut ok = Vec::new();
super::write_diag(&mut ok, format_args!("hi"));
assert_eq!(ok, b"hi\n");
}
#[test]
fn export_message_names_the_written_files() {
use serde_json::json;
let direct = json!({"job": 1, "path": "out-apv.mp4.mov", "result": {"path": "out-apv.mp4.mov"}});
assert_eq!(super::written_paths(&direct, "out-apv.mp4"), ["out-apv.mp4.mov"]);
let queued = json!({"added": [1, 2], "items": [{"path": "a.mp4.mov"}, {"path": "b.wav"}]});
assert_eq!(super::written_paths(&queued, "a.mp4"), ["a.mp4.mov", "b.wav"]);
assert_eq!(super::written_paths(&json!({}), "x.mov"), ["x.mov"]);
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
}
#[test]
fn offline_media_from_direct_and_queued_results() {
use serde_json::json;
let a = json!({"item": 8, "name": "a.mp4", "path": "/m/a.mp4", "status": "missing", "error": ""});
let b = json!({"item": 9, "name": "b.mov", "path": "/m/b.mov", "status": "offline", "error": ""});
assert_eq!(super::offline_media(&json!({"path": "x.mp4", "offlineMedia": [a]})), std::slice::from_ref(&a));
let queued = json!({"items": [{"offlineMedia": [a, b]}, {"offlineMedia": [a]}, {"offlineMedia": []}]});
assert_eq!(super::offline_media(&queued), [a.clone(), b.clone()]);
assert!(super::offline_media(&json!({"path": "x.mp4"})).is_empty());
let w = super::offline_warning(&[a, b]);
assert!(w.contains("2 offline media item(s)") && w.contains("item 8 a.mp4 (missing): /m/a.mp4") && w.contains("item 9 b.mov (offline)"), "{w}");
}
#[test]
fn export_lone_bound_is_forwarded_as_custom_range() {
use serde_json::json;
let mut p = json!({});
super::set_custom_bounds(&mut p, Some("0.25"), None);
assert_eq!(p["range"], "custom");
assert_eq!(p["startSeconds"], 0.25);
assert!(p.get("endSeconds").is_none());
let mut p = json!({});
super::set_custom_bounds(&mut p, None, Some("0.25"));
assert_eq!(p["range"], "custom");
assert_eq!(p["endSeconds"], 0.25);
let mut p = json!({});
super::set_custom_bounds(&mut p, None, None);
assert!(p.get("range").is_none());
}
/// `import` used `canonicalize`, which resolves symlinks and on Windows stores `\\?\C:\…`
/// verbatim paths in the project (#850).
#[test]
fn imported_paths_are_absolute_without_resolving_links() {
let cwd = std::env::current_dir().unwrap();
assert_eq!(super::absolute("clip.mov"), cwd.join("clip.mov").to_string_lossy());
assert!(!super::absolute("clip.mov").starts_with(r"\\?\"));
assert_eq!(super::absolute(""), "");
#[cfg(unix)]
{
let dir = std::env::temp_dir().join(format!("filmcraft-cli-abs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("real.mov"), b"x").unwrap();
let link = dir.join("link.mov");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(dir.join("real.mov"), &link).unwrap();
assert_eq!(super::absolute(&link.to_string_lossy()), link.to_string_lossy());
std::fs::remove_dir_all(&dir).unwrap();
}
}
#[test]
fn export_format_from_extension() {
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
+162 -1
View File
@@ -22,6 +22,35 @@ fn help_and_usage_errors() {
assert_eq!(cli(&[]).status.code(), Some(2));
assert_eq!(cli(&["frobnicate"]).status.code(), Some(2));
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
// Invalid --bridge is a usage error (exit 2) for both exec and mcp (#921).
assert_eq!(cli(&["--bridge", "0.0.0.0:9876", "exec", "inspect.project"]).status.code(), Some(2));
assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9876"]).status.code(), Some(2));
}
#[test]
fn version_after_global_options() {
let want = format!("filmcraft-cli {}\n", env!("CARGO_PKG_VERSION"));
for args in [&["--version"][..], &["-V"], &["version"], &["--demo", "--version"], &["--compact", "-V"], &["--demo", "version"]] {
let o = cli(args);
assert!(o.status.success(), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
assert_eq!(String::from_utf8_lossy(&o.stdout), want, "{args:?}");
}
}
#[test]
fn long_help_matches_help_command() {
let reference = cli(&["help"]);
assert!(reference.status.success());
let long_help = cli(&["--help"]);
assert!(long_help.status.success(), "--help failed: {}", String::from_utf8_lossy(&long_help.stderr));
assert_eq!(long_help.stdout, reference.stdout);
assert!(long_help.stderr.is_empty());
let short_help = cli(&["-h"]);
assert!(short_help.status.success());
assert_eq!(short_help.stdout, reference.stdout);
let sub_help = cli(&["export", "--help"]);
assert!(sub_help.status.success(), "export --help failed: {}", String::from_utf8_lossy(&sub_help.stderr));
assert_eq!(sub_help.stdout, reference.stdout);
}
#[test]
@@ -44,7 +73,7 @@ fn probe_reports_mpeg_transport_and_program_streams() {
assert!(st.success());
let v = json_out(&cli(&["probe", out.to_str().unwrap()]));
assert!(v["video"]["codec"].as_str().unwrap().starts_with("MPEG-2 Video"), "{v}");
assert_eq!(v["audio"]["codec"], "MPEG Audio");
assert_eq!(v["audio_streams"][0]["codec"], "MPEG Audio");
let streams = v["mpeg"]["streams"].as_array().unwrap();
assert_eq!(streams.len(), 2, "{v}");
assert_eq!(streams[0]["codec"], "MPEG-2 Video");
@@ -179,9 +208,141 @@ fn export_with_presets() {
let v = json_out(&cli(&["--demo", "--data-dir", data_s, "export", q.to_str().unwrap(), "--preset", "Tiny WAV", "--queue", "--range", "entire"]));
assert_eq!(v["items"][0]["status"], "done", "{v}");
assert!(q.exists());
// a format that changes the extension: the completion message names the file written (#341)
let apv = dir.join("apv.mp4");
let o =
cli(&["--demo", "--data-dir", data_s, "export", apv.to_str().unwrap(), "--preset", "APV 422-10", "--start", "0", "--end", "0.1", "--scale", "0.125"]);
let v = json_out(&o);
let written = v["path"].as_str().unwrap().to_string();
assert!(written.ends_with("apv.mp4.mov") && std::path::Path::new(&written).exists(), "{v}");
let msg = String::from_utf8_lossy(&o.stderr);
assert!(msg.contains(&format!("exported {written} in ")), "{msg}");
let qa = dir.join("queued-apv.mp4");
let o = cli(&[
"--demo",
"--data-dir",
data_s,
"export",
qa.to_str().unwrap(),
"--preset",
"APV 422-10",
"--queue",
"--start",
"0",
"--end",
"0.1",
"--scale",
"0.125",
]);
let v = json_out(&o);
let written = v["items"][0]["path"].as_str().unwrap().to_string();
assert!(written.ends_with("queued-apv.mp4.mov"), "{v}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("exported {written} in ")), "{}", String::from_utf8_lossy(&o.stderr));
// unknown preset: exit status 1 and a message naming it
let bad = cli(&["--demo", "--data-dir", data_s, "export", dir.join("x.mp4").to_str().unwrap(), "--preset", "No Such Preset"]);
assert_eq!(bad.status.code(), Some(1));
assert!(String::from_utf8_lossy(&bad.stderr).contains("No Such Preset"));
let _ = std::fs::remove_dir_all(&dir);
}
/// `filmcraft-cli commands | head -c 10` panicked with "failed printing to stdout: Broken pipe
/// (os error 32)" and exit status 101 (#285).
#[test]
fn closed_stdout_ends_quietly() {
use std::io::Read;
for args in [&["commands"][..], &["help"], &["--demo", "commands", "--json"]] {
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut first = [0u8; 10];
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && err.is_empty(), "{args:?}: {:?}: {err}", out.status);
}
}
/// `run -` with stdout already closed: `run` reads its script to the end before it prints, so its
/// first write fails for sure. The rest of the script and the save still happen, and the exit status
/// still reports a failing command.
fn run_with_closed_stdout(args: &[&str], script: &[u8]) -> Output {
let mut child =
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
child.stdin.take().unwrap().write_all(script).unwrap();
child.wait_with_output().unwrap()
}
#[test]
fn closed_stdout_still_runs_the_script_and_saves() {
let dir = std::env::temp_dir().join(format!("fc-cli-pipe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let proj = dir.join("p.fcproj");
let p = proj.to_str().unwrap();
let script = b"{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped One\"}}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped Two\"}}\n";
let out = run_with_closed_stdout(&["--demo", "--save-as", p, "run", "-"], script);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let tree = json_out(&cli(&["--project", p, "inspect", "project"])).to_string();
assert!(tree.contains("Piped One") && tree.contains("Piped Two"), "{tree}");
// a failing command is still a failure, whether or not anyone reads stdout
let out = run_with_closed_stdout(&["--demo", "run", "-"], b"{\"id\":\"no.such.command\"}\n");
assert_eq!(out.status.code(), Some(1));
let out = run_with_closed_stdout(
&["--demo", "--keep-going", "run", "-"],
b"{\"id\":\"no.such.command\"}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"x\"}}\n",
);
assert_eq!(out.status.code(), Some(1));
let _ = std::fs::remove_dir_all(&dir);
}
/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the
/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the
/// placeholder frames; then it still warns.
#[test]
fn export_with_missing_media_fails_unless_allowed() {
let dir = std::env::temp_dir().join(format!("fc-cli-1054-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = |n: &str| dir.join(n).to_str().unwrap().to_string();
let (still, proj) = (f("still.png"), f("p.fcproj"));
let o = cli(&["--demo", "render", "--seconds", "1", "--scale", "0.1", "--out", &still]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
json_out(&cli(&["exec", "file.newSequence", "name=s", "width=64", "height=36", "fps=24", "--save-as", &proj]));
let paths = format!("paths=[\"{}\"]", still.replace('\\', "\\\\"));
let item = json_out(&cli(&["--project", &proj, "--save", "exec", "file.import", &paths]))["items"][0].as_u64().unwrap();
json_out(&cli(&["--project", &proj, "--save", "exec", "timeline.place", &format!("item={item}"), "track=V1", "frame=0", "audio=false"]));
let export = |out: &str, extra: &[&str]| {
let mut args = vec!["--project", proj.as_str(), "--compact", "export", out, "--format", "png", "--start", "0", "--end", "0.05"];
args.extend_from_slice(extra);
cli(&args)
};
let on = export(&f("on.png"), &[]);
let v = json_out(&on);
assert_eq!(v["offlineMedia"], serde_json::json!([]), "{v}");
assert!(!String::from_utf8_lossy(&on.stderr).contains("warning"), "{}", String::from_utf8_lossy(&on.stderr));
std::fs::remove_file(&still).unwrap();
let off = export(&f("off.png"), &[]);
let err = String::from_utf8_lossy(&off.stderr);
assert_eq!(off.status.code(), Some(1), "{err}");
assert!(err.contains("still.png") && err.contains("missing") && err.contains("--allow-offline"), "{err}");
let v: Value = serde_json::from_slice(&off.stdout).expect("the result JSON is still printed");
assert_eq!(v["offlineMedia"][0]["item"], item, "{v}");
assert_eq!(v["offlineMedia"][0]["status"], "missing", "{v}");
let allowed = export(&f("allowed.png"), &["--allow-offline"]);
let err = String::from_utf8_lossy(&allowed.stderr);
assert!(allowed.status.success(), "{err}");
assert!(err.contains("still.png") && err.contains("missing"), "still warns: {err}");
assert_eq!(json_out(&allowed)["offlineMedia"][0]["status"], "missing");
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png")]);
let err = String::from_utf8_lossy(&render.stderr);
assert_eq!(render.status.code(), Some(1), "{err}");
assert!(err.contains("still.png") && err.contains("--allow-offline"), "{err}");
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png"), "--allow-offline"]);
assert!(render.status.success(), "{}", String::from_utf8_lossy(&render.stderr));
assert!(String::from_utf8_lossy(&render.stderr).contains("still.png"));
let _ = std::fs::remove_dir_all(&dir);
}
+488
View File
@@ -0,0 +1,488 @@
//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent
//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`,
//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot:
//!
//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames);
//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3).
//!
//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross
//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes)
//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which
//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels.
//!
//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never
//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure).
//! ffmpeg and ffprobe are external test oracles only.
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use serde_json::{Value, json};
// ---------------------------------------------------------------------------------------------
// Fixtures
/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped
/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The
/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0
/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers.
fn phone_vfr_mp4(ffmpeg: &Path) -> Option<PathBuf> {
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4");
filmcraft_testkit::fixtures::generate(&out, |tmp| {
Command::new(ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"])
.args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"])
.args([
"-filter_complex",
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\
select='not(eq(mod(n,7),3))'[v];\
[1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]",
])
.args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"])
.args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
})
}
/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe,
/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3.
fn camera_long_gop_mkv(ffmpeg: &Path) -> Option<PathBuf> {
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv");
filmcraft_testkit::fixtures::generate(&out, |tmp| {
Command::new(ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"])
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"])
.args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"])
.args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"])
.args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"])
.args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
})
}
/// A scratch directory under the workspace `target/`, removed when dropped.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Scratch(d)
}
fn path(&self, name: &str) -> String {
self.0.join(name).to_string_lossy().into_owned()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// ---------------------------------------------------------------------------------------------
// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio)
struct Mcp {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: u64,
}
impl Mcp {
/// Start the headless server (an empty project) and initialise the session. `data_dir` keeps
/// user presets away from the real data directory.
fn start(data_dir: &str) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli"))
.args(["--data-dir", data_dir, "mcp"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn filmcraft-cli mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut m = Mcp { child, stdin, stdout, next_id: 1 };
let init =
m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}}));
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}");
m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
m
}
fn send(&mut self, msg: &Value) {
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
}
/// Send a request and return its response (notifications in between are skipped).
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
loop {
let mut line = String::new();
assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending");
let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}"));
if v["id"] == id {
return v;
}
}
}
/// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error.
fn tool(&mut self, name: &str, arguments: Value) -> Value {
let r = self.request("tools/call", json!({"name": name, "arguments": arguments}));
let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}");
serde_json::from_str(&text).unwrap_or(Value::String(text))
}
/// `command_run`: one engine command by id.
fn run(&mut self, id: &str, params: Value) -> Value {
self.tool("command_run", json!({"id": id, "params": params}))
}
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
// ---------------------------------------------------------------------------------------------
// Oracles
fn probe(ffprobe: &Path, path: &str) -> Value {
let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap();
assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr));
serde_json::from_slice(&out.stdout).unwrap()
}
fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> {
j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect()
}
fn num(v: &Value) -> f64 {
v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN)
}
/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout.
fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec<u8> {
let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap();
assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr));
out.stdout
}
/// Size of the frames [`frames`] returns.
const FW: usize = 80;
const FH: usize = 60;
/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes
/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it).
fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec<Vec<u8>> {
let chain = match vf {
Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"),
None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"),
};
let bpp = if pix_fmt == "gray" { 1 } else { 3 };
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]);
raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect()
}
/// Mean absolute difference of two equally sized frames.
fn diff(a: &[u8], b: &[u8]) -> f64 {
a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.len().max(1) as f64
}
/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close.
fn assert_shows(got: &[u8], src: &[Vec<u8>], want: usize, what: &str) {
let d: Vec<f64> = src.iter().map(|s| diff(got, s)).collect();
let best = d.iter().copied().fold(f64::INFINITY, f64::min);
let at = d.iter().position(|x| *x == best).unwrap_or(0);
assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]);
}
/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz.
fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec<f32> {
let map = format!("0:a:{stream}");
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]);
raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
}
/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart.
fn onsets(x: &[f32]) -> Vec<f64> {
let peak = x.iter().fold(0f32, |m, v| m.max(v.abs()));
let mut out = Vec::new();
let mut last = f64::NEG_INFINITY;
for (i, block) in x.chunks(48).enumerate() {
let t = i as f64 * 48.0 / 48_000.0;
if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 {
out.push(t);
}
if block.iter().any(|v| v.abs() > peak / 4.0) {
last = t;
}
}
out
}
fn rms(x: &[f32], from: f64, to: f64) -> f64 {
let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default();
(s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::<f64>() / s.len().max(1) as f64).sqrt()
}
/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel).
fn tone(x: &[f32], at: f64, freq: f64) -> f64 {
let start = (at * 48_000.0) as usize;
let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default();
let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos();
let (mut s1, mut s2) = (0.0, 0.0);
for v in s {
let s0 = f64::from(*v) + c * s1 - s2;
s2 = s1;
s1 = s0;
}
(s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64
}
fn db(ratio: f64) -> f64 {
20.0 * ratio.log10()
}
fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec<Value> {
seq[kind][track]["items"].as_array().unwrap()
}
// ---------------------------------------------------------------------------------------------
// Workflows
/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the
/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and
/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame
/// holds the one before), the cut lands on the right frames, picture and sound stay in sync
/// across the cut, the title is in the picture only while it is on the timeline.
#[test]
fn phone_vfr_clip_extract_gain_title_to_h264_mp4() {
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
let Some(src) = phone_vfr_mp4(&ffmpeg) else {
return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)");
};
let src = src.to_string_lossy().into_owned();
let dir = Scratch::new("phone");
let mut m = Mcp::start(&dir.path("data"));
let imported = m.tool("media_import", json!({"paths": [src]}));
assert_eq!(imported["errors"], json!([]), "{imported}");
let item = imported["items"][0].as_u64().unwrap();
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
assert!(made["sequence"].is_u64(), "{made}");
let seq = m.tool("sequence_inspect", json!({}));
assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]);
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate");
assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps");
// Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain.
m.run("markers.markIn", json!({"seconds": 1.5}));
m.run("markers.markOut", json!({"seconds": 2.5}));
m.run("sequence.extract", json!({}));
m.run("edit.selectAll", json!({}));
let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0}));
assert_eq!(gain["clips"], 2, "both audio pieces: {gain}");
m.run("edit.deselectAll", json!({}));
m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5}));
let seq = m.tool("sequence_inspect", json!({}));
assert_eq!(seq["durationFrames"], 89, "{seq}");
assert_eq!(clips_on(&seq, "video", 0).len(), 2);
assert_eq!(clips_on(&seq, "audio", 0).len(), 2);
let out = dir.path("phone_edit.mp4");
let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true}));
assert_eq!(r["result"]["frames"], 89, "{r}");
// container and streams
let j = probe(&ffprobe, &out);
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240)));
assert_eq!(v[0]["r_frame_rate"], "30/1");
assert_eq!(v[0]["nb_read_frames"], "89");
assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)));
let duration = num(&j["format"]["duration"]);
assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}");
assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]);
// picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source
// as an editor plays it (CFR 30, a dropped frame holding the previous one)
let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray");
let got = frames(&ffmpeg, &out, None, "gray");
assert!(src_frames.len() >= 120, "{} source frames", src_frames.len());
assert_eq!(got.len(), 89);
for k in [20, 32, 40, 44, 45, 50, 70, 88] {
let want = if k < 45 { k } else { k + 31 };
assert_shows(&got[k], &src_frames, want, &format!("export frame {k}"));
}
// the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31
let mean = |f: &Vec<u8>| f.iter().map(|v| f64::from(*v)).sum::<f64>() / f.len() as f64;
let flashes: Vec<usize> = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect();
assert_eq!(flashes, [30, 31, 59], "white frames");
// the title: over frames 0..15 only (its pixels differ from the source there, nowhere after)
let blocks_changed = |k: usize| {
let mut n = 0;
for by in (0..FH).step_by(10) {
for bx in (0..FW).step_by(10) {
let mut d = 0.0;
for y in by..by + 10 {
for x in bx..bx + 10 {
d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x]));
}
}
n += usize::from(d / 100.0 > 15.0);
}
}
n
};
for k in [2, 8, 14] {
assert!(blocks_changed(k) >= 2, "title missing on frame {k}");
}
for k in [16, 25] {
assert_eq!(blocks_changed(k), 0, "title still on frame {k}");
}
// sound: beeps where the flashes are (A/V sync across the extract), 6 dB down
let src_pcm = pcm(&ffmpeg, &src, 0);
let got_pcm = pcm(&ffmpeg, &out, 0);
let beeps = onsets(&got_pcm);
assert_eq!(beeps.len(), 2, "beeps at {beeps:?}");
for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) {
assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s");
}
let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09));
assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB");
let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09));
assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB");
}
/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make
/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at
/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross
/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes.
/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio
/// tracks are in the mix at their own level and stop where the clip does.
#[test]
fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() {
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
let Some(src) = camera_long_gop_mkv(&ffmpeg) else {
return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)");
};
let src = src.to_string_lossy().into_owned();
let dir = Scratch::new("camera");
let data = dir.path("data");
let project = dir.path("camera.fcproj");
{
let mut m = Mcp::start(&data);
let imported = m.tool("media_import", json!({"paths": [src]}));
assert_eq!(imported["errors"], json!([]), "{imported}");
let item = imported["items"][0].as_u64().unwrap();
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
let clips: Vec<u64> = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}");
let seq = m.tool("sequence_inspect", json!({}));
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1}));
assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks");
let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap();
// 65 frames off the head and 15 off the tail, the linked audio following
m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65}));
m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15}));
let seq = m.tool("sequence_inspect", json!({}));
let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap();
for t in 0..2 {
let a = &clips_on(&seq, "audio", t)[0];
assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1);
}
let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap();
let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false}));
let matte_clip = placed["clips"][0].as_u64().unwrap();
m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"}));
let seq = m.tool("sequence_inspect", json!({}));
let tr = &seq["video"][0]["transitions"][0];
assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}");
m.run("file.saveAs", json!({"path": project}));
}
assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit");
// a fresh process opens the saved project and renders it
let out = dir.path("camera_edit.mov");
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap();
assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr));
let r: Value = serde_json::from_slice(&o.stdout).unwrap();
let total = 71 + 50;
assert_eq!(r["result"]["frames"], total, "{r}");
let j = probe(&ffprobe, &out);
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240)));
assert_eq!(v[0]["r_frame_rate"], "25/1");
assert_eq!(v[0]["nb_read_frames"], total.to_string());
assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2)));
let duration = num(&j["format"]["duration"]);
assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}");
// picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it
let src_frames = frames(&ffmpeg, &src, None, "rgb24");
let got = frames(&ffmpeg, &out, None, "rgb24");
assert_eq!((src_frames.len(), got.len()), (150, total as usize));
for k in [0, 1, 2, 30, 64] {
assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}"));
}
let red = &got[100];
assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red");
// the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut
let mix = |k: usize| {
let (cam, e) = (&src_frames[65 + k], &got[k]);
let (mut num, mut den) = (0.0, 0.0);
for ((c, r), e) in cam.iter().zip(red).zip(e) {
let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e));
num += (e - c) * (r - c);
den += (r - c) * (r - c);
}
num / den
};
let alphas: Vec<f64> = (64..=78).map(mix).collect();
assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}");
assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}");
assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}");
// sound: both tracks in the mix at their own level while the clip plays, silence on the matte
let got_pcm = pcm(&ffmpeg, &out, 0);
let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1));
for at in [0.2, 1.5, 2.6] {
let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0));
let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0));
assert!(
db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0,
"at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})"
);
}
assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte");
}
/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through
/// `filmcraft-cli --project … inspect sequence`.
fn end_frame_of(project: &str) -> Option<u64> {
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?;
let v: Value = serde_json::from_slice(&o.stdout).ok()?;
v["video"][0]["items"][0]["endFrame"].as_u64()
}
+22 -6
View File
@@ -170,11 +170,11 @@ pub fn install() -> Result<(), JsValue> {
&obj,
"readFile",
Closure::<dyn Fn(String) -> js_sys::Promise>::new(|path: String| {
future_to_promise(async move {
future_to_promise(caught(async move {
let blob = crate::fs::blob(&path).ok_or_else(|| JsValue::from_str(&format!("{path}: not found")))?;
let buf = JsFuture::from(blob.array_buffer()).await?;
Ok(js_sys::Uint8Array::new(&buf).into())
})
}))
})
.into_js_value(),
)?;
@@ -191,10 +191,10 @@ pub fn install() -> Result<(), JsValue> {
&obj,
"importUrl",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|url: String, name: JsValue| {
future_to_promise(async move {
future_to_promise(caught(async move {
let f = fetch_file(&url, name.as_string()).await?;
Ok(to_js(&crate::import::import_files(vec![f]).await))
})
}))
})
.into_js_value(),
)?;
@@ -202,13 +202,13 @@ pub fn install() -> Result<(), JsValue> {
&obj,
"openProject",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|src: JsValue| {
future_to_promise(async move {
future_to_promise(caught(async move {
let file = match src.as_string() {
Some(url) => fetch_file(&url, None).await?,
None => files_of(&src).into_iter().next().ok_or("need a File or URL")?,
};
crate::import::open_project_file(file).await.map(|v| to_js(&v)).map_err(|e| JsValue::from(js_sys::Error::new(&e)))
})
}))
})
.into_js_value(),
)?;
@@ -216,3 +216,19 @@ pub fn install() -> Result<(), JsValue> {
js_sys::Reflect::set(&w, &"filmcraft".into(), &obj)?;
Ok(())
}
/// `f`, rejecting with an [`as_error`] `Error`.
async fn caught(f: impl std::future::Future<Output = Result<JsValue, JsValue>>) -> Result<JsValue, JsValue> {
f.await.map_err(as_error)
}
/// Every member rejects with an `Error` (#90), as `execute` and `request` do, so `e.message` and
/// `e instanceof Error` work: a plain string (a fetch status, "not found") becomes one, and an
/// `Error` passes through unchanged.
fn as_error(e: JsValue) -> JsValue {
if e.is_instance_of::<js_sys::Error>() {
return e;
}
let msg = e.as_string().unwrap_or_else(|| format!("{e:?}"));
js_sys::Error::new(&msg).into()
}
+24
View File
@@ -0,0 +1,24 @@
//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
//! anchor clicks live in [`crate::fs::download`]).
//!
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
//! (one file per frame) delivered only about half of its frames (#378). Each download is given a
//! slot [`GAP_MS`] after the previous one.
/// Milliseconds between two download clicks.
pub const GAP_MS: f64 = 125.0;
/// Hands out click times.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Pacer {
next_free: f64,
}
impl Pacer {
/// Reserve the next slot at time `now` (ms); returns how long to wait before clicking.
pub fn reserve(&mut self, now: f64) -> f64 {
let start = now.max(self.next_free);
self.next_free = start + GAP_MS;
start - now
}
}
+21 -17
View File
@@ -54,6 +54,7 @@ struct Fs {
thread_local! {
static FS: RefCell<Fs> = RefCell::new(Fs::default());
static DOWNLOADS: RefCell<crate::download_pacing::Pacer> = RefCell::new(crate::download_pacing::Pacer::default());
}
/// Wake-up hook for arriving data.
@@ -70,9 +71,8 @@ fn file_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Register a browser `File` (or `Blob` with a name) and return its virtual path. A file with
/// the same name and size as an existing entry replaces it (the same file picked again keeps
/// its path, so projects referencing it come back online); otherwise a numbered name is used.
/// Register a browser `File` (or named `Blob`) at an unused virtual path; collisions get numbered names.
/// Name + size do not identify content: replacing an occupied path changes every project item referencing it.
pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
let size = blob.size() as u64;
let clean: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
@@ -85,18 +85,11 @@ pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
let mut n = 1;
let path = loop {
let p = if n == 1 { format!("/files/{clean}") } else { format!("/files/{stem} ({n}){ext}") };
match fs.entries.get(&p) {
None => break p,
Some(Entry::Blob { size: s, .. }) if *s == size => break p,
Some(Entry::Mem(b)) if b.len() as u64 == size => break p,
_ => n += 1,
if !fs.entries.contains_key(&p) {
break p;
}
n += 1;
};
if let Some(Entry::Blob { id, .. }) = fs.entries.get(&path) {
let id = *id;
fs.blobs.remove(&id);
fs.chunks.retain(|k, _| k.0 != id);
}
let id = fs.next_id;
fs.next_id += 1;
fs.blobs.insert(id, blob);
@@ -413,7 +406,8 @@ impl Services for WebServices {
}
}
/// Offer bytes as a browser download named `name`.
/// Offer bytes as a browser download named `name`. Downloads are clicked at least
/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378).
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let blob = bytes_to_blob(data, "application/octet-stream")?;
@@ -422,10 +416,20 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
a.set_href(&url);
a.set_download(file_name(name));
a.style().set_property("display", "none")?;
doc.body().ok_or("no body")?.append_child(&a)?;
a.click();
a.remove();
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now()));
let name = name.to_owned();
wasm_bindgen_futures::spawn_local(async move {
if delay > 0.0 {
sleep(delay as i32).await;
}
let clicked = match doc.body() {
Some(body) => body.append_child(&a).map(|_| a.click()),
None => Err("no body".into()),
};
a.remove();
if let Err(e) = clicked {
log::warn!("download {name}: {e:?}");
}
sleep(60_000).await;
let _ = web_sys::Url::revoke_object_url(&url);
});
+51 -7
View File
@@ -10,7 +10,7 @@ use std::cell::RefCell;
use std::rc::Rc;
use filmcraft_engine::Services;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::{FilmcraftApp, RelinkHint};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
@@ -81,12 +81,12 @@ async fn prewarm(path: &str, size: u64) {
}
/// `file.import` one path, retried while its bytes are loading.
async fn import_path(path: String) -> Result<Value, String> {
async fn import_path(path: String, bin: u64) -> Result<Value, String> {
for _ in 0..2000 {
let p = path.clone();
let (r, pending) = on_ui(move |w, _| {
let _ = filmcraft_media::pending::take();
let r = w.app.session.execute("file.import", json!({"paths": [p]})).map_err(|e| e.to_string());
let r = w.app.session.execute("file.import", json!({"paths": [p], "bin": bin})).map_err(|e| e.to_string());
(r, filmcraft_media::pending::take())
})
.await;
@@ -105,6 +105,8 @@ async fn import_path(path: String) -> Result<Value, String> {
/// Import browser files (no copies): returns `{items, errors, paths}`.
pub async fn import_files(files: Vec<web_sys::File>) -> Value {
// Capture once per batch: navigation while prewarming must not redirect pending imports.
let bin = on_ui(|w, _| w.app.import_bin().0).await;
let mut items = Vec::new();
let mut errors = Vec::new();
let mut paths = Vec::new();
@@ -113,7 +115,7 @@ pub async fn import_files(files: Vec<web_sys::File>) -> Value {
let size = f.size() as u64;
let path = fs::register_blob(&name, f.clone().into());
prewarm(&path, size).await;
match import_path(path.clone()).await {
match import_path(path.clone(), bin).await {
Ok(v) => {
items.extend(v.get("items").and_then(Value::as_array).cloned().unwrap_or_default());
errors.extend(v.get("errors").and_then(Value::as_array).cloned().unwrap_or_default());
@@ -153,12 +155,43 @@ pub fn take_files(files: Vec<web_sys::File>) {
});
}
/// Files a picker returned: imported, or with a relink hint, the first one is registered under
/// `/files/<name>` and the hint's command runs on it (#111).
fn picked(files: Vec<web_sys::File>, relink: Option<RelinkHint>) {
let Some(hint) = relink else { return take_files(files) };
let Some(f) = files.into_iter().next() else { return };
wasm_bindgen_futures::spawn_local(async move {
let path = fs::register_blob(&f.name(), f.clone().into());
crate::recovery::keep_media(&path, &f);
let mut params = hint.params;
if !params.is_object() {
params = json!({});
}
params["path"] = json!(path);
let command = hint.command;
let outcome = on_ui(move |w, _| w.app.session.execute(&command, params).map_err(|e| e.to_string())).await;
crate::post(move |w, _| match outcome {
Ok(v) => {
if let Some(n) = v.get("relinked").and_then(Value::as_array).map(Vec::len) {
w.app.status(format!("Linked {n} clip(s)"));
}
}
Err(e) => w.app.status(e),
});
});
}
fn has_fsa_picker() -> bool {
web_sys::window().is_some_and(|w| js_sys::Reflect::has(&w, &"showOpenFilePicker".into()).unwrap_or(false))
}
/// Let the user pick files: `showOpenFilePicker` when available, else a file input.
pub fn pick(exts: &[&str], multiple: bool) {
pick_then(exts, multiple, None);
}
/// [`pick`], handing the files to [`picked`]: a cancelled picker leaves nothing pending.
fn pick_then(exts: &[&str], multiple: bool, relink: Option<RelinkHint>) {
let accept: Vec<String> = exts.iter().map(|e| format!(".{e}")).collect();
if has_fsa_picker() {
let opts = js_sys::Object::new();
@@ -178,7 +211,7 @@ pub fn pick(exts: &[&str], multiple: bool) {
files.push(f);
}
}
take_files(files);
picked(files, relink);
});
return;
}
@@ -190,7 +223,7 @@ pub fn pick(exts: &[&str], multiple: bool) {
let inp = input.clone();
let on_change = Closure::<dyn FnMut()>::new(move || {
if let Some(list) = inp.files() {
take_files((0..list.length()).filter_map(|i| list.get(i)).collect());
picked((0..list.length()).filter_map(|i| list.get(i)).collect(), relink.clone());
}
});
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
@@ -230,5 +263,16 @@ pub fn install_hooks(app: &mut FilmcraftApp) {
}));
// Saving writes the file in memory and offers it as a download (`fs::WebServices`).
app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}"))));
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| Some(format!("/exports/{name}"))));
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| {
let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy());
Some(format!("/exports/{file}"))
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the
// hint's command runs once the user has chosen (#111).
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], hint: Option<RelinkHint>| {
if hint.is_some() {
pick_then(exts, false, hint);
}
None
}));
}
+13 -1
View File
@@ -20,10 +20,12 @@
pub mod api;
pub mod audio;
pub mod download_pacing;
pub mod fs;
pub mod import;
pub mod opfs;
pub mod recovery;
pub mod recovery_policy;
pub mod webcodecs;
use std::cell::RefCell;
@@ -58,6 +60,13 @@ pub fn repaint() {
});
}
/// Give a host that builds its own [`WebApp`] the egui context that [`post`] and [`repaint`] wake.
/// [`start`] calls it; an embedding app that runs `WebApp` in its own eframe runner calls it once
/// from its app creator, or posted actions and JS API requests wait for the next input event.
pub fn set_context(ctx: egui::Context) {
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
/// Environment facts reported by `filmcraft.info()` (backend, isolation, codecs…).
pub fn set_info(key: &str, v: serde_json::Value) {
INFO.with(|i| {
@@ -150,7 +159,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
canvas,
web_options,
Box::new(move |cc| {
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
set_context(cc.egui_ctx.clone());
let ctx = cc.egui_ctx.clone();
fs::set_on_data(move || ctx.request_repaint());
fs::set_on_write(|path, data| {
@@ -177,6 +186,9 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
}
let mut app = FilmcraftApp::new(session);
import::install_hooks(&mut app);
// Settings ▸ General ▸ Interface Language ▸ System Language (#218): the browser's languages.
app.hooks.system_languages =
Some(Box::new(|| web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default()));
let (tx, rx) = std::sync::mpsc::channel();
api::set_sender(tx);
app = app.with_control(rx);
+21 -4
View File
@@ -10,8 +10,13 @@ use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle};
/// Called with whether a queued write reached OPFS.
pub type Done = Box<dyn FnOnce(bool)>;
thread_local! {
static QUEUE: RefCell<(BTreeMap<String, Vec<u8>>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
/// Pending writes per path (bytes and the callbacks of every write coalesced into them), and
/// whether the writer task is running.
static QUEUE: RefCell<(BTreeMap<String, (Vec<u8>, Vec<Done>)>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
}
/// Whether this browser has OPFS (`navigator.storage.getDirectory`).
@@ -60,9 +65,17 @@ async fn write_now(rel: &str, data: &[u8]) -> Result<(), JsValue> {
/// Queue a write of `data` to `rel` (relative to the OPFS root).
pub fn write(rel: String, data: Vec<u8>) {
write_then(rel, data, None);
}
/// [`write`], then call `done` with whether it succeeded. A write replaced by a newer one to the
/// same path reports the newer one's result.
pub fn write_then(rel: String, data: Vec<u8>, done: Option<Done>) {
let start = QUEUE.with(|q| {
let mut q = q.borrow_mut();
q.0.insert(rel, data);
let e = q.0.entry(rel).or_default();
e.0 = data;
e.1.extend(done);
!std::mem::replace(&mut q.1, true)
});
if !start {
@@ -81,10 +94,14 @@ pub fn write(rel: String, data: Vec<u8>) {
}
}
});
let Some((rel, data)) = next else { break };
if let Err(e) = write_now(&rel, &data).await {
let Some((rel, (data, done))) = next else { break };
let r = write_now(&rel, &data).await;
if let Err(e) = &r {
log::warn!("OPFS write {rel}: {e:?}");
}
for d in done {
d(r.is_ok());
}
}
});
}
+48 -18
View File
@@ -6,24 +6,32 @@
//! - Imported media are copied to `media/<name>` (the browser streams the copy; nothing goes
//! through wasm memory), so the snapshot's `/files/<name>` references resolve after a reload.
//! - At start-up a dirty snapshot is reopened (`?norecover` skips it, `?fresh` also skips the media).
//! A skipped snapshot stays dirty until this session writes its own ([`crate::recovery_policy`]).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use filmcraft_engine::Session;
use serde_json::{Value, json};
use crate::opfs;
use crate::recovery_policy::{Action, Policy};
const SNAPSHOT: &str = "recovery/snapshot.fcproj";
const META: &str = "recovery/meta.json";
/// Seconds between snapshots of a changing project.
const INTERVAL_S: f64 = 5.0;
/// Media larger than this are not copied to OPFS.
const KEEP_MAX: f64 = 4.0 * 1024.0 * 1024.0 * 1024.0;
/// A finished OPFS write, reported back to the next [`Autosave::tick`].
enum Outcome {
Snapshot { revision: u64, ok: bool },
Clean { ok: bool },
}
#[derive(Default)]
pub struct Autosave {
last_revision: u64,
last_time: f64,
last_dirty: Option<bool>,
policy: Policy,
outcomes: Rc<RefCell<Vec<Outcome>>>,
}
impl Autosave {
@@ -33,22 +41,44 @@ impl Autosave {
return;
}
let now = ctx.input(|i| i.time);
let dirty = s.revision != s.saved_revision;
if dirty {
if s.revision != self.last_revision && now - self.last_time >= INTERVAL_S {
opfs::write(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false));
for o in self.outcomes.borrow_mut().drain(..) {
match o {
Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now),
Outcome::Clean { ok } => self.policy.cleaned(ok, now),
}
}
match self.policy.tick(now, s.revision, s.saved_revision) {
Action::Nothing => {}
Action::WakeAfter(secs) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(secs)),
Action::WriteSnapshot(revision) => {
// Snapshot and meta both have to land; either failing retries the pair.
let failed = Rc::new(Cell::new(false));
let left = Rc::new(Cell::new(2u8));
let done = |out: Rc<RefCell<Vec<Outcome>>>, failed: Rc<Cell<bool>>, left: Rc<Cell<u8>>, ctx: egui::Context| -> opfs::Done {
Box::new(move |ok| {
failed.set(failed.get() || !ok);
left.set(left.get().saturating_sub(1));
if left.get() == 0 {
out.borrow_mut().push(Outcome::Snapshot { revision, ok: !failed.get() });
ctx.request_repaint();
}
})
};
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
opfs::write(META.into(), meta.to_string().into_bytes());
self.last_revision = s.revision;
self.last_time = now;
} else if s.revision != self.last_revision {
// the UI may go idle: wake up when the snapshot is due
ctx.request_repaint_after(std::time::Duration::from_secs_f64((INTERVAL_S - (now - self.last_time)).max(0.05)));
let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone());
let d2 = done(self.outcomes.clone(), failed, left, ctx.clone());
opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1));
opfs::write_then(META.into(), meta.to_string().into_bytes(), Some(d2));
}
Action::WriteClean => {
let (out, ctx) = (self.outcomes.clone(), ctx.clone());
let done: opfs::Done = Box::new(move |ok| {
out.borrow_mut().push(Outcome::Clean { ok });
ctx.request_repaint();
});
opfs::write_then(META.into(), json!({"dirty": false}).to_string().into_bytes(), Some(done));
}
} else if self.last_dirty != Some(false) {
opfs::write(META.into(), json!({"dirty": false}).to_string().into_bytes());
}
self.last_dirty = Some(dirty);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in
//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
//!
//! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write
//! that fails is retried after the interval, without waiting for another edit (#197).
//! - The meta is marked clean only to retire a snapshot this session wrote. A snapshot left by an
//! earlier session that was not reopened (`?norecover`, `?fresh`) stays dirty (#193).
/// Seconds between snapshots of a changing project.
pub const INTERVAL_S: f64 = 5.0;
/// What the caller should do this frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Action {
Nothing,
/// Write the snapshot and a dirty meta for this revision, then report with [`Policy::written`].
WriteSnapshot(u64),
/// Write a clean meta, then report with [`Policy::cleaned`].
WriteClean,
/// Nothing now; wake up after this many seconds.
WakeAfter(f64),
}
#[derive(Debug, Default)]
pub struct Policy {
/// Revision whose snapshot was last written or is being written.
last_revision: Option<u64>,
last_time: Option<f64>,
/// This session wrote a dirty meta that is not yet retired.
owns_dirty_meta: bool,
/// A clean-meta write is in flight.
cleaning: bool,
/// Snapshot writes started so far, and how many had started when the clean write did (a
/// snapshot started after it makes the meta dirty again).
snapshots: u64,
snapshots_at_clean: u64,
}
impl Policy {
/// Called every frame with the session's revisions and the time in seconds.
pub fn tick(&mut self, now: f64, revision: u64, saved_revision: u64) -> Action {
if revision != saved_revision {
if self.last_revision == Some(revision) {
return Action::Nothing;
}
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
if since >= INTERVAL_S {
self.last_revision = Some(revision);
self.last_time = Some(now);
self.owns_dirty_meta = true;
self.snapshots += 1;
return Action::WriteSnapshot(revision);
}
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
}
if self.owns_dirty_meta && !self.cleaning {
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
if self.last_revision.is_some() || since >= INTERVAL_S {
self.cleaning = true;
self.snapshots_at_clean = self.snapshots;
return Action::WriteClean;
}
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
}
Action::Nothing
}
/// The snapshot write for `revision` finished.
pub fn written(&mut self, revision: u64, ok: bool, now: f64) {
if !ok && self.last_revision == Some(revision) {
// retry after the interval, even if nothing else changes
self.last_revision = None;
self.last_time = Some(now);
}
}
/// The clean-meta write finished.
pub fn cleaned(&mut self, ok: bool, now: f64) {
self.cleaning = false;
if ok {
self.owns_dirty_meta = self.snapshots != self.snapshots_at_clean;
} else {
// retry after the interval
self.last_revision = None;
self.last_time = Some(now);
}
}
}
+111 -21
View File
@@ -12,8 +12,7 @@
//! the job on a later frame, when the decoder's output callback has delivered the picture.
//! - A session that has not delivered the frame it was started for is not restarted by other
//! requests (thumbnails and the monitor would otherwise keep resetting each other).
//! - Outputs are converted to RGBA by drawing the `VideoFrame` on an `OffscreenCanvas` (the
//! browser applies the stream's colour description).
//! - Native YUV planes feed the desktop frame/compositor contract. Unsupported browser formats or geometry use Canvas RGBA.
//! - Any decoder error or unsupported configuration switches the source to our decoder.
//!
//! `?nowebcodecs` disables it. Support is probed at start-up with `VideoDecoder.isConfigSupported`.
@@ -34,6 +33,20 @@ use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
mod pixels;
#[derive(Default)]
struct Transfers {
native: u64,
canvas: u64,
closed: u64,
stale: u64,
bytes: u64,
native_ms: f64,
canvas_ms: f64,
reasons: BTreeMap<&'static str, u64>,
}
/// Samples fed past the wanted one (decoders hold pictures for reordering).
const LOOKAHEAD: usize = 8;
/// Decoded frames kept per source.
@@ -52,6 +65,7 @@ thread_local! {
static DECODED: Cell<u64> = const { Cell::new(0) };
static FALLBACKS: Cell<u64> = const { Cell::new(0) };
static SOURCES: Cell<u64> = const { Cell::new(0) };
static TRANSFERS: RefCell<Transfers> = RefCell::new(Transfers::default());
}
fn now_ms() -> f64 {
@@ -160,11 +174,15 @@ pub fn stats() -> serde_json::Value {
.iter()
.map(|(id, s)| {
json!({"id": id, "start": s.start, "next": s.next, "target": s.target, "needsKey": s.needs_key, "flushing": s.flushing.get(),
"errored": s.errored.get(), "queue": s.queue(), "idleMs": now_ms() - s.last_output.get()})
"errored": s.errored.get(), "queue": s.queue(), "copies": s.copying.get(), "idleMs": now_ms() - s.last_output.get()})
})
.collect()
});
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions})
let transfers = TRANSFERS.with(|s| {
let s = s.borrow();
json!({"nativeFrames":s.native,"canvasFrames":s.canvas,"closedFrames":s.closed,"staleFrames":s.stale,"nativeBytes":s.bytes,"nativeMs":s.native_ms,"canvasMs":s.canvas_ms,"canvasReasons":s.reasons})
});
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions,"transfers":transfers})
}
/// A [`SharedReader`] as the demuxer's byte source.
@@ -210,6 +228,8 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
});
SOURCES.with(|c| c.set(c.get() + 1));
log::info!("{name}: video decoded with WebCodecs ({codec})");
let color = info.video.as_ref().map(|v| v.color).unwrap_or_default();
let par = info.video.as_ref().map(|v| v.par).unwrap_or((1, 1));
Some(Ok(Arc::new(WcSource {
inner,
info,
@@ -217,11 +237,12 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
file,
track,
id,
config: Config { codec, description, width: w, height: h, rotation },
config: Config { codec, description, width: w, height: h, rotation, color, par },
state: Mutex::new(Cache::default()),
})))
}
#[derive(Clone)]
struct Config {
codec: String,
description: Option<Vec<u8>>,
@@ -229,6 +250,8 @@ struct Config {
height: u32,
/// Clockwise quarter turns from the track matrix, applied to decoded frames.
rotation: u8,
color: filmcraft_color::ColorInfo,
par: (u32, u32),
}
#[derive(Default)]
@@ -253,6 +276,9 @@ struct Session {
/// Highest presentation time output since the session (re)started.
out_max: Rc<Cell<i64>>,
out: Rc<RefCell<Vec<(i64, VideoFrame)>>>,
copying: Rc<Cell<u32>>,
generation: Rc<Cell<u64>>,
alive: Rc<Cell<bool>>,
_closures: (Closure<dyn FnMut(JsValue)>, Closure<dyn FnMut(JsValue)>),
}
@@ -286,17 +312,65 @@ impl Session {
let errored = Rc::new(Cell::new(false));
let last_output = Rc::new(Cell::new(now_ms()));
let out_max = Rc::new(Cell::new(i64::MIN));
let copying = Rc::new(Cell::new(0u32));
let generation = Rc::new(Cell::new(0u64));
let alive = Rc::new(Cell::new(true));
let (o, lo, om) = (out.clone(), last_output.clone(), out_max.clone());
let (copies, epoch, active, config, errors) = (copying.clone(), generation.clone(), alive.clone(), cfg.clone(), errored.clone());
let on_output = Closure::<dyn FnMut(JsValue)>::new(move |frame: JsValue| {
let ts = get(&frame, "timestamp").as_f64().unwrap_or(0.0) as i64;
om.set(om.get().max(ts));
if let Some(f) = to_rgba(&frame) {
o.borrow_mut().push((ts, f));
DECODED.with(|c| c.set(c.get() + 1));
}
let _ = call(&frame, "close", &[]);
lo.set(now_ms());
crate::repaint();
let expected = epoch.get();
let (o, lo, om, copies, epoch, active, config, errors) =
(o.clone(), lo.clone(), om.clone(), copies.clone(), epoch.clone(), active.clone(), config.clone(), errors.clone());
copies.set(copies.get().saturating_add(1));
wasm_bindgen_futures::spawn_local(async move {
let begin = now_ms();
let converted = match pixels::read(&frame, &config).await {
Ok((f, bytes)) => {
TRANSFERS.with(|s| {
let mut s = s.borrow_mut();
s.native = s.native.saturating_add(1);
s.bytes = s.bytes.saturating_add(bytes as u64);
s.native_ms += now_ms() - begin;
});
Some(f)
}
Err(reason) => {
let begin = now_ms();
let f = to_rgba(&frame);
TRANSFERS.with(|s| {
let mut s = s.borrow_mut();
s.canvas = s.canvas.saturating_add(1);
s.canvas_ms += now_ms() - begin;
let count = s.reasons.entry(reason).or_default();
*count = count.saturating_add(1);
});
f
}
};
let _ = call(&frame, "close", &[]);
copies.set(copies.get().saturating_sub(1));
TRANSFERS.with(|s| {
let mut s = s.borrow_mut();
s.closed = s.closed.saturating_add(1);
});
if !active.get() || epoch.get() != expected {
TRANSFERS.with(|s| {
let mut s = s.borrow_mut();
s.stale = s.stale.saturating_add(1);
});
return;
}
if let Some(f) = converted {
o.borrow_mut().push((ts, f));
om.set(om.get().max(ts));
DECODED.with(|c| c.set(c.get().saturating_add(1)));
} else {
errors.set(true);
}
lo.set(now_ms());
crate::repaint();
});
});
let e = errored.clone();
let on_error = Closure::<dyn FnMut(JsValue)>::new(move |err: JsValue| {
@@ -317,6 +391,9 @@ impl Session {
last_output,
out_max,
out,
copying,
generation,
alive,
_closures: (on_output, on_error),
};
s.configure(cfg)?;
@@ -341,6 +418,14 @@ impl Session {
}
}
impl Drop for Session {
fn drop(&mut self) {
self.alive.set(false);
self.generation.set(self.generation.get().wrapping_add(1));
let _ = call(&self.decoder, "close", &[]);
}
}
/// An MP4/MOV source whose video is decoded by the browser.
pub struct WcSource {
inner: SharedSource,
@@ -388,12 +473,15 @@ impl WcSource {
// already output but the cache dropped needs a restart).
let covers = !s.needs_key && s.start <= i && key <= s.next && (i >= s.next || want > s.out_max.get());
if !covers {
// Don't interrupt a session that is still working towards its own frame.
let busy = !s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS;
// Don't interrupt a session that is still working towards its own frame, unless it
// has gone quiet for STALE_MS (a copyTo that never settles must not wedge it).
let fresh = now_ms() - s.last_output.get() < STALE_MS;
let busy = fresh && (s.copying.get() > 0 || (!s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target));
if busy {
return Drive::Waiting;
}
if !s.needs_key || s.next > 0 {
s.generation.set(s.generation.get().wrapping_add(1));
let _ = call(&s.decoder, "reset", &[]);
if s.configure(&self.config).is_err() {
return Drive::Failed;
@@ -410,7 +498,7 @@ impl WcSource {
s.target = want;
}
let upto = (i + LOOKAHEAD).min(n - 1);
while s.next <= upto && s.queue() < MAX_QUEUE {
while s.next <= upto && s.queue().saturating_add(s.copying.get()) < MAX_QUEUE {
let smp = &t.samples[s.next];
let mut data = vec![0u8; smp.size as usize];
match self.reader.read_at(smp.offset, &mut data) {
@@ -480,10 +568,8 @@ impl WcSource {
impl Drop for WcSource {
fn drop(&mut self) {
SESSIONS.with(|ss| {
if let Ok(mut ss) = ss.try_borrow_mut()
&& let Some(s) = ss.remove(&self.id)
{
let _ = call(&s.decoder, "close", &[]);
if let Ok(mut ss) = ss.try_borrow_mut() {
ss.remove(&self.id);
}
});
}
@@ -502,7 +588,8 @@ impl MediaSource for WcSource {
}
let t = &self.file.tracks[self.track];
let n = t.samples.len();
let target = req.time.max(Tick::ZERO).to_rational_floor(1, t.timescale.max(1) as i64);
// nearest, as `Mp4Source` looks frames up (sample times are rounded to the timescale)
let target = req.time.max(Tick::ZERO).to_rational_round(1, t.timescale.max(1) as i64);
let i = t.sample_at_presentation_time(target).unwrap_or(n - 1).min(n - 1);
let want = t.samples[i].pts;
self.collect(&mut c, want);
@@ -531,4 +618,7 @@ impl MediaSource for WcSource {
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
self.inner.audio(start, frames, sample_rate)
}
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
self.inner.audio_stream(stream, start, frames, sample_rate)
}
}
+148
View File
@@ -0,0 +1,148 @@
//! Browser planes -> core frames; no color conversion or chroma reconstruction here.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use filmcraft_time::Tick;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use super::{Config, call, get, obj};
// One 8K 4:4:4 16-bit picture + alpha fits within 256 MiB; cap browser-driven allocations.
const MAX_COPY_BYTES: u32 = 256 * 1024 * 1024;
fn integer(value: JsValue) -> Option<u32> {
let n = value.as_f64()?;
if n.is_finite() && n >= 0.0 && n <= u32::MAX as f64 && n.fract() == 0.0 { Some(n as u32) } else { None }
}
fn color(frame: &JsValue, fallback: ColorInfo) -> Option<ColorInfo> {
let c = get(frame, "colorSpace");
let matrix = match get(&c, "matrix").as_string().as_deref() {
Some("bt709") => Matrix::Bt709,
Some("bt470bg" | "smpte170m") => Matrix::Bt601,
Some("bt2020-ncl") => Matrix::Bt2020Ncl,
None => fallback.matrix,
_ => return None,
};
let transfer = match get(&c, "transfer").as_string().as_deref() {
Some("bt709") => Transfer::Bt709,
Some("iec61966-2-1") => Transfer::Srgb,
Some("smpte2084") => Transfer::Pq,
Some("arib-std-b67") => Transfer::Hlg,
None => fallback.transfer,
_ => return None,
};
let primaries = match get(&c, "primaries").as_string().as_deref() {
Some("bt709") => Primaries::Bt709,
Some("bt470bg") => Primaries::Bt601_625,
Some("smpte170m") => Primaries::Bt601_525,
Some("bt2020") => Primaries::Bt2020,
None => fallback.primaries,
_ => return None,
};
let range = match get(&c, "fullRange").as_bool() {
Some(true) => Range::Full,
Some(false) => Range::Limited,
None => fallback.range,
};
Some(ColorInfo { matrix, transfer, primaries, range })
}
fn plane(raw: &js_sys::Uint8Array, layout: &JsValue, width: u32, height: u32) -> Result<Vec<u8>, &'static str> {
let offset = integer(get(layout, "offset")).ok_or("layout")?;
let stride = integer(get(layout, "stride")).filter(|s| *s >= width).ok_or("layout")?;
let end =
height.checked_sub(1).and_then(|h| h.checked_mul(stride)).and_then(|n| offset.checked_add(n)).and_then(|n| n.checked_add(width)).ok_or("layout")?;
if end > raw.length() {
return Err("layout");
}
let len = width.checked_mul(height).filter(|n| *n <= MAX_COPY_BYTES).ok_or("allocation")? as usize;
let mut out = Vec::new();
out.try_reserve_exact(len).map_err(|_| "memory")?;
out.resize(len, 0);
if stride == width {
raw.subarray(offset, end).copy_to(&mut out);
} else {
for (y, row) in out.chunks_exact_mut(width as usize).enumerate() {
// Checked for the last row above; earlier rows are smaller, but stay checked anyway.
let start = u32::try_from(y).ok().and_then(|y| y.checked_mul(stride)).and_then(|n| offset.checked_add(n)).ok_or("layout")?;
let row_end = start.checked_add(width).filter(|e| *e <= end).ok_or("layout")?;
raw.subarray(start, row_end).copy_to(row);
}
}
Ok(out)
}
/// Copy native 8-bit YUV planes; unsupported formats/geometry return a counted Canvas fallback reason.
pub(super) async fn read(frame: &JsValue, cfg: &Config) -> Result<(VideoFrame, u32), &'static str> {
let format = get(frame, "format").as_string().ok_or("format")?;
let (chroma, alpha, interleaved) = match format.as_str() {
"NV12" => (Chroma::C420, false, true),
"I420" => (Chroma::C420, false, false),
"I420A" => (Chroma::C420, true, false),
"I422" => (Chroma::C422, false, false),
"I422A" => (Chroma::C422, true, false),
"I444" => (Chroma::C444, false, false),
"I444A" => (Chroma::C444, true, false),
_ => return Err("format"),
};
let rect = get(frame, "visibleRect");
let w = integer(get(&rect, "width")).filter(|n| *n > 0).ok_or("geometry")?;
let h = integer(get(&rect, "height")).filter(|n| *n > 0).ok_or("geometry")?;
if integer(get(frame, "displayWidth")) != Some(w)
|| integer(get(frame, "displayHeight")) != Some(h)
|| get(frame, "rotation").as_f64().is_some_and(|r| r != 0.0)
|| get(frame, "flip").as_bool() == Some(true)
{
return Err("geometry");
}
let color = color(frame, cfg.color).ok_or("color")?;
let options = obj(&[("rect", rect)]);
let bytes =
integer(call(frame, "allocationSize", &[&options]).map_err(|_| "allocation")?).filter(|n| *n > 0 && *n <= MAX_COPY_BYTES).ok_or("allocation")?;
let array: js_sys::Function = get(&js_sys::global(), "Uint8Array").dyn_into().map_err(|_| "allocation")?;
let raw: js_sys::Uint8Array =
js_sys::Reflect::construct(&array, &js_sys::Array::of1(&bytes.into())).and_then(|v| v.dyn_into()).map_err(|_| "allocation")?;
let promise = call(frame, "copyTo", &[raw.as_ref(), &options]).map_err(|_| "copy")?.dyn_into::<js_sys::Promise>().map_err(|_| "copy")?;
let layout = js_sys::Array::from(&JsFuture::from(promise).await.map_err(|_| "copy")?);
let count = if interleaved {
2
} else if alpha {
4
} else {
3
};
if layout.length() != count {
return Err("layout");
}
let (sx, sy) = chroma.shifts();
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
let y = plane(&raw, &layout.get(0), w, h)?;
let (u, v) = if interleaved {
let uv = plane(&raw, &layout.get(1), cw.checked_mul(2).ok_or("allocation")?, ch)?;
let pairs = uv.as_chunks::<2>().0;
let (mut u, mut v) = (Vec::new(), Vec::new());
u.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
v.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
u.extend(pairs.iter().map(|p| p[0]));
v.extend(pairs.iter().map(|p| p[1]));
(u, v)
} else {
(plane(&raw, &layout.get(1), cw, ch)?, plane(&raw, &layout.get(2), cw, ch)?)
};
let alpha = if alpha { Some(Arc::new(plane(&raw, &layout.get(3), w, h)?)) } else { None };
Ok((
VideoFrame {
width: w,
height: h,
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha },
color,
par: cfg.par,
pts: Tick::ZERO,
},
bytes,
))
}
@@ -0,0 +1,26 @@
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
//! wasm32-only.
#[path = "../src/download_pacing.rs"]
mod download_pacing;
use download_pacing::{GAP_MS, Pacer};
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
#[test]
fn burst_of_downloads_is_spaced_out() {
let mut p = Pacer::default();
let delays: Vec<f64> = (0..20).map(|_| p.reserve(1000.0)).collect();
assert_eq!(delays[0], 0.0);
for (i, d) in delays.iter().enumerate() {
assert_eq!(*d, i as f64 * GAP_MS);
}
}
/// A download after a quiet period is not delayed.
#[test]
fn download_after_a_pause_is_immediate() {
let mut p = Pacer::default();
assert_eq!(p.reserve(0.0), 0.0);
assert_eq!(p.reserve(10_000.0), 0.0);
}
+67
View File
@@ -0,0 +1,67 @@
// Web import destinations: active bin tab, browser drop + navigation during pending reads.
// Synthetic browser drop event exercises the production handler; physical OS drag/picker UI is outside this test.
export async function checkImportBins({ evaluate, media, timeoutMs = 120000 }) {
const report = {gates: {}, cases: []};
await evaluate('filmcraft.execute("file.newProject", {name: "Import bins"})');
const parent = (await evaluate('filmcraft.execute("file.newBin", {name: "Footage"})')).bin;
const child = (await evaluate(`filmcraft.execute("file.newBin", {name: "Selects", parent: ${parent}})`)).bin;
await evaluate(`(async () => {window.__importBinFixture = new Uint8Array(await (await fetch(${JSON.stringify(media)})).arrayBuffer());})()`);
const setBin = bin => evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${JSON.stringify(bin)}, activeTab: null}})`);
const importExpression = name => `filmcraft.importFiles([new File([window.__importBinFixture], ${JSON.stringify(name)}, {type: "video/mp4"})])`;
const locate = async name => {
const deadline = Date.now() + timeoutMs;
const pollMs = 100;
for (;;) {
const project = await evaluate('filmcraft.execute("project.inspect")');
const find = bin => {
for (const entry of bin.children) {
if (entry.item !== undefined && entry.name === name) return bin.id;
if (entry.children) {const found = find(entry); if (found !== undefined) return found;}
}
};
const found = find(project.root);
if (found !== undefined) return found;
if (Date.now() >= deadline) throw new Error(`Import never appeared: ${name}`);
await new Promise(resolve => setTimeout(resolve, pollMs));
}
};
const check = async (key, expected, name) => {
const actual = await locate(name);
report.cases.push({key, expected, actual, name}); report.gates[key] = actual === expected;
};
await evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${child}, activeTab: 0, tabs: [{bin: ${parent}}]}})`);
await evaluate(importExpression("tab.mp4")); await check("activeTab", parent, "tab.mp4");
await setBin(child);
await evaluate(`(() => {
const transfer = new DataTransfer();
transfer.items.add(new File([window.__importBinFixture], "drop.mp4", {type: "video/mp4"}));
window.dispatchEvent(new DragEvent("drop", {dataTransfer: transfer, bubbles: true, cancelable: true}));
})()`);
await check("browserDrop", child, "drop.mp4");
await setBin(child);
await evaluate(`(() => {
const original = Blob.prototype.arrayBuffer;
let release;
const pending = new Promise(resolve => {release = resolve;});
const gate = window.__importBinGate = {hits: 0, release, original};
Blob.prototype.arrayBuffer = function () {gate.hits++; return pending.then(() => original.call(this));};
window.__pendingBinImport = ${importExpression("pending.mp4")};
})()`);
try {
const deadline = Date.now() + timeoutMs;
const pollMs = 100;
for (;;) {
if (await evaluate("window.__importBinGate.hits > 0")) break;
if (Date.now() >= deadline) throw new Error("Read-delay probe did not intercept any Blob reads");
await new Promise(resolve => setTimeout(resolve, pollMs));
}
report.delayedReadHits = await evaluate("window.__importBinGate.hits");
await setBin(parent);
await evaluate("window.__importBinGate.release(); window.__pendingBinImport");
await check("destinationCapturedBeforeRead", child, "pending.mp4");
} finally {
await evaluate("Blob.prototype.arrayBuffer = window.__importBinGate.original; window.__importBinGate.release(); delete window.__importBinGate; delete window.__pendingBinImport; delete window.__importBinFixture");
}
report.ok = Object.values(report.gates).every(Boolean);
return report;
}
@@ -0,0 +1,78 @@
// #194 regression: same-name/same-size imports must retain independent sources across OPFS recovery.
// evaluate(expression) awaits a browser expression; navigate(url) waits for FilmCraft readiness. Generated PCM16 mono WAVs only; no binary fixtures.
import { createHash } from "node:crypto";
function tone(frequency, seconds) {
// RIFF/WAVE PCM16 mono: 44-byte header + 2 bytes/sample; fixture matches #194 (48 kHz, amplitude 12000).
const rate = 48000, bytesPerSample = 2, samples = rate * seconds;
const b = Buffer.alloc(44 + samples * bytesPerSample);
b.write("RIFF", 0); b.writeUInt32LE(b.length - 8, 4); b.write("WAVEfmt ", 8);
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * bytesPerSample, 28);
b.writeUInt16LE(bytesPerSample, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(samples * bytesPerSample, 40);
for (let i = 0; i < samples; i++) b.writeInt16LE(Math.round(12000 * Math.sin(2 * Math.PI * frequency * i / rate)), 44 + bytesPerSample * i);
return b;
}
export async function checkImportCollision({ evaluate, navigate, url, timeoutMs = 120000 }) {
const fixtures = [tone(440, 2), tone(880, 2)];
const hashes = fixtures.map(b => createHash("sha256").update(b).digest("hex"));
const report = { hashes, fixtureBytes: fixtures.map(b => b.length), gates: {} };
const hash = expression => evaluate(`(async () => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", ${expression})), b => b.toString(16).padStart(2, "0")).join(""))()`);
const readHash = path => hash(`await filmcraft.readFile(${JSON.stringify(path)})`);
const importFile = index => evaluate(`filmcraft.importFiles([new File([Uint8Array.from(atob(${JSON.stringify(fixtures[index].toString("base64"))}), c => c.charCodeAt(0))], "collision.wav", {type: "audio/wav"})])`);
await evaluate('filmcraft.execute("file.newProject", {name: "Import collision"})');
const a = await importFile(0); report.a = a;
if (a.errors?.length || !a.items?.length) throw new Error("baseline import failed: " + JSON.stringify(a));
report.initialHash = await readHash(a.paths[0]);
report.gates.healthyInitial = report.initialHash === hashes[0];
await evaluate(`filmcraft.execute("file.newSequence", {name: "Original A", fromItem: ${a.items[0]}})`);
const b = await importFile(1); report.b = b;
if (b.errors?.length || !b.items?.length) throw new Error("second import failed: " + JSON.stringify(b));
report.afterBHash = await readHash(a.paths[0]);
report.gates.distinctPaths = a.paths[0] !== b.paths[0];
report.gates.originalPreserved = report.afterBHash === hashes[0];
report.beforeProject = await evaluate('filmcraft.execute("project.inspect")');
const revision = report.beforeProject.revision;
const deadline = Date.now() + timeoutMs;
const persistencePollMs = 100;
for (;;) {
const snapshot = await evaluate(`(async () => {
try {
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("recovery");
const read = async name => JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text());
return {meta: await read("meta.json"), project: await read("snapshot.fcproj")};
} catch (e) {return {error: String(e)};}
})()`);
report.recoveryMeta = snapshot.meta;
if (snapshot.meta?.dirty && snapshot.meta.revision === revision && JSON.stringify(snapshot.project).includes("Original A")) break;
if (Date.now() >= deadline) throw new Error("Recovery persistence gate failed: " + JSON.stringify(snapshot));
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
}
// Media writes run separately from the snapshot queue; gate their actual bytes before navigation.
for (;;) {
report.opfs = await evaluate(`(async () => {
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("media");
const files = [];
for await (const [name, handle] of dir.entries()) {
const bytes = await (await handle.getFile()).arrayBuffer();
files.push({name, hash: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), b => b.toString(16).padStart(2, "0")).join("")});
}
return files;
})()`);
// Before the fix, A and B intentionally share the same path; qualify the last write there.
const expected = new Map([[a.paths[0], hashes[0]], [b.paths[0], hashes[1]]]);
if ([...expected].every(([path, digest]) => report.opfs.some(f => f.name === path.slice("/files/".length) && f.hash === digest))) break;
if (Date.now() >= deadline) throw new Error("Imported media did not reach OPFS: " + JSON.stringify(report.opfs));
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
}
const recoveryUrl = new URL(url);
recoveryUrl.searchParams.delete("fresh"); recoveryUrl.searchParams.delete("norecover");
await navigate(recoveryUrl.href);
report.recovered = await evaluate('filmcraft.execute("project.inspect")');
report.afterReload = {a: await readHash(a.paths[0]), b: await readHash(b.paths[0])};
report.gates.recoveredProject = JSON.stringify(report.recovered).includes("Original A");
report.gates.recoveredSources = report.afterReload.a === hashes[0] && report.afterReload.b === hashes[1];
report.ok = Object.values(report.gates).every(Boolean);
return report;
}
@@ -0,0 +1,77 @@
//! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the
//! crate is wasm32-only.
#[path = "../src/recovery_policy.rs"]
mod recovery_policy;
use recovery_policy::{Action, INTERVAL_S, Policy};
/// #197: a snapshot write that fails is retried after the interval without another edit.
#[test]
fn failed_snapshot_write_is_retried_without_another_edit() {
let mut p = Policy::default();
assert_eq!(p.tick(0.0, 3, 0), Action::WriteSnapshot(3));
p.written(3, false, 0.1);
// not immediately (no spinning on a failing disk)…
assert!(matches!(p.tick(1.0, 3, 0), Action::WakeAfter(_)));
// …but once the interval has passed, with the revision unchanged
assert_eq!(p.tick(0.1 + INTERVAL_S, 3, 0), Action::WriteSnapshot(3));
p.written(3, true, 5.2);
assert_eq!(p.tick(20.0, 3, 0), Action::Nothing);
}
/// A failure reported for an older revision doesn't make the newer one be written again.
#[test]
fn stale_failure_is_ignored() {
let mut p = Policy::default();
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
assert_eq!(p.tick(INTERVAL_S, 2, 0), Action::WriteSnapshot(2));
p.written(1, false, INTERVAL_S);
assert_eq!(p.tick(3.0 * INTERVAL_S, 2, 0), Action::Nothing);
}
/// #193: a clean session (started with `?norecover` / `?fresh`, so the snapshot left by an
/// earlier session was not reopened) never marks that snapshot clean.
#[test]
fn clean_start_leaves_an_unconsumed_snapshot_dirty() {
let mut p = Policy::default();
for t in 0..100 {
assert_eq!(p.tick(t as f64, 0, 0), Action::Nothing);
}
}
/// The snapshot this session wrote is retired once the project is saved.
#[test]
fn saving_marks_own_snapshot_clean() {
let mut p = Policy::default();
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
p.written(1, true, 0.1);
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
assert_eq!(p.tick(1.1, 1, 1), Action::Nothing);
p.cleaned(true, 1.2);
assert_eq!(p.tick(30.0, 1, 1), Action::Nothing);
}
/// A failed clean write is retried after the interval.
#[test]
fn failed_clean_write_is_retried() {
let mut p = Policy::default();
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
p.written(1, true, 0.1);
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
p.cleaned(false, 1.1);
assert!(matches!(p.tick(2.0, 1, 1), Action::WakeAfter(_)));
assert_eq!(p.tick(1.1 + INTERVAL_S, 1, 1), Action::WriteClean);
}
/// An edit made while the clean write is in flight keeps the meta dirty after it lands.
#[test]
fn edit_during_clean_write_keeps_meta_owned() {
let mut p = Policy::default();
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
assert_eq!(p.tick(10.0, 1, 1), Action::WriteClean);
assert_eq!(p.tick(20.0, 2, 1), Action::WriteSnapshot(2));
p.cleaned(true, 20.1);
// saved again: the newer snapshot must be retired too
assert_eq!(p.tick(30.0, 2, 2), Action::WriteClean);
}
+15
View File
@@ -13,6 +13,8 @@ import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { checkImportBins } from "./import-bins.mjs";
import { checkImportCollision } from "./import-collision.mjs";
const arg = (k, d) => {
const i = process.argv.indexOf(`--${k}`);
@@ -162,6 +164,19 @@ try {
await sleep(500);
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
report.steps.modes = { screenshot: await shot("05-modes") };
report.steps.importBins = await checkImportBins({evaluate: js, media});
if (!report.steps.importBins.ok) throw new Error("import bin regression: " + JSON.stringify(report.steps.importBins.gates));
report.steps.importCollision = await checkImportCollision({
evaluate: js,
url,
navigate: async (nextUrl) => {
await send("Page.navigate", { url: nextUrl });
await until("!!(window.filmcraftLoad && (window.filmcraftLoad.readyMs || window.filmcraftLoad.error))", 120000);
const load = await js("window.filmcraftLoad");
if (load.error) throw new Error(load.error);
},
});
if (!report.steps.importCollision.ok) throw new Error("import collision regression: " + JSON.stringify(report.steps.importCollision.gates));
// still alive? (a panicked app never answers)
await Promise.race([js("filmcraft.inspect().then(() => true)"), sleep(5000).then(() => { throw new Error("app stopped answering"); })]);
const fatal = logs.filter((l) => /panicked at|RuntimeError|EXCEPTION/.test(l));
+23
View File
@@ -14,17 +14,35 @@ workspace = true
default = []
# Speech-to-text (Text panel ▸ Transcribe) with Whisper on the CPU, and model downloads.
whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]
# Natural Text to Speech voices (Kokoro-82M on the CPU, downloaded on first use).
neural-voices = ["filmcraft-engine/neural-voices"]
[dependencies]
filmcraft-engine = { workspace = true }
filmcraft-export = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-time = { workspace = true }
filmcraft-gpu = { workspace = true }
filmcraft-ui-egui = { workspace = true }
filmcraft-platform = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
cpal = "0.16"
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
sys-locale = "0.3"
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
open = "5"
[dev-dependencies]
glob = "0.3"
# graphics.rs: enumerate the real adapters in a test.
pollster = "0.4"
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"
@@ -34,6 +52,11 @@ objc2 = "0.6"
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
# portal file dialogs (rfd) and accesskit already use.
zbus = "5.19"
# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
+188
View File
@@ -0,0 +1,188 @@
//! The system's light or dark appearance for Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with
//! system, on Linux desktops where winit reports none (Wayland compositors send no theme).
//!
//! The XDG desktop portal is read once (`org.freedesktop.portal.Settings.Read` of
//! `org.freedesktop.appearance` / `color-scheme`), then its `SettingChanged` signal is followed on a
//! worker thread, which wakes the UI only when the value changes. There is no polling. Without a
//! portal answer, `gsettings` runs once at start-up, by absolute path. Elsewhere the host returns no
//! service and the UI uses what egui reports.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
/// The `u32` inside a portal reply. `Read` returns a variant, and some portals nest another
/// variant inside it.
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's `color-scheme`: 0 no preference, 1 prefer dark, 2 prefer light.
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// One-shot fallback for desktops without the settings portal: runs once, by absolute path.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Publish initial and subsequent answers alike: the initial Read may finish after the UI's
/// bounded startup wait, so it must also wake an already-created context.
#[cfg(target_os = "linux")]
fn publish(value: &std::sync::atomic::AtomicU8, code: u8, repaint: impl FnOnce()) {
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code {
repaint();
}
}
/// The host hook (`HostHooks::system_theme`), or `None` where egui's own report is used.
pub fn service() -> Option<filmcraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, OnceLock};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
let worker = move || {
let proxy = portal_proxy();
// Subscribe before Read so a change during startup cannot be missed.
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
publish(&worker_value, code_of(initial), || {
if let Some(ctx) = worker_wake.get() {
ctx.request_repaint();
}
});
let _ = ready.send(());
let Some(signals) = signals else { return };
// Sleeps in the signal iterator: no polling.
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
publish(&worker_value, code, || {
if let Some(ctx) = worker_wake.get() {
ctx.request_repaint();
}
});
}
};
let spawned = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(worker)).is_err() {
log::warn!("the system appearance watcher stopped; Sync with system keeps the last value");
}
});
if spawned.is_err() {
return None;
}
// The first reading, so the first frame already shows the right theme (bounded wait).
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx: &egui::Context| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn initial_and_signal_answers_repaint_only_on_changes() {
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
let repaints = AtomicUsize::new(0);
let value = AtomicU8::new(0);
let repaint = || {
repaints.fetch_add(1, Ordering::Relaxed);
};
// A late initial Read takes the same publication path as later signals.
super::publish(&value, 2, repaint);
assert_eq!(repaints.load(Ordering::Relaxed), 1);
super::publish(&value, 2, repaint);
assert_eq!(repaints.load(Ordering::Relaxed), 1);
super::publish(&value, 1, repaint);
assert_eq!(repaints.load(Ordering::Relaxed), 2);
assert_eq!(value.load(Ordering::Relaxed), 1);
}
#[cfg(target_os = "linux")]
#[test]
fn portal_and_gsettings_values_decode() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
let plain = zbus::zvariant::OwnedValue::from(1u32);
assert_eq!(super::portal_code(plain), Some(1));
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
assert_eq!(super::portal_code(wrong), None);
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
assert_eq!(super::parse_gtk_scheme(""), None);
assert_eq!((super::code_of(None), super::code_of(Some(egui::Theme::Dark)), super::code_of(Some(egui::Theme::Light))), (0, 1, 2));
}
}
+325
View File
@@ -0,0 +1,325 @@
//! The desktop app's command line (`filmcraft --help`).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// `filmcraft --help`.
pub const USAGE: &str = "\
Usage: filmcraft [options] [project.fcproj | media files...]
Options:
--control <port> start the localhost JSON-lines control server
(also: FILMCRAFT_CONTROL_PORT)
--control-input-only accept widget input only from the control server
(requires --control or FILMCRAFT_CONTROL_PORT)
--demo open the demo project
--empty start with an empty project
--recover recover the newest unsaved changes without asking
--no-recover start without asking about unsaved changes
--data-dir <dir> folder for auto-save, crash recovery and logs
(also: FILMCRAFT_DATA_DIR)
--graphics <mode> start with this graphics mode and keep it: default,
dx12 (Windows), software (Windows, Linux);
`--graphics default` undoes an automatic fallback
--version print the version and exit
-h, --help print this help and exit
";
/// What the command line asks for.
#[derive(Debug, PartialEq)]
pub enum Cli {
Run(Launch),
Help,
Version,
}
/// Options of a normal start.
#[derive(Clone, Debug, PartialEq)]
pub struct Launch {
pub control_port: Option<u16>,
pub control_input_only: bool,
/// A project and / or media files to open.
pub files: Vec<String>,
pub demo: bool,
/// `--demo` / `--empty` given: skip Settings ▸ General ▸ At Startup.
pub startup_flag: bool,
pub recover: Option<bool>,
pub data_dir: Option<std::path::PathBuf>,
/// `--graphics <mode>`: start with this graphics mode and keep it (see `graphics.rs`).
pub graphics: Option<crate::graphics::Mode>,
}
/// Parse the arguments after the program name. `env_control_port` is the value of
/// `FILMCRAFT_CONTROL_PORT`, used when `--control` is not given (an empty value counts as unset).
///
/// An option we don't know, or one without a usable value, is an error: opening a window for a
/// mistyped flag helps nobody, and starting without the control server that was asked for leaves
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
/// too, and for a port from the environment exactly as for one from `--control`.
///
/// The arguments come from `std::env::args_os`: not every argument is Unicode (a Linux file name
/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily
/// (its import then reports it missing); `--data-dir` keeps the folder exactly.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = OsString>) -> Result<Cli, String> {
let mut l = Launch {
control_port: None,
control_input_only: false,
files: Vec::new(),
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None,
};
let (mut help, mut version) = (false, false);
let mut args = args.into_iter();
while let Some(raw) = args.next() {
let a = raw.to_string_lossy();
match a.as_ref() {
"-h" | "--help" => help = true,
"--version" => version = true,
"--control" => {
let port = args.next().ok_or("--control needs a port number")?;
let port = port.to_string_lossy();
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
}
"--demo" => (l.demo, l.startup_flag) = (true, true),
"--control-input-only" => l.control_input_only = true,
"--empty" => (l.demo, l.startup_flag) = (false, true),
"--recover" => l.recover = Some(true),
"--no-recover" => l.recover = Some(false),
"--data-dir" => l.data_dir = Some(args.next().map(PathBuf::from).ok_or("--data-dir needs a folder")?),
"--graphics" => {
let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?;
l.graphics = Some(graphics_mode(&name.to_string_lossy())?);
}
// everything after `--` is a file, whatever it looks like
"--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())),
// macOS adds a process serial number when it launches an app bundle
_ if a.starts_with("-psn_") => {}
_ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")),
_ => l.files.push(a.into_owned()),
}
}
if help {
return Ok(Cli::Help);
}
if version {
return Ok(Cli::Version);
}
if l.control_port.is_none()
&& let Some(port) = env_control_port.as_deref().map(str::trim).filter(|p| !p.is_empty())
{
l.control_port = Some(port.parse().map_err(|_| format!("FILMCRAFT_CONTROL_PORT needs a port number, got `{port}`"))?);
}
if l.control_input_only && l.control_port.is_none() {
return Err("--control-input-only requires --control or FILMCRAFT_CONTROL_PORT".into());
}
Ok(Cli::Run(l))
}
/// Whether a launch file is a FilmCraft project. The extension's case does not matter: Windows file
/// associations and the Linux MIME glob start the app for `CUT.FCPROJ` too (#850).
pub fn is_project(path: &str) -> bool {
Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("fcproj"))
}
/// `path` made absolute against the working directory, so a project or media file named relative
/// to a terminal's folder is still found once the app runs from another one (Recent Projects, the
/// media paths saved in the project). Unlike `canonicalize`, `std::path::absolute` keeps symlinks
/// and adds no Windows verbatim `\\?\` prefix. A path it cannot resolve is kept as given.
pub fn absolute(path: &str) -> String {
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
}
/// The launch files as the project to open (the first one, see [`is_project`]) and the media files
/// to import (every file that is not a project), all made [`absolute`].
pub fn project_and_media(files: &[String]) -> (Option<String>, Vec<String>) {
let project = files.iter().find(|f| is_project(f)).map(|f| absolute(f));
let media = files.iter().filter(|f| !is_project(f)).map(|f| absolute(f)).collect();
(project, media)
}
/// A `--graphics` value: one of this platform's modes.
fn graphics_mode(name: &str) -> Result<crate::graphics::Mode, String> {
use crate::graphics::{LADDER, Mode};
let here = || LADDER.iter().map(|m| m.name()).collect::<Vec<_>>().join(", ");
let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?;
if !LADDER.contains(&mode) {
return Err(format!("--graphics {name} is not available on this platform (use one of {})", here()));
}
Ok(mode)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_env(env: Option<&str>, args: &[&str]) -> Result<Cli, String> {
parse(env.map(str::to_string), args.iter().map(OsString::from))
}
fn parse_args(args: &[&str]) -> Result<Cli, String> {
parse_env(None, args)
}
fn launch(args: &[&str]) -> Launch {
match parse_args(args) {
Ok(Cli::Run(l)) => l,
other => panic!("{args:?} should start the app, got {other:?}"),
}
}
/// `filmcraft --help` used to open the app with "--help" as a media file to import.
#[test]
fn help_and_version_do_not_start_the_app() {
assert_eq!(parse_args(&["--help"]), Ok(Cli::Help));
assert_eq!(parse_args(&["-h"]), Ok(Cli::Help));
assert_eq!(parse_args(&["--empty", "--help"]), Ok(Cli::Help));
assert_eq!(parse_args(&["--version"]), Ok(Cli::Version));
assert_eq!(parse_args(&["--version", "--help"]), Ok(Cli::Help));
assert_eq!(parse_args(&["a.mp4", "--version"]), Ok(Cli::Version));
for flag in ["--control", "--demo", "--empty", "--recover", "--no-recover", "--data-dir", "--version", "--help"] {
assert!(USAGE.contains(flag), "usage lists {flag}");
}
}
#[test]
fn unknown_options_are_errors() {
assert_eq!(parse_args(&["--frobnicate"]), Err("unknown option `--frobnicate`".to_string()));
assert!(parse_args(&["a.mp4", "-x"]).is_err());
assert!(parse_args(&["--Help"]).is_err());
assert!(parse_args(&["--control"]).is_err(), "missing port");
assert!(parse_args(&["--control", "abc"]).unwrap_err().contains("abc"));
assert!(parse_args(&["--control", "70000"]).is_err(), "not a port");
assert!(parse_args(&["--data-dir"]).is_err(), "missing folder");
// the whole line is checked, whatever comes first
assert_eq!(parse_args(&["--version", "--bogus"]), Err("unknown option `--bogus`".to_string()));
assert_eq!(parse_args(&["--bogus", "--version"]), Err("unknown option `--bogus`".to_string()));
assert_eq!(parse_args(&["--help", "--bogus"]), Err("unknown option `--bogus`".to_string()));
assert!(parse_args(&["--help", "--control"]).is_err());
}
/// A bad `FILMCRAFT_CONTROL_PORT` used to start the app without the control server, silently.
#[test]
fn a_bad_port_in_the_environment_is_an_error_like_a_bad_control_flag() {
for bad in ["abc", "70000", "-1", "98 76", "9876x", "\u{fffd}"] {
let e = parse_env(Some(bad), &[]).unwrap_err();
assert!(e.contains("FILMCRAFT_CONTROL_PORT") && e.contains(bad), "{bad}: {e}");
assert!(parse_env(Some(bad), &["--empty", "a.mp4"]).is_err(), "{bad}");
// the flag wins, so the environment's value is not looked at; nor for --help / --version
assert_eq!(parse_env(Some(bad), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true), "{bad}");
assert_eq!(parse_env(Some(bad), &["--help"]), Ok(Cli::Help));
assert_eq!(parse_env(Some(bad), &["--version"]), Ok(Cli::Version));
}
// unset or empty: no control server
assert_eq!(launch(&[]).control_port, None);
assert_eq!(parse_env(Some(""), &[]), Ok(Cli::Run(launch(&[]))));
assert_eq!(parse_env(Some(" "), &[]), Ok(Cli::Run(launch(&[]))));
// a good one is the default
assert_eq!(parse_env(Some("1234"), &[]), Ok(Cli::Run(Launch { control_port: Some(1234), ..launch(&[]) })));
assert_eq!(parse_env(Some("1234"), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true));
}
#[test]
fn known_options_and_files_parse_as_before() {
let none = launch(&[]);
assert_eq!(
none,
Launch {
control_port: None,
control_input_only: false,
files: vec![],
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None
}
);
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
assert_eq!(l.control_port, Some(9876));
assert_eq!((l.demo, l.startup_flag, l.recover), (false, true, Some(false)));
assert_eq!(l.data_dir, Some(std::path::PathBuf::from("/tmp/fc")));
assert_eq!(l.files, ["cut.fcproj", "a.mp4"]);
assert_eq!((launch(&["--demo"]).demo, launch(&["--demo"]).startup_flag, launch(&["--recover"]).recover), (true, true, Some(true)));
// `--` ends the options; a lone `-` and macOS' process serial number are not options
assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]);
assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]);
}
/// `std::env::args` panics on an argument that is not Unicode, so such a file name (Latin-1 on
/// Linux) stopped the app before its window opened.
#[cfg(unix)]
#[test]
fn arguments_that_are_not_unicode_still_start_the_app() {
use std::os::unix::ffi::OsStringExt;
let dir = OsString::from_vec(b"/tmp/d\xe9".to_vec());
let file = OsString::from_vec(b"caf\xe9.mov".to_vec());
let Ok(Cli::Run(l)) = parse(None, [OsString::from("--data-dir"), dir.clone(), file.clone(), OsString::from("--"), file]) else {
panic!("should start the app");
};
assert_eq!(l.data_dir, Some(PathBuf::from(dir)), "the data folder is kept exactly");
assert_eq!(l.files, ["caf\u{fffd}.mov", "caf\u{fffd}.mov"]);
}
/// Windows file associations and the Linux MIME glob match `CUT.FCPROJ` too; it used to be
/// imported as a media file instead of opened (#850).
#[test]
fn a_project_is_recognised_whatever_the_case_of_its_extension() {
for p in ["cut.fcproj", "CUT.FCPROJ", "Cut.FcProj", "/edits/a b.fcproj", r"C:\Edits\CUT.FCPROJ"] {
assert!(is_project(p), "{p}");
}
for p in ["cut.mp4", "fcproj", ".fcproj", "a.fcproj.mov", "a.fcprojx", ""] {
assert!(!is_project(p), "{p}");
}
}
/// Relative launch paths were opened and imported as given, so the project's media paths and
/// Recent Projects pointed nowhere once the app ran from another folder (#850).
#[test]
fn launch_files_are_made_absolute_and_split_case_insensitively() {
let cwd = std::env::current_dir().unwrap();
let nested = Path::new("clips").join("a.mp4");
let already = cwd.join("b.wav").to_string_lossy().into_owned();
let files = ["Cut.FCPROJ".to_string(), nested.to_string_lossy().into_owned(), "other.fcproj".to_string(), already.clone()];
let (project, media) = project_and_media(&files);
assert_eq!(project, Some(cwd.join("Cut.FCPROJ").to_string_lossy().into_owned()));
assert_eq!(media, [cwd.join(&nested).to_string_lossy().into_owned(), already]);
for p in media.iter().chain(&project) {
assert!(Path::new(p).is_absolute() && !p.starts_with(r"\\?\"), "{p}");
}
assert_eq!(project_and_media(&[]), (None, vec![]));
// nothing to resolve against: kept as given rather than dropped
assert_eq!(absolute(""), "");
}
#[test]
fn isolated_input_is_explicit_and_requires_a_control_server() {
assert!(!launch(&["--control", "9876"]).control_input_only);
assert!(parse_args(&["--control-input-only"]).is_err());
assert!(launch(&["--control-input-only", "--control", "9876"]).control_input_only);
assert!(matches!(parse_env(Some("9876"), &["--control-input-only"]), Ok(Cli::Run(Launch { control_input_only: true, control_port: Some(9876), .. }))));
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
assert!(USAGE.contains("--control-input-only"));
}
#[test]
fn graphics_picks_one_of_this_platforms_modes() {
use crate::graphics::{LADDER, Mode};
assert_eq!(launch(&[]).graphics, None);
assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default));
for mode in LADDER {
assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode));
}
// a mode this platform has not is refused, not ignored
for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) {
assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available"));
}
assert!(parse_args(&["--graphics"]).is_err(), "missing mode");
assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan"));
assert!(parse_args(&["--graphics", "DEFAULT"]).is_err());
assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err());
assert!(USAGE.contains("--graphics"));
}
}
+491 -38
View File
@@ -1,20 +1,63 @@
//! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host
//! ("Device Class"), the output device, the I/O buffer size and the sample rate.
//!
//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which
//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by
//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound
//! resumes where it stopped instead of playback going silent (#840).
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex, PoisonError, TryLockError};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use filmcraft_engine::settings::AudioHardwarePrefs;
use filmcraft_ui_egui::{AudioDevices, AudioOut};
/// The playback mixer callback handed to [`AudioOut::start`].
type Fill = Box<dyn FnMut(&mut [f32], usize) + Send>;
pub struct CpalOut {
stream: Option<cpal::Stream>,
/// The running playback's mixer, shared with the stream so a restarted stream continues it.
fill: Option<Arc<Mutex<Fill>>>,
played: Arc<AtomicU64>,
health: Arc<Health>,
watchdog: Watchdog,
/// Time base for the watchdog.
epoch: Instant,
rate: u32,
channels: u16,
hw: AudioHardwarePrefs,
/// Sequence sample rate for "Attempt to force hardware to document sample rate".
document_rate: Option<u32>,
/// Status-bar note: the host's default device could not be opened and a fallback device is
/// in use, or no output device was found (playing without sound).
note: Option<Note>,
/// The fallback picked when the default device could not be opened, as (host, device) names,
/// so later lookups (`devices`, `configure`, `start`) reuse it instead of probing every PCM.
fallback: Mutex<Option<(String, String)>>,
}
/// A hardware note for the status bar, translated when it is shown.
#[derive(Clone, Debug, PartialEq)]
enum Note {
/// The default output device cannot be opened; this named device is used instead.
Fallback(String),
/// No output device at all: playback runs without sound.
NoDevice,
}
impl Note {
fn text(&self) -> String {
use filmcraft_ui_egui::i18n::{fmt, t};
match self {
Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]),
Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(),
}
}
}
/// The host named in the settings (empty or unknown = the default host).
@@ -28,48 +71,151 @@ fn host(name: &str) -> cpal::Host {
cpal::default_host()
}
/// The output device named in the settings (empty or missing = the host's default).
fn output_device(h: &cpal::Host, name: &str) -> Option<cpal::Device> {
/// The output device the settings ask for, with a status-bar note when a fallback is in use.
struct DeviceChoice {
device: cpal::Device,
/// The host's default device could not be opened: this device is the fallback.
fallback: Option<Note>,
}
/// Fallback order when the default device cannot be opened: the sound-server bridges first
/// (`pipewire`, `pulse`: they share the card with every other app), then other named PCMs, and
/// raw `hw:`/`plughw:` devices last (opening one takes the card exclusively and silences the
/// rest of the desktop).
fn fallback_rank(name: &str) -> u8 {
let n = name.to_ascii_lowercase();
if n == "pipewire" || n.starts_with("pipewire:") {
0
} else if n == "pulse" || n.starts_with("pulse:") {
1
} else if n.starts_with("hw:") || n.starts_with("plughw:") {
3
} else {
2
}
}
/// The output device named in the settings (empty or missing = the host's default). When the
/// default cannot be opened — a broken ALSA `default` (missing `99-pipewire-default.conf`,
/// #23/#106) — a fallback that can, so the app is not silently mute. The fallback list is probed
/// only after the default fails, in [`fallback_rank`] order, and the pick is cached in `cache`.
fn output_device(h: &cpal::Host, name: &str, cache: &Mutex<Option<(String, String)>>) -> Option<DeviceChoice> {
if !name.is_empty()
&& let Ok(mut devs) = h.output_devices()
&& let Some(d) = devs.find(|d| d.name().is_ok_and(|n| n == name))
{
return Some(d);
return Some(DeviceChoice { device: d, fallback: None });
}
h.default_output_device()
if let Some(d) = h.default_output_device()
&& d.default_output_config().is_ok()
{
return Some(DeviceChoice { device: d, fallback: None });
}
let host_name = h.id().name().to_string();
let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
let mut devs: Vec<(String, cpal::Device)> = h.output_devices().ok()?.filter_map(|d| d.name().ok().map(|n| (n, d))).collect();
if let Some((cached_host, cached)) = cache.as_ref()
&& *cached_host == host_name
&& let Some(i) = devs.iter().position(|(n, _)| n == cached)
{
let (found, d) = devs.swap_remove(i);
return Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) });
}
devs.sort_by_key(|(n, _)| fallback_rank(n));
let (found, d) = devs.into_iter().find(|(_, d)| d.default_output_config().is_ok())?;
log::warn!("the default audio output device cannot be opened; falling back to '{found}'");
*cache = Some((host_name, found.clone()));
Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) })
}
impl CpalOut {
pub fn new() -> Option<Self> {
pub fn new() -> Self {
let host = cpal::default_host();
let dev = host.default_output_device()?;
let cfg = dev.default_output_config().ok()?;
Some(Self { stream: None, played: Arc::new(AtomicU64::new(0)), rate: cfg.sample_rate().0, hw: AudioHardwarePrefs::default(), document_rate: None })
let fallback = Mutex::new(None);
let choice = output_device(&host, "", &fallback);
let cfg = choice.as_ref().and_then(|c| c.device.default_output_config().ok());
let note = match choice {
Some(c) => c.fallback,
None => {
log::warn!("no audio output device found");
Some(Note::NoDevice)
}
};
Self {
stream: None,
fill: None,
played: Arc::new(AtomicU64::new(0)),
health: Arc::new(Health::default()),
watchdog: Watchdog::default(),
epoch: Instant::now(),
rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0),
channels: cfg.as_ref().map_or(2, |c| c.channels()),
hw: AudioHardwarePrefs::default(),
document_rate: None,
note,
fallback,
}
}
/// The stream configuration the settings ask for, falling back to the device default.
fn config(&self, dev: &cpal::Device) -> Result<cpal::SupportedStreamConfig, String> {
let default = dev.default_output_config().map_err(|e| e.to_string())?;
let want = if self.hw.force_document_rate { self.document_rate.unwrap_or(self.hw.sample_rate) } else { self.hw.sample_rate };
let exact = dev.supported_output_configs().ok().and_then(|mut it| {
it.find(|c| {
c.sample_format() == cpal::SampleFormat::F32
&& c.channels() == default.channels()
&& (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&want)
})
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(want)))
let exact = dev.supported_output_configs().ok().and_then(|it| {
it.filter(|c| c.channels() > 0 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&want))
.max_by_key(|c| {
(c.channels() == default.channels(), c.sample_format() == cpal::SampleFormat::F32, c.sample_format() == default.sample_format())
})
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(want)))
});
Ok(exact.unwrap_or(default))
}
}
impl AudioOut for CpalOut {
fn start(&mut self, mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
self.stop();
fn converted_stream<T>(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc<Mutex<Fill>>) -> Result<cpal::Stream, cpal::BuildStreamError>
where
T: cpal::SizedSample + cpal::FromSample<f32>,
{
let channels = usize::from(config.channels);
let played = self.played.clone();
let health = self.health.clone();
let error_health = health.clone();
let mut scratch = Vec::new();
dev.build_output_stream(
config,
move |buf: &mut [T], _| {
// Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread.
if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() {
buf.fill(T::EQUILIBRIUM);
if !health.failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: unable to allocate the audio conversion buffer");
}
return;
}
scratch.resize(buf.len(), 0.0);
render(&fill, &mut scratch, channels, &played, &health);
convert_samples(&scratch, buf);
},
move |e| stream_error(&error_health, e),
None,
)
}
/// Open the configured output device and play `fill` on it. `expect` is the (sample rate,
/// channels) a restarted stream must keep: the mixer was set up for them.
fn open(&mut self, fill: Arc<Mutex<Fill>>, expect: Option<(u32, u16)>) -> Result<(), String> {
let host = host(&self.hw.device_class);
let dev = output_device(&host, &self.hw.default_output).ok_or("no output device")?;
let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?;
self.note = fallback;
let cfg = self.config(&dev)?;
let channels = cfg.channels() as usize;
if channels == 0 || cfg.sample_rate().0 == 0 {
return Err("the output device returned an invalid channel count or sample rate".into());
}
if let Some((rate, ch)) = expect
&& (rate, ch) != (cfg.sample_rate().0, cfg.channels())
{
return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels()));
}
let mut config: cpal::StreamConfig = cfg.clone().into();
if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size()
&& (*min..=*max).contains(&self.hw.buffer_size)
@@ -77,40 +223,216 @@ impl AudioOut for CpalOut {
config.buffer_size = cpal::BufferSize::Fixed(self.hw.buffer_size);
}
self.rate = cfg.sample_rate().0;
self.played.store(0, Ordering::SeqCst);
let played = self.played.clone();
let err = |e| eprintln!("filmcraft: audio stream error: {e}");
self.channels = cfg.channels();
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => dev.build_output_stream(
&config,
move |buf: &mut [f32], _| {
fill(buf, channels);
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
},
err,
None,
),
cpal::SampleFormat::F32 => {
let played = self.played.clone();
let health = self.health.clone();
let error_health = health.clone();
dev.build_output_stream(
&config,
move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health),
move |e| stream_error(&error_health, e),
None,
)
}
cpal::SampleFormat::I8 => self.converted_stream::<i8>(&dev, &config, fill),
cpal::SampleFormat::I16 => self.converted_stream::<i16>(&dev, &config, fill),
cpal::SampleFormat::I24 => self.converted_stream::<cpal::I24>(&dev, &config, fill),
cpal::SampleFormat::I32 => self.converted_stream::<i32>(&dev, &config, fill),
cpal::SampleFormat::I64 => self.converted_stream::<i64>(&dev, &config, fill),
cpal::SampleFormat::U8 => self.converted_stream::<u8>(&dev, &config, fill),
cpal::SampleFormat::U16 => self.converted_stream::<u16>(&dev, &config, fill),
cpal::SampleFormat::U32 => self.converted_stream::<u32>(&dev, &config, fill),
cpal::SampleFormat::U64 => self.converted_stream::<u64>(&dev, &config, fill),
cpal::SampleFormat::F64 => self.converted_stream::<f64>(&dev, &config, fill),
other => return Err(format!("unsupported sample format {other:?}")),
}
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
self.stream = Some(stream);
Ok(())
}
/// Replace a dropped stream with a new one on the same mixer, clock and format.
fn restart(&mut self) -> Result<(), String> {
let fill = self.fill.clone().ok_or("nothing is playing")?;
let expect = (self.rate, self.channels);
// The old stream (and its audio thread) goes first: only one stream ever runs the mixer.
self.stream = None;
self.health.clear_errors();
self.open(fill, Some(expect))
}
}
/// What the audio thread reports to the UI thread.
#[derive(Default)]
struct Health {
/// The device went away (unplugged): restart the stream at once.
lost: AtomicBool,
/// A backend error since the last check (cpal keeps the stream alive after these): restart
/// only if the stream also stopped consuming samples.
errored: AtomicBool,
/// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out.
/// [`AudioOut::played_frames`] reports `None` and playback goes on without sound.
failed: AtomicBool,
/// The first error of a stream has been printed.
reported: AtomicBool,
}
impl Health {
fn clear_errors(&self) {
self.lost.store(false, Ordering::SeqCst);
self.errored.store(false, Ordering::SeqCst);
self.reported.store(false, Ordering::SeqCst);
}
}
/// How long a stream that reported an error may go without consuming samples before it is
/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock.
const STALL_RESTART: Duration = Duration::from_millis(300);
/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up.
const MAX_RESTARTS: usize = 3;
const RESTART_WINDOW: Duration = Duration::from_secs(10);
/// What [`Watchdog::check`] decides about the running stream.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Verdict {
Healthy,
Restart,
GiveUp,
}
/// Decides when a stream that reported errors is restarted (UI thread, every frame).
#[derive(Default)]
struct Watchdog {
/// Played-frame count last seen, and when it last changed.
seen: (u64, Duration),
/// An error was reported and the stream has not consumed samples since.
pending_error: bool,
/// When recent restarts happened (at most [`MAX_RESTARTS`] kept).
restarts: Vec<Duration>,
}
impl Watchdog {
fn reset(&mut self, now: Duration) {
*self = Self { seen: (0, now), ..Self::default() };
}
fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict {
if played != self.seen.0 {
self.seen = (played, now);
self.pending_error = false;
}
// An error that arrives with the stream still consuming samples is only a hiccup.
self.pending_error |= errored;
let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART;
if !lost && !(self.pending_error && stalled) {
return Verdict::Healthy;
}
self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW);
if self.restarts.len() >= MAX_RESTARTS {
return Verdict::GiveUp;
}
self.restarts.push(now);
self.seen = (played, now);
self.pending_error = false;
Verdict::Restart
}
}
/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a
/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never
/// expected: a restart drops the old stream first) plays silence without advancing the clock.
fn render(fill: &Mutex<Fill>, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) {
let mut guard = match fill.try_lock() {
Ok(guard) => guard,
Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(TryLockError::WouldBlock) => {
buf.fill(0.0);
return;
}
};
if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() {
buf.fill(0.0);
if !health.failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: the audio mixer failed; playing without sound");
}
return;
}
played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst);
}
fn stream_error(health: &Health, e: cpal::StreamError) {
match e {
cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst),
cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst),
}
if !health.reported.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: audio stream error: {e}");
}
}
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
for (sample, converted) in input.iter().zip(output) {
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
}
}
impl AudioOut for CpalOut {
fn start(&mut self, fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
self.stop();
self.played.store(0, Ordering::SeqCst);
self.health = Arc::new(Health::default());
self.watchdog.reset(self.epoch.elapsed());
let fill = Arc::new(Mutex::new(fill));
self.open(fill.clone(), None)?;
self.fill = Some(fill);
Ok(self.rate)
}
fn stop(&mut self) {
self.stream = None;
self.fill = None;
}
fn recover(&mut self) {
if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) {
return;
}
let lost = self.health.lost.swap(false, Ordering::SeqCst);
let errored = self.health.errored.swap(false, Ordering::SeqCst);
match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) {
Verdict::Healthy => {}
Verdict::Restart => {
log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" });
if let Err(e) = self.restart() {
log::warn!("the audio output stream could not be restarted: {e}");
self.stream = None;
self.health.failed.store(true, Ordering::SeqCst);
}
}
Verdict::GiveUp => {
log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs());
self.health.failed.store(true, Ordering::SeqCst);
}
}
}
fn sample_rate(&self) -> u32 {
self.rate
}
fn channels(&self) -> usize {
self.channels as usize
}
fn played_frames(&self) -> Option<u64> {
self.stream.as_ref().map(|_| self.played.load(Ordering::SeqCst))
self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
}
fn devices(&self) -> AudioDevices {
let h = host(&self.hw.device_class);
let outputs = h.output_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
let inputs = h.input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
let output_channels = output_device(&h, &self.hw.default_output).and_then(|d| self.config(&d).ok()).map(|c| c.channels()).unwrap_or(0);
let output_channels =
output_device(&h, &self.hw.default_output, &self.fallback).and_then(|c| self.config(&c.device).ok()).map(|cfg| cfg.channels()).unwrap_or(0);
AudioDevices { hosts: cpal::available_hosts().iter().map(|h| h.name().to_string()).collect(), inputs, outputs, output_channels }
}
fn configure(&mut self, hw: &AudioHardwarePrefs, document_rate: Option<u32>) {
@@ -118,10 +440,141 @@ impl AudioOut for CpalOut {
self.document_rate = document_rate;
// the rate playback mixes at must be known before `start`
let host = host(&self.hw.device_class);
if let Some(dev) = output_device(&host, &self.hw.default_output)
&& let Ok(cfg) = self.config(&dev)
if let Some(choice) = output_device(&host, &self.hw.default_output, &self.fallback)
&& let Ok(cfg) = self.config(&choice.device)
{
self.rate = cfg.sample_rate().0;
self.channels = cfg.channels();
self.note = choice.fallback;
}
}
fn note(&self) -> Option<String> {
self.note.as_ref().map(Note::text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cpal::Sample;
#[test]
fn fallback_prefers_sound_servers_over_raw_hardware() {
let mut names = vec!["plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0", "sysdefault:CARD=PCH", "pulse", "pipewire"];
names.sort_by_key(|n| fallback_rank(n));
assert_eq!(names, ["pipewire", "pulse", "sysdefault:CARD=PCH", "plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0"]);
}
#[test]
fn notes_name_the_fallback_device() {
assert!(Note::Fallback("pipewire".into()).text().contains("'pipewire'"));
assert!(!Note::NoDevice.text().is_empty());
}
#[test]
fn integer_conversion_clips_and_preserves_interleaved_channels() {
let mut signed = [0_i16; 8];
convert_samples(&[-2.0, 2.0, -0.5, 0.5, 0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY], &mut signed);
assert_eq!(signed, [i16::MIN, i16::MAX, -16_384, 16_384, 0, 0, 0, 0]);
let mut unsigned = [0_u16; 3];
convert_samples(&[-1.0, 0.0, 1.0], &mut unsigned);
assert_eq!(unsigned, [0, 32_768, u16::MAX]);
}
#[test]
fn full_scale_24_bit_samples_remain_representable() {
let mut samples = [cpal::I24::EQUILIBRIUM; 3];
convert_samples(&[-1.0, 0.0, 1.0], &mut samples);
assert_eq!(samples.map(|s| s.inner()), [-8_388_608, 0, 8_388_607]);
}
#[test]
fn stream_errors_ask_for_a_restart_instead_of_failing_playback() {
let health = Health::default();
stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } });
assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst));
stream_error(&health, cpal::StreamError::DeviceNotAvailable);
assert!(health.lost.load(Ordering::SeqCst));
assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback");
let output = CpalOut::new();
assert_eq!(output.played_frames(), None, "no stream, no clock");
}
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
#[test]
fn an_error_while_samples_keep_flowing_is_only_a_hiccup() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy);
// the stream keeps consuming samples after the error: never restarted
for (i, t) in (20..2_000).step_by(20).enumerate() {
assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy);
}
}
#[test]
fn a_stream_that_errors_and_stops_consuming_is_restarted() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy);
// POLLERR: the backend reports errors and no longer calls back
assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy);
assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy);
assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart);
// the restarted stream gets a fresh grace period
assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy);
// a stall without any reported error is left to the UI's own watchdog
assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy);
}
#[test]
fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp);
// sporadic failures far apart keep being recovered
assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart);
}
#[test]
fn the_device_callback_never_panics_and_counts_played_frames() {
let (played, health) = (AtomicU64::new(0), Health::default());
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25)));
let mut buf = [1.0_f32; 8];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4));
// a mixer still held elsewhere plays silence without moving the clock
{
let _held = fill.lock().unwrap();
render(&fill, &mut buf, 2, &played, &health);
}
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4));
// zero channels from a broken driver must not divide by zero
render(&fill, &mut buf, 0, &played, &health);
assert_eq!(played.load(Ordering::SeqCst), 12);
}
#[test]
fn a_panicking_mixer_plays_silence_and_fails_the_stream() {
let (played, health) = (AtomicU64::new(0), Health::default());
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| {
buf.fill(0.5);
panic!("mixer bug");
}));
let mut buf = [1.0_f32; 4];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!(buf, [0.0; 4]);
assert_eq!(played.load(Ordering::SeqCst), 0);
assert!(health.failed.load(Ordering::SeqCst));
// later callbacks stay silent instead of re-entering the broken mixer
buf = [1.0; 4];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!(buf, [0.0; 4]);
}
}
+170 -56
View File
@@ -14,11 +14,13 @@ pub struct CpalIn {
host: String,
buf: Arc<Mutex<Vec<Vec<f32>>>>,
stop: Option<mpsc::Sender<()>>,
worker: Option<std::thread::JoinHandle<()>>,
error: Arc<Mutex<Option<String>>>,
}
impl CpalIn {
pub fn new(host: &str) -> Self {
Self { host: host.to_string(), buf: Arc::default(), stop: None }
Self { host: host.to_string(), buf: Arc::default(), stop: None, worker: None, error: Arc::default() }
}
}
@@ -46,7 +48,63 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn report_error(error: &Mutex<Option<String>>, message: String) {
let mut error = lock(error);
if error.is_none() {
eprintln!("filmcraft: audio input error: {message}");
*error = Some(message);
}
}
fn append<T: cpal::Sample>(data: &[T], channels: usize, buf: &Mutex<Vec<Vec<f32>>>, error: &Mutex<Option<String>>)
where
f32: cpal::FromSample<T>,
{
if lock(error).is_some() {
return;
}
let mut b = lock(buf);
if channels == 0 || channels > 256 || data.len() > 1_048_576 {
report_error(error, "invalid input device buffer".into());
return;
}
if b.len() != channels {
*b = vec![Vec::new(); channels];
}
// The recorder drains at stop. Bound accumulated samples and report exhaustion instead of
// allowing a long take or a broken/unpaced driver to exhaust the application heap.
let samples = b.first().map_or(0, Vec::len).saturating_mul(channels).saturating_add(data.len());
let frames = data.len() / channels;
if samples > 268_435_456 || b.iter_mut().any(|c| c.try_reserve(frames).is_err()) {
report_error(error, "voice-over input buffer is full; recording failed".into());
return;
}
for frame in data.chunks_exact(channels) {
for (channel, sample) in b.iter_mut().zip(frame) {
let sample = sample.to_sample::<f32>();
channel.push(if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 });
}
}
}
fn input_stream<T: cpal::SizedSample>(
dev: &cpal::Device,
config: &cpal::StreamConfig,
buf: Arc<Mutex<Vec<Vec<f32>>>>,
error: Arc<Mutex<Option<String>>>,
) -> Result<cpal::Stream, cpal::BuildStreamError>
where
f32: cpal::FromSample<T>,
{
let channels = usize::from(config.channels);
let stream_error = error.clone();
dev.build_input_stream(config, move |data: &[T], _| append(data, channels, &buf, &error), move |e| report_error(&stream_error, e.to_string()), None)
}
impl AudioInput for CpalIn {
fn configure_host(&mut self, name: &str) {
self.host = name.to_string();
}
fn devices(&self) -> Vec<String> {
host(&self.host).input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default()
}
@@ -56,68 +114,77 @@ impl AudioInput for CpalIn {
fn start(&mut self, name: &str, sample_rate: u32) -> Result<InputFormat, String> {
self.stop();
lock(&self.buf).clear();
*lock(&self.error) = None;
let (host_name, name) = (self.host.clone(), name.to_string());
let buf = self.buf.clone();
let error = self.error.clone();
let (fmt_tx, fmt_rx) = mpsc::channel::<Result<InputFormat, String>>();
let (stop_tx, stop_rx) = mpsc::channel::<()>();
std::thread::Builder::new()
.name("voice-over input".into())
.spawn(move || {
let open = || -> Result<(cpal::Stream, InputFormat), String> {
let h = host(&host_name);
let dev = device(&h, &name).ok_or("no input device")?;
let default = dev.default_input_config().map_err(|e| e.to_string())?;
// the sequence rate when the device offers it in f32, else the device default
let cfg = dev
.supported_input_configs()
.ok()
.and_then(|mut it| {
it.find(|c| c.sample_format() == cpal::SampleFormat::F32 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&sample_rate))
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(sample_rate)))
})
.unwrap_or(default);
if cfg.sample_format() != cpal::SampleFormat::F32 {
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
}
let ch = cfg.channels() as usize;
let fmt = InputFormat { sample_rate: cfg.sample_rate().0, channels: cfg.channels() };
let stream = dev
.build_input_stream(
&cfg.into(),
move |data: &[f32], _| {
let mut b = lock(&buf);
if b.len() != ch {
*b = vec![Vec::new(); ch];
}
for frame in data.chunks_exact(ch.max(1)) {
for (c, x) in frame.iter().enumerate() {
b[c].push(*x);
}
}
},
|e| eprintln!("filmcraft: audio input error: {e}"),
None,
)
self.worker = Some(
std::thread::Builder::new()
.name("voice-over input".into())
.spawn(move || {
let open = || -> Result<(cpal::Stream, InputFormat), String> {
let h = host(&host_name);
let dev = device(&h, &name).ok_or("no input device")?;
let default = dev.default_input_config().map_err(|e| e.to_string())?;
// Prefer f32 at the sequence rate, retaining integer-only input devices.
let cfg = dev
.supported_input_configs()
.ok()
.and_then(|it| {
it.filter(|c| c.channels() > 0 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&sample_rate))
.max_by_key(|c| (c.channels() == default.channels(), c.sample_format() == cpal::SampleFormat::F32))
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(sample_rate)))
})
.unwrap_or(default);
if cfg.channels() == 0 || cfg.channels() > 256 || cfg.sample_rate().0 == 0 {
return Err("the input device returned an invalid format".into());
}
let fmt = InputFormat { sample_rate: cfg.sample_rate().0, channels: cfg.channels() };
let format = cfg.sample_format();
let config = cfg.into();
let stream = match format {
cpal::SampleFormat::I8 => input_stream::<i8>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::I16 => input_stream::<i16>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::I24 => input_stream::<cpal::I24>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::I32 => input_stream::<i32>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::I64 => input_stream::<i64>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::U8 => input_stream::<u8>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::U16 => input_stream::<u16>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::U32 => input_stream::<u32>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::U64 => input_stream::<u64>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::F32 => input_stream::<f32>(&dev, &config, buf.clone(), error.clone()),
cpal::SampleFormat::F64 => input_stream::<f64>(&dev, &config, buf.clone(), error.clone()),
other => return Err(format!("unsupported input sample format {other:?}")),
}
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
Ok((stream, fmt))
};
match open() {
Ok((stream, fmt)) => {
let _ = fmt_tx.send(Ok(fmt));
// keep the stream alive until stop (or the sender is dropped)
let _ = stop_rx.recv();
drop(stream);
stream.play().map_err(|e| e.to_string())?;
Ok((stream, fmt))
};
let opened = std::panic::catch_unwind(std::panic::AssertUnwindSafe(open)).unwrap_or_else(|_| Err("audio input worker panicked".into()));
match opened {
Ok((stream, fmt)) => {
let _ = fmt_tx.send(Ok(fmt));
// keep the stream alive until stop (or the sender is dropped)
let _ = stop_rx.recv();
drop(stream);
}
Err(e) => {
let _ = fmt_tx.send(Err(e));
}
}
Err(e) => {
let _ = fmt_tx.send(Err(e));
}
}
})
.map_err(|e| e.to_string())?;
let fmt = fmt_rx.recv().map_err(|e| e.to_string())??;
})
.map_err(|e| e.to_string())?,
);
self.stop = Some(stop_tx);
Ok(fmt)
match fmt_rx.recv().map_err(|e| e.to_string()).and_then(|f| f) {
Ok(fmt) => Ok(fmt),
Err(e) => {
self.stop();
Err(e)
}
}
}
fn read(&mut self, frames: usize) -> Vec<Vec<f32>> {
let mut b = lock(&self.buf);
@@ -131,9 +198,56 @@ impl AudioInput for CpalIn {
fn discard(&mut self) {
lock(&self.buf).iter_mut().for_each(Vec::clear);
}
fn error(&self) -> Option<String> {
lock(&self.error).clone()
}
fn stop(&mut self) {
if let Some(tx) = self.stop.take() {
let _ = tx.send(());
}
if let Some(worker) = self.worker.take()
&& worker.join().is_err()
{
report_error(&self.error, "audio input worker panicked".into());
}
}
}
impl Drop for CpalIn {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signed_and_unsigned_input_is_deinterleaved_to_float() {
let buf = Mutex::new(Vec::new());
let error = Mutex::new(None);
append(&[i16::MIN, i16::MAX, 0, 16384], 2, &buf, &error);
let data = lock(&buf);
assert_eq!(data[0], [-1.0, 0.0]);
assert!((data[1][0] - 1.0).abs() < 0.0001);
assert_eq!(data[1][1], 0.5);
drop(data);
lock(&buf).clear();
append(&[0_u16, 32768, 65535, 32768], 2, &buf, &error);
let data = lock(&buf);
assert_eq!(data[1], [0.0, 0.0]);
assert_eq!(data[0][0], -1.0);
assert!(lock(&error).is_none());
}
#[test]
fn invalid_input_is_reported_without_panicking_or_appending() {
let buf = Mutex::new(Vec::new());
let error = Mutex::new(None);
append(&[0.0_f32], 0, &buf, &error);
assert!(lock(&error).is_some());
append(&[1.0_f32], 1, &buf, &error);
assert!(lock(&buf).is_empty());
}
}
+261 -34
View File
@@ -1,13 +1,27 @@
//! Localhost JSON-lines control server (one request per line, one reply per line). Loopback only.
//!
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use filmcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
pub const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
@@ -18,44 +32,257 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
break;
let tx = tx.clone();
let ctx = ctx.clone();
// The slot is given back when the guard drops: after `serve` returns, if it panics, or if
// the thread can't be started (the closure, and with it the guard, is dropped).
let slot = Slot(Arc::clone(&open));
let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx);
});
if let Err(e) = spawned {
eprintln!("filmcraft: control server could not start a connection thread: {e}");
}
}
}
/// One counted connection; decrements the open-connection count when dropped.
struct Slot(Arc<AtomicUsize>);
impl Drop for Slot {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
}
/// One parsed line.
enum Line {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let Some(mut r) = handle(method, params) else { return };
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
return;
}
continue;
}
},
};
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
return;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
serve_lines(input, &mut out, |m, _p| {
calls.push(m);
Some(json!({"ok": true, "result": null}))
});
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
(replies, calls)
}
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
assert_eq!(calls, ["a", "b", "c"]);
assert_eq!(r.len(), 3);
assert_eq!(r[0]["id"], 1);
assert_eq!(r[1]["id"], 2);
assert_eq!(r[2]["id"], Value::Null);
}
#[test]
fn http_request_closes_before_the_body_runs() {
// A browser's cross-origin `fetch` POST (issue #119): the request line is rejected and the
// connection closed, so the JSON body is never executed.
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9876\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.openDemoProject\",\"params\":{}}}\n";
let (r, calls) = run(http);
assert!(calls.is_empty(), "nothing may run: {calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
}
#[test]
fn non_request_json_closes_the_connection() {
for junk in [
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
b"{\"id\":1}\n{\"method\":\"a\"}\n",
b"{\"method\":5}\n{\"method\":\"a\"}\n",
b"\xff\xfe\n{\"method\":\"a\"}\n",
] {
let (r, calls) = run(junk);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
}
}
#[test]
fn a_request_before_junk_still_runs() {
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
assert_eq!(calls, ["a"]);
assert_eq!(r.len(), 2);
assert_eq!(r[1]["ok"], false);
}
#[test]
fn overlong_line_is_refused() {
// One byte over the limit, then a valid request: neither runs.
let mut input = vec![b'x'; MAX_LINE + 1];
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
let (r, calls) = run(&input);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
}
#[test]
fn line_at_the_limit_is_accepted() {
let mut buf = Vec::new();
let line = vec![b'a'; 10];
let mut input = line.clone();
input.push(b'\n');
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
assert_eq!(buf, line);
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].contains("\"id\":7"));
assert!(lines[1].contains("closing the connection"));
}
#[test]
fn extra_connections_are_refused() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}
+329
View File
@@ -0,0 +1,329 @@
//! Dev-time Linux desktop entry: the shell icon fallback.
//!
//! The Linux shell (Wayland *and* X11) resolves the window to a `.desktop` file by app id (set in
//! `main.rs` via `eframe`'s `with_app_id`) and takes the taskbar / window-list icon from its
//! `Icon=` entry in the hicolor theme. Packaging installs both
//! (`packaging/linux/ai.storyteller.filmcraft.desktop` and the hicolor PNGs) — but a source-tree
//! run has neither, so KDE/GNOME fall back to the generic Wayland icon. The fix is to ensure a
//! user-level entry for those runs: when no entry for the app id exists in any XDG data dir,
//! install one (plus the hicolor icon PNGs, read from this checkout's `assets/app-icon`, so
//! nothing is embedded in the binary) into `$XDG_DATA_HOME`/`~/.local/share`. An existing
//! packaged entry is never overridden; the user-owned dev copy is rewritten when it points at
//! another binary (a dev build moves between checkouts). The shell picks the icon up at the *next* app start;
//! `update-desktop-database`/`kbuildsycoca` is not needed for icon lookup.
//!
//! Opt-in only: nothing is written unless `FILMCRAFT_DEV_DESKTOP_ENTRY=1` is set, so ordinary
//! `cargo run`s (agents, worktrees, CI) never touch the user's profile. Packaged runs are
//! excluded even then — AppImage (`APPIMAGE`) mounts at a fresh `/tmp/.mount_*` each launch,
//! so its `Exec` path changes every time and it ships its own entry, and a Flatpak
//! (`FLATPAK_ID`) installs its entry through the manifest. `packaging/linux/install.sh` writes the packaged
//! `Exec=` the same two-layer way as here; keep the two in sync.
//!
//! Every failure is logged and skipped — a missing dev icon must never stop the app
//! ([`AGENTS.md`](/AGENTS.md) §0 "never crash").
/// Install the user-level desktop entry and icons for a source-tree run (Linux).
pub fn ensure_dev_desktop_entry() {
// Packaged and sandboxed runs never write into the user's profile: an AppImage mounts at a
// fresh /tmp/.mount_* every launch (the Exec path changes each time) and a Flatpak installs
// its own entry.
if std::env::var_os("APPIMAGE").is_some() || std::env::var_os("FLATPAK_ID").is_some() {
return;
}
// Opt-in only: a plain `cargo run` (agents, worktrees, other checkouts) must not write into
// the user's profile, and a packaged release never does (packages and install.sh install
// the real entry).
if std::env::var_os("FILMCRAFT_DEV_DESKTOP_ENTRY").is_none_or(|v| v != "1") {
return;
}
if let Err(e) = ensure() {
log::warn!("desktop entry (taskbar icon) not installed: {e}");
}
}
/// Install missing files; `Ok` whether anything was written or an entry already existed.
fn ensure() -> Result<(), DevIconError> {
let file_name = format!("{}.desktop", crate::APP_ID);
let user_dir = user_data_dir()?;
let exe = std::env::current_exe().map_err(DevIconError::Io)?;
// Not `to_string_lossy`: a substituted U+FFFD would install an `Exec` that can never start
// the app, and the same lossy string would match on every later run, so the broken entry
// would never be rewritten or warned about.
let exe = exe.to_str().ok_or(DevIconError::BadExecPath)?;
if exe.contains('=') || exe.contains('%') || exe.chars().any(|c| c.is_control()) {
// A desktop entry cannot carry these (install.sh rejects them for the same reason):
// '=' separates key and value, '%' introduces a field code, control characters are
// not valid in the string layer.
return Err(DevIconError::BadExecPath);
}
for dir in search_dirs(&user_dir) {
let candidate = dir.join("applications").join(&file_name);
if !candidate.is_file() {
continue;
}
// A packaged entry (any system dir) already gives the shell its icon; only our own
// user-level copy is kept fresh.
if dir != user_dir {
return Ok(());
}
let body = std::fs::read_to_string(&candidate).map_err(DevIconError::Io)?;
if desktop_file_exec_is_ours(&body, exe) {
return Ok(());
}
break; // a dev entry from another checkout: fall through and rewrite it
}
for size in ICON_SIZES {
let rel = std::path::Path::new("hicolor").join(format!("{size}x{size}")).join("apps").join(format!("{}.png", crate::APP_ID));
let dest = user_dir.join("icons").join(&rel);
if !dest.is_file()
&& let Some(parent) = dest.parent()
{
// The checkout this binary was built from; a missing icon only costs the icon.
let src = std::path::Path::new(ICON_DIR).join(&rel);
if !src.is_file() {
log::warn!("dev desktop entry: icon {} not found; skipping it", src.display());
continue;
}
std::fs::create_dir_all(parent).map_err(DevIconError::Io)?;
std::fs::copy(&src, &dest).map_err(DevIconError::Io)?;
}
}
let applications = user_dir.join("applications");
std::fs::create_dir_all(&applications).map_err(DevIconError::Io)?;
let body = desktop_file_content(crate::APP_ID, "FilmCraft", exe);
std::fs::write(applications.join(&file_name), body).map_err(DevIconError::Io)?;
Ok(())
}
/// The hicolor icon sizes installed for the taskbar (a subset large enough for panel and topbar;
/// the shell picks the nearest size, so 256 and 512 cover both).
const ICON_SIZES: [u16; 2] = [256, 512];
/// The app icons in the source tree this binary was built from (a path, not embedded bytes:
/// the entry is a dev-checkout convenience and release binaries must not carry the PNGs).
const ICON_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/app-icon");
/// The user data dir we install into: `$XDG_DATA_HOME`, else `$HOME/.local/share` (XDG base
/// directory spec). Untrusted environment: an empty or relative `XDG_DATA_HOME` means the
/// default, and a missing or relative `HOME` is an error — never a guess that would write
/// into the working directory.
fn user_data_dir() -> Result<std::path::PathBuf, DevIconError> {
if let Some(v) = std::env::var_os("XDG_DATA_HOME")
&& !v.is_empty()
&& std::path::Path::new(&v).is_absolute()
{
return Ok(std::path::PathBuf::from(v));
}
match std::env::var_os("HOME").filter(|h| !h.is_empty() && std::path::Path::new(h).is_absolute()) {
Some(home) => Ok(std::path::PathBuf::from(home).join(".local").join("share")),
None => Err(DevIconError::NoHome),
}
}
/// The XDG data dirs to search for an existing entry, user dir first, then `$XDG_DATA_DIRS` (or
/// the spec default `/usr/local/share:/usr/share`). Only absolute paths are taken; the spec
/// requires them and a relative one would read the working directory.
fn search_dirs(user: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut dirs = vec![user.to_path_buf()];
let system = std::env::var_os("XDG_DATA_DIRS")
.filter(|v| !v.is_empty())
.map(|v| v.to_string_lossy().into_owned())
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
for dir in system.split(':') {
if std::path::Path::new(dir).is_absolute() {
dirs.push(std::path::PathBuf::from(dir));
}
}
dirs
}
/// Whether the `.desktop` body's `Exec` points at our binary exactly (a stale dev entry from
/// another checkout — `/a/filmcraft-old` is not `/a/filmcraft` — does not, and is rewritten; a
/// packaged `Exec=filmcraft` never matches a source-tree path).
fn desktop_file_exec_is_ours(body: &str, exe: &str) -> bool {
exec_line(body).is_some_and(|arg| arg == exe)
}
/// The first `Exec=` argument (the executable), unescaped through both Desktop Entry layers:
/// first the string layer of the file value (`\\` `\s` `\n` `\t` `\r`, per
/// <https://specifications.freedesktop.org/desktop-entry/latest/>), then the shell layer of the
/// argument (double quotes, and `\` before `` ` `"` `\` `$` inside them). `None` when there is no
/// `Exec=` line or its first argument cannot be parsed.
fn exec_line(body: &str) -> Option<String> {
let value = body.lines().find(|l| l.starts_with("Exec=")).and_then(|l| l.strip_prefix("Exec="))?;
let value = unescape_string_value(value);
if let Some(rest) = value.strip_prefix('"') {
let mut out = String::new();
let mut chars = rest.chars();
while let Some(c) = chars.next() {
match c {
'"' => return Some(out),
'\\' => match chars.next() {
Some(e @ ('\\' | '"' | '`' | '$')) => out.push(e),
Some(e) => {
out.push('\\');
out.push(e);
}
None => return None, // a dangling escape ends the argument
},
c => out.push(c),
}
}
None // no closing quote
} else {
Some(value.split(' ').next().unwrap_or_default().to_string())
}
}
/// Undo the desktop entry's string layer: `\\` `\s` `\n` `\t` `\r` are the defined sequences; a
/// backslash before anything else is kept as the character itself (lenient, like desktop file
/// parsers in the wild).
fn unescape_string_value(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('\\') => out.push('\\'),
Some('s') => out.push(' '),
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some(e) => out.push(e),
None => out.push('\\'),
}
}
out
}
/// The `.desktop` body: same fields as `packaging/linux/ai.storyteller.filmcraft.desktop`, with
/// `Exec` pointing at this exact binary (a dev build isn't on `$PATH`, so `TryExec` is left out)
/// and the icon by theme name.
fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String {
let mut out = String::new();
out.push_str("[Desktop Entry]\n");
out.push_str("Type=Application\n");
out.push_str(&format!("Name={name}\n"));
out.push_str("GenericName=Video Editor\n");
out.push_str("Comment=Edit video, color and sound\n");
out.push_str(&format!("Exec=\"{}\" %F\n", escape_exec_argument(exe)));
out.push_str(&format!("Icon={app_id}\n"));
out.push_str("Terminal=false\n");
out.push_str("StartupNotify=true\n");
out.push_str(&format!("StartupWMClass={app_id}\n"));
out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n");
out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n");
out
}
/// Escape `s` for the `Exec=` value's two layers, like `packaging/linux/install.sh`: first the
/// shell layer (`` ` `` `"` `$` `\` get a backslash, and the argument is double-quoted), then
/// the string layer of the file value (every backslash doubled).
fn escape_exec_argument(s: &str) -> String {
let mut shell = String::with_capacity(s.len() + 2);
for c in s.chars() {
if matches!(c, '`' | '"' | '$' | '\\') {
shell.push('\\');
}
shell.push(c);
}
shell.replace('\\', "\\\\")
}
/// Everything that can keep the dev icon out of the taskbar; logged, never fatal.
#[derive(Debug)]
enum DevIconError {
Io(std::io::Error),
NoHome,
BadExecPath,
}
impl std::fmt::Display for DevIconError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DevIconError::Io(e) => write!(f, "{e}"),
DevIconError::NoHome => write!(f, "no home directory for the user data dir"),
DevIconError::BadExecPath => {
write!(f, "the binary path has a character a desktop entry cannot carry (=, % or a control character), or is not valid UTF-8")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The entry mirrors the packaged one, derived from the real file so the two cannot drift:
/// minus `TryExec` (a dev build isn't on `$PATH`), plus the quoted dev `Exec` path.
/// The icons the entry installs exist in the checkout.
#[test]
fn icon_sources_exist_in_the_checkout() {
for size in ICON_SIZES {
let p = std::path::Path::new(ICON_DIR).join(format!("hicolor/{size}x{size}/apps/ai.storyteller.filmcraft.png"));
assert!(p.is_file(), "{}", p.display());
}
}
#[test]
fn desktop_file_matches_the_packaged_fields() {
let packaged = include_str!("../../../packaging/linux/ai.storyteller.filmcraft.desktop");
let expected = packaged
.lines()
.filter(|l| !l.starts_with("TryExec="))
.map(|l| if l.starts_with("Exec=") { "Exec=\"/target/debug/filmcraft\" %F" } else { l })
.collect::<Vec<_>>()
.join("\n")
+ "\n";
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", "/target/debug/filmcraft");
assert_eq!(body, expected);
}
/// Both layers are escaped, like install.sh: shell first (`` ` `` `"` `$` `\` get a
/// backslash), then *every* backslash doubled for the file value — including the escape
/// backslashes just added.
#[test]
fn exec_escapes_both_desktop_entry_layers() {
assert_eq!(escape_exec_argument("/opt/film craft/filmcraft"), "/opt/film craft/filmcraft");
// a\b → shell a\\b → string a\\\\b
assert_eq!(escape_exec_argument("a\\b"), "a\\\\\\\\b");
// a$b → shell a\$b → string a\\$b
assert_eq!(escape_exec_argument("a$b"), "a\\\\$b");
}
/// A written entry reads back as the exact binary it was written for, through both layers.
#[test]
fn exec_round_trips_through_both_layers() {
for exe in ["/home/dev/Projects/filmcraft/target/debug/filmcraft", "/opt/film craft/filmcraft", "/a$b\"c`d\\e"] {
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", exe);
assert_eq!(exec_line(&body).as_deref(), Some(exe));
assert!(desktop_file_exec_is_ours(&body, exe));
}
}
/// Only the exact executable counts: another checkout's path, a path with our path as a
/// prefix, and a packaged `Exec=filmcraft` are all someone else's entry.
#[test]
fn desktop_file_exec_is_ours_matches_only_our_binary() {
let body = desktop_file_content("ai.storyteller.filmcraft", "FilmCraft", "/a/filmcraft");
assert!(!desktop_file_exec_is_ours(&body, "/a/filmcraft-old"));
assert!(!desktop_file_exec_is_ours(&body, "/b/filmcraft"));
assert!(!desktop_file_exec_is_ours("Exec=filmcraft %F\n", "/a/filmcraft"));
assert!(!desktop_file_exec_is_ours("Name=filmcraft\n", "/a/filmcraft"));
assert!(!desktop_file_exec_is_ours("Exec=\"dangling\n", "/a/filmcraft"));
}
/// The string layer is undone before the argument is split: `\s` becomes a space, which
/// ends a bare argument (the shell layer's quoting, not `\s`, is how a space travels), and
/// `\\` is one backslash. install.sh always quotes, so a space in our own `Exec` never
/// splits.
#[test]
fn exec_reads_the_string_layer() {
assert_eq!(exec_line("Exec=/a\\sb %F\n").as_deref(), Some("/a"));
assert_eq!(exec_line("Exec=\"/a\\sb\" %F\n").as_deref(), Some("/a b"));
assert_eq!(exec_line("Exec=/a\\\\b %F\n").as_deref(), Some("/a\\b"));
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Extension filters for native file dialogs.
//!
//! Every filtered file dialog (import, relink, open file/preset, open/save project) builds
//! its filter with [`extensions`]. On Linux and the BSDs, rfd's XDG portal, GTK and Zenity
//! backends turn each extension into a case-sensitive `*.{ext}` glob, so camera files such
//! as `shot.MP4` or `shot.Mp4` would be hidden. The helper appends one bracket pattern per
//! extension (`mp4` -> `[mM][pP]4`) after the literal extensions, which stay first so save
//! dialogs keep their normal default suffix. macOS and Windows dialogs already ignore case
//! and receive the plain list.
pub fn extensions(exts: &[&str]) -> Vec<String> {
let extensions: Vec<_> = exts.iter().map(|ext| (*ext).to_owned()).collect();
// XDG portal/Zenity globs are case-sensitive. Keep the literal extensions first so
// Save dialogs can still infer a normal default suffix, then cover mixed case too.
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let mut extensions = extensions;
for ext in exts {
if !ext.bytes().any(|b| b.is_ascii_alphabetic()) {
continue;
}
let mut pattern = String::new();
for c in ext.chars() {
if c.is_ascii_alphabetic() {
pattern.extend(['[', c.to_ascii_lowercase(), c.to_ascii_uppercase(), ']']);
} else {
pattern.push(c);
}
}
extensions.push(pattern);
}
extensions
}
// Native macOS and Windows filters take ordinary extensions.
#[cfg(any(target_os = "windows", target_os = "macos"))]
extensions
}
#[cfg(test)]
mod tests {
use super::extensions;
// rfd's XDG portal and Zenity backends turn each extension into `*.{ext}`.
// Exercise those case-sensitive patterns against filenames, as the picker does.
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
#[test]
fn camera_files_are_visible_in_every_capitalization() {
let filters: Vec<_> = extensions(&["mp4", "mov", "m2ts", "wav", "fcproj"]).iter().map(|ext| glob::Pattern::new(&format!("*.{ext}")).unwrap()).collect();
for name in ["camera.mp4", "camera.MP4", "camera.Mp4", "clip.MOV", "clip.moV", "take.M2TS", "sound.WaV", "Edit.FCPROJ", "Edit.FcProj"] {
assert!(filters.iter().any(|f| f.matches(name)), "the picker hides {name}");
}
for name in ["camera.mp4.bak", "camera.mp44", "camera.xmp4", "notes.txt", "mp4"] {
assert!(!filters.iter().any(|f| f.matches(name)), "the picker admits {name}");
}
}
#[test]
fn save_filters_keep_the_literal_default_extension() {
assert_eq!(extensions(&["mp4", "mov"]).first().map(String::as_str), Some("mp4"));
assert!(extensions(&[]).is_empty());
assert_eq!(extensions(&["*", "", "123"]), ["*", "", "123"]);
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[test]
fn native_filters_receive_only_literal_extensions() {
assert_eq!(extensions(&["mp4", "mov", "fcproj"]), ["mp4", "mov", "fcproj"]);
}
}
+839
View File
@@ -0,0 +1,839 @@
//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback
//! when a start fails (docs/gaps.md G4, #820).
//!
//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
//!
//! # When a start counts as failed
//! The data folder keeps the mode to start with and the failed starts in a row in it
//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/<pid>-<n>.json`,
//! so concurrent launches never write the same file) that is removed once the window has painted
//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened
//! by then. An attempt file found by a later launch therefore means a process that died within
//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs,
//! or is force-quit or crashes later, is not counted; neither is another instance that is still
//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After
//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next
//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A
//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once,
//! within the same process.
//!
//! # Getting back
//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`,
//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command
//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves
//! the saved state alone: the user is in charge.
//!
//! All files are written beside and renamed into place, so a reader never sees half a file; two
//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted
//! once less, a confirmation repeated by the next start), never corrupt it.
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{self, Backends},
};
/// The saved graphics state in the data folder: `{"mode": "<mode>", "failures": <n>}`.
pub const STATE_FILE: &str = "graphics-start.json";
/// The folder in the data folder with one attempt file per start that has not painted yet.
pub const ATTEMPTS_DIR: &str = "graphics-start";
/// How long after launch a start that has not painted may die and still count as failed. A
/// driver crash in instance or device creation happens within a second or two.
pub const DEATH_WINDOW: Duration = Duration::from_secs(15);
/// Failed starts in a row, in the same mode, that move on to the next mode.
pub const FAILURES_TO_MOVE_ON: u32 = 2;
/// Attempt files looked at per launch; anything beyond is left for a later launch.
const MAX_ATTEMPTS: usize = 64;
/// The state and attempt files are a few bytes; anything longer is not ours.
const MAX_FILE: u64 = 4096;
/// How the window's graphics device is created.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on
/// macOS, Vulkan or OpenGL elsewhere.
Default,
/// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up.
Dx12,
/// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then
/// composites on the CPU (`FilmcraftApp::set_wgpu`).
Software,
}
impl Mode {
/// The name in [`STATE_FILE`] and on the command line (`--graphics <name>`).
pub fn name(self) -> &'static str {
match self {
Mode::Default => "default",
Mode::Dx12 => "dx12",
Mode::Software => "software",
}
}
/// The English name shown in the interface (translated there).
pub fn label(self) -> &'static str {
match self {
Mode::Default => "default graphics",
Mode::Dx12 => "DirectX 12 only",
Mode::Software => "software rendering",
}
}
pub fn parse(name: &str) -> Option<Mode> {
[Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name)
}
}
/// The modes tried in order on this platform. macOS has only Metal, and no software adapter.
pub const LADDER: &[Mode] = if cfg!(target_os = "windows") {
&[Mode::Default, Mode::Dx12, Mode::Software]
} else if cfg!(target_os = "macos") {
&[Mode::Default]
} else {
&[Mode::Default, Mode::Software]
};
/// The mode after `mode` in `ladder`, if any.
fn next_in(ladder: &[Mode], mode: Mode) -> Option<Mode> {
let i = ladder.iter().position(|m| *m == mode)?;
ladder.get(i.checked_add(1)?).copied()
}
/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Saved {
mode: Mode,
failures: u32,
}
/// An attempt file left by a start: its process id (from the file name), mode and launch time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Attempt {
pid: u32,
mode: Mode,
/// Seconds since the Unix epoch.
started: u64,
}
/// What a launch makes of the saved state and the attempt files it found.
#[derive(Debug, PartialEq, Eq)]
struct Decision {
/// The state to start with and save.
saved: Saved,
/// Moved on to the next mode because the last starts failed.
moved_on: bool,
/// Per attempt: done with (counted, or not ours to count), delete it.
delete: Vec<bool>,
}
/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix
/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was
/// reused), `window` is [`DEATH_WINDOW`] in seconds.
fn decide(saved: Option<Saved>, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision {
let first = ladder.first().copied().unwrap_or(Mode::Default);
// Nothing saved, or a mode this platform does not use (a data folder copied from another OS).
let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 });
let mut failures = saved.failures;
let mut delete = Vec::with_capacity(attempts.len());
for a in attempts {
if a.started > now.saturating_add(window) {
// From the future (a clock that jumped back): unknown, and not worth keeping.
delete.push(true);
continue;
}
let dead = a.pid == pid || now.saturating_sub(a.started) >= window;
// A younger attempt is another instance starting right now: leave it to that instance.
delete.push(dead);
// Only failures of the mode we would start in count; older ones are history.
if dead && a.mode == saved.mode {
failures = failures.saturating_add(1);
}
}
if failures >= FAILURES_TO_MOVE_ON {
return match next_in(ladder, saved.mode) {
Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete },
// Nothing left to try: the last mode is still the best chance.
None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete },
};
}
Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete }
}
fn read_small(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?;
Some(text)
}
/// [`Saved`] from its text; `None` for anything else. A missing failure count is none.
fn parse_state(text: &str) -> Option<Saved> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let mode = Mode::parse(v.get("mode")?.as_str()?)?;
let failures = match v.get("failures") {
None => 0,
Some(n) => u32::try_from(n.as_u64()?).ok()?,
};
Some(Saved { mode, failures })
}
fn state_json(s: Saved) -> String {
serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string()
}
/// An attempt from its file name (`<pid>-<n>.json`) and text; `None` for anything else.
fn parse_attempt(file_name: &str, text: &str) -> Option<Attempt> {
let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?;
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? })
}
fn attempt_json(mode: Mode, started: u64) -> String {
serde_json::json!({ "mode": mode.name(), "started": started }).to_string()
}
/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a
/// start that dies mid-write, or another instance reading, never sees half a file.
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(".{}.tmp", std::process::id()));
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
fn remove_quietly(path: &Path) {
match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()),
_ => {}
}
}
/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable.
fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option<Attempt>)> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
entries
.filter_map(Result::ok)
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
name.ends_with(".json").then(|| {
let path = e.path();
let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text));
(path, attempt)
})
})
.take(MAX_ATTEMPTS)
.collect()
}
fn unix_now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Attempt numbers within this process, so each attempt file has its own name.
static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0);
/// This launch's graphics mode and its files in the data folder.
#[derive(Clone, Debug)]
pub struct GraphicsStart {
/// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set.
state: Option<PathBuf>,
/// This start's attempt file (with `state`).
attempt: Option<PathBuf>,
mode: Mode,
/// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it.
reset: Arc<AtomicBool>,
}
impl GraphicsStart {
/// Pick this launch's mode from the data folder and record the attempt. `forced`
/// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set)
/// leaves the data folder alone and starts in `forced`, else [`Mode::Default`].
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>) -> Self {
let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id());
if let Some(path) = start.attempt.clone() {
expire_later(path);
}
start
}
/// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer.
fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>, now: u64, pid: u32) -> Self {
let first = LADDER.first().copied().unwrap_or(Mode::Default);
let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first });
let reset = Arc::new(AtomicBool::new(false));
let Some(dir) = data_dir.filter(|_| !explicit_backend) else {
return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset };
};
let state = dir.join(STATE_FILE);
let attempts_dir = dir.join(ATTEMPTS_DIR);
let saved = read_small(&state).and_then(|t| parse_state(&t));
let found = list_attempts(&attempts_dir);
let known: Vec<Attempt> = found.iter().filter_map(|(_, a)| *a).collect();
let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER);
let mut to_delete = decision.delete.iter();
for (path, attempt) in &found {
// unreadable files in our folder are debris (a different version, a damaged disk)
if attempt.is_none() || to_delete.next().copied().unwrap_or(false) {
remove_quietly(path);
}
}
let file = state.display();
let next = match forced {
Some(mode) => {
log::info!("graphics mode {} chosen with --graphics", mode.name());
Saved { mode, failures: 0 }
}
None => {
if decision.moved_on {
let failed = saved.map_or(first, |s| s.mode).name();
log::warn!(
"the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)",
decision.saved.mode.name()
);
} else if decision.saved.failures > 0 {
log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name());
} else if decision.saved.mode != Mode::Default {
log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name());
}
decision.saved
}
};
if saved != Some(next)
&& let Err(e) = write_atomic(&state, &state_json(next))
{
log::warn!("can't write {file}: {e}");
}
let start = Self { state: Some(state), attempt: None, mode: next.mode, reset };
start.with_new_attempt(&attempts_dir, now, pid)
}
/// Record a new attempt file for the current mode.
fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self {
let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{pid}-{n}.json"));
match write_atomic(&path, &attempt_json(self.mode, now)) {
Ok(()) => self.attempt = Some(path),
Err(e) => log::warn!("can't write {}: {e}", path.display()),
}
self
}
pub fn mode(&self) -> Mode {
self.mode
}
/// Running in a fallback mode, i.e. not the platform default.
pub fn degraded(&self) -> bool {
self.mode != Mode::Default
}
/// Move on to the next mode after a start-up error wgpu reported; `false` when none is left.
/// The error is certain, so the next mode is saved at once.
pub fn step_down(&mut self) -> bool {
let Some(next) = next_in(LADDER, self.mode) else {
return false;
};
log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name());
self.mode = next;
if let Some(state) = self.state.clone() {
self.save(&state, Saved { mode: next, failures: 0 });
if let Some(old) = self.attempt.take() {
remove_quietly(&old);
if let Some(dir) = old.parent() {
let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt;
if let Some(path) = &path {
expire_later(path.clone());
}
self.attempt = path;
}
}
}
true
}
/// The window is up: keep this mode, and the failed starts in a row are over.
pub fn confirm(&self) {
self.finish();
let Some(state) = &self.state else { return };
if self.reset.load(Ordering::Relaxed) {
return;
}
let ok = Saved { mode: self.mode, failures: 0 };
if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) {
self.save(state, ok);
}
}
/// This start is over, whether it painted or not (the window closed, or every mode failed
/// with an error that was reported): its attempt no longer means a crash.
pub fn finish(&self) {
if let Some(path) = &self.attempt {
remove_quietly(path);
}
}
/// Start with the default mode next time (the notice's reset button). This process keeps
/// its mode: the graphics device can't be swapped under a running window.
pub fn reset_to_default(&self) -> Result<(), String> {
self.reset.store(true, Ordering::Relaxed);
let Some(state) = &self.state else { return Ok(()) };
let first = LADDER.first().copied().unwrap_or(Mode::Default);
write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?;
log::info!("graphics mode reset to {} for the next start", first.name());
Ok(())
}
fn save(&self, path: &Path, s: Saved) {
if let Err(e) = write_atomic(path, &state_json(s)) {
log::warn!("can't write {}: {e}", path.display());
}
}
}
/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not
/// counted as a failed one, painted or not.
fn expire_later(path: PathBuf) {
let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || {
let _ = std::panic::catch_unwind(move || {
std::thread::sleep(DEATH_WINDOW);
remove_quietly(&path);
});
});
if let Err(e) = spawned {
log::warn!("can't start the graphics start timer: {e}");
}
}
/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override`
/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`.
pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>) {
apply_for(options, mode, backend_override, cfg!(target_os = "windows"));
}
fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>, windows: bool) {
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
return;
};
match mode {
// eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it
// includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter
// selection or any Rust error handling runs, so the window never appears. Choosing an
// adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep
// Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects
// fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no
// backend at all.
Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY),
Mode::Default => {}
// Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU.
Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12,
Mode::Software => {
create.instance_descriptor.backends = software_backends(windows);
create.native_adapter_selector = Some(Arc::new(pick_software));
}
}
}
/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on
/// Windows for the reason in [`apply_for`].
fn software_backends(windows: bool) -> Backends {
if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL }
}
/// The first software adapter that can draw to the window's surface.
fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result<wgpu::Adapter, String> {
adapters
.iter()
.find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s)))
.cloned()
.ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len()))
}
/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may
/// avoid (as opposed to a display, event-loop or app error).
pub fn is_graphics_failure(err: &eframe::Error) -> bool {
matches!(err, eframe::Error::Wgpu(_))
}
#[cfg(test)]
mod tests {
use super::*;
const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software];
const LINUX: &[Mode] = &[Mode::Default, Mode::Software];
const MACOS: &[Mode] = &[Mode::Default];
fn configured(mode: Mode, backend_override: Option<Backends>, windows: bool) -> (Backends, bool) {
let mut options = NativeOptions::default();
apply_for(&mut options, mode, backend_override, windows);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
(create.instance_descriptor.backends, create.native_adapter_selector.is_some())
}
#[test]
fn windows_default_leaves_out_gl() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
let (backends, selector) = configured(Mode::Default, None, true);
assert_eq!(backends, Backends::PRIMARY);
assert!(!backends.contains(Backends::GL));
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
assert!(!selector);
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured(Mode::Default, Some(backends), true).0, backends);
}
}
#[test]
fn fallback_modes_leave_out_the_crashing_backends() {
// DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413).
assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false));
// Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477).
assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true));
assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true));
// Off Windows the default is eframe's own.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false));
}
const W: u64 = 15;
const NOW: u64 = 1_000_000;
const ME: u32 = 7;
fn saved(mode: Mode, failures: u32) -> Option<Saved> {
Some(Saved { mode, failures })
}
fn dead(mode: Mode) -> Attempt {
Attempt { pid: 1, mode, started: NOW - 100 }
}
fn mode_after(s: Option<Saved>, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) {
let d = decide(s, attempts, NOW, ME, W, ladder);
(d.saved.mode, d.saved.failures, d.moved_on)
}
#[test]
fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() {
// first launch
assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false));
// one start died: counted, but the same mode is tried again
assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false));
// the second in a row moves on: DX12, then software
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true));
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true));
// two dead instances found at once count as two
assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
// a saved mode is kept
assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false));
// nothing left to try: stay
assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false));
// a mode this platform has not: start over
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false));
assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false));
assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false));
// a hostile count doesn't overflow
assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
}
#[test]
fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() {
let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 };
let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW };
let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 };
let older_mode = dead(Mode::Dx12);
let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 };
let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS);
// another instance still starting is left alone; one from the future is dropped; a
// failure in another mode is history; our own process id can only be a dead process
assert_eq!(d.delete, [false, false, true, true, true]);
assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false));
// the clock a little behind another instance's: still starting
let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 };
assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]);
}
#[test]
fn the_files_round_trip_and_reject_anything_else() {
for mode in WINDOWS {
for failures in [0, 1, 2, u32::MAX] {
assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures));
}
assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW }));
}
// without a count (an earlier build of this change wrote `pending`): none
assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0));
for bad in [
"",
"{",
"null",
"[]",
r#"{"mode":"vulkan","failures":1}"#,
r#"{"failures":1}"#,
r#"{"mode":1,"failures":1}"#,
r#"{"mode":"dx12","failures":"one"}"#,
r#"{"mode":"dx12","failures":-1}"#,
r#"{"mode":"dx12","failures":4294967296}"#,
] {
assert_eq!(parse_state(bad), None, "{bad}");
}
let good = attempt_json(Mode::Default, NOW);
for (name, text) in [
("12-0.json", "{"),
("12-0.json", r#"{"mode":"default"}"#),
("12-0.json", r#"{"mode":"default","started":-5}"#),
("x-0.json", good.as_str()),
("12.json", good.as_str()),
("-0.json", good.as_str()),
("99999999999-0.json", good.as_str()),
("12-0.txt", good.as_str()),
] {
assert_eq!(parse_attempt(name, text), None, "{name} {text}");
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn state_of(dir: &Path) -> Option<Saved> {
read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t))
}
fn attempt_files(dir: &Path) -> usize {
std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count())
}
/// A launch as another process (`pid`) at `now`.
fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart {
GraphicsStart::begin_at(Some(dir), false, None, now, pid)
}
#[test]
fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() {
let dir = temp_dir("paint");
let start = launch(&dir, 10, NOW);
assert_eq!(start.mode(), Mode::Default);
assert!(!start.degraded());
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 1);
start.confirm();
assert_eq!(attempt_files(&dir), 0);
// a window closed before it painted is no failure either
let start = launch(&dir, 11, NOW + 60);
start.finish();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() {
let dir = temp_dir("dead");
let second = LADDER.get(1).copied().unwrap_or(Mode::Default);
// dies without painting
let _ = launch(&dir, 20, NOW);
// the next launch counts it, tries the same mode again and dies too
let again = launch(&dir, 21, NOW + 60);
assert_eq!(again.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 1));
// two in a row: the next mode, kept
let moved = launch(&dir, 22, NOW + 120);
assert_eq!(moved.mode(), second);
assert_eq!(moved.degraded(), second != Mode::Default);
moved.confirm();
assert_eq!(launch(&dir, 23, NOW + 180).mode(), second);
// one failure, then a start that paints: the count starts over
let _ = std::fs::remove_dir_all(&dir);
let _ = launch(&dir, 30, NOW);
let painted = launch(&dir, 31, NOW + 60);
painted.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = launch(&dir, 32, NOW + 120);
assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn concurrent_launches_neither_count_nor_disturb_each_other() {
let dir = temp_dir("race");
// two instances start within the same seconds; neither has painted yet
let a = launch(&dir, 40, NOW);
let b = launch(&dir, 41, NOW + 1);
let c = launch(&dir, 42, NOW + 2);
assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file");
b.confirm();
a.confirm();
c.confirm();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_huge_or_foreign_files_start_with_the_default() {
let dir = temp_dir("corrupt");
let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR));
let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000));
for text in ["garbage", huge.as_str()] {
let _ = std::fs::write(dir.join(STATE_FILE), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text);
let start = launch(&dir, 51, NOW);
assert_eq!(start.mode(), Mode::Default);
// replaced with a well-formed state; the unreadable attempt is gone, other files stay
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists());
assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists());
start.finish();
}
// a data folder that is a file: nothing can be written, the default still starts
let file = dir.join("not-a-folder");
let _ = std::fs::write(&file, "x");
let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert!(start.reset_to_default().is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn many_attempt_files_are_bounded_per_launch() {
let dir = temp_dir("many");
let attempts = dir.join(ATTEMPTS_DIR);
let _ = std::fs::create_dir_all(&attempts);
let n = MAX_ATTEMPTS + 10;
for pid in 0..n {
let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1));
}
// all of them belong to instances still starting: none is counted or removed
let start = launch(&dir, 60, NOW);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(attempt_files(&dir), n + 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() {
let dir = temp_dir("forced");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 }));
launch(&dir, 70, NOW).confirm();
// `--graphics default`
let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default);
// `--graphics <the last mode>` is kept for the next launches
GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm();
assert_eq!(launch(&dir, 74, NOW + 240).mode(), last);
// a mode this platform has not starts with the first one
if !LADDER.contains(&Mode::Dx12) {
assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reset_to_default_wins_over_a_later_confirm() {
let dir = temp_dir("reset");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 }));
let start = launch(&dir, 80, NOW);
assert_eq!(start.mode(), last);
// the notice's button, from a clone of the start (the app holds one)
assert_eq!(start.clone().reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_explicit_backend_leaves_the_data_folder_alone() {
let dir = temp_dir("explicit");
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 }));
let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert_eq!(start.reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
assert_eq!(attempt_files(&dir), 0);
// with --graphics too, that mode starts, still without touching the folder
let last = LADDER.last().copied().unwrap_or(Mode::Default);
assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last);
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
// no data folder: nothing is written, the default still starts
assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn step_down_walks_the_ladder_and_saves_each_mode_at_once() {
let dir = temp_dir("step");
let mut start = launch(&dir, ME, NOW);
for expected in LADDER.iter().skip(1) {
assert!(start.step_down());
assert_eq!(start.mode(), *expected);
assert_eq!(state_of(&dir), saved(*expected, 0));
assert_eq!(attempt_files(&dir), 1, "one attempt file per process");
}
assert!(!start.step_down(), "nothing after the last mode");
start.finish();
assert_eq!(attempt_files(&dir), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_software_selector_only_picks_a_software_adapter() {
if wgpu::Instance::enabled_backend_features().is_empty() {
return;
}
// only the backends software mode uses: no driver this test loads is one the app avoids
let backends = software_backends(cfg!(target_os = "windows"));
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
match pick_software(&adapters, None) {
Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()),
Err(why) => {
assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}");
}
}
assert!(pick_software(&[], None).is_err());
}
}
+562
View File
@@ -0,0 +1,562 @@
//! The desktop app's logger: `log::` records go to standard error and to a log file.
//!
//! Without a logger every `log::warn!`/`log::error!` in the workspace (GPU compositor fallbacks,
//! audio output problems, a panicked frame job or audio mixer, container warnings) vanished. A
//! launch from a desktop menu has no terminal, so the file is what a bug report can attach:
//! `<data dir>/Logs/filmcraft.log`, next to the crash logs (`--data-dir`, `FILMCRAFT_DATA_DIR` or
//! `filmcraft_engine::autosave::default_data_dir`; Help ▸ Reveal Log Files opens the folder). Each
//! start moves the previous log to `filmcraft.1.log` (and that one to `.2`), so the log of a run
//! that crashed survives the next launch.
//!
//! Levels: `info` for FilmCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
//! `warn,filmcraft_codecs=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
//! that starts with it (`filmcraft*=debug`).
//!
//! Records logged before the data directory is known are kept (up to [`MAX_PENDING`]) and written
//! once the file is attached. Writing never panics: a file that can't be created or written leaves
//! standard error as the only sink.
//!
//! Realtime audio: the cpal callbacks (`audio.rs`, `audio_in.rs`, the play-ahead reader) do not
//! log; they report through flags and a one-time `eprintln!`. A record takes the sink lock only for
//! one `write_all` of an already formatted line (standard error is written outside the lock), so a
//! logging thread such as the audio mixer never waits on more than one other line being written.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::SystemTime;
use log::LevelFilter;
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "filmcraft.log";
/// The log directory inside the data directory (shared with the crash logs and Reveal Log Files).
pub const LOG_DIR: &str = "Logs";
/// How many previous logs are kept (`filmcraft.1.log` … `filmcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The built-in filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,filmcraft*=info";
/// Per-target level filter parsed from env_logger-style directives.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Filter {
default: LevelFilter,
directives: Vec<(String, LevelFilter)>,
}
impl Filter {
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
/// directive names log errors only.
pub fn parse(spec: &str) -> Filter {
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match part.split_once('=') {
Some((name, level)) => {
let name = name.trim();
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
f.directives.push((name.to_owned(), level));
}
}
None => match parse_level(part) {
Some(level) => f.default = level,
// A bare target name: everything from it (env_logger does the same).
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
},
}
}
f
}
/// The level that applies to `target` (the most specific matching directive wins).
pub fn level_for(&self, target: &str) -> LevelFilter {
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
}
/// The most verbose level any target can reach (for `log::set_max_level`).
pub fn max(&self) -> LevelFilter {
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
}
}
fn parse_level(s: &str) -> Option<LevelFilter> {
s.trim().parse().ok()
}
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
/// `name*` matches every target starting with `name`.
fn matches(name: &str, target: &str) -> bool {
match name.strip_suffix('*') {
Some(prefix) => target.starts_with(prefix),
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("filmcraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, line: &str) {
if self.file.is_none() {
if self.capped {
return;
}
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// No file will come: drop the kept lines and keep none from now on.
pub fn close(&mut self) {
self.file = None;
self.pending = Vec::new();
self.capped = true;
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink (and
// stop buffering, since no file will come).
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
match rotate(dir) {
Ok((path, file)) => {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
Err(e) => {
self.stderr_only();
Err(format!("{}: {e}", dir.display()))
}
}
}
/// No log file this run (no data directory, or it can't be used): stop keeping records for one.
pub fn stderr_only(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
}
}
impl log::Log for AppLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= self.filter.level_for(metadata.target())
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
// Format before taking the lock, so the lock covers one write only.
let thread = std::thread::current();
let line = format_line(&timestamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
if self.stderr {
let _ = std::io::stderr().write_all(line.as_bytes());
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
}
fn flush(&self) {}
}
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
/// logger was installed first. Call [`AppLogger::attach_dir`] once the data directory is known.
pub fn install() -> Option<&'static AppLogger> {
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
let max = filter.max();
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
log::set_logger(logger).ok()?;
log::set_max_level(max);
Some(logger)
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("filmcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
}
#[test]
fn the_default_filter_shows_filmcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("filmcraft"), LevelFilter::Info);
assert_eq!(f.level_for("filmcraft::audio"), LevelFilter::Info);
assert_eq!(f.level_for("filmcraft_ui_egui::play_ahead"), LevelFilter::Info);
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,filmcraft_codecs=trace,filmcraft_codecs::mxf=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("filmcraft_codecs::ogg"), LevelFilter::Trace);
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_ui_egui::frames"), LevelFilter::Debug);
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_engine"), LevelFilter::Warn);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive; the last bare level wins.
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "a".repeat(100_000);
let specs = [
"",
",,,",
"=",
"==",
"=debug",
"filmcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=debug",
"a=b=c",
"warn,,filmcraft=DEBUG",
" \t ",
"\u{0}",
"é*=trace",
&long,
];
for spec in specs {
let f = Filter::parse(spec);
for target in ["filmcraft", "", "🦀", "é", "a::b"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,filmcraft=DEBUG").level_for("filmcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("filmcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("filmcraft.2.log")), "run 2");
assert!(!dir.join("filmcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
assert!(rotate(&dir.join("below")).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "filmcraft_ui_egui", "audio output unavailable: no output device"),
"2026-10-08T07:59:17.728Z WARN [main] filmcraft_ui_egui: audio output unavailable: no output device\n"
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_max_pending_lines_drop_nothing() {
let dir = temp_dir("exact");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
record(&logger, log::Level::Info, "filmcraft", "FilmCraft starting");
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(&logger, log::Level::Debug, "filmcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("filmcraft: FilmCraft starting")), "{text}");
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("filmcraft_engine::autosave").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// A second launch rotates: the first log becomes `.1`.
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
assert_eq!(read(&dir.join("filmcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
let dir = temp_dir("threads");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100 {
record(&logger, log::Level::Info, "filmcraft", "from a worker");
}
});
}
});
let text = read(&path);
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| l.ends_with("filmcraft: from a worker")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
let dir = temp_dir("unusable");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
assert!(e.contains(&dir.display().to_string()), "{e}");
record(&logger, log::Level::Error, "x", "still fine");
let sink = logger.sink.lock().expect("sink");
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logger = AppLogger::new(Filter::parse("info"), false);
let path = logger.attach_dir(&dir).expect("attach");
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = logger.sink.lock();
panic!("poison the sink");
}));
assert!(logger.sink.is_poisoned());
record(&logger, log::Level::Info, "filmcraft", "after the panic");
assert!(read(&path).contains("after the panic"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+397 -131
View File
@@ -1,35 +1,50 @@
//! FilmCraft desktop app.
//!
//! Usage: `filmcraft [--control <port>] [--demo|--empty] [--recover|--no-recover] [--data-dir <dir>]
//! [project.fcproj | media files…]`
//! [project.fcproj | media files…]`; `--help` prints the options, `--version` the version. An
//! unknown option is an error (exit code 2), not a file to open.
//!
//! Without a project, `--demo` or `--empty`, Settings ▸ General ▸ At Startup decides: Show Home
//! (the demo project), Open Most Recent, or an empty project.
//!
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `filmcraft_ui_egui::control` for the methods.
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
//! either source.
//!
//! Auto-save and the crash-recovery journal run in every session (data in `--data-dir`, else
//! `FILMCRAFT_DATA_DIR`, else the per-user application data folder). If a previous session died
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
//! Unsaved Changes…).
//!
//! `log` records go to standard error and `<data dir>/Logs/filmcraft.log` (`RUST_LOG` sets the
//! levels); see [`logging`].
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod app_nap;
mod appearance;
mod args;
mod audio;
mod audio_in;
mod control_server;
#[cfg(target_os = "linux")]
mod dev_icon;
mod file_filters;
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod window_raise;
use args::{Cli, Launch};
use chrono::TimeZone;
use filmcraft_engine::Session;
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
use filmcraft_ui_egui::FilmcraftApp;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id.
const APP_ID: &str = "ai.storyteller.filmcraft";
@@ -45,35 +60,112 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// Print `text` (stdout, or stderr for an error). A Windows release build has no console
/// (`windows_subsystem = "windows"`), so there it is also shown in a message box: otherwise `--help`
/// would print nothing and a bad option would quit without a word.
fn tell(text: &str, error: bool) {
if error {
eprint!("{text}");
} else {
print!("{text}");
}
#[cfg(all(target_os = "windows", not(debug_assertions)))]
{
let level = if error { rfd::MessageLevel::Error } else { rfd::MessageLevel::Info };
let _ = rfd::MessageDialog::new().set_level(level).set_title("FilmCraft").set_description(text).show();
}
}
fn main() -> eframe::Result {
app_nap::disable();
let mut control_port: Option<u16> = std::env::var("FILMCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
// --demo / --empty given: skip Settings ▸ General ▸ At Startup
let mut startup_flag = false;
let mut recover: Option<bool> = None;
let mut data_dir = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => (demo, startup_flag) = (true, true),
"--empty" => (demo, startup_flag) = (false, true),
"--recover" => recover = Some(true),
"--no-recover" => recover = Some(false),
"--data-dir" => data_dir = args.next().map(std::path::PathBuf::from),
"--version" => {
println!("filmcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
// First, so the panic hook and every start-up record are logged (`logging`).
let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
let launch = match args::parse(env_port, std::env::args_os().skip(1)) {
Ok(Cli::Run(l)) => l,
Ok(Cli::Help) => {
tell(args::USAGE, false);
return Ok(());
}
Ok(Cli::Version) => {
tell(&format!("filmcraft {}\n", env!("CARGO_PKG_VERSION")), false);
return Ok(());
}
Err(e) => {
tell(&format!("filmcraft: {e}\n\n{}", args::USAGE), true);
std::process::exit(2);
}
};
let data_root = launch.data_dir.clone().or_else(default_data_dir);
let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR));
log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives next to the crash logs; opened after the arguments, so `--help`,
// `--version` and usage errors leave no file behind. Records logged until now go in first.
if let Some(logger) = logger {
match log_dir.as_deref().map(|d| logger.attach_dir(d)) {
Some(Ok(path)) => log::info!("log file {}", path.display()),
Some(Err(e)) => log::warn!("no log file: {e}"),
None => {
logger.stderr_only();
log::warn!("no log file: no data directory (set FILMCRAFT_DATA_DIR or --data-dir)");
}
_ => files.push(a),
}
}
app_nap::disable();
#[cfg(target_os = "linux")]
dev_icon::ensure_dev_desktop_entry();
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
let options = eframe::NativeOptions {
filmcraft_ui_egui::crash::install(log_dir);
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
register_hardware_decoders();
// Which graphics backends and adapter the window starts with: `--graphics`, else the saved
// mode, which moves on after starts that died before their window painted (a driver crash),
// see graphics.rs.
let backend_override = eframe::wgpu::Backends::from_env();
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics);
// The GPU export renderer creates its own wgpu instance with every backend; after a start
// fell back, that would load the driver that failed. Exports then composite on the CPU.
if graphics.mode() == graphics::Mode::Default {
register_gpu_frame_renderer();
}
let started = loop {
let mut options = native_options(launch.control_port.is_some());
// Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs).
graphics::apply(&mut options, graphics.mode(), backend_override);
let app_created = Arc::new(AtomicBool::new(false));
let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created)));
let app_created = app_created.load(Ordering::Relaxed);
match &result {
// wgpu reported the failure (no adapter, device creation failed) before the app
// existed: try the next mode in this process.
Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => {
log::warn!("graphics start failed: {e}");
continue;
}
// Closed before the second frame: the window still came up.
Ok(()) if app_created => graphics.confirm(),
_ => {}
}
break result;
};
// However it ended, this start no longer counts as one that died.
graphics.finish();
// The window could not be created (no usable GPU / graphics driver, no display): say so
// instead of exiting silently, and keep the reason in the crash log.
if let Err(e) = &started {
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
log::error!("{msg}");
filmcraft_ui_egui::crash::record(&msg);
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
}
started
}
/// The main window's options; built for each start attempt (they hold a one-shot hook).
fn native_options(agent: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FilmCraft")
.with_inner_size([1600.0, 980.0])
@@ -83,116 +175,187 @@ fn main() -> eframe::Result {
.with_titlebar_shown(false)
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
.with_active(!agent)
.with_icon(app_icon())
// Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`.
.with_app_id(APP_ID),
event_loop_builder: agent_event_loop(control_port.is_some()),
event_loop_builder: agent_event_loop(agent),
..Default::default()
};
let started = eframe::run_native(
"FilmCraft",
options,
Box::new(move |cc| {
let mut session = Session::default();
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
let mut cfg = AutosaveConfig::new(dir);
cfg.local_offset = local_offset;
if let Err(e) = session.start_autosave(cfg) {
eprintln!("filmcraft: auto-save and crash recovery unavailable: {e}");
}
}
// voice-over recording reads the microphone through cpal
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
eprintln!("filmcraft: {e}");
}
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
match recent {
Some(p) => {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
eprintln!("filmcraft: {e}");
}
}
None => {
let _ = session.execute("file.openDemoProject", json!({}));
}
}
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect();
if !media.is_empty() {
let _ = session.execute("file.import", json!({"paths": media}));
}
if recover == Some(true) && !session.recovery_candidates().is_empty() {
let id = session.recovery_candidates()[0].id.clone();
match session.execute("file.recover", json!({"id": id})) {
Ok(r) => eprintln!("filmcraft: recovered {r}"),
Err(e) => eprintln!("filmcraft: recovery failed: {e}"),
}
}
let mut app = FilmcraftApp::new(session);
if recover == Some(false) {
app.dialog = None;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
{
app.set_wgpu(rs);
}
if let Some(out) = audio::CpalOut::new() {
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`)
app.audio = Some(Box::new(out));
}
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
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_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.open_path = Some(Box::new(open_path));
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));
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, 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 {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
Ok(Box::new(app))
}),
);
// The window could not be created (no usable GPU / graphics driver, no display): say so
// instead of exiting silently, and keep the reason in the crash log.
if let Err(e) = &started {
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
eprintln!("filmcraft: {msg}");
filmcraft_ui_egui::crash::record(&msg);
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
}
started
}
/// Creates the app once eframe has its window and graphics device; sets `created` when it runs,
/// and confirms the graphics mode once two frames have painted.
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
Box::new(move |cc| {
created.store(true, Ordering::Relaxed);
// A fallback graphics mode says so in the window, with a button back to the default.
let notice = graphics.degraded().then(|| {
let start = graphics.clone();
filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default()))
});
let confirmed = AtomicBool::new(false);
cc.egui_ctx.on_end_pass(
"graphics-start",
Arc::new(move |ui| {
if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) {
graphics.confirm();
}
}),
);
let mut session = Session::default();
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
let mut cfg = AutosaveConfig::new(dir);
cfg.local_offset = local_offset;
if let Err(e) = session.start_autosave(cfg) {
log::warn!("auto-save and crash recovery unavailable: {e}");
}
}
// voice-over recording reads the microphone through cpal
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
let (project, media) = args::project_and_media(&files);
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
match recent {
Some(p) => {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
}
None => {
let _ = session.execute("file.openDemoProject", json!({}));
}
}
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
if !media.is_empty() {
let _ = session.execute("file.import", json!({"paths": media}));
}
if recover == Some(true) && !session.recovery_candidates().is_empty() {
let id = session.recovery_candidates()[0].id.clone();
match session.execute("file.recover", json!({"id": id})) {
Ok(r) => log::info!("recovered {r}"),
Err(e) => log::error!("recovery failed: {e}"),
}
}
let mut app = FilmcraftApp::new(session);
app.graphics_notice = notice;
if recover == Some(false) {
app.dialog = None;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
{
app.set_wgpu(rs);
}
// Keep device selection available even when the default device is unavailable.
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
app.audio = Some(Box::new(audio::CpalOut::new()));
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
// a full path (Quick Export, Export mode) opens the dialog in its folder
let path = std::path::Path::new(name);
let mut dialog = rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(path.file_name().map_or(name.into(), |n| n.to_string_lossy()));
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
dialog = dialog.set_directory(dir);
}
dialog.save_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_folder_at =
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
// Linux: the XDG portal call blocks until the user chooses, which would stop the frame
// loop (GNOME offers to force-quit the window after about five seconds, #260). Show the
// dialog on its own thread instead; the app keeps running and acts on the answer.
#[cfg(target_os = "linux")]
{
app.hooks.run_dialog = Some(Box::new(run_dialog_off_thread));
}
app.hooks.open_path = Some(Box::new(open_path));
// Help menu, About and Discord links (#642): eframe is built without its `links`
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
// that waits for the browser never freezes the UI.
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
// compositor reports no theme to winit (no polling: see appearance.rs).
app.hooks.system_theme = appearance::service();
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(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_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
app.command_inbox = Some(rx);
app.hooks.menu_changed = Some(update);
app.ui.show_menu_bar = false;
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
}
Ok(Box::new(app))
})
}
/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611).
fn clipboard_media() -> Option<filmcraft_ui_egui::ClipboardMedia> {
let mut clipboard = arboard::Clipboard::new().ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(filmcraft_ui_egui::ClipboardMedia::Files(files.iter().map(|p| p.to_string_lossy().into_owned()).collect()));
}
let image = clipboard.get_image().ok()?;
Some(filmcraft_ui_egui::ClipboardMedia::Image { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Open a file in its default application, or reveal it in the file manager (Edit Original,
@@ -214,6 +377,7 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
}
c
} else {
// Linux file managers have no select-file flag: open the parent folder instead.
let mut c = std::process::Command::new("xdg-open");
let p = std::path::Path::new(path);
c.arg(if reveal { p.parent().unwrap_or(p) } else { p });
@@ -222,6 +386,39 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
cmd.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
}
/// The GPU export frame renderer (filmcraft-gpu's off-screen compositor behind filmcraft-export's
/// frame-renderer hook): exports with GPU rendering Auto composite on the GPU and fall back to the
/// CPU reference renderer wherever it cannot.
struct GpuFrameRenderer(filmcraft_gpu::ExportRenderer);
impl filmcraft_export::FrameRenderer for GpuFrameRenderer {
fn render(
&mut self,
project: &filmcraft_project::Project,
seq: filmcraft_project::ItemId,
t: filmcraft_time::Tick,
opts: filmcraft_render::RenderOptions,
sources: &dyn filmcraft_render::SourceProvider,
) -> Option<filmcraft_render::Image> {
self.0.render(project, seq, t, opts, sources)
}
}
fn register_gpu_frame_renderer() {
filmcraft_export::register_frame_renderer(|| {
filmcraft_gpu::ExportRenderer::new().map(|r| Box::new(GpuFrameRenderer(r)) as Box<dyn filmcraft_export::FrameRenderer>)
});
}
/// Put the OS hardware video decoders in front of our own. Registered in a statement of its own:
/// a log macro does not evaluate its arguments while no logger takes its level, which left the
/// hardware decoders out of every run.
fn register_hardware_decoders() -> filmcraft_platform::Availability {
let hardware = filmcraft_platform::register();
log::info!("hardware decoding: {hardware:?}");
hardware
}
/// Local UTC offset at a unix time (auto-save file names and recovery times use local time).
fn local_offset(unix: i64) -> i32 {
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)
@@ -240,3 +437,72 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
let _ = agent;
None
}
/// Show `dialog` with rfd on a worker thread and answer on the returned channel (#260). Filter
/// names are translated here, on the UI thread (the interface language is per thread). A dialog
/// thread that can't start or panics drops the sender: the app takes that as cancelled.
#[cfg(target_os = "linux")]
fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> std::sync::mpsc::Receiver<Vec<String>> {
use filmcraft_ui_egui::native_dialogs::FileDialog;
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
let media = t("Media");
let project = t("FilmCraft Project");
let dialog = match dialog {
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts },
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts, name },
other => other,
};
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
let shown = std::panic::catch_unwind(move || {
let path = |p: std::path::PathBuf| p.to_string_lossy().into_owned();
match dialog {
FileDialog::OpenFiles { exts } => {
rfd::FileDialog::new().add_filter(&media, &exts).pick_files().unwrap_or_default().into_iter().map(path).collect()
}
FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(),
FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(),
FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &["fcproj"]).pick_file().map(path).into_iter().collect(),
FileDialog::SaveProject { name } => {
rfd::FileDialog::new().add_filter(&project, &["fcproj"]).set_file_name(name).save_file().map(path).into_iter().collect()
}
FileDialog::SaveAs { filter, exts, name } => {
rfd::FileDialog::new().add_filter(&filter, &exts).set_file_name(name).save_file().map(path).into_iter().collect()
}
FileDialog::Folder { at } => {
let d = rfd::FileDialog::new();
let d = match at {
Some(dir) if !dir.is_empty() => d.set_directory(dir),
_ => d,
};
d.pick_folder().map(path).into_iter().collect::<Vec<_>>()
}
}
});
match shown {
Ok(paths) => {
let _ = tx.send(paths);
}
Err(_) => log::warn!("the file dialog thread panicked; taken as cancelled"),
}
});
if let Err(e) = spawned {
log::warn!("could not start the file dialog thread: {e}");
}
rx
}
#[cfg(test)]
mod tests {
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
/// installed here, as in a normal run).
#[test]
fn startup_registers_the_hardware_decoders_without_a_logger() {
assert!(!log::log_enabled!(log::Level::Info));
let hardware = super::register_hardware_decoders();
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
}
}
+84 -20
View File
@@ -4,7 +4,7 @@
use std::sync::mpsc::{Receiver, channel};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items};
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items};
use muda::accelerator::Accelerator;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
@@ -19,50 +19,73 @@ fn accel(s: &str) -> Option<Accelerator> {
mapped.parse().ok()
}
/// Updates native key equivalents when the active keyboard shortcuts change.
pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, MenuUpdater) {
let items = menu_items(app);
let mut native: Vec<(String, MenuItem)> = Vec::new();
// each native item with the menu entry it currently shows, so updates touch only what changed
let mut native: Vec<(Item, MenuItem)> = Vec::new();
let mut titles: Vec<(String, Submenu)> = Vec::new();
let bar = Menu::new();
let app_menu = Submenu::new("FilmCraft", true);
let [settings_label, hide_label, hide_others_label, quit_label] = NATIVE_APP_MENU;
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
let settings = Submenu::new("Settings", true);
let settings = Submenu::new(app.ui.language.tr(settings_label), true);
titles.push((settings_label.into(), settings.clone()));
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
let label = it.label.trim_end_matches('…').to_string();
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
native.push((it.id.clone(), mi.clone()));
let mi = MenuItem::with_id(it.id.clone(), label, it.enabled, it.shortcut.as_deref().and_then(accel));
native.push((it.clone(), mi.clone()));
let _ = settings.append(&mi);
}
// About and Discord repeat Help menu commands: same translated labels, updated with them
let label = |id: &str, english: &str| items.iter().find(|i| i.id == id).map_or_else(|| english.to_string(), |i| i.label.clone());
let about = MenuItem::with_id("app.about", label("app.about", "About FilmCraft"), true, None);
let discord = MenuItem::with_id("help.discord", label("help.discord", "Join the ArtCraft Discord…"), true, None);
for (id, mi) in [("app.about", &about), ("help.discord", &discord)] {
if let Some(it) = items.iter().find(|i| i.id == id) {
// recorded as built (enabled, no shortcut), so the first update applies the real state
native.push((Item { enabled: true, shortcut: None, ..it.clone() }, mi.clone()));
}
}
// the predefined items keep their native actions; our text lets them follow the language
let predefined = [
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
(quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))),
];
let [(_, hide), (_, hide_others), (_, quit)] = &predefined;
let _ = app_menu.append_items(&[
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
&MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None),
&about,
&discord,
&PredefinedMenuItem::separator(),
&settings,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
hide,
hide_others,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
quit,
]);
let _ = bar.append(&app_menu);
for top in MENUS {
let sub = Submenu::new(top, true);
let sub = Submenu::new(app.ui.language.tr(top), true);
titles.push((top.into(), sub.clone()));
let mine: Vec<&Item> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top) && !i.id.starts_with("app.settings.")).collect();
// submenus by path prefix (e.g. Clip ▸ Video Options ▸ Time Interpolation), created where
// their first item appears
let mut subs: Vec<(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()));
let mi = MenuItem::with_id(it.id.clone(), &it.label, it.enabled, it.shortcut.as_deref().and_then(accel));
native.push(((*it).clone(), mi.clone()));
let mut parent = sub.clone();
for depth in 1..it.path.len() {
let key = it.path[..=depth].to_vec();
parent = match subs.iter().find(|(k, _)| *k == key) {
Some((_, s)) => s.clone(),
None => {
let s = Submenu::new(&it.path[depth], true);
let s = Submenu::new(app.ui.language.tr(&it.path[depth]), true);
titles.push((it.path[depth].clone(), s.clone()));
let _ = parent.append(&s);
subs.push((key, s.clone()));
s
@@ -87,11 +110,52 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
ctx.request_repaint();
}
});
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 mut shown_language = app.ui.language;
let update: MenuUpdater = Box::new(move |items: &[Item]| {
let checked = |id: &str| items.iter().any(|it| it.id == id && it.checked == Some(true));
let language = if checked("app.language.japanese") {
filmcraft_ui_egui::i18n::Language::Ja
} else if checked("app.language.spanish") {
filmcraft_ui_egui::i18n::Language::Es
} else if checked("app.language.portuguese") {
filmcraft_ui_egui::i18n::Language::PtBr
} else if checked("app.language.ukrainian") {
filmcraft_ui_egui::i18n::Language::Uk
} else if checked("app.language.chinese") {
filmcraft_ui_egui::i18n::Language::ZhCn
} else if checked("app.language.german") {
filmcraft_ui_egui::i18n::Language::De
} else if checked("app.language.russian") {
filmcraft_ui_egui::i18n::Language::Ru
} else if checked("app.language.french") {
filmcraft_ui_egui::i18n::Language::Fr
} else if checked("app.language.indonesian") {
filmcraft_ui_egui::i18n::Language::Id
} else {
filmcraft_ui_egui::i18n::Language::En
};
if language != shown_language {
shown_language = language;
for (title, submenu) in &titles {
submenu.set_text(language.tr(title));
}
}
for (label, item) in &predefined {
item.set_text(language.tr(label));
}
for (shown, mi) in &mut native {
let Some(it) = items.iter().find(|i| i.id == shown.id) else { continue };
if it.label != shown.label {
// Settings entries drop the trailing ellipsis, as when the menu was built
mi.set_text(if it.id.starts_with("app.settings.") { it.label.trim_end_matches('…') } else { &it.label });
}
if it.shortcut != shown.shortcut {
let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel));
}
if it.enabled != shown.enabled {
mi.set_enabled(it.enabled);
}
*shown = it.clone();
}
});
(rx, update)
File diff suppressed because it is too large. Load diff
+79
View File
@@ -0,0 +1,79 @@
# Contributor names for the About window (craftrules standards/contributors.md).
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
# display_name = "Nickname" # shown when the viewer picks "Display name"
# sync_github_name = true # or: use the name on your public GitHub profile as display name
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.ahmedhosnypro]
real_name = "Ahmed Hosny"
display_name = "Ahmed Hosny"
sync_github_name = false
[people.sanyamsingh001]
display_name = "sanyamsingh001"
sync_github_name = false
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.swillunderscore]
display_name = "Swill Software"
[people.IppSec]
sync_github_name = true
[people.ItzBloxy]
display_name = "Bloxy"
[people.Hathor875]
real_name = "Krzysztof Cieślik"
sync_github_name = true
[people.ReReddo]
display_name = "ReReddo"
sync_github_name = false
[people.liangforstudy]
sync_github_name = true
[people.keller-lukas]
real_name = "Lukas Keller"
display_name = "Lukas Keller"
sync_github_name = false
[people.maxm11]
real_name = "Max Murphy"
display_name = "Max Murphy"
sync_github_name = false
[people.ossini]
sync_github_name = true
[people.opentechnepal]
sync_github_name = true
[people.vangel76]
real_name = "Valentin Klein"
display_name = "Valentin Klein"
sync_github_name = false
+3
View File
@@ -48,6 +48,9 @@ from bitrate/quality), `tns`, `ms_stereo`, `window_shape` (sine/KBD), `adts` (pr
| 7 | 0 + PCE | C L R Ls Rs Cs LFE (PCE in the ASC; repeated in every AU when ADTS-framed) |
| 8 | 7 | C Lc Rc L R Ls Rs LFE |
The decoder also reads configurations 11 (6.1: C L R Ls Rs Cs LFE) and 12 (7.1: C L R Ls Rs Lsr Rsr
LFE) from ISO/IEC 14496-3 Amd 4; ffmpeg 9 writes 7.1 AAC as configuration 12.
`Decoder::layout()` reports the element layout (it follows a PCE for configuration 0).
## Encoder
+12 -2
View File
@@ -136,7 +136,8 @@ impl ProgramConfig {
}
}
/// Element layout of `channelConfiguration` 1..=7 (tags as conventionally assigned).
/// Element layout of `channelConfiguration` 1..=7, 11 and 12 (tags as conventionally assigned; 11 and
/// 12 from ISO/IEC 14496-3 Amd 4: 6.1 with a back centre, 7.1 with back surrounds).
pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
use ElementType::*;
Some(match channel_config {
@@ -146,7 +147,8 @@ pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
4 => vec![(Sce, 0), (Cpe, 0), (Sce, 1)],
5 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1)],
6 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Lfe, 0)],
7 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
7 | 12 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
11 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Sce, 1), (Lfe, 0)],
_ => return None,
})
}
@@ -388,6 +390,14 @@ mod tests {
assert_eq!(AudioSpecificConfig::parse(&a.to_bytes()).unwrap().channels(), 6);
}
#[test]
fn amd4_channel_configurations() {
// 6.1 and 7.1 back (#922); 22.2 and 7.1 with heights stay unsupported
let channels = |c| AudioSpecificConfig::parse(&AudioSpecificConfig::lc(3, c).to_bytes()).map(|a| a.channels()).ok();
assert_eq!([7, 11, 12].map(channels), [Some(8), Some(7), Some(8)]);
assert_eq!([13, 14].map(config_layout), [None, None]);
}
#[test]
fn asc_with_pce() {
let pce = ProgramConfig {
+42 -2
View File
@@ -19,6 +19,10 @@ const ID_PCE: u32 = 5;
const ID_FIL: u32 = 6;
const ID_END: u32 = 7;
/// `extension_type` of a fill element carrying SBR data (ISO/IEC 14496-3 §4.5.2.8.2, Table 4.121).
const EXT_SBR_DATA: u32 = 13;
const EXT_SBR_DATA_CRC: u32 = 14;
/// One decoded `individual_channel_stream` before stereo processing and synthesis.
struct Ics {
info: IcsInfo,
@@ -45,6 +49,8 @@ pub struct Decoder {
offsets: Vec<usize>,
channels: Vec<ChannelState>,
noise_state: u32,
/// A fill element carried SBR data (implicitly signalled HE-AAC).
sbr_seen: bool,
}
impl Decoder {
@@ -67,7 +73,7 @@ impl Decoder {
if layout.is_empty() && asc.channel_config != 0 {
return Err(Error::InvalidConfig("unsupported channel configuration"));
}
let mut d = Decoder { asc, layout: Vec::new(), offsets: Vec::new(), channels: Vec::new(), noise_state: 0x2545_F491 };
let mut d = Decoder { asc, layout: Vec::new(), offsets: Vec::new(), channels: Vec::new(), noise_state: 0x2545_F491, sbr_seen: false };
d.set_layout(layout);
Ok(d)
}
@@ -90,6 +96,12 @@ impl Decoder {
pub fn sample_rate(&self) -> u32 {
self.asc.sample_rate
}
/// Whether the stream is HE-AAC: SBR signalled in the `AudioSpecificConfig` (explicit) or SBR
/// data seen in a fill element of a decoded access unit (implicit). Only the AAC-LC core is
/// decoded, at [`Self::sample_rate`]; the SBR high band is not reconstructed.
pub fn sbr(&self) -> bool {
self.asc.sbr || self.sbr_seen
}
pub fn channels(&self) -> usize {
self.channels.len()
}
@@ -174,7 +186,11 @@ impl Decoder {
count += br.read_bits(8)? as usize;
count -= 1;
}
br.skip(count * 8)?;
if count > 0 {
let ext = br.read_bits(4)?;
self.sbr_seen |= matches!(ext, EXT_SBR_DATA | EXT_SBR_DATA_CRC);
br.skip(count * 8 - 4)?;
}
}
_ => break, // ID_END
}
@@ -542,3 +558,27 @@ fn synth(state: &mut ChannelState, info: &IcsInfo, spec: &[f32]) -> Vec<f32> {
state.prev_shape = cur;
out
}
#[cfg(test)]
mod tests {
use super::*;
/// Implicitly signalled HE-AAC (an AAC-LC config) is recognised from the SBR fill element.
#[test]
fn sbr_fill_element_marks_implicit_he_aac() {
// AAC-LC, 22.05 kHz, stereo
let mut dec = Decoder::new(&[0x13, 0x90]).unwrap();
assert!(!dec.sbr());
// ID_FIL, count 1, extension_type EXT_SBR_DATA (+ 4 bits of payload), ID_END
let _ = dec.decode(&[0b1100_0011, 0b1010_0001, 0b1100_0000]);
assert!(dec.sbr());
// other fill payloads (EXT_FILL) don't
let mut dec = Decoder::new(&[0x13, 0x90]).unwrap();
let _ = dec.decode(&[0b1100_0010, 0b0000_0001, 0b1100_0000]);
assert!(!dec.sbr());
// explicit signalling: AOT 5, core 22.05 kHz, stereo, extension 44.1 kHz, core AOT 2
let dec = Decoder::new(&[0x2B, 0x92, 0x08, 0x00]).unwrap();
assert!(dec.sbr());
assert_eq!(dec.sample_rate(), 22_050);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "filmcraft-ac3"
description = "Clean-room AC-3 (Dolby Digital, ATSC A/52) audio decoder"
description = "Clean-room AC-3 and E-AC-3 (Dolby Digital / Dolby Digital Plus, ATSC A/52) audio decoder"
version.workspace = true
edition.workspace = true
license.workspace = true
+42 -6
View File
@@ -1,6 +1,6 @@
# filmcraft-ac3
Clean-room AC-3 (Dolby Digital) audio decoder in pure Rust. Layer L0: depends only on
Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder in pure Rust. Layer L0: depends only on
`thiserror`. Builds for `wasm32-unknown-unknown`; no `unsafe`. No GPL/LGPL code (FFmpeg,
liba52) was consulted; FFmpeg is used only as an external fixture generator and test oracle.
@@ -11,14 +11,17 @@ publicly available standard: §5 bit stream syntax and semantics, §6 decoding o
exponents, §7.2 parametric bit allocation (Tables 7.6-7.16 extracted mechanically from the
standard's text), §7.3 mantissas and dither, §7.4 coupling, §7.5 rematrixing, §7.7.1 dynamic
range, §7.9 transforms and block switching. The window is computed as the Kaiser-Bessel derived
window (α = 5) and checked against Table 7.33 to 6·10⁻⁶.
window (α = 5) and checked against Table 7.33 to 6·10⁻⁶. E-AC-3: Annex E §E2 bit stream syntax
(syncinfo, bsi, audfrm, audblk; Tables E2.1-E2.13, frame exponent strategies from Table E2.10),
§E3.3 modified parameters, §E3.4.2 AHT helper variables (to detect AHT), §E3.6 spectral extension
(attenuation Table E3.14, which is 2^(-(code + 1)(bin + 1) / 15), checked against the table).
## API
```rust
let mut dec = filmcraft_ac3::Decoder::new();
let h = filmcraft_ac3::parse_header(frame)?; // rate, channels, frame size
let out = dec.decode(frame)?; // 1536 samples per channel, WAV channel order
let out = dec.decode(frame)?; // h.samples() per channel, WAV channel order
dec.reset(); // before decoding from another position
```
@@ -26,7 +29,7 @@ dec.reset(); // before decoding from another
- Every audio coding mode (1+1, 1/0, 2/0, 3/0, 2/1, 3/1, 2/2, 3/2) with or without LFE; 32 /
44.1 / 48 kHz and every frame size; bsid ≤ 8 (9 and 10, the half / quarter-rate variants, are
accepted). E-AC-3 (bsid 11-16) is refused with `Error::Unsupported`.
accepted).
- D15 / D25 / D45 exponents with reuse across blocks; the parametric bit allocation in the
standard's fixed-point steps, delta bit allocation, the all-zero SNR offset special case;
grouped 3-, 5- and 11-level mantissas (groups shared across exponent sets), 7- and 15-level
@@ -43,6 +46,26 @@ dec.reset(); // before decoding from another
- Errors (bad exponents, reserved codes, blocks running past the frame) return `Err`; garbage
never panics.
## E-AC-3 (bsid 11-16)
- Independent substream 0 is decoded (stream types 0 and 2); dependent substreams (the channels
beyond 5.1 of a 7.1 program) and further independent programs decode to no channels, so a 7.1
stream plays as its 5.1 core. `Header::is_primary` tells them apart.
- 1, 2, 3 or 6 blocks per syncframe (`Header::samples`), 32 / 44.1 / 48 kHz and the reduced rates
16 / 22.05 / 24 kHz, every acmod with or without LFE.
- The whole bsi (mixing and informational metadata, custom channel maps, addbsi) is parsed and
skipped; audfrm: per-block or frame-based exponent strategies (exponents may be reused across
syncframes of fewer than six blocks; the decoder keeps them until `reset`), the syntax-enable
flags and their defaults, SNR offset strategies 1-3, converter fields, block start information.
- Audio blocks share the AC-3 path: coupling with the default or transmitted band structure
(`firstcplcos` / `firstcplleak` states), rematrixing with the Annex E band count, end mantissas
derived from spectral extension.
- Spectral extension: band structure, coordinates, blending factors, translation with wrap,
banded RMS, the border / wrap-point notch filter, noise blending and scaling (§E3.6.4).
- Refused with `Error::Unsupported`: the adaptive hybrid transform (when a channel uses it; `ahte`
alone is fine) and enhanced coupling. Transient pre-noise processing data is read and not
applied.
## Accuracy (vs FFmpeg as an external oracle)
AC-3 output is not bit-exact across decoders: zero-bit mantissas are filled with
@@ -56,10 +79,17 @@ FFmpeg's decode (worst channel SNR):
| 1/0 32 kHz 96 kb/s | 94.1 dB | 95.7 dB |
| 3/2 + LFE 48 kHz 448 kb/s | 81.4 dB | 90.2 dB |
| 2/0 pink noise 128 kb/s (many zero-bit mantissas) | 27.7 dB | 27.9 dB |
| E-AC-3 2/0 48 kHz 192 kb/s (tones, chirp) | 64.8 dB | 74.7 dB |
| E-AC-3 2/0 44.1 kHz 64 kb/s with coupling and rematrixing | 65.5 dB | 66.1 dB |
| E-AC-3 1/0 32 kHz 64 kb/s | 91.0 dB | 90.1 dB |
| E-AC-3 3/2 + LFE 48 kHz 384 kb/s with mixing / informational metadata | 81.6 dB | 85.3 dB |
| E-AC-3 2/0 pink noise 96 kb/s | 48.6 dB | 47.9 dB |
The noise row shows the difference is dither: two of our own decodes with different dither
sequences differ as much as we differ from FFmpeg. In MPEG-2 TS (AVCHD-style `.mts`) and VOB the
maximum sample difference to FFmpeg is 2.3·10⁻³.
maximum sample difference to FFmpeg is 2.3·10⁻³. E-AC-3 through `filmcraft-codecs`
(`tests/eac3_oracle.rs`): 5.1 in MP4 and Matroska 91.3 dB, stereo in QuickTime 87.0 dB and
MPEG-TS 79.5 dB against FFmpeg's decode.
## Gaps
@@ -67,4 +97,10 @@ maximum sample difference to FFmpeg is 2.3·10⁻³.
§7.9.4.2 but not checked against an oracle.
- No downmixing, no heavy compression (`compr`) or dialogue normalisation (both are metadata
for playback systems), no CRC check (corrupt frames are caught by consistency checks).
- E-AC-3 is not decoded.
- FFmpeg's E-AC-3 encoder writes six-block syncframes with frame-based exponent strategies,
coupling with the default band structure and none of the optional per-block syntax. Spectral
extension (no free encoder writes it), fewer blocks per syncframe, the reduced rates and the
per-block syntax (shared with AC-3) are implemented from the standard and covered by unit tests
on synthetic data, not by an oracle.
- E-AC-3: no adaptive hybrid transform, enhanced coupling, transient pre-noise processing, or
channels from dependent substreams (7.1 plays as 5.1).
+634
View File
@@ -0,0 +1,634 @@
//! E-AC-3 (ATSC A/52:2012 Annex E): the syncframe header and bit stream information (§E2.2.2),
//! the audio frame (§E2.2.3) and spectral extension (§E2.2.4, §E3.6). The audio blocks share the
//! AC-3 decoder (`Decoder::audio_block`), which reads the E-AC-3 differences from [`FrameInfo`].
use crate::{Bits, Error, Header, NFCHANS, Result};
/// Sample rates by fscod, and by fscod2 when fscod is 3 (Tables E2.2, E2.3).
const RATES: [u32; 3] = [48_000, 44_100, 32_000];
const REDUCED_RATES: [u32; 3] = [24_000, 22_050, 16_000];
/// Audio blocks per syncframe by numblkscod (Table E2.4).
const BLOCKS: [u8; 4] = [1, 2, 3, 6];
/// Exponent strategies of the six blocks for each frame-based strategy code (Table E2.10):
/// 0 reuse, 1 D15, 2 D25, 3 D45.
const FRAME_EXPSTR: [[u8; 6]; 32] = [
[1, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 3],
[1, 0, 0, 0, 2, 0],
[1, 0, 0, 0, 3, 3],
[2, 0, 0, 2, 0, 0],
[2, 0, 0, 2, 0, 3],
[2, 0, 0, 3, 2, 0],
[2, 0, 0, 3, 3, 3],
[2, 0, 1, 0, 0, 0],
[2, 0, 2, 0, 0, 3],
[2, 0, 2, 0, 2, 0],
[2, 0, 2, 0, 3, 3],
[2, 0, 3, 2, 0, 0],
[2, 0, 3, 2, 0, 3],
[2, 0, 3, 3, 2, 0],
[2, 0, 3, 3, 3, 3],
[3, 1, 0, 0, 0, 0],
[3, 1, 0, 0, 0, 3],
[3, 2, 0, 0, 2, 0],
[3, 2, 0, 0, 3, 3],
[3, 2, 0, 2, 0, 0],
[3, 2, 0, 2, 0, 3],
[3, 2, 0, 3, 2, 0],
[3, 2, 0, 3, 3, 3],
[3, 3, 1, 0, 0, 0],
[3, 3, 2, 0, 0, 3],
[3, 3, 2, 0, 2, 0],
[3, 3, 2, 0, 3, 3],
[3, 3, 3, 2, 0, 0],
[3, 3, 3, 2, 0, 3],
[3, 3, 3, 3, 2, 0],
[3, 3, 3, 3, 3, 3],
];
/// Default coupling band structure by coupling sub-band (Table E2.12, "Default Coupling Banding
/// Structure"; sub-band 0 never starts a combined band).
const DEFAULT_CPLBNDSTRC: [bool; 18] = {
let mut t = [false; 18];
let ones = [8, 10, 11, 13, 14, 15, 16, 17];
let mut i = 0;
while i < ones.len() {
t[ones[i]] = true;
i += 1;
}
t
};
/// Default spectral extension band structure by sub-band (Table E2.11).
const DEFAULT_SPXBNDSTRC: [bool; 17] = {
let mut t = [false; 17];
let mut i = 8;
while i < 17 {
t[i] = true;
i += 2;
}
t
};
/// First transform coefficient of spectral extension sub-band `k` (Table E3.13, k ≤ 17).
const fn spx_band_bin(k: usize) -> usize {
25 + 12 * k
}
/// Spectral extension attenuation (Table E3.14): spxattentab[code][binindex] = 2^(-(code + 1) ·
/// (binindex + 1) / 15), which the table lists to nine decimals.
fn spx_atten(code: usize, binindex: usize) -> f32 {
let e = (code as f64 + 1.0) * (binindex as f64 + 1.0) / 15.0;
(-e).exp2() as f32
}
/// Parse the header of an E-AC-3 syncframe (bsid 11-16) at the start of `b` (at least 7 bytes).
pub(crate) fn parse_header(b: &[u8], bsid: u8) -> Result<Header> {
let mut r = Bits::new(b.get(2..).unwrap_or_default());
let strmtyp = r.read(2) as u8;
let substreamid = r.read(3) as u8;
let words = r.read(11) as usize + 1;
let fscod = r.read(2) as usize;
let (sample_rate, blocks) = match RATES.get(fscod) {
Some(&rate) => (rate, BLOCKS[r.read(2) as usize & 3]),
None => (*REDUCED_RATES.get(r.read(2) as usize).ok_or(Error::Invalid("reserved fscod2"))?, 6),
};
let acmod = r.read(3) as u8;
let lfeon = r.bit();
if strmtyp == 3 {
return Err(Error::Unsupported("reserved E-AC-3 stream type 3".into()));
}
let frame_bytes = words * 2;
if frame_bytes < 8 {
return Err(Error::Invalid("E-AC-3 syncframe too short"));
}
// frame bits per second of audio
let bits = frame_bytes as u64 * 8 * u64::from(sample_rate) / (256 * u64::from(blocks));
let bitrate_kbps = u32::try_from(bits / 1000).unwrap_or(u32::MAX);
Ok(Header { sample_rate, bitrate_kbps, frame_bytes, bsid, acmod, lfeon, blocks, strmtyp, substreamid })
}
/// What the audio frame and blocks need from the bit stream information.
pub(crate) struct Bsi {
pub strmtyp: u8,
/// Sample rate code for the bit allocation's hearing threshold: fscod, or fscod2 at the
/// reduced rates.
pub fscod: usize,
pub numblkscod: usize,
}
/// Read syncinfo and bsi (§E2.2.1-2), leaving `r` at the audio frame.
pub(crate) fn read_bsi(r: &mut Bits) -> Result<Bsi> {
r.skip(16); // syncword
let strmtyp = r.read(2) as u8;
r.skip(3 + 11); // substreamid, frmsiz
let fscod = r.read(2) as usize;
let (rate_code, numblkscod) = if fscod == 3 {
let fscod2 = r.read(2) as usize;
if fscod2 == 3 {
return Err(Error::Invalid("reserved fscod2"));
}
(fscod2, 3)
} else {
(fscod, r.read(2) as usize)
};
let nblocks = BLOCKS[numblkscod & 3] as usize;
let acmod = r.read(3) as usize;
let lfeon = r.bit();
r.skip(5 + 5); // bsid, dialnorm
if r.bit() {
r.skip(8); // compr
}
if acmod == 0 {
r.skip(5); // dialnorm2
if r.bit() {
r.skip(8); // compr2
}
}
if strmtyp == 1 && r.bit() {
r.skip(16); // chanmap
}
if r.bit() {
// mixing metadata
if acmod > 2 {
r.skip(2); // dmixmod
}
if acmod & 1 != 0 && acmod > 2 {
r.skip(6); // ltrtcmixlev, lorocmixlev
}
if acmod & 4 != 0 {
r.skip(6); // ltrtsurmixlev, lorosurmixlev
}
if lfeon && r.bit() {
r.skip(5); // lfemixlevcod
}
if strmtyp == 0 {
if r.bit() {
r.skip(6); // pgmscl
}
if acmod == 0 && r.bit() {
r.skip(6); // pgmscl2
}
if r.bit() {
r.skip(6); // extpgmscl
}
match r.read(2) {
1 => r.skip(5), // premixcmpsel, drcsrc, premixcmpscl
2 => r.skip(12),
// mixdeflen: the mixdata field (through mixdatafill) is mixdeflen + 2 bytes
3 => {
let len = r.read(5) as usize;
r.skip(8 * (len + 2));
}
_ => {}
}
if acmod < 2 {
if r.bit() {
r.skip(14); // panmean, paninfo
}
if acmod == 0 && r.bit() {
r.skip(14); // panmean2, paninfo2
}
}
if r.bit() {
// frame mixing configuration information
if numblkscod == 0 {
r.skip(5);
} else {
for _ in 0..nblocks {
if r.bit() {
r.skip(5);
}
}
}
}
}
}
if r.bit() {
// informational metadata
r.skip(5); // bsmod, copyrightb, origbs
if acmod == 2 {
r.skip(4); // dsurmod, dheadphonmod
}
if acmod >= 6 {
r.skip(2); // dsurexmod
}
if r.bit() {
r.skip(8); // mixlevel, roomtyp, adconvtyp
}
if acmod == 0 && r.bit() {
r.skip(8); // mixlevel2, roomtyp2, adconvtyp2
}
if fscod < 3 {
r.skip(1); // sourcefscod
}
}
if strmtyp == 0 && numblkscod != 3 {
r.skip(1); // convsync
}
if strmtyp == 2 && (numblkscod == 3 || r.bit()) {
r.skip(6); // blkid → frmsizecod
}
if r.bit() {
let l = r.read(6) as usize;
r.skip((l + 1) * 8); // addbsi
}
Ok(Bsi { strmtyp, fscod: rate_code, numblkscod })
}
/// The audio frame (§E2.2.3) and the state that runs from block to block within a syncframe.
pub(crate) struct FrameInfo {
pub strmtyp: u8,
pub blkswe: bool,
pub dithflage: bool,
pub bamode: bool,
pub frmfgaincode: bool,
pub dbaflde: bool,
pub skipflde: bool,
pub snroffststr: u8,
pub frmcsnroffst: i32,
pub frmfsnroffst: i32,
pub cplstre: [bool; 6],
pub cplinu: [bool; 6],
pub cplexpstr: [u8; 6],
pub chexpstr: [[u8; 5]; 6],
pub lfeexpstr: [u8; 6],
/// Spectral extension attenuation code per channel (spxattencod), when in use.
pub spxatten: [Option<usize>; 5],
pub firstcplcos: [bool; 5],
pub firstcplleak: bool,
/// Coupling band structure by absolute coupling sub-band.
pub cplbndstrc: [bool; 18],
pub spx: Spx,
}
impl FrameInfo {
/// Read the audio frame. `words` is the syncframe size in 16-bit words.
pub(crate) fn read(r: &mut Bits, bsi: &Bsi, acmod: usize, lfeon: bool, nblocks: usize, words: usize) -> Result<FrameInfo> {
let nf = NFCHANS[acmod & 7];
let six = bsi.numblkscod == 3;
let (expstre, ahte) = if six { (r.bit(), r.bit()) } else { (true, false) };
let snroffststr = r.read(2) as u8;
if snroffststr == 3 {
return Err(Error::Invalid("reserved SNR offset strategy"));
}
let transproce = r.bit();
let mut e = FrameInfo {
strmtyp: bsi.strmtyp,
blkswe: r.bit(),
dithflage: r.bit(),
bamode: r.bit(),
frmfgaincode: r.bit(),
dbaflde: r.bit(),
skipflde: r.bit(),
snroffststr,
frmcsnroffst: 0,
frmfsnroffst: 0,
cplstre: [false; 6],
cplinu: [false; 6],
cplexpstr: [0; 6],
chexpstr: [[0; 5]; 6],
lfeexpstr: [0; 6],
spxatten: [None; 5],
firstcplcos: [true; 5],
firstcplleak: true,
cplbndstrc: DEFAULT_CPLBNDSTRC,
spx: Spx::new(),
};
let spxattene = r.bit();
if acmod > 1 {
e.cplstre[0] = true;
e.cplinu[0] = r.bit();
for blk in 1..nblocks {
e.cplstre[blk] = r.bit();
e.cplinu[blk] = if e.cplstre[blk] { r.bit() } else { e.cplinu[blk - 1] };
}
}
if expstre {
for blk in 0..nblocks {
if e.cplinu[blk] {
e.cplexpstr[blk] = r.read(2) as u8;
}
for ch in 0..nf {
e.chexpstr[blk][ch] = r.read(2) as u8;
}
}
} else {
// frame-based strategies (six blocks only)
if acmod > 1 && e.cplinu.iter().any(|&c| c) {
e.cplexpstr = FRAME_EXPSTR[r.read(5) as usize & 31];
}
for ch in 0..nf {
let s = FRAME_EXPSTR[r.read(5) as usize & 31];
for (blk, &v) in s.iter().enumerate() {
e.chexpstr[blk][ch] = v;
}
}
}
if lfeon {
for blk in 0..nblocks {
e.lfeexpstr[blk] = r.read(1) as u8;
}
}
if bsi.strmtyp == 0 && (six || r.bit()) {
r.skip(5 * nf); // convexpstr
}
if ahte {
// AHT flags exist for channels whose exponents are sent once in the frame (§E3.4.2)
let unsupported = || Err(Error::Unsupported("E-AC-3 adaptive hybrid transform".into()));
let ncplblks = e.cplinu.iter().filter(|&&c| c).count();
let ncplregs = (0..6).filter(|&b| e.cplstre[b] || e.cplexpstr[b] != 0).count();
if ncplblks == 6 && ncplregs == 1 && r.bit() {
return unsupported();
}
for ch in 0..nf {
if (0..6).filter(|&b| e.chexpstr[b][ch] != 0).count() == 1 && r.bit() {
return unsupported();
}
}
if lfeon && e.lfeexpstr.iter().filter(|&&s| s != 0).count() == 1 && r.bit() {
return unsupported();
}
}
if snroffststr == 0 {
e.frmcsnroffst = r.read(6) as i32;
e.frmfsnroffst = r.read(4) as i32;
}
if transproce {
// transient pre-noise processing (§E3.7) is not applied
for _ in 0..nf {
if r.bit() {
r.skip(10 + 8); // transprocloc, transproclen
}
}
}
if spxattene {
for ch in 0..nf {
if r.bit() {
e.spxatten[ch] = Some(r.read(5) as usize);
}
}
}
if bsi.numblkscod != 0 && r.bit() {
// blkstrtinfo: (blocks - 1) × (4 + ceil(log2(words per frame))) bits (§E2.3.2.27)
let log2 = (usize::BITS - words.saturating_sub(1).leading_zeros()) as usize;
r.skip((nblocks - 1) * (4 + log2));
}
Ok(e)
}
}
/// Spectral extension parameters (§E2.2.4, §E3.6), carried from block to block of a syncframe.
pub(crate) struct Spx {
pub inu: bool,
pub chinspx: [bool; 5],
pub begf: usize,
strtf: usize,
/// First sub-band and one past the last (spx_begin_subbnd, spx_end_subbnd).
begin: usize,
end: usize,
bndstrc: [bool; 17],
/// Band sizes in coefficients (spxbndsztab) and their count (nspxbnds).
sizes: [usize; 17],
nbands: usize,
first: [bool; 5],
co: [[f32; 17]; 5],
nblend: [[f32; 17]; 5],
sblend: [[f32; 17]; 5],
}
impl Spx {
fn new() -> Spx {
Spx {
inu: false,
chinspx: [false; 5],
begf: 0,
strtf: 0,
begin: 0,
end: 0,
bndstrc: DEFAULT_SPXBNDSTRC,
sizes: [0; 17],
nbands: 0,
first: [true; 5],
co: [[0.0; 17]; 5],
nblend: [[0.0; 17]; 5],
sblend: [[1.0; 17]; 5],
}
}
/// First coefficient of the extension region.
pub fn begin_bin(&self) -> usize {
spx_band_bin(self.begin)
}
/// Read the strategy and coordinates of block `blk`.
pub(crate) fn read(&mut self, r: &mut Bits, blk: usize, acmod: usize, nf: usize) -> Result<()> {
if blk == 0 || r.bit() {
self.inu = r.bit();
if self.inu {
if acmod == 1 {
self.chinspx = [true, false, false, false, false];
} else {
for ch in 0..nf {
self.chinspx[ch] = r.bit();
}
}
self.strtf = r.read(2) as usize;
self.begf = r.read(3) as usize;
let endf = r.read(3) as usize;
self.begin = if self.begf < 6 { self.begf + 2 } else { self.begf * 2 - 3 };
self.end = if endf < 3 { endf + 5 } else { endf * 2 + 3 };
if r.bit() {
for b in self.begin + 1..self.end {
self.bndstrc[b] = r.bit();
}
}
if self.begin >= self.end {
return Err(Error::Invalid("spectral extension ends before it begins"));
}
if self.strtf >= self.begin {
return Err(Error::Invalid("spectral extension copies from above its start"));
}
self.nbands = 1;
self.sizes = [0; 17];
self.sizes[0] = 12;
for b in self.begin + 1..self.end {
if self.bndstrc[b] {
self.sizes[self.nbands - 1] += 12;
} else {
self.sizes[self.nbands] = 12;
self.nbands += 1;
}
}
} else {
self.chinspx = [false; 5];
self.first = [true; 5];
}
}
if !self.inu {
return Ok(());
}
for ch in 0..nf {
if !self.chinspx[ch] {
self.first[ch] = true;
continue;
}
let coe = std::mem::replace(&mut self.first[ch], false) || r.bit();
if !coe {
continue;
}
let blend = r.read(5) as f32 / 32.0;
let mstr = r.read(2) as i32;
// coordinates (§E3.6.3) and blending factors (§E3.6.4.2.1)
let mut mant = self.begin_bin() as f32;
let end = spx_band_bin(self.end) as f32;
for b in 0..self.nbands {
let e = r.read(4) as i32;
let m = r.read(2) as f32;
let v = if e == 15 { m / 4.0 } else { (m + 4.0) / 8.0 };
self.co[ch][b] = v * 2f32.powi(-(e + 3 * mstr));
let size = self.sizes[b] as f32;
let nratio = ((mant + 0.5 * size) / end - blend).clamp(0.0, 1.0);
self.nblend[ch][b] = nratio.sqrt();
self.sblend[ch][b] = (1.0 - nratio).sqrt();
mant += size;
}
}
Ok(())
}
/// Synthesize the extension region of one channel's coefficients (§E3.6.4): translate the
/// low band up, notch-filter the borders when attenuation is on, blend with `noise` (zero
/// mean, unit variance) scaled to the band's energy, and scale by the coordinates.
pub(crate) fn synthesize(&self, ch: usize, atten: Option<usize>, tc: &mut [f32; 256], mut noise: impl FnMut() -> f32) {
let copystart = spx_band_bin(self.strtf);
let copyend = self.begin_bin();
let sizes = &self.sizes[..self.nbands];
// translation
let mut wrap = [false; 17];
let (mut copy, mut insert) = (copystart, copyend);
for (b, &size) in sizes.iter().enumerate() {
if copy + size > copyend {
copy = copystart;
wrap[b] = true;
}
for _ in 0..size {
if copy == copyend {
copy = copystart;
}
if let (Some(&v), true) = (tc.get(copy), insert < tc.len()) {
tc[insert] = v;
}
insert += 1;
copy += 1;
}
}
// banded RMS energy of the translated coefficients
let mut rms = [0f32; 17];
let mut at = copyend;
for (b, &size) in sizes.iter().enumerate() {
let band = tc.get(at..at + size).unwrap_or_default();
rms[b] = (band.iter().map(|v| v * v).sum::<f32>() / size as f32).sqrt();
at += size;
}
// notch filter around the baseband / extension border and each wrap point
if let Some(code) = atten {
let g = [spx_atten(code, 0), spx_atten(code, 1), spx_atten(code, 2)];
let mut notch = |center: usize| {
for (k, gain) in [g[0], g[1], g[2], g[1], g[0]].into_iter().enumerate() {
if let Some(v) = (center + k).checked_sub(2).and_then(|i| tc.get_mut(i)) {
*v *= gain;
}
}
};
notch(copyend);
let mut start = copyend;
for (b, &size) in sizes.iter().enumerate() {
if b > 0 && wrap[b] {
notch(start);
}
start += size;
}
}
// noise blending and scaling
let mut at = copyend;
for (b, &size) in sizes.iter().enumerate() {
let nscale = rms[b] * self.nblend[ch][b];
let sscale = self.sblend[ch][b];
let co = self.co[ch][b] * 32.0;
for _ in 0..size {
let n = noise();
if let Some(v) = tc.get_mut(at) {
*v = (*v * sscale + n * nscale) * co;
}
at += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attenuation_matches_table_e3_14() {
// rows 0, 4, 14 and 31 as printed
let rows: [(usize, [f64; 3]); 4] = [
(0, [0.954841604, 0.911722489, 0.870550563]),
(4, [0.793700526, 0.629960525, 0.500000000]),
(14, [0.500000000, 0.250000000, 0.125000000]),
(31, [0.227930622, 0.051952369, 0.011841536]),
];
for (code, want) in rows {
for (i, w) in want.into_iter().enumerate() {
assert!((f64::from(spx_atten(code, i)) - w).abs() < 1e-7, "{code} {i}");
}
}
}
#[test]
fn default_band_structures() {
assert_eq!(DEFAULT_CPLBNDSTRC.iter().filter(|&&b| b).count(), 8);
assert!(DEFAULT_CPLBNDSTRC[8] && !DEFAULT_CPLBNDSTRC[9] && !DEFAULT_CPLBNDSTRC[12] && DEFAULT_CPLBNDSTRC[17]);
assert_eq!(DEFAULT_SPXBNDSTRC.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect::<Vec<_>>(), vec![8, 10, 12, 14, 16]);
// every frame-based strategy sends new exponents in block 0
assert!(FRAME_EXPSTR.iter().all(|s| s[0] != 0));
}
#[test]
fn spectral_extension_translation_and_scaling() {
// begin at sub-band 2 (bin 49), end at 5 (bin 85), copy from sub-band 0 (bin 25): three
// 12-bin bands, the third wraps back to the copy start
let mut s = Spx::new();
s.inu = true;
(s.strtf, s.begin, s.end, s.nbands) = (0, 2, 5, 3);
s.sizes[..3].copy_from_slice(&[12, 12, 12]);
s.co[0][..3].copy_from_slice(&[1.0 / 32.0, 0.5 / 32.0, 0.25 / 32.0]);
s.nblend[0] = [0.0; 17];
s.sblend[0] = [1.0; 17];
let mut tc = [0f32; 256];
for (i, v) in tc[25..49].iter_mut().enumerate() {
*v = i as f32 + 1.0;
}
s.synthesize(0, None, &mut tc, || 0.0);
assert_eq!(tc[49], 1.0);
assert_eq!(tc[61], 13.0 * 0.5);
assert_eq!(tc[73], 1.0 * 0.25);
assert_eq!(tc[84], 12.0 * 0.25);
assert_eq!(tc[85], 0.0);
// pure noise: the band gets the energy of its translated coefficients
s.nblend[0] = [1.0; 17];
s.sblend[0] = [0.0; 17];
s.co[0] = [1.0 / 32.0; 17];
let mut tc2 = [0f32; 256];
tc2[25..49].fill(2.0);
let mut k = 0;
s.synthesize(0, None, &mut tc2, || {
k += 1;
if k % 2 == 0 { 1.0 } else { -1.0 }
});
assert!(tc2[49..85].iter().all(|v| v.abs() == 2.0));
}
}
+321 -80
View File
@@ -1,5 +1,6 @@
//! Clean-room AC-3 (Dolby Digital) audio decoder, written from ATSC A/52:2012 ("Digital Audio
//! Compression Standard", §5 bit stream syntax, §6-7 decoding).
//! Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder, written from
//! ATSC A/52:2012 ("Digital Audio Compression Standard", §5 bit stream syntax, §6-7 decoding,
//! Annex E for E-AC-3).
//!
//! - All audio coding modes (1+1, 1/0 … 3/2) with or without LFE, 32 / 44.1 / 48 kHz, every
//! frame size (Table 5.18).
@@ -11,10 +12,15 @@
//! - Output: planar f32 at full scale ±1, channels in WAV / SMPTE order (L R C LFE Ls Rs); no
//! downmix.
//!
//! E-AC-3 (bsid 11-16) is recognised and refused.
//! E-AC-3 (bsid 11-16, Annex E): independent substream 0 (dependent substreams and further
//! programs are skipped), 1 / 2 / 3 / 6 blocks per syncframe, the reduced sample rates, frame-based
//! exponent strategies and the other frame-level syntax, and spectral extension (§E3.6). The
//! adaptive hybrid transform and enhanced coupling are refused with [`Error::Unsupported`];
//! transient pre-noise processing data is read and not applied.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod eac3;
mod tables;
use std::sync::OnceLock;
@@ -47,12 +53,35 @@ pub struct Header {
pub bsid: u8,
pub acmod: u8,
pub lfeon: bool,
/// Audio blocks (of 256 samples per channel) in the syncframe: 6 for AC-3; 1, 2, 3 or 6 for
/// E-AC-3.
pub blocks: u8,
/// E-AC-3 stream type (Table E2.1): 0 independent, 1 dependent, 2 independent converted from
/// AC-3. 0 for AC-3.
pub strmtyp: u8,
/// E-AC-3 substream id (0 for AC-3).
pub substreamid: u8,
}
impl Header {
/// Output channels (full bandwidth + LFE).
pub fn channels(&self) -> usize {
NFCHANS[self.acmod as usize] + self.lfeon as usize
NFCHANS[self.acmod as usize & 7] + self.lfeon as usize
}
/// Samples per channel the syncframe decodes to.
pub fn samples(&self) -> usize {
256 * self.blocks as usize
}
/// Whether this is an E-AC-3 syncframe (bsid 11-16).
pub fn is_eac3(&self) -> bool {
self.bsid > 10
}
/// Whether the syncframe carries the program [`Decoder::decode`] outputs: AC-3, or E-AC-3
/// independent substream 0 (§E2.3.1.2). [`Decoder::decode`] returns no channels for the
/// others: dependent substreams (the channels beyond 5.1 of a 7.1 program) and further
/// independent programs.
pub fn is_primary(&self) -> bool {
self.strmtyp != 1 && self.substreamid == 0
}
}
@@ -64,8 +93,11 @@ pub fn parse_header(b: &[u8]) -> Result<Header> {
let fscod = b[4] >> 6;
let frmsizecod = b[4] & 0x3F;
let bsid = b[5] >> 3;
if bsid > 10 {
return Err(Error::Unsupported(format!("bsid {bsid} (E-AC-3)")));
if (11..=16).contains(&bsid) {
return eac3::parse_header(b, bsid);
}
if bsid > 16 {
return Err(Error::Unsupported(format!("bsid {bsid}")));
}
if fscod == 3 || frmsizecod >= 38 {
return Err(Error::Invalid("reserved fscod / frmsizecod"));
@@ -91,7 +123,7 @@ pub fn parse_header(b: &[u8]) -> Result<Header> {
r.skip(2);
}
let lfeon = r.read(1) == 1;
Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon })
Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon, blocks: 6, strmtyp: 0, substreamid: 0 })
}
/// MSB-first bit reader; reads past the end give zeros.
@@ -469,7 +501,9 @@ struct Frame {
chincpl: [bool; 5],
phsflginu: bool,
cplbegf: usize,
cplendf: usize,
/// cplendf + 3: one past the last coupling sub-band (with spectral extension cplendf may be
/// negative, §E3.3.1).
cplend: usize,
/// Coupling band of each coupling sub-band (relative to cplbegf).
sub_to_band: [usize; 18],
ncplbnd: usize,
@@ -500,7 +534,7 @@ impl Frame {
chincpl: [false; 5],
phsflginu: false,
cplbegf: 0,
cplendf: 0,
cplend: 0,
sub_to_band: [0; 18],
ncplbnd: 0,
cplco: [[0.0; 18]; 5],
@@ -523,7 +557,7 @@ impl Frame {
37 + 12 * self.cplbegf
}
fn cplendmant(&self) -> usize {
37 + 12 * (self.cplendf + 3)
37 + 12 * self.cplend
}
}
@@ -622,6 +656,9 @@ pub struct Decoder {
delay: [[f32; 256]; 6],
dither: u32,
seed: u32,
/// E-AC-3: the block state of the last syncframe (acmod, lfeon, sample rate code), whose
/// exponents a syncframe of fewer than six blocks may reuse.
carry: Option<(usize, bool, usize, Box<Frame>)>,
}
impl Default for Decoder {
@@ -630,7 +667,8 @@ impl Default for Decoder {
}
}
/// A decoded syncframe: 1536 samples per channel, WAV channel order.
/// A decoded syncframe: [`Header::samples`] samples per channel (1536 for AC-3), WAV channel
/// order. No channels for an E-AC-3 syncframe that is not [`Header::is_primary`].
#[derive(Clone, Debug)]
pub struct Decoded {
pub header: Header,
@@ -639,7 +677,7 @@ pub struct Decoded {
impl Decoder {
pub fn new() -> Decoder {
Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678 }
Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678, carry: None }
}
/// Seed the dither generator (dither is decoder-specific noise; tests compare two seeds).
@@ -650,6 +688,7 @@ impl Decoder {
/// Forget the overlap (before decoding from another position).
pub fn reset(&mut self) {
self.delay = [[0.0; 256]; 6];
self.carry = None;
}
fn dither(&mut self) -> f32 {
@@ -658,12 +697,20 @@ impl Decoder {
((self.dither >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0) * 0.707
}
/// Zero-mean, unit-variance noise for spectral extension (§E3.6.4.2.4): uniform in ±√3.
fn noise(&mut self) -> f32 {
self.dither() * (3f32.sqrt() / 0.707)
}
/// Decode one syncframe.
pub fn decode(&mut self, frame: &[u8]) -> Result<Decoded> {
let h = parse_header(frame)?;
if frame.len() < h.frame_bytes {
return Err(Error::Invalid("truncated syncframe"));
}
if h.is_eac3() {
return self.decode_eac3(&frame[..h.frame_bytes], h);
}
let fscod = (frame[4] >> 6) as usize;
// Dither is reseeded per syncframe from its CRC words (plus the configured seed), so a
// frame decodes to the same samples however it was reached (seeking, render caches).
@@ -708,39 +755,83 @@ impl Decoder {
let l = r.read(6) as usize;
r.skip((l + 1) * 8);
}
let nfchans = NFCHANS[acmod];
let mut f = Frame::new(nfchans, fscod);
let mut f = Frame::new(NFCHANS[acmod], fscod);
let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, 6, None)?;
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
}
/// Decode one E-AC-3 syncframe (`frame` is exactly the syncframe).
fn decode_eac3(&mut self, frame: &[u8], h: Header) -> Result<Decoded> {
if !h.is_primary() {
return Ok(Decoded { header: h, channels: Vec::new() });
}
// E-AC-3 has a single CRC, at the end (§E2.2.6): reseed the dither from the last words
let n = frame.len();
let tail = frame.get(n.saturating_sub(4)..).and_then(|t| <[u8; 4]>::try_from(t).ok()).unwrap_or_default();
self.dither = self.seed ^ u32::from_be_bytes(tail).wrapping_mul(0x9E37_79B9);
let mut r = Bits::new(frame);
let bsi = eac3::read_bsi(&mut r)?;
let (acmod, lfeon) = (h.acmod as usize & 7, h.lfeon);
let nblocks = h.blocks as usize;
let mut e = eac3::FrameInfo::read(&mut r, &bsi, acmod, lfeon, nblocks, n / 2)?;
let mut f = match self.carry.take() {
Some((a, l, fs, f)) if (a, l, fs) == (acmod, lfeon, bsi.fscod) => f,
_ => Box::new(Frame::new(NFCHANS[acmod], bsi.fscod)),
};
let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, nblocks, Some(&mut e))?;
self.carry = Some((acmod, lfeon, bsi.fscod, f));
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
}
/// Decode `nblocks` audio blocks and synthesize them (inverse transform, overlap-add), in coded
/// channel order with the LFE last.
fn blocks(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, nblocks: usize, mut x: Option<&mut eac3::FrameInfo>) -> Result<Vec<Vec<f32>>> {
let nfchans = f.nfchans;
let nout = nfchans + lfeon as usize;
let mut pcm: Vec<Vec<f32>> = vec![Vec::with_capacity(1536); nout];
for blk in 0..6 {
let mut pcm: Vec<Vec<f32>> = vec![Vec::with_capacity(256 * nblocks); nout];
for blk in 0..nblocks {
let mut coefs = [[0f32; 256]; 6];
self.audio_block(&mut r, &mut f, acmod, lfeon, blk, &mut coefs)?;
self.audio_block(r, f, acmod, lfeon, blk, &mut coefs, x.as_deref_mut())?;
if r.overrun() {
return Err(Error::Invalid("audio block runs past the frame"));
}
// inverse transform, overlap-add
let mut buf = [0f32; 512];
for ch in 0..nout {
for (ch, out) in pcm.iter_mut().enumerate() {
let src = if ch < nfchans { ch } else { LFE };
let short = ch < nfchans && f.blksw[ch];
imdct(&coefs[src], short, &mut buf);
let d = &mut self.delay[src];
for n in 0..256 {
pcm[ch].push(2.0 * (buf[n] + d[n]));
out.push(2.0 * (buf[n] + d[n]));
d[n] = buf[256 + n];
}
}
}
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
Ok(pcm)
}
fn audio_block(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, blk: usize, coefs: &mut [[f32; 256]; 6]) -> Result<()> {
#[allow(clippy::too_many_arguments)]
fn audio_block(
&mut self,
r: &mut Bits,
f: &mut Frame,
acmod: usize,
lfeon: bool,
blk: usize,
coefs: &mut [[f32; 256]; 6],
mut x: Option<&mut eac3::FrameInfo>,
) -> Result<()> {
let nf = f.nfchans;
for ch in 0..nf {
f.blksw[ch] = r.bit();
let eac3 = x.is_some();
match x.as_deref() {
Some(e) if !e.blkswe => f.blksw = [false; 5],
_ => (0..nf).for_each(|ch| f.blksw[ch] = r.bit()),
}
for ch in 0..nf {
f.dithflag[ch] = r.bit();
match x.as_deref() {
// dither on when the flags are not sent (§E2.2.4)
Some(e) if !e.dithflage => f.dithflag = [true; 5],
_ => (0..nf).for_each(|ch| f.dithflag[ch] = r.bit()),
}
for k in 0..if acmod == 0 { 2 } else { 1 } {
if r.bit() {
@@ -749,26 +840,62 @@ impl Decoder {
f.dynrng[k] = 1.0;
}
}
// E-AC-3 spectral extension strategy and coordinates
if let Some(e) = x.as_deref_mut() {
e.spx.read(r, blk, acmod, nf)?;
}
let spx = x.as_deref().map(|e| &e.spx).filter(|s| s.inu);
let spxbegf = spx.map(|s| s.begf);
let spx_begin = spx.map(|s| s.begin_bin());
let spx_chans = spx.map(|s| s.chinspx).unwrap_or([false; 5]);
// coupling strategy
if r.bit() {
f.cplinu = r.bit();
let strategy = match x.as_deref() {
Some(e) => e.cplstre[blk].then_some(e.cplinu[blk]),
None => r.bit().then(|| r.bit()),
};
if let Some(inu) = strategy {
f.cplinu = inu;
if f.cplinu {
for ch in 0..nf {
f.chincpl[ch] = r.bit();
if eac3 && r.bit() {
return Err(Error::Unsupported("E-AC-3 enhanced coupling".into()));
}
if eac3 && acmod == 2 {
f.chincpl = [true, true, false, false, false];
} else {
for ch in 0..nf {
f.chincpl[ch] = r.bit();
}
}
if acmod == 2 {
f.phsflginu = r.bit();
}
f.cplbegf = r.read(4) as usize;
f.cplendf = r.read(4) as usize;
if f.cplendf + 3 <= f.cplbegf {
f.cplend = match spxbegf {
// the coupling region ends where spectral extension begins (§E2.2.4)
Some(b) if b < 6 => b + 1,
Some(b) => 2 * b - 4,
None => r.read(4) as usize + 3,
};
if f.cplend <= f.cplbegf {
return Err(Error::Invalid("coupling end below start"));
}
let nsub = 3 + f.cplendf - f.cplbegf;
// coupling band structure, by absolute sub-band
let mut strc = [false; 18];
match x.as_deref_mut() {
Some(e) => {
if r.bit() {
for s in f.cplbegf + 1..f.cplend {
e.cplbndstrc[s] = r.bit();
}
}
strc = e.cplbndstrc;
}
None => (f.cplbegf + 1..f.cplend).for_each(|s| strc[s] = r.bit()),
}
let mut band = 0;
f.sub_to_band[0] = 0;
for s in 1..nsub {
if !r.bit() {
for s in 1..f.cplend - f.cplbegf {
if !strc[f.cplbegf + s] {
band += 1;
}
f.sub_to_band[s] = band;
@@ -776,15 +903,33 @@ impl Decoder {
f.ncplbnd = band + 1;
} else {
f.chincpl = [false; 5];
if let Some(e) = x.as_deref_mut() {
e.firstcplcos = [true; 5];
e.firstcplleak = true;
f.phsflginu = false;
}
}
} else if blk == 0 {
} else if blk == 0 && !eac3 {
return Err(Error::Invalid("no coupling strategy in block 0"));
}
// coupling coordinates
if f.cplinu {
let mut any = false;
for ch in 0..nf {
if f.chincpl[ch] && r.bit() {
if !f.chincpl[ch] {
if let Some(e) = x.as_deref_mut() {
e.firstcplcos[ch] = true;
}
continue;
}
let coe = match x.as_deref_mut() {
Some(e) if e.firstcplcos[ch] => {
e.firstcplcos[ch] = false;
true
}
_ => r.bit(),
};
if coe {
any = true;
let mstr = r.read(2) as i32;
for bnd in 0..f.ncplbnd {
@@ -792,7 +937,7 @@ impl Decoder {
let m = r.read(4) as f32;
let v = if e == 15 { m / 16.0 } else { (m + 16.0) / 32.0 };
let co = v * 2f32.powi(-(e + 3 * mstr));
for s in 0..(3 + f.cplendf - f.cplbegf) {
for s in 0..(f.cplend - f.cplbegf) {
if f.sub_to_band[s] == bnd {
f.cplco[ch][s] = co;
}
@@ -803,7 +948,7 @@ impl Decoder {
if acmod == 2 && f.phsflginu && any {
for bnd in 0..f.ncplbnd {
let flag = r.bit();
for s in 0..(3 + f.cplendf - f.cplbegf) {
for s in 0..(f.cplend - f.cplbegf) {
if f.sub_to_band[s] == bnd {
f.phsflg[s] = flag;
}
@@ -811,34 +956,55 @@ impl Decoder {
}
}
}
// rematrixing
if acmod == 2 && r.bit() {
let n = if !f.cplinu || f.cplbegf > 2 {
4
} else if f.cplbegf > 0 {
3
// rematrixing (E-AC-3 always sends the flags in block 0)
if acmod == 2 && ((eac3 && blk == 0) || r.bit()) {
let n = if f.cplinu {
match f.cplbegf {
0 => 2,
1 | 2 => 3,
_ => 4,
}
} else {
2
match spxbegf {
Some(b) if b < 2 => 3,
_ => 4,
}
};
f.rematflg = [false; 4];
for k in 0..n {
f.rematflg[k] = r.bit();
}
}
// exponent strategies
if f.cplinu {
f.expstr[CPL] = r.read(2) as u8;
}
for ch in 0..nf {
f.expstr[ch] = r.read(2) as u8;
}
if lfeon {
f.expstr[LFE] = r.read(1) as u8;
// exponent strategies (E-AC-3: from the audio frame)
match x.as_deref() {
Some(e) => {
if f.cplinu {
f.expstr[CPL] = e.cplexpstr[blk];
}
f.expstr[..nf].copy_from_slice(&e.chexpstr[blk][..nf]);
if lfeon {
f.expstr[LFE] = e.lfeexpstr[blk];
}
}
None => {
if f.cplinu {
f.expstr[CPL] = r.read(2) as u8;
}
for ch in 0..nf {
f.expstr[ch] = r.read(2) as u8;
}
if lfeon {
f.expstr[LFE] = r.read(1) as u8;
}
}
}
for ch in 0..nf {
if f.expstr[ch] != 0 {
if f.chincpl[ch] && f.cplinu {
f.endmant[ch] = f.cplstrtmant();
} else if let (true, Some(begin)) = (spx_chans[ch], spx_begin) {
// §E3.3.3: the channel ends where spectral extension begins
f.endmant[ch] = begin;
} else {
let bw = r.read(6) as usize;
if bw > 60 {
@@ -870,40 +1036,105 @@ impl Decoder {
decode_exponents(r, abs, 2, 1, &mut f.exps[LFE], 0, false)?;
}
// bit allocation parametric information
if r.bit() {
f.g.sdcycod = r.read(2) as usize;
f.g.fdcycod = r.read(2) as usize;
f.g.sgaincod = r.read(2) as usize;
f.g.dbpbcod = r.read(2) as usize;
f.g.floorcod = r.read(3) as usize;
} else if blk == 0 {
return Err(Error::Invalid("no bit allocation information in block 0"));
match x.as_deref() {
Some(e) if !e.bamode => {
// §E2.2.4 defaults
(f.g.sdcycod, f.g.fdcycod, f.g.sgaincod, f.g.dbpbcod, f.g.floorcod) = (2, 1, 1, 2, 7);
}
_ => {
if r.bit() {
f.g.sdcycod = r.read(2) as usize;
f.g.fdcycod = r.read(2) as usize;
f.g.sgaincod = r.read(2) as usize;
f.g.dbpbcod = r.read(2) as usize;
f.g.floorcod = r.read(3) as usize;
} else if blk == 0 && !eac3 {
return Err(Error::Invalid("no bit allocation information in block 0"));
}
}
}
if r.bit() {
f.csnroffst = r.read(6) as i32;
if f.cplinu {
f.fsnroffst[CPL] = r.read(4) as i32;
f.fgaincod[CPL] = r.read(3) as usize;
match x.as_deref() {
Some(e) => {
// SNR offset strategies (Table E2.9), fast gain codes
let sets = [CPL, 0, 1, 2, 3, 4, LFE];
match e.snroffststr {
0 => {
f.csnroffst = e.frmcsnroffst;
sets.iter().for_each(|&c| f.fsnroffst[c] = e.frmfsnroffst);
}
s if blk == 0 || r.bit() => {
f.csnroffst = r.read(6) as i32;
if s == 1 {
let v = r.read(4) as i32;
sets.iter().for_each(|&c| f.fsnroffst[c] = v);
} else {
if f.cplinu {
f.fsnroffst[CPL] = r.read(4) as i32;
}
for ch in 0..nf {
f.fsnroffst[ch] = r.read(4) as i32;
}
if lfeon {
f.fsnroffst[LFE] = r.read(4) as i32;
}
}
}
_ => {}
}
if e.frmfgaincode && r.bit() {
if f.cplinu {
f.fgaincod[CPL] = r.read(3) as usize;
}
for ch in 0..nf {
f.fgaincod[ch] = r.read(3) as usize;
}
if lfeon {
f.fgaincod[LFE] = r.read(3) as usize;
}
} else {
f.fgaincod = [4; 7];
}
if e.strmtyp == 0 && r.bit() {
r.skip(10); // convsnroffst
}
}
for ch in 0..nf {
f.fsnroffst[ch] = r.read(4) as i32;
f.fgaincod[ch] = r.read(3) as usize;
None => {
if r.bit() {
f.csnroffst = r.read(6) as i32;
if f.cplinu {
f.fsnroffst[CPL] = r.read(4) as i32;
f.fgaincod[CPL] = r.read(3) as usize;
}
for ch in 0..nf {
f.fsnroffst[ch] = r.read(4) as i32;
f.fgaincod[ch] = r.read(3) as usize;
}
if lfeon {
f.fsnroffst[LFE] = r.read(4) as i32;
f.fgaincod[LFE] = r.read(3) as usize;
}
} else if blk == 0 {
return Err(Error::Invalid("no SNR offsets in block 0"));
}
}
if lfeon {
f.fsnroffst[LFE] = r.read(4) as i32;
f.fgaincod[LFE] = r.read(3) as usize;
}
} else if blk == 0 {
return Err(Error::Invalid("no SNR offsets in block 0"));
}
if f.cplinu && r.bit() {
f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768);
if f.cplinu {
let leak = match x.as_deref_mut() {
Some(e) if e.firstcplleak => {
e.firstcplleak = false;
true
}
_ => r.bit(),
};
if leak {
f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768);
}
}
// delta bit allocation
if blk == 0 {
f.deltbae = [2; 7];
}
if r.bit() {
if x.as_deref().is_none_or(|e| e.dbaflde) && r.bit() {
if f.cplinu {
f.deltbae[CPL] = r.read(2) as u8;
}
@@ -921,7 +1152,7 @@ impl Decoder {
}
}
// skip field
if r.bit() {
if x.as_deref().is_none_or(|e| e.skipflde) && r.bit() {
let l = r.read(9) as usize;
r.skip(l * 8);
}
@@ -1014,6 +1245,16 @@ impl Decoder {
}
}
}
// spectral extension: synthesize the high band of each channel from its low band
if let Some(e) = x.as_deref()
&& e.spx.inu
{
for ch in 0..nf {
if e.spx.chinspx[ch] {
e.spx.synthesize(ch, e.spxatten[ch], &mut coefs[ch], || self.noise());
}
}
}
// dynamic range
for ch in 0..nf {
let g = if acmod == 0 && ch == 1 { f.dynrng[1] } else { f.dynrng[0] };
+174 -1
View File
@@ -56,7 +56,7 @@ fn headers_and_gains() {
let b = [0x0B, 0x77, 0, 0, 30, 8 << 3, 0b1110_0001, 0b0100_0000];
let h = parse_header(&b).unwrap();
assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes, h.acmod, h.lfeon, h.channels()), (48_000, 448, 1792, 7, true, 6));
assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 16 << 3, 0, 0]), Err(Error::Unsupported("bsid 16 (E-AC-3)".into())));
assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 17 << 3, 0, 0]), Err(Error::Unsupported("bsid 17".into())));
assert_eq!(dynrng_gain(0), 1.0);
assert!((dynrng_gain(0b1110_0000) - 0.5).abs() < 1e-7);
assert!((dynrng_gain(0b0111_1111) - 16.0 * 63.0 / 64.0).abs() < 1e-5);
@@ -96,3 +96,176 @@ fn garbage_never_panics() {
}
}
}
/// MSB-first bit writer for synthetic E-AC-3 syncframes.
#[derive(Default)]
struct W {
b: Vec<u8>,
n: usize,
}
impl W {
fn put(&mut self, bits: u32, v: u32) {
for i in (0..bits).rev() {
if self.n.is_multiple_of(8) {
self.b.push(0);
}
let bit = ((v >> i) & 1) as u8;
*self.b.last_mut().unwrap() |= bit << (7 - self.n % 8);
self.n += 1;
}
}
}
/// An E-AC-3 syncframe of `words` 16-bit words: syncinfo and a bsi with no optional metadata
/// (fscod < 3 with `numblkscod`), then `audio` writes the rest; zero padded.
fn eac3_frame(strmtyp: u32, substreamid: u32, words: u32, numblkscod: u32, acmod: u32, audio: impl FnOnce(&mut W)) -> Vec<u8> {
let mut w = W::default();
w.put(16, 0x0B77);
w.put(2, strmtyp);
w.put(3, substreamid);
w.put(11, words - 1);
w.put(2, 0); // 48 kHz
w.put(2, numblkscod);
w.put(3, acmod);
w.put(1, 0); // lfeon
w.put(5, 16); // bsid
w.put(5, 31); // dialnorm
w.put(1, 0); // compre
if strmtyp == 1 {
w.put(1, 0); // chanmape
}
w.put(2, 0); // mixmdate, infomdate
if strmtyp == 0 && numblkscod != 3 {
w.put(1, 0); // convsync
}
w.put(1, 0); // addbsie
audio(&mut w);
let mut b = w.b;
b.resize(words as usize * 2, 0);
b
}
#[test]
fn eac3_headers() {
let f = eac3_frame(0, 0, 384, 3, 2, |_| {});
let h = parse_header(&f).unwrap();
assert_eq!((h.sample_rate, h.blocks, h.samples(), h.frame_bytes, h.bitrate_kbps, h.channels()), (48_000, 6, 1536, 768, 192, 2));
assert!(h.is_eac3() && h.is_primary());
// one block per syncframe: 256 samples
let h = parse_header(&eac3_frame(0, 0, 100, 0, 7, |_| {})).unwrap();
assert_eq!((h.blocks, h.samples(), h.channels(), h.bitrate_kbps), (1, 256, 5, 300));
// reduced sample rate (fscod 3, fscod2 1): six blocks at 22.05 kHz
let mut f = eac3_frame(0, 0, 384, 0, 2, |_| {});
f[4] = 0b1101_0100;
let h = parse_header(&f).unwrap();
assert_eq!((h.sample_rate, h.blocks, h.acmod), (22_050, 6, 2));
f[4] = 0b1111_0100;
assert_eq!(parse_header(&f), Err(Error::Invalid("reserved fscod2")));
// a dependent substream and a second independent program are recognised and skipped
let mut d = Decoder::new();
for (strmtyp, id) in [(1, 0), (0, 1)] {
let f = eac3_frame(strmtyp, id, 64, 3, 2, |_| {});
let out = d.decode(&f).unwrap();
assert!(!out.header.is_primary() && out.channels.is_empty());
}
assert_eq!(parse_header(&eac3_frame(3, 0, 64, 3, 2, |_| {})), Err(Error::Unsupported("reserved E-AC-3 stream type 3".into())));
}
#[test]
fn eac3_tools_we_do_not_decode_are_refused() {
// mono, six blocks, AHT enabled and used: chexpstr D15 then reuse, so chahtinu is sent
let aht = eac3_frame(0, 0, 64, 3, 1, |w| {
w.put(1, 1); // expstre
w.put(1, 1); // ahte
w.put(2, 0); // snroffststr
w.put(8, 0); // transproce … spxattene
w.put(2, 1); // chexpstr[0] = D15
w.put(10, 0); // chexpstr[1..6] = reuse
w.put(5, 0); // convexpstr
w.put(1, 1); // chahtinu
});
assert_eq!(Decoder::new().decode(&aht).unwrap_err(), Error::Unsupported("E-AC-3 adaptive hybrid transform".into()));
// stereo with coupling in block 0 using enhanced coupling
let ecpl = eac3_frame(0, 0, 64, 3, 2, |w| {
w.put(1, 1); // expstre
w.put(1, 0); // ahte
w.put(2, 0); // snroffststr
w.put(8, 0);
w.put(1, 1); // cplinu[0]
w.put(5, 0); // cplstre[1..6]
w.put(6, 0b01_01_01); // block 0: cplexpstr, chexpstr × 2
w.put(30, 0); // blocks 1-5: reuse
w.put(10, 0); // convexpstr
w.put(10, 0); // frmcsnroffst, frmfsnroffst
w.put(1, 0); // blkstrtinfoe
w.put(1, 0); // dynrnge
w.put(1, 0); // spxinu
w.put(1, 1); // ecplinu
});
assert_eq!(Decoder::new().decode(&ecpl).unwrap_err(), Error::Unsupported("E-AC-3 enhanced coupling".into()));
}
#[test]
fn eac3_single_block_frames_reuse_exponents_across_syncframes() {
// mono, one block per syncframe; the first sends exponents (all 10, bandwidth code 0), the
// second reuses them. Zero SNR offsets allocate no bits: every coefficient is dither.
let frame = |new_exps: bool| {
eac3_frame(0, 0, 32, 0, 1, |w| {
// (expstre and ahte are not sent with fewer than six blocks)
w.put(2, 0); // snroffststr
w.put(8, 0); // transproce … spxattene
w.put(2, new_exps as u32); // chexpstr[0]: D15 or reuse
w.put(1, 0); // convexpstre
w.put(10, 0); // frmcsnroffst, frmfsnroffst
w.put(1, 0); // dynrnge
w.put(1, 0); // spxinu
if new_exps {
w.put(6, 0); // chbwcod → 73 coefficients
w.put(4, 10); // absolute exponent
for _ in 0..24 {
w.put(7, 62); // three zero deltas
}
w.put(2, 0); // gainrng
}
w.put(1, 0); // convsnroffste
})
};
let (first, second) = (frame(true), frame(false));
let mut d = Decoder::new();
let a = d.decode(&first).unwrap();
assert_eq!((a.header.samples(), a.channels.len(), a.channels[0].len()), (256, 1, 256));
let b = d.decode(&second).unwrap();
let peak = |c: &[f32]| c.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!(peak(&b.channels[0]) > 1e-5 && peak(&b.channels[0]) < 0.1, "{}", peak(&b.channels[0]));
// after a reset there are no exponents to reuse: no coefficients, silence
d.reset();
let c = d.decode(&second).unwrap();
assert_eq!(peak(&c.channels[0]), 0.0);
}
#[test]
fn eac3_garbage_never_panics() {
let mut d = Decoder::new();
let mut x = 0x5EEDu32;
for len in [8usize, 40, 300, 1500, 4096] {
for _ in 0..300 {
let mut f: Vec<u8> = (0..len)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x as u8
})
.collect();
f[0] = 0x0B;
f[1] = 0x77;
// independent substream 0, frmsiz within the buffer, bsid 11-16
let words = (len / 2).clamp(4, 2048) - 1;
f[2] = (words >> 8) as u8 & 7;
f[3] = words as u8;
f[5] = (f[5] & 7) | ((11 + x % 6) as u8) << 3;
let _ = d.decode(&f);
}
}
}
+125 -7
View File
@@ -1,5 +1,5 @@
//! AC-3 against ffmpeg: ffmpeg's AC-3 encoder writes raw syncframes from generated tones and
//! noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across
//! AC-3 and E-AC-3 against ffmpeg: ffmpeg's AC-3 and E-AC-3 encoders write raw syncframes from
//! generated tones and noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across
//! decoders (zero-bit mantissas are filled with decoder-specific dither, §7.3.4; float vs fixed
//! arithmetic), so the criterion is SNR against ffmpeg's decode.
@@ -8,6 +8,8 @@ use std::process::{Command, Stdio};
struct Spec {
name: &'static str,
/// ffmpeg encoder and raw muxer: "ac3" or "eac3".
codec: &'static str,
input: &'static str,
rate: u32,
channels: u32,
@@ -19,6 +21,7 @@ struct Spec {
const SPECS: &[Spec] = &[
Spec {
name: "ac3_stereo_192k",
codec: "ac3",
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000",
rate: 48_000,
channels: 2,
@@ -28,16 +31,26 @@ const SPECS: &[Spec] = &[
// low bitrate stereo: coupling and rematrixing
Spec {
name: "ac3_stereo_96k_coupling",
codec: "ac3",
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100",
rate: 44_100,
channels: 2,
enc: &["-b:a", "96k", "-channel_coupling", "1"],
min_snr: 40.0,
},
Spec { name: "ac3_mono_32k", input: "sine=frequency=1000:sample_rate=32000", rate: 32_000, channels: 1, enc: &["-b:a", "96k"], min_snr: 60.0 },
Spec {
name: "ac3_mono_32k",
codec: "ac3",
input: "sine=frequency=1000:sample_rate=32000",
rate: 32_000,
channels: 1,
enc: &["-b:a", "96k"],
min_snr: 60.0,
},
// 5.1 with LFE
Spec {
name: "ac3_51_448k",
codec: "ac3",
input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1",
rate: 48_000,
channels: 6,
@@ -47,22 +60,86 @@ const SPECS: &[Spec] = &[
// noise (wideband, many zero-bit mantissas): the SNR is limited by dither
Spec {
name: "ac3_noise_stereo",
codec: "ac3",
input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0",
rate: 48_000,
channels: 2,
enc: &["-b:a", "128k"],
min_snr: 18.0,
},
// E-AC-3 (Annex E): ffmpeg writes six-block syncframes with frame-based exponent strategies
Spec {
name: "eac3_stereo_192k",
codec: "eac3",
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000",
rate: 48_000,
channels: 2,
enc: &["-b:a", "192k"],
min_snr: 60.0,
},
// low bitrate stereo: coupling (with the default band structure) and rematrixing
Spec {
name: "eac3_stereo_64k_coupling",
codec: "eac3",
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100",
rate: 44_100,
channels: 2,
enc: &["-b:a", "64k", "-channel_coupling", "1"],
min_snr: 35.0,
},
Spec {
name: "eac3_mono_32k",
codec: "eac3",
input: "sine=frequency=1000:sample_rate=32000",
rate: 32_000,
channels: 1,
enc: &["-b:a", "64k"],
min_snr: 60.0,
},
// 5.1 with LFE and mixing / informational metadata in the bsi
Spec {
name: "eac3_51_384k_metadata",
codec: "eac3",
input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1",
rate: 48_000,
channels: 6,
enc: &[
"-b:a",
"384k",
"-dmix_mode",
"loro",
"-ltrt_cmixlev",
"0.707",
"-loro_surmixlev",
"0.5",
"-dsurex_mode",
"on",
"-mixing_level",
"100",
"-room_type",
"small",
],
min_snr: 55.0,
},
Spec {
name: "eac3_noise_stereo",
codec: "eac3",
input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0",
rate: 48_000,
channels: 2,
enc: &["-b:a", "96k", "-dsur_mode", "on", "-dheadphone_mode", "off"],
min_snr: 18.0,
},
];
fn make(ff: &Path, s: &Spec) -> Option<PathBuf> {
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name));
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec));
filmcraft_testkit::fixtures::generate(&out, |tmp| {
let st = Command::new(ff)
.args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1"])
.args(["-c:a", "ac3"])
.args(["-c:a", s.codec])
.args(s.enc)
.args(["-f", "ac3"])
.args(["-f", s.codec])
.arg(tmp)
.stdin(Stdio::null())
.status();
@@ -94,6 +171,7 @@ fn ffmpeg_encoded_streams_decode() {
for fr in frames(&data) {
let d = dec.decode(fr).unwrap();
assert_eq!((d.header.sample_rate, d.channels.len() as u32), (s.rate, s.channels), "{}", s.name);
assert_eq!(d.header.is_eac3(), s.codec == "eac3", "{}", s.name);
for (o, c) in ours.iter_mut().zip(d.channels) {
o.extend(c);
}
@@ -136,12 +214,52 @@ fn ffmpeg_encoded_streams_decode() {
}
}
/// Damaged E-AC-3 syncframes (bit flips, truncation, corrupt sizes) give errors or noise, never
/// a panic.
#[test]
fn damaged_eac3_frames_never_panic() {
let ff = filmcraft_testkit::require_ffmpeg!();
let mut x = 0x00C0_FFEEu32;
let mut rnd = move || {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x
};
for s in SPECS.iter().filter(|s| s.codec == "eac3") {
let f = make(&ff, s).unwrap_or_else(|| panic!("could not generate {}", s.name));
let data = std::fs::read(&f).unwrap();
let mut dec = filmcraft_ac3::Decoder::new();
for fr in frames(&data).into_iter().take(8) {
for k in 0..300 {
let mut m = fr.to_vec();
match k % 3 {
0 => {
for _ in 0..1 + rnd() % 8 {
let bit = rnd() as usize % (m.len() * 8);
m[bit / 8] ^= 0x80 >> (bit % 8);
}
}
1 => m.truncate(rnd() as usize % m.len()),
_ => {
// keep the sync word, scramble frmsiz / fscod / numblkscod / acmod
m[2] ^= rnd() as u8;
m[3] ^= rnd() as u8;
m[4] ^= rnd() as u8;
}
}
let _ = dec.decode(&m);
}
}
}
}
#[test]
#[ignore]
fn generate_fixtures() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("ac3 fixtures") else { return };
for s in SPECS {
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name));
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec));
filmcraft_testkit::fixtures::generate_and_report(&format!("ac3/{}", s.name), &[out], || make(&ff, s));
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "filmcraft-apv"
description = "Clean-room Advanced Professional Video (APV, IETF RFC 9924) decoder and encoder"
version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["threads"]
threads = ["dep:rayon"]
[dependencies]
filmcraft-bitstream.workspace = true
rayon = { workspace = true, optional = true }
thiserror.workspace = true
[dev-dependencies]
filmcraft-testkit.workspace = true
[lints]
workspace = true
+74
View File
@@ -0,0 +1,74 @@
# filmcraft-apv
Clean-room Advanced Professional Video (APV) decoder and encoder in pure Rust, implemented strictly
from the public **IETF RFC 9924** specification (*Advanced Professional Video*, February 2026,
ISSN 2070-1721).
Layer L0: depends only on `filmcraft-bitstream`, `thiserror`, and (optional, feature `threads`,
default on) `rayon`. Builds for `wasm32-unknown-unknown`; `unsafe_code = "forbid"`.
## API
```rust
use filmcraft_apv::*;
// Decode one APV Access Unit (or raw bitstream AU prefixed with a 32-bit au_size).
let frame: Frame = decode_frame(&au)?;
let f8 = decode_frame_with(&au, &DecodeOptions { bit_depth: Some(8), threads: true })?;
decode_frame_into(&au, &DecodeOptions::default(), &mut frame_buf)?;
let hdr: FrameHeader = probe(&au)?;
// Split a multi-AU raw .apv bitstream (RFC 9924 Appendix A).
let aus: Vec<&[u8]> = split_raw_bitstream(&raw_apv_bytes)?;
// Encode progressive intra frames.
let mut enc = Encoder::new(Profile::P422_10, 1920, 1080)?;
let au: Vec<u8> = enc.encode(&frame)?; // Access Unit starting with 'aPv1'
let raw_au: Vec<u8> = enc.encode_raw_au(&frame)?; // Prefixed with 32-bit au_size (ISOBMFF apv1 / .apv)
let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationRecord payload
```
## Specification Coverage (IETF RFC 9924)
### Decoder
- **Access Units & PBUs (§5.3.1–5.3.3, Appendix A)**: Accepts both unprefixed Access Units
(`'aPv1'` signature) and length-prefixed `raw_bitstream_access_unit()` packets. Parses primary
(`pbu_type == 1`), non-primary (`2`), preview (`25`), auxiliary alpha (`27`), AU info (`65`),
metadata (`66`), and filler (`67`) PBUs. PBUs or frame headers with non-zero `reserved_zero_*`
fields are ignored per §5.3.3 and §5.3.5.
- **Profiles & Sampling (§9.3)**: All 7 standard profiles (`422-10` idc 33, `422-12` idc 44,
`444-10` idc 55, `444-12` idc 66, `4444-10` idc 77, `4444-12` idc 88, `400-10` idc 99) at
coded bit depths 10–16, with optional output depth rescaling (`8..=16`).
- **Quantization Matrices & Tiles (§5.3.7–5.3.8, §5.3.12–5.3.14)**: Default flat (`16`) and
custom per-component 8×8 quantization matrices (`use_q_matrix`), optional `tile_size_in_fh`
verification, and independent per-tile, per-component byte-aligned entropy partitions. Tiles
decode in parallel via `rayon` when `threads` is enabled.
- **Entropy Decoding (§5.3.15–5.3.16, §7.1)**: Adaptive Golomb-Rice / Exp-Golomb VLC decoding for
`abs_dc_coeff_diff`, `coeff_zero_run`, and `abs_ac_coeff_minus1` with state tracking (`PrevDC`,
`PrevDcDiff`, `Prev1stAcLevel`, `PrevLevel`, `PrevRun`) across the 8×8 blocks of each macroblock.
- **Scaling & Inverse Transform (§6.3)**: Exact integer dequantization (`levelScale = [40, 45, 51,
57, 64, 71]`, `bdShift = BitDepth - 2`) and 2-D separable 8×8 integer inverse transform using the
RFC 9924 Figure 25 `transMatrix`. Note: in RFC 9924 §6.3.2.2, `transMatrix[m][n]` has row `m` as
the frequency index (row 0 is `[64; 8]`) and column `n` as the spatial sample index, so the 1-D
inverse transform evaluates `sum_j transMatrix[j][i] * x[j]`.
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs`
across thousands of mutated and random inputs.
### Encoder
- Progressive intra encoding for all 7 profiles (`422-10`, `422-12`, `444-10`, `444-12`,
`4444-10`, `4444-12`, `400-10`).
- Supports fixed `qp` or binary-search frame-budget rate control over forward-transformed tiles.
- Emits conforming tile grids (`tile_width_in_mbs >= 16`, `tile_height_in_mbs >= 8`,
`TileCols, TileRows <= 20`), optional custom quantization matrices, optional `MDCV`/`CLL`
metadata PBUs, and `APVDecoderConfigurationRecord` (`apvC`) metadata.
## Accuracy (vs FFmpeg as an External Oracle)
`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`.
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
(`max_diff = 0`) with FFmpeg's APV decoder across every profile and test case.**
+217
View File
@@ -0,0 +1,217 @@
//! RFC 9924 §6.3 scaling and 8x8 integer inverse transform, plus the matching forward transform.
use crate::tables::LEVEL_SCALE;
/// 1D 8-point inverse transform using the RFC 9924 §6.3.2.3 even/odd butterfly decomposition,
/// shifting each output by `(sum + round) >> shift`.
#[inline(always)]
fn idct_1d(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
if (x1 | x2 | x3 | x4 | x5 | x6 | x7) == 0 {
let v = (x0 * 64 + round) >> shift;
return [v; 8];
}
let e0 = 64 * (x0 + x4);
let e1 = 64 * (x0 - x4);
let e2 = 35 * x2 - 84 * x6;
let e3 = 84 * x2 + 35 * x6;
let even0 = e0 + e3;
let even1 = e1 + e2;
let even2 = e1 - e2;
let even3 = e0 - e3;
let odd0 = 89 * x1 + 75 * x3 + 50 * x5 + 18 * x7;
let odd1 = 75 * x1 - 18 * x3 - 89 * x5 - 50 * x7;
let odd2 = 50 * x1 - 89 * x3 + 18 * x5 + 75 * x7;
let odd3 = 18 * x1 - 50 * x3 + 75 * x5 - 89 * x7;
[
(even0 + odd0 + round) >> shift,
(even1 + odd1 + round) >> shift,
(even2 + odd2 + round) >> shift,
(even3 + odd3 + round) >> shift,
(even3 - odd3 + round) >> shift,
(even2 - odd2 + round) >> shift,
(even1 - odd1 + round) >> shift,
(even0 - odd0 + round) >> shift,
]
}
/// Fast path when an 8x8 block has only a DC coefficient (`coeff[1..64]` are all zero).
#[inline]
pub fn scale_and_idct8x8_dc_only(dc_coeff: i16, q0: u8, qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
let qp_shift = (qp / 6) as u32;
let scaled = ((dc_coeff as i64 * q0 as i64 * level_scale) << qp_shift) + scale_round;
let d0 = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
let g0 = (d0 * 64 + 64) >> 7;
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
let round = 1i32 << (bd_shift - 1);
let mid = 1i32 << (bit_depth - 1);
let max_val = (1i32 << bit_depth) - 1;
let sample = (((g0 * 64 + round) >> bd_shift) + mid).clamp(0, max_val) as u16;
out.fill(sample);
}
/// Scale and inverse-transform one 8x8 block of transform coefficients (indexed by `y * 8 + x`)
/// into reconstructed samples at `bit_depth` (`10..=16`), per RFC 9924 §6.3.
#[inline]
pub fn scale_and_idct8x8(coeff: &[i16; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
let qp_shift = (qp / 6) as u32;
let mut d = [0i32; 64];
for idx in 0..64 {
let c = coeff[idx] as i64;
if c != 0 {
let qm = q_matrix[idx] as i64;
let scaled = ((c * qm * level_scale) << qp_shift) + scale_round;
d[idx] = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
}
}
// 1D vertical transform on each column x = 0..8, followed by (e + 64) >> 7
let mut g = [0i32; 64];
for x in 0..8 {
let col = idct_1d(d[x], d[8 + x], d[16 + x], d[24 + x], d[32 + x], d[40 + x], d[48 + x], d[56 + x], 64, 7);
for i in 0..8 {
g[i * 8 + x] = col[i];
}
}
// 1D horizontal transform on each row y = 0..8, followed by final bit-depth shift and offset
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
let round = 1i32 << (bd_shift - 1);
let mid = 1i32 << (bit_depth - 1);
let max_val = (1i32 << bit_depth) - 1;
for y in 0..8 {
let r = &g[y * 8..y * 8 + 8];
let row = idct_1d(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], round, bd_shift);
for i in 0..8 {
out[y * 8 + i] = (row[i] + mid).clamp(0, max_val) as u16;
}
}
}
/// 1D 8-point forward butterfly transform matching `TRANS_MATRIX`.
#[inline(always)]
fn fdct_1d(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
let s0 = x0 + x7;
let d0 = x0 - x7;
let s1 = x1 + x6;
let d1 = x1 - x6;
let s2 = x2 + x5;
let d2 = x2 - x5;
let s3 = x3 + x4;
let d3 = x3 - x4;
let es0 = s0 + s3;
let ed0 = s0 - s3;
let es1 = s1 + s2;
let ed1 = s1 - s2;
[
(64 * (es0 + es1) + round) >> shift,
(89 * d0 + 75 * d1 + 50 * d2 + 18 * d3 + round) >> shift,
(84 * ed0 + 35 * ed1 + round) >> shift,
(75 * d0 - 18 * d1 - 89 * d2 - 50 * d3 + round) >> shift,
(64 * (es0 - es1) + round) >> shift,
(50 * d0 - 89 * d1 + 18 * d2 + 75 * d3 + round) >> shift,
(35 * ed0 - 84 * ed1 + round) >> shift,
(18 * d0 - 50 * d1 + 75 * d2 - 89 * d3 + round) >> shift,
]
}
/// Forward 8x8 transform of level-shifted samples (`sample - (1 << (bit_depth - 1))`) into the
/// scaled coefficient domain `d[y * 8 + x]` matching [`scale_and_idct8x8`].
#[inline]
pub fn fdct8x8(samples: &[u16; 64], bit_depth: u8) -> [i32; 64] {
let mid = 1i32 << (bit_depth - 1);
let shift1 = (bit_depth as u32).saturating_sub(5);
let add1 = if shift1 > 0 { 1i32 << (shift1 - 1) } else { 0 };
// Horizontal forward pass
let mut t = [0i32; 64];
for y in 0..8 {
let s = &samples[y * 8..y * 8 + 8];
let row = fdct_1d(
s[0] as i32 - mid,
s[1] as i32 - mid,
s[2] as i32 - mid,
s[3] as i32 - mid,
s[4] as i32 - mid,
s[5] as i32 - mid,
s[6] as i32 - mid,
s[7] as i32 - mid,
add1,
shift1,
);
t[y * 8..y * 8 + 8].copy_from_slice(&row);
}
// Vertical forward pass
let mut f = [0i32; 64];
for u in 0..8 {
let col = fdct_1d(t[u], t[8 + u], t[16 + u], t[24 + u], t[32 + u], t[40 + u], t[48 + u], t[56 + u], 128, 8);
for v in 0..8 {
f[v * 8 + u] = col[v];
}
}
f
}
/// Quantize forward-transformed coefficients `f` (in the `d` domain) at `qp` with `q_matrix`.
#[inline]
pub fn quantize8x8(f: &[i32; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8) -> [i16; 64] {
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
let qp_shift = (qp / 6) as u32;
let mut out = [0i16; 64];
for idx in 0..64 {
let val = f[idx] as i64;
if val == 0 {
continue;
}
let sign = if val < 0 { -1i64 } else { 1i64 };
let abs_val = val.unsigned_abs() as i64;
let qm = q_matrix[idx].max(1) as i64;
let den = (qm * level_scale) << qp_shift;
let num = abs_val << bd_shift_scale;
// Deadzone/rounding offset: 1/2 for DC, 3/8 for AC
let offset = if idx == 0 { den / 2 } else { (den * 3) / 8 };
let level = ((num + offset) / den).min(32767);
out[idx] = (level * sign) as i16;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fdct_idct_roundtrip_10bit() {
let mut src = [0u16; 64];
for (i, s) in src.iter_mut().enumerate() {
*s = (64 + (i * 13) % 850) as u16;
}
let qm = [16u8; 64];
// At qp = 0 (very fine quantization), roundtrip is within 1 LSB
let f = fdct8x8(&src, 10);
let q = quantize8x8(&f, &qm, 0, 10);
let mut rec = [0u16; 64];
scale_and_idct8x8(&q, &qm, 0, 10, &mut rec);
for i in 0..64 {
let err = (src[i] as i32 - rec[i] as i32).abs();
assert!(err <= 1, "idx {i}: src={} rec={} err={err}", src[i], rec[i]);
}
}
}
+484
View File
@@ -0,0 +1,484 @@
//! RFC 9924 APV frame and tile decoder.
use filmcraft_bitstream::BitReader;
use crate::dct::{scale_and_idct8x8, scale_and_idct8x8_dc_only};
use crate::header::{
FrameHeader, PBU_ALPHA_FRAME, PBU_METADATA, PBU_NON_PRIMARY_FRAME, PBU_PREVIEW_FRAME, PBU_PRIMARY_FRAME, parse_au_pbus, parse_frame_header,
parse_metadata_pbu, read_byte_alignment,
};
use crate::tables::ZIGZAG_8X8;
use crate::{ChromaFormat, ContentLightLevel, Error, Frame, MasteringDisplay, Result};
/// Options for [`decode_frame_with`] and [`decode_frame_into`].
#[derive(Debug, Clone)]
pub struct DecodeOptions {
/// Output sample bit depth (`8..=16`). `None` keeps the stream's coded bit depth (`10..=16`).
pub bit_depth: Option<u8>,
/// Decode tiles in parallel when the `threads` feature is enabled.
pub threads: bool,
}
impl Default for DecodeOptions {
fn default() -> Self {
Self { bit_depth: None, threads: true }
}
}
/// Parse RFC 9924 §7.1.4 variable-length codeword (`h(v)`).
#[inline]
pub(crate) fn read_vlc(r: &mut BitReader, k_param: u32) -> Result<u32> {
let mut symbol_value: u64 = 0;
let mut k = k_param;
let parse_exp_golomb = if r.read_bit().map_err(|_| Error::Truncated)? {
false
} else if !r.read_bit().map_err(|_| Error::Truncated)? {
symbol_value += 1u64 << k;
false
} else {
symbol_value += 2u64 << k;
true
};
if parse_exp_golomb {
loop {
if r.read_bit().map_err(|_| Error::Truncated)? {
break;
}
symbol_value += 1u64 << k;
k += 1;
if k > 28 {
return Err(Error::Invalid("VLC codeword exceeds maximum length"));
}
}
}
if k > 0 {
symbol_value += r.read_bits(k).map_err(|_| Error::Truncated)? as u64;
}
u32::try_from(symbol_value).map_err(|_| Error::Invalid("VLC symbol overflow"))
}
/// Probe the primary frame header of an Access Unit without decoding its tiles.
pub fn probe(data: &[u8]) -> Result<FrameHeader> {
let pbus = parse_au_pbus(data)?;
for pbu in &pbus {
if matches!(pbu.pbu_type, PBU_PRIMARY_FRAME | PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME)
&& let Some(hdr) = parse_frame_header(pbu.payload)?
{
return Ok(hdr);
}
}
Err(Error::Invalid("no decodable frame PBU in access unit"))
}
/// Decode one APV Access Unit with default options.
pub fn decode_frame(data: &[u8]) -> Result<Frame> {
decode_frame_with(data, &DecodeOptions::default())
}
/// Decode one APV Access Unit with custom [`DecodeOptions`].
pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result<Frame> {
let mut out = Frame::new(2, 2, ChromaFormat::Yuv422, 10, false);
decode_frame_into(data, opts, &mut out)?;
Ok(out)
}
/// Decode one APV Access Unit into `out`, reusing its plane allocations when possible.
pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<FrameHeader> {
if let Some(d) = opts.bit_depth
&& !(8..=16).contains(&d)
{
return Err(Error::Invalid("requested output bit_depth must be in 8..=16"));
}
let pbus = parse_au_pbus(data)?;
let mut primary: Option<(u16, FrameHeader, &[u8])> = None;
let mut alpha_pbu: Option<(FrameHeader, &[u8])> = None;
let mut mdcv: Option<MasteringDisplay> = None;
let mut cll: Option<ContentLightLevel> = None;
for pbu in &pbus {
match pbu.pbu_type {
PBU_PRIMARY_FRAME => {
if primary.is_none()
&& let Some(hdr) = parse_frame_header(pbu.payload)?
{
primary = Some((pbu.group_id, hdr, pbu.payload));
}
}
PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME => {
if primary.is_none()
&& let Some(hdr) = parse_frame_header(pbu.payload)?
{
primary = Some((pbu.group_id, hdr, pbu.payload));
}
}
PBU_ALPHA_FRAME => {
if alpha_pbu.is_none()
&& let Some(hdr) = parse_frame_header(pbu.payload)?
{
alpha_pbu = Some((hdr, pbu.payload));
}
}
PBU_METADATA => {
let _ = parse_metadata_pbu(pbu.payload, &mut mdcv, &mut cll);
}
_ => {}
}
}
let (_group_id, hdr, payload) = primary.ok_or(Error::Invalid("no primary frame PBU in access unit"))?;
let out_depth = opts.bit_depth.unwrap_or(hdr.bit_depth);
decode_pbu_planes(&hdr, payload, out_depth, opts.threads, out)?;
out.mastering_display = mdcv;
out.content_light = cll;
// If the primary frame has no 4th component and an auxiliary alpha frame PBU (type 27) is present
// with matching dimensions, decode its luma plane as the alpha plane.
if out.alpha.is_none()
&& let Some((ahdr, apayload)) = alpha_pbu
&& ahdr.width == hdr.width
&& ahdr.height == hdr.height
{
let mut aframe = Frame::new(ahdr.width, ahdr.height, ahdr.chroma, out_depth, false);
if decode_pbu_planes(&ahdr, apayload, out_depth, opts.threads, &mut aframe).is_ok() {
out.alpha = Some(aframe.y);
}
}
Ok(hdr)
}
#[derive(Clone)]
struct DecodedTile {
x0: u32,
y0: u32,
w_luma: u32,
h_luma: u32,
/// Decoded pixels for each component `c` in `0..num_comps`, packed with stride `w_luma / sub_w`.
comps: [Vec<u16>; 4],
}
fn decode_pbu_planes(hdr: &FrameHeader, payload: &[u8], out_depth: u8, threads: bool, out: &mut Frame) -> Result<()> {
let num_tiles = hdr.num_tiles();
let mut pos = hdr.header_bytes;
let mut tile_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_tiles);
for tile_idx in 0..num_tiles {
let sz_bytes = payload.get(pos..pos + 4).ok_or(Error::Truncated)?;
let tile_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
if tile_size == 0 {
return Err(Error::Invalid("tile_size cannot be 0"));
}
if let Some(fh_sizes) = &hdr.tile_sizes_in_fh
&& fh_sizes.get(tile_idx).copied() != Some(tile_size as u32)
{
return Err(Error::Invalid("tile_size does not match tile_size_in_fh"));
}
pos += 4;
let tile_end = pos.checked_add(tile_size).ok_or(Error::Truncated)?;
let tile_bytes = payload.get(pos..tile_end).ok_or(Error::Truncated)?;
pos = tile_end;
tile_slices.push((tile_idx, tile_bytes));
}
// Verify any trailing filler bytes in the frame PBU are 0xFF (RFC 9924 §5.3.4 & §5.3.11)
if payload.get(pos..).is_some_and(|rest| rest.iter().any(|&b| b != 0xFF)) {
return Err(Error::Invalid("non-0xFF filler byte at end of frame PBU"));
}
#[cfg(feature = "threads")]
let decoded_tiles: Result<Vec<DecodedTile>> = if threads && tile_slices.len() > 1 {
use rayon::prelude::*;
tile_slices.into_par_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
} else {
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, threads)).collect()
};
#[cfg(not(feature = "threads"))]
let decoded_tiles: Result<Vec<DecodedTile>> = {
let _ = threads;
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
};
let mut decoded_tiles = decoded_tiles?;
// Prepare output frame metadata
let w = hdr.width as usize;
let h = hdr.height as usize;
let cw = hdr.chroma.chroma_width(hdr.width) as usize;
let ch = hdr.chroma.chroma_height(hdr.height) as usize;
let n_luma = w.checked_mul(h).ok_or(Error::Invalid("frame luma size overflow"))?;
let n_chroma = cw.checked_mul(ch).ok_or(Error::Invalid("frame chroma size overflow"))?;
out.width = hdr.width;
out.height = hdr.height;
out.chroma = hdr.chroma;
out.bit_depth = out_depth;
out.color = hdr.color;
out.profile_idc = hdr.profile_idc;
out.level_idc = hdr.level_idc;
out.band_idc = hdr.band_idc;
// Zero-copy fast path when a single tile covers the exact frame dimensions without macroblock padding.
if decoded_tiles.len() == 1 && decoded_tiles[0].w_luma == hdr.width && decoded_tiles[0].h_luma == hdr.height {
let [y, cb, cr, a] = std::mem::take(&mut decoded_tiles[0].comps);
out.y = y;
if hdr.chroma.num_comps() >= 3 {
out.cb = cb;
out.cr = cr;
} else {
out.cb.clear();
out.cr.clear();
}
out.alpha = if hdr.chroma == ChromaFormat::Yuv4444 { Some(a) } else { None };
return Ok(());
}
out.y.resize(n_luma, 0);
out.cb.resize(n_chroma, 0);
out.cr.resize(n_chroma, 0);
if hdr.chroma == ChromaFormat::Yuv4444 {
let a = out.alpha.get_or_insert_with(Vec::new);
a.resize(n_luma, 0);
} else {
out.alpha = None;
}
// Copy each tile's reconstructed samples into the cropped output planes (RFC 9924 §6)
let sub_w = hdr.chroma.sub_width_c() as usize;
let sub_h = hdr.chroma.sub_height_c() as usize;
for tile in &decoded_tiles {
copy_tile_comp(&tile.comps[0], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, &mut out.y, w, h);
if hdr.chroma.num_comps() >= 3 {
let tw_c = (tile.w_luma as usize) / sub_w;
let th_c = (tile.h_luma as usize) / sub_h;
let tx_c = (tile.x0 as usize) / sub_w;
let ty_c = (tile.y0 as usize) / sub_h;
copy_tile_comp(&tile.comps[1], tw_c, th_c, tx_c, ty_c, &mut out.cb, cw, ch);
copy_tile_comp(&tile.comps[2], tw_c, th_c, tx_c, ty_c, &mut out.cr, cw, ch);
}
if hdr.chroma == ChromaFormat::Yuv4444
&& let Some(alpha) = out.alpha.as_mut()
{
copy_tile_comp(&tile.comps[3], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, alpha, w, h);
}
}
Ok(())
}
fn copy_tile_comp(src: &[u16], tw: usize, th: usize, tx: usize, ty: usize, dst: &mut [u16], fw: usize, fh: usize) {
if tx >= fw || ty >= fh {
return;
}
let copy_w = tw.min(fw - tx);
let copy_h = th.min(fh - ty);
for r in 0..copy_h {
let s_off = r * tw;
let d_off = (ty + r) * fw + tx;
dst[d_off..d_off + copy_w].copy_from_slice(&src[s_off..s_off + copy_w]);
}
}
fn decode_single_tile(hdr: &FrameHeader, tile_idx: usize, tile_bytes: &[u8], out_depth: u8, par_comps: bool) -> Result<DecodedTile> {
let num_comps = hdr.chroma.num_comps();
let min_hdr_size = 5 + 5 * num_comps;
if tile_bytes.len() < min_hdr_size {
return Err(Error::Truncated);
}
// tile_header(tileIdx) (RFC 9924 §5.3.13)
let tile_header_size = u16::from_be_bytes([tile_bytes[0], tile_bytes[1]]) as usize;
let tile_index = u16::from_be_bytes([tile_bytes[2], tile_bytes[3]]) as usize;
if tile_index != tile_idx {
return Err(Error::CorruptTile(tile_idx));
}
if tile_header_size < min_hdr_size || tile_header_size > tile_bytes.len() {
return Err(Error::CorruptTile(tile_idx));
}
let mut tile_data_size = [0usize; 4];
for c in 0..num_comps {
let off = 4 + c * 4;
let sz = u32::from_be_bytes([tile_bytes[off], tile_bytes[off + 1], tile_bytes[off + 2], tile_bytes[off + 3]]) as usize;
if sz == 0 {
return Err(Error::CorruptTile(tile_idx));
}
tile_data_size[c] = sz;
}
let max_tile_qp = (51 + hdr.qp_bd_offset()) as u8;
let mut tile_qp = [0u8; 4];
for c in 0..num_comps {
let qp = tile_bytes[4 + num_comps * 4 + c];
if qp > max_tile_qp {
return Err(Error::CorruptTile(tile_idx));
}
tile_qp[c] = qp;
}
let reserved_zero_8bits = tile_bytes[4 + num_comps * 5];
if reserved_zero_8bits != 0 {
return Err(Error::CorruptTile(tile_idx));
}
let tx = tile_idx % (hdr.tile_cols as usize);
let ty = tile_idx / (hdr.tile_cols as usize);
let x0 = hdr.col_starts[tx];
let x1 = hdr.col_starts[tx + 1];
let y0 = hdr.row_starts[ty];
let y1 = hdr.row_starts[ty + 1];
let w_luma = x1.saturating_sub(x0);
let h_luma = y1.saturating_sub(y0);
let mut comp_off = tile_header_size;
let mut comp_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_comps);
for c in 0..num_comps {
let comp_end = comp_off.checked_add(tile_data_size[c]).ok_or(Error::CorruptTile(tile_idx))?;
let comp_bytes = tile_bytes.get(comp_off..comp_end).ok_or(Error::CorruptTile(tile_idx))?;
comp_off = comp_end;
comp_slices.push((c, comp_bytes));
}
#[cfg(feature = "threads")]
let decoded_comps: Result<Vec<Vec<u16>>> = if par_comps && comp_slices.len() > 1 {
use rayon::prelude::*;
comp_slices.into_par_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
} else {
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
};
#[cfg(not(feature = "threads"))]
let decoded_comps: Result<Vec<Vec<u16>>> = {
let _ = par_comps;
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
};
let mut comps: [Vec<u16>; 4] = Default::default();
for (c, buf) in decoded_comps?.into_iter().enumerate() {
comps[c] = buf;
}
Ok(DecodedTile { x0, y0, w_luma, h_luma, comps })
}
#[allow(clippy::too_many_arguments)]
fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[u8], qp: u8, w_luma: u32, h_luma: u32, out_depth: u8) -> Result<Vec<u16>> {
let num_mb_cols = w_luma / 16;
let num_mb_rows = h_luma / 16;
let sub_w = if c == 0 { 1 } else { hdr.chroma.sub_width_c() };
let sub_h = if c == 0 { 1 } else { hdr.chroma.sub_height_c() };
let tw_c = (w_luma / sub_w) as usize;
let th_c = (h_luma / sub_h) as usize;
let blk_w = (16 / sub_w) as usize;
let blk_h = (16 / sub_h) as usize;
let blocks_x = blk_w / 8;
let blocks_y = blk_h / 8;
let mut comp_buf = vec![0u16; tw_c * th_c];
let mut r = BitReader::new(comp_bytes);
let mut prev_dc: i32 = 0;
let mut prev_dc_diff: u32 = 20;
let mut prev_1st_ac_level: u32 = 0;
let q_mat = &hdr.q_matrix[c];
let coded_depth = hdr.bit_depth;
for mb_y in 0..(num_mb_rows as usize) {
for mb_x in 0..(num_mb_cols as usize) {
for by in 0..blocks_y {
for bx in 0..blocks_x {
// abs_dc_coeff_diff (RFC 9924 §5.3.15 & §7.1.1)
let k_dc = (prev_dc_diff >> 1).min(5);
let abs_dc_diff = read_vlc(&mut r, k_dc).map_err(|_| Error::CorruptTile(tile_idx))?;
let sign_dc = if abs_dc_diff > 0 { r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))? } else { false };
let diff = if sign_dc { -(abs_dc_diff as i64) } else { abs_dc_diff as i64 };
let dc = prev_dc as i64 + diff;
if !(-32768..=32767).contains(&dc) {
return Err(Error::CorruptTile(tile_idx));
}
prev_dc = dc as i32;
prev_dc_diff = abs_dc_diff;
let mut coeff = [0i16; 64];
coeff[0] = dc as i16;
// ac_coeff_coding (RFC 9924 §5.3.16 & §7.1.2–7.1.3)
let mut scan_pos = 1usize;
let mut first_ac = true;
let mut prev_level = prev_1st_ac_level;
let mut prev_run = 0u32;
while scan_pos < 64 {
let k_run = (prev_run >> 2).min(2);
let run = read_vlc(&mut r, k_run).map_err(|_| Error::CorruptTile(tile_idx))? as usize;
scan_pos = scan_pos.checked_add(run).ok_or(Error::CorruptTile(tile_idx))?;
if scan_pos > 64 {
return Err(Error::CorruptTile(tile_idx));
}
prev_run = run as u32;
if scan_pos < 64 {
let k_lvl = (prev_level >> 2).min(4);
let abs_ac_minus1 = read_vlc(&mut r, k_lvl).map_err(|_| Error::CorruptTile(tile_idx))?;
let sign_ac = r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))?;
let abs_level = (abs_ac_minus1 as u64) + 1;
let level_i64 = if sign_ac { -(abs_level as i64) } else { abs_level as i64 };
if !(-32768..=32767).contains(&level_i64) {
return Err(Error::CorruptTile(tile_idx));
}
coeff[ZIGZAG_8X8[scan_pos] as usize] = level_i64 as i16;
scan_pos += 1;
prev_level = abs_level as u32;
if first_ac {
first_ac = false;
prev_1st_ac_level = prev_level;
}
}
}
let mut rec = [0u16; 64];
if first_ac {
scale_and_idct8x8_dc_only(coeff[0], q_mat[0], qp, coded_depth, &mut rec);
} else {
scale_and_idct8x8(&coeff, q_mat, qp, coded_depth, &mut rec);
}
if out_depth != coded_depth {
rescale_block(&mut rec, coded_depth, out_depth);
}
let px0 = mb_x * blk_w + bx * 8;
let py0 = mb_y * blk_h + by * 8;
for ry in 0..8 {
let dst_row = (py0 + ry) * tw_c + px0;
comp_buf[dst_row..dst_row + 8].copy_from_slice(&rec[ry * 8..ry * 8 + 8]);
}
}
}
}
}
read_byte_alignment(&mut r).map_err(|_| Error::CorruptTile(tile_idx))?;
Ok(comp_buf)
}
#[inline]
fn rescale_block(block: &mut [u16; 64], from_depth: u8, to_depth: u8) {
if to_depth > from_depth {
let shift = (to_depth - from_depth) as u32;
let fill_shift = from_depth as u32;
for s in block.iter_mut() {
let v = *s as u32;
*s = ((v << shift) | (v >> (fill_shift - shift))) as u16;
}
} else if to_depth < from_depth {
let shift = (from_depth - to_depth) as u32;
let round = 1u32 << (shift - 1);
let max_v = (1u32 << to_depth) - 1;
for s in block.iter_mut() {
*s = (((*s as u32) + round) >> shift).min(max_v) as u16;
}
}
}
+598
View File
@@ -0,0 +1,598 @@
//! RFC 9924 APV intra frame encoder.
use filmcraft_bitstream::BitWriter;
use crate::dct::{fdct8x8, quantize8x8};
use crate::header::{FrameHeader, MAX_DIMENSION, PBU_PRIMARY_FRAME, write_frame_header, write_metadata_pbu};
use crate::tables::{DEFAULT_Q_MATRIX_VAL, ZIGZAG_8X8, select_level_and_band};
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, Frame, MasteringDisplay, Profile, Result};
/// Generate RFC 9924 §7.2.4 variable-length codeword (`h(v)`).
#[inline]
pub(crate) fn write_vlc(w: &mut BitWriter, symbol_val: u32, k_param: u32) {
const PREFIX_VLC: [[u32; 2]; 3] = [[1, 0], [0, 0], [0, 1]];
let mut symbol_value = symbol_val;
let val_prefix = ((symbol_val >> k_param) as usize).min(2);
let mut bit_count = 0usize;
let mut k = k_param;
while symbol_value >= (1u32 << k) {
symbol_value -= 1u32 << k;
if bit_count < 2 {
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
} else {
w.write_bits(0, 1);
}
if bit_count >= 2 {
k += 1;
}
bit_count += 1;
}
if bit_count < 2 {
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
} else {
w.write_bits(1, 1);
}
if k > 0 {
w.write_bits(symbol_value, k);
}
}
/// Exact bit length of a VLC codeword generated by [`write_vlc`].
#[inline]
pub(crate) fn vlc_bit_len(symbol_val: u32, k_param: u32) -> u32 {
if symbol_val < (1u32 << k_param) {
1 + k_param
} else if symbol_val < (2u32 << k_param) {
2 + k_param
} else {
let rem = (symbol_val >> k_param) - 1;
let zeros = 31 - rem.leading_zeros();
3 + k_param + 2 * zeros
}
}
/// APV encoder configuration.
#[derive(Debug, Clone)]
pub struct EncoderConfig {
pub profile: Profile,
pub width: u32,
pub height: u32,
pub chroma: ChromaFormat,
pub bit_depth: u8,
pub color: ColorInfo,
pub color_description_present: bool,
/// Fixed tile quantization parameter (`0..=51 + QpBdOffset`). When `None`, rate control
/// chooses a uniform QP per frame to fit [`Self::target_frame_bytes`].
pub qp: Option<u8>,
/// Target encoded frame size in bytes when `qp` is `None`.
pub target_frame_bytes: u32,
/// Tile width in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 16`).
pub tile_width_in_mbs: u32,
/// Tile height in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 8`).
pub tile_height_in_mbs: u32,
pub level_idc: u8,
pub band_idc: u8,
/// Optional custom per-component 8x8 quantization matrices (`y * 8 + x`).
pub q_matrix: Option<[[u8; 64]; 4]>,
/// Write `tile_size_in_fh` array in `frame_header()` (`tile_size_present_in_fh_flag = 1`).
pub write_tile_sizes_in_fh: bool,
pub mastering_display: Option<MasteringDisplay>,
pub content_light: Option<ContentLightLevel>,
}
impl EncoderConfig {
pub fn new(profile: Profile, width: u32, height: u32) -> Self {
let chroma = profile.default_chroma();
let bit_depth = profile.default_bit_depth();
let w_mbs = width.div_ceil(16).max(1);
let h_mbs = height.div_ceil(16).max(1);
// Choose conforming tile dimensions (>= 16x8 MBs, <= 20x20 tiles) with ~4-8 tiles on HD/4K.
let tile_width_in_mbs = w_mbs.div_ceil(4).max(16).max(w_mbs.div_ceil(20));
let tile_height_in_mbs = h_mbs.div_ceil(4).max(8).max(h_mbs.div_ceil(20));
let target_frame_bytes = profile.nominal_frame_bytes(width, height);
let kbps = ((target_frame_bytes as u64) * 8 * 30 / 1000).max(1000) as u32;
let (level_idc, band_idc) = select_level_and_band((width as u64) * (height as u64) * 30, kbps);
Self {
profile,
width,
height,
chroma,
bit_depth,
color: ColorInfo::BT709,
color_description_present: true,
qp: None,
target_frame_bytes,
tile_width_in_mbs,
tile_height_in_mbs,
level_idc,
band_idc,
q_matrix: None,
write_tile_sizes_in_fh: false,
mastering_display: None,
content_light: None,
}
}
}
/// APV intra encoder.
pub struct Encoder {
cfg: EncoderConfig,
hdr: FrameHeader,
}
struct PreparedTileComp {
/// Forward-transformed 8x8 blocks in MB-then-subblock scan order.
blocks: Vec<[i32; 64]>,
}
struct PreparedTile {
tile_idx: usize,
comps: Vec<PreparedTileComp>,
}
impl Encoder {
pub fn new(profile: Profile, width: u32, height: u32) -> Result<Self> {
Self::with_config(EncoderConfig::new(profile, width, height))
}
pub fn with_config(cfg: EncoderConfig) -> Result<Self> {
if cfg.width == 0 || cfg.height == 0 || cfg.width > MAX_DIMENSION || cfg.height > MAX_DIMENSION {
return Err(Error::Input(format!("unsupported dimensions {}x{}", cfg.width, cfg.height)));
}
if cfg.chroma == ChromaFormat::Yuv422 && !cfg.width.is_multiple_of(2) {
return Err(Error::Input("4:2:2 requires even frame width".into()));
}
if !(10..=16).contains(&cfg.bit_depth) {
return Err(Error::Input(format!("unsupported coded bit depth {}", cfg.bit_depth)));
}
if !cfg.profile.supports(cfg.chroma, cfg.bit_depth) {
return Err(Error::Input(format!("profile {} does not support {:?} {}-bit", cfg.profile.name(), cfg.chroma, cfg.bit_depth)));
}
if cfg.tile_width_in_mbs == 0 || cfg.tile_height_in_mbs == 0 {
return Err(Error::Input("tile dimensions in MBs must be non-zero".into()));
}
let w_mbs = cfg.width.div_ceil(16);
let h_mbs = cfg.height.div_ceil(16);
let tile_cols = w_mbs.div_ceil(cfg.tile_width_in_mbs);
let tile_rows = h_mbs.div_ceil(cfg.tile_height_in_mbs);
if tile_cols > 20 || tile_rows > 20 {
return Err(Error::Input(format!("tile grid {tile_cols}x{tile_rows} exceeds RFC 9924 limit of 20x20")));
}
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
let mut s = 0u32;
while s < w_mbs {
col_starts.push(s * 16);
s += cfg.tile_width_in_mbs;
}
col_starts.push(w_mbs * 16);
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
let mut s = 0u32;
while s < h_mbs {
row_starts.push(s * 16);
s += cfg.tile_height_in_mbs;
}
row_starts.push(h_mbs * 16);
if let Some(qm) = &cfg.q_matrix {
for c in 0..cfg.chroma.num_comps() {
if qm[c].contains(&0) {
return Err(Error::Input("q_matrix entries must be non-zero (1..=255)".into()));
}
}
}
let hdr = FrameHeader {
profile_idc: cfg.profile.idc(),
level_idc: cfg.level_idc,
band_idc: cfg.band_idc.min(3),
width: cfg.width,
height: cfg.height,
chroma: cfg.chroma,
bit_depth: cfg.bit_depth,
capture_time_distance: 0,
color_description_present: cfg.color_description_present,
color: cfg.color,
use_q_matrix: cfg.q_matrix.is_some(),
q_matrix: cfg.q_matrix.unwrap_or([[DEFAULT_Q_MATRIX_VAL; 64]; 4]),
tile_width_in_mbs: cfg.tile_width_in_mbs,
tile_height_in_mbs: cfg.tile_height_in_mbs,
tile_cols,
tile_rows,
col_starts,
row_starts,
tile_sizes_in_fh: None,
header_bytes: 0,
};
Ok(Self { cfg, hdr })
}
pub fn config(&self) -> &EncoderConfig {
&self.cfg
}
pub fn target_frame_bytes(&self) -> u32 {
self.cfg.target_frame_bytes
}
/// Encode one frame into an APV Access Unit (`b"aPv1"...`), suitable for MP4/MOV (`apv1`) or MKV (`V_APV`).
pub fn encode(&mut self, frame: &Frame) -> Result<Vec<u8>> {
if frame.width != self.cfg.width || frame.height != self.cfg.height {
return Err(Error::Input(format!("frame is {}x{}, encoder is {}x{}", frame.width, frame.height, self.cfg.width, self.cfg.height)));
}
if frame.chroma != self.cfg.chroma {
return Err(Error::Input(format!("frame chroma {:?} does not match encoder {:?}", frame.chroma, self.cfg.chroma)));
}
if !(8..=16).contains(&frame.bit_depth) {
return Err(Error::Input(format!("unsupported input bit depth {}", frame.bit_depth)));
}
let n_luma = (frame.width as usize) * (frame.height as usize);
let n_chroma = (frame.chroma_width() as usize) * (frame.chroma_height() as usize);
if frame.y.len() != n_luma || frame.cb.len() != n_chroma || frame.cr.len() != n_chroma {
return Err(Error::Input("plane buffer lengths do not match frame geometry".into()));
}
if self.cfg.chroma == ChromaFormat::Yuv4444 && frame.alpha.as_ref().is_none_or(|a| a.len() != n_luma) {
return Err(Error::Input("4:4:4:4 profile requires an alpha plane of width * height samples".into()));
}
let prepared = self.prepare_tiles(frame);
let max_qp = (51 + self.hdr.qp_bd_offset()) as u8;
let qp = match self.cfg.qp {
Some(q) => q.min(max_qp),
None => self.select_qp(&prepared, max_qp),
};
let encoded_tiles: Vec<Vec<u8>> = {
#[cfg(feature = "threads")]
{
use rayon::prelude::*;
prepared.par_iter().map(|t| self.encode_tile(t, qp)).collect()
}
#[cfg(not(feature = "threads"))]
{
prepared.iter().map(|t| self.encode_tile(t, qp)).collect()
}
};
let mut hdr = self.hdr.clone();
hdr.color = if !self.cfg.color_description_present {
ColorInfo::default()
} else if frame.color != ColorInfo::BT709 {
frame.color
} else {
self.cfg.color
};
if self.cfg.write_tile_sizes_in_fh {
hdr.tile_sizes_in_fh = Some(encoded_tiles.iter().map(|t| t.len() as u32).collect());
}
let fh_bytes = write_frame_header(&hdr);
let tiles_total_bytes: usize = encoded_tiles.iter().map(|t| 4 + t.len()).sum();
let pbu_size = 4 + fh_bytes.len() + tiles_total_bytes;
let mdcv = frame.mastering_display.as_ref().or(self.cfg.mastering_display.as_ref());
let cll = frame.content_light.as_ref().or(self.cfg.content_light.as_ref());
let meta_pbu = write_metadata_pbu(1, mdcv, cll);
let meta_total = meta_pbu.as_ref().map_or(0, |m| 4 + m.len());
let mut au = Vec::with_capacity(4 + 4 + pbu_size + meta_total);
au.extend_from_slice(b"aPv1");
// Primary frame PBU (pbu_type = 1, group_id = 1)
au.extend_from_slice(&(pbu_size as u32).to_be_bytes());
au.push(PBU_PRIMARY_FRAME);
au.extend_from_slice(&1u16.to_be_bytes());
au.push(0); // reserved_zero_8bits
au.extend_from_slice(&fh_bytes);
for t in &encoded_tiles {
au.extend_from_slice(&(t.len() as u32).to_be_bytes());
au.extend_from_slice(t);
}
if let Some(m) = meta_pbu {
au.extend_from_slice(&(m.len() as u32).to_be_bytes());
au.extend_from_slice(&m);
}
Ok(au)
}
/// Encode one frame as a `raw_bitstream_access_unit()` (prefixed by `au_size: u32 BE`, RFC 9924 Appendix A).
pub fn encode_raw_au(&mut self, frame: &Frame) -> Result<Vec<u8>> {
let au = self.encode(frame)?;
let mut raw = Vec::with_capacity(4 + au.len());
raw.extend_from_slice(&(au.len() as u32).to_be_bytes());
raw.extend_from_slice(&au);
Ok(raw)
}
/// Build the `APVDecoderConfigurationRecord` payload (for ISOBMFF `apvC` or Matroska CodecPrivate).
pub fn decoder_config_record(&self) -> Vec<u8> {
let mut v = Vec::with_capacity(24);
v.push(1); // configurationVersion = 1
v.push(1); // number_of_configuration_entry = 1
v.push(PBU_PRIMARY_FRAME); // pbu_type[0] = 1
v.push(1); // number_of_frame_info[0] = 1
let flags = if self.cfg.color_description_present { 0x02 } else { 0x00 } | 0x01; // capture_time_distance_ignored = 1
v.push(flags);
v.push(self.hdr.profile_idc);
v.push(self.hdr.level_idc);
v.push(self.hdr.band_idc);
v.extend_from_slice(&self.hdr.width.to_be_bytes());
v.extend_from_slice(&self.hdr.height.to_be_bytes());
v.push((self.hdr.chroma.idc() << 4) | (self.hdr.bit_depth.saturating_sub(8) & 0x0F));
v.push(0); // capture_time_distance = 0
if self.cfg.color_description_present {
v.push(self.cfg.color.primaries);
v.push(self.cfg.color.transfer);
v.push(self.cfg.color.matrix);
v.push(if self.cfg.color.full_range { 0x80 } else { 0x00 });
}
v
}
fn prepare_tiles(&self, frame: &Frame) -> Vec<PreparedTile> {
let num_tiles = self.hdr.num_tiles();
let prepare_one = |tile_idx: usize| -> PreparedTile {
let tx = tile_idx % (self.hdr.tile_cols as usize);
let ty = tile_idx / (self.hdr.tile_cols as usize);
let x0 = self.hdr.col_starts[tx];
let x1 = self.hdr.col_starts[tx + 1];
let y0 = self.hdr.row_starts[ty];
let y1 = self.hdr.row_starts[ty + 1];
let num_mb_cols = (x1 - x0) / 16;
let num_mb_rows = (y1 - y0) / 16;
let num_comps = self.hdr.chroma.num_comps();
let mut comps = Vec::with_capacity(num_comps);
for c in 0..num_comps {
let (plane, pw, ph, sub_w, sub_h) = match c {
0 => (frame.y.as_slice(), frame.width as usize, frame.height as usize, 1u32, 1u32),
1 => (
frame.cb.as_slice(),
frame.chroma_width() as usize,
frame.chroma_height() as usize,
self.hdr.chroma.sub_width_c(),
self.hdr.chroma.sub_height_c(),
),
2 => (
frame.cr.as_slice(),
frame.chroma_width() as usize,
frame.chroma_height() as usize,
self.hdr.chroma.sub_width_c(),
self.hdr.chroma.sub_height_c(),
),
_ => (frame.alpha.as_deref().unwrap_or(&[]), frame.width as usize, frame.height as usize, 1u32, 1u32),
};
let blk_w = (16 / sub_w) as usize;
let blk_h = (16 / sub_h) as usize;
let blocks_x = blk_w / 8;
let blocks_y = blk_h / 8;
let total_blocks = (num_mb_cols as usize) * (num_mb_rows as usize) * blocks_x * blocks_y;
let mut blocks = Vec::with_capacity(total_blocks);
let coded_depth = self.hdr.bit_depth;
let in_depth = frame.bit_depth;
for mb_y in 0..num_mb_rows {
for mb_x in 0..num_mb_cols {
let x_mb_c = ((x0 + mb_x * 16) / sub_w) as usize;
let y_mb_c = ((y0 + mb_y * 16) / sub_h) as usize;
for by in 0..blocks_y {
for bx in 0..blocks_x {
let bx0 = x_mb_c + bx * 8;
let by0 = y_mb_c + by * 8;
let mut samples = [0u16; 64];
for ry in 0..8 {
let sy = (by0 + ry).min(ph.saturating_sub(1));
for rx in 0..8 {
let sx = (bx0 + rx).min(pw.saturating_sub(1));
let v = plane[sy * pw + sx];
samples[ry * 8 + rx] = scale_sample(v, in_depth, coded_depth);
}
}
blocks.push(fdct8x8(&samples, coded_depth));
}
}
}
}
comps.push(PreparedTileComp { blocks });
}
PreparedTile { tile_idx, comps }
};
#[cfg(feature = "threads")]
{
use rayon::prelude::*;
(0..num_tiles).into_par_iter().map(prepare_one).collect()
}
#[cfg(not(feature = "threads"))]
{
(0..num_tiles).map(prepare_one).collect()
}
}
fn select_qp(&self, tiles: &[PreparedTile], max_qp: u8) -> u8 {
let target_bytes = (self.cfg.target_frame_bytes as usize).max(64);
let frame_bytes_at = |qp: u8| -> usize {
let tile_bytes = |t: &PreparedTile| -> usize {
let num_comps = t.comps.len();
let mut sz = 4 + (5 + 5 * num_comps);
for (c, comp) in t.comps.iter().enumerate() {
let bits = self.estimate_comp_bits(comp, c, qp);
sz += (bits.div_ceil(8) as usize).max(1);
}
sz
};
#[cfg(feature = "threads")]
let sum: usize = {
use rayon::prelude::*;
tiles.par_iter().map(tile_bytes).sum()
};
#[cfg(not(feature = "threads"))]
let sum: usize = tiles.iter().map(tile_bytes).sum();
24 + sum
};
let mut lo = 0u8;
let mut hi = max_qp;
if frame_bytes_at(0) <= target_bytes {
return 0;
}
while lo < hi {
let mid = lo + (hi - lo) / 2;
if frame_bytes_at(mid) <= target_bytes {
hi = mid;
} else {
lo = mid + 1;
}
}
lo
}
fn estimate_comp_bits(&self, comp: &PreparedTileComp, c: usize, qp: u8) -> u64 {
let qm = &self.hdr.q_matrix[c];
let depth = self.hdr.bit_depth;
let mut prev_dc: i32 = 0;
let mut prev_dc_diff: u32 = 20;
let mut prev_1st_ac_level: u32 = 0;
let mut bits: u64 = 0;
for f_blk in &comp.blocks {
let coeff = quantize8x8(f_blk, qm, qp, depth);
let dc = coeff[0] as i32;
let diff = dc - prev_dc;
let abs_dc_diff = diff.unsigned_abs();
let k_dc = (prev_dc_diff >> 1).min(5);
bits += vlc_bit_len(abs_dc_diff, k_dc) as u64;
if abs_dc_diff > 0 {
bits += 1;
}
prev_dc = dc;
prev_dc_diff = abs_dc_diff;
let mut scan_pos = 1usize;
let mut first_ac = true;
let mut prev_level = prev_1st_ac_level;
let mut prev_run = 0u32;
while scan_pos < 64 {
let mut run = 0u32;
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
run += 1;
}
let k_run = (prev_run >> 2).min(2);
bits += vlc_bit_len(run, k_run) as u64;
scan_pos += run as usize;
prev_run = run;
if scan_pos < 64 {
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
let abs_level = level.unsigned_abs() as u32;
let k_lvl = (prev_level >> 2).min(4);
bits += vlc_bit_len(abs_level - 1, k_lvl) as u64 + 1;
scan_pos += 1;
prev_level = abs_level;
if first_ac {
first_ac = false;
prev_1st_ac_level = prev_level;
}
}
}
}
bits
}
fn encode_tile(&self, tile: &PreparedTile, qp: u8) -> Vec<u8> {
let num_comps = tile.comps.len();
let mut comp_payloads: Vec<Vec<u8>> = Vec::with_capacity(num_comps);
let depth = self.hdr.bit_depth;
for (c, comp) in tile.comps.iter().enumerate() {
let qm = &self.hdr.q_matrix[c];
let mut w = BitWriter::new();
let mut prev_dc: i32 = 0;
let mut prev_dc_diff: u32 = 20;
let mut prev_1st_ac_level: u32 = 0;
for f_blk in &comp.blocks {
let coeff = quantize8x8(f_blk, qm, qp, depth);
let dc = coeff[0] as i32;
let diff = dc - prev_dc;
let abs_dc_diff = diff.unsigned_abs();
let k_dc = (prev_dc_diff >> 1).min(5);
write_vlc(&mut w, abs_dc_diff, k_dc);
if abs_dc_diff > 0 {
w.write_bit(diff < 0);
}
prev_dc = dc;
prev_dc_diff = abs_dc_diff;
let mut scan_pos = 1usize;
let mut first_ac = true;
let mut prev_level = prev_1st_ac_level;
let mut prev_run = 0u32;
while scan_pos < 64 {
let mut run = 0u32;
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
run += 1;
}
let k_run = (prev_run >> 2).min(2);
write_vlc(&mut w, run, k_run);
scan_pos += run as usize;
prev_run = run;
if scan_pos < 64 {
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
let abs_level = level.unsigned_abs() as u32;
let k_lvl = (prev_level >> 2).min(4);
write_vlc(&mut w, abs_level - 1, k_lvl);
w.write_bit(level < 0);
scan_pos += 1;
prev_level = abs_level;
if first_ac {
first_ac = false;
prev_1st_ac_level = prev_level;
}
}
}
}
comp_payloads.push(w.finish());
}
// Write tile_header(tileIdx) (RFC 9924 §5.3.13)
let tile_header_size = (5 + 5 * num_comps) as u16;
let total_size = tile_header_size as usize + comp_payloads.iter().map(Vec::len).sum::<usize>();
let mut out = Vec::with_capacity(total_size);
out.extend_from_slice(&tile_header_size.to_be_bytes());
out.extend_from_slice(&(tile.tile_idx as u16).to_be_bytes());
for cp in &comp_payloads {
out.extend_from_slice(&(cp.len() as u32).to_be_bytes());
}
for _ in 0..num_comps {
out.push(qp);
}
out.push(0); // reserved_zero_8bits
for cp in comp_payloads {
out.extend_from_slice(&cp);
}
out
}
}
#[inline]
fn scale_sample(v: u16, from_depth: u8, to_depth: u8) -> u16 {
let max_in = (1u32 << from_depth) - 1;
let v = (v as u32).min(max_in);
if to_depth == from_depth {
v as u16
} else if to_depth > from_depth {
let shift = (to_depth - from_depth) as u32;
((v << shift) | (v >> ((from_depth as u32) - shift))) as u16
} else {
let shift = (from_depth - to_depth) as u32;
let round = 1u32 << (shift - 1);
let max_out = (1u32 << to_depth) - 1;
(((v + round) >> shift).min(max_out)) as u16
}
}
+460
View File
@@ -0,0 +1,460 @@
//! RFC 9924 §5.3 and §8 syntax structures: Access Unit, PBU, Frame Header, Tile Info, and Metadata.
use filmcraft_bitstream::{BitReader, BitWriter};
use crate::tables::DEFAULT_Q_MATRIX_VAL;
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, MasteringDisplay, Result};
/// Four-character signature `'aPv1'` (`0x61507631`) at the start of every APV Access Unit (RFC 9924 §5.3.1).
pub const AU_SIGNATURE: [u8; 4] = *b"aPv1";
/// Primitive Bitstream Unit (PBU) types (RFC 9924 §5.3.3 Table 3).
pub const PBU_PRIMARY_FRAME: u8 = 1;
pub const PBU_NON_PRIMARY_FRAME: u8 = 2;
pub const PBU_PREVIEW_FRAME: u8 = 25;
pub const PBU_DEPTH_FRAME: u8 = 26;
pub const PBU_ALPHA_FRAME: u8 = 27;
pub const PBU_AU_INFO: u8 = 65;
pub const PBU_METADATA: u8 = 66;
pub const PBU_FILLER: u8 = 67;
/// Maximum supported frame dimension in luma samples (guards against hostile allocations).
pub const MAX_DIMENSION: u32 = 16384;
/// Maximum tiles per axis (RFC 9924 §9.4.1 specifies 20; we allow up to 64 for robustness).
pub const MAX_TILE_DIM: u32 = 64;
/// Parsed `frame_header()` and derived tile geometry (RFC 9924 §5.3.5–5.3.8).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrameHeader {
pub profile_idc: u8,
pub level_idc: u8,
pub band_idc: u8,
pub width: u32,
pub height: u32,
pub chroma: ChromaFormat,
pub bit_depth: u8,
pub capture_time_distance: u8,
pub color_description_present: bool,
pub color: ColorInfo,
pub use_q_matrix: bool,
/// Per-component 8x8 quantization matrix in raster order (`y * 8 + x`).
pub q_matrix: [[u8; 64]; 4],
pub tile_width_in_mbs: u32,
pub tile_height_in_mbs: u32,
pub tile_cols: u32,
pub tile_rows: u32,
/// Tile column start positions in luma samples (`len == tile_cols + 1`).
pub col_starts: Vec<u32>,
/// Tile row start positions in luma samples (`len == tile_rows + 1`).
pub row_starts: Vec<u32>,
pub tile_sizes_in_fh: Option<Vec<u32>>,
/// Byte offset within the PBU payload where `frame_header()` ends (start of the tile loop).
pub header_bytes: usize,
}
impl FrameHeader {
pub fn frame_width_in_mbs(&self) -> u32 {
self.width.div_ceil(16)
}
pub fn frame_height_in_mbs(&self) -> u32 {
self.height.div_ceil(16)
}
pub fn num_tiles(&self) -> usize {
(self.tile_cols as usize) * (self.tile_rows as usize)
}
pub fn qp_bd_offset(&self) -> i32 {
(self.bit_depth.saturating_sub(8) as i32) * 6
}
}
/// A parsed PBU slice inside an Access Unit.
#[derive(Debug, Clone)]
pub struct PbuSlice<'a> {
pub pbu_type: u8,
pub group_id: u16,
/// Payload after the 4-byte `pbu_header()`.
pub payload: &'a [u8],
}
/// Unwrap an Access Unit byte slice: accepts either a direct `access_unit` starting with `'aPv1'`
/// or a `raw_bitstream_access_unit` prefixed with a 32-bit big-endian `au_size` (RFC 9924 Appendix A).
pub fn unwrap_au(data: &[u8]) -> Result<&[u8]> {
if data.len() < 4 {
return Err(Error::Truncated);
}
if data[..4] == AU_SIGNATURE {
return Ok(data);
}
if data.len() >= 8 && data[4..8] == AU_SIGNATURE {
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if au_size < 4 || au_size == 0xFFFF_FFFF {
return Err(Error::Invalid("invalid au_size in raw bitstream"));
}
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
return data.get(4..end).ok_or(Error::Truncated);
}
Err(Error::Invalid("missing aPv1 access unit signature"))
}
/// Split a raw `.apv` bitstream (`raw_bitstream_access_unit()` sequence per RFC 9924 Appendix A)
/// into individual Access Unit slices (each starting with `'aPv1'`).
pub fn split_raw_bitstream(mut data: &[u8]) -> Result<Vec<&[u8]>> {
if data.is_empty() {
return Err(Error::Truncated);
}
// If the buffer starts directly with 'aPv1', treat the entire buffer as a single AU.
if data.len() >= 4 && data[..4] == AU_SIGNATURE {
return Ok(vec![data]);
}
let mut aus = Vec::new();
while !data.is_empty() {
if data.len() < 8 {
return Err(Error::Truncated);
}
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if au_size < 4 || au_size == 0xFFFF_FFFF {
return Err(Error::Invalid("invalid au_size in raw bitstream"));
}
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
let au = data.get(4..end).ok_or(Error::Truncated)?;
if au[..4] != AU_SIGNATURE {
return Err(Error::Invalid("missing aPv1 signature in raw bitstream AU"));
}
aus.push(au);
data = &data[end..];
}
if aus.is_empty() {
return Err(Error::Truncated);
}
Ok(aus)
}
/// Parse the PBU list of a single Access Unit (RFC 9924 §5.3.1–5.3.3).
/// PBUs with `reserved_zero_8bits > 0` are ignored as required by RFC 9924 §5.3.3.
pub fn parse_au_pbus(data: &[u8]) -> Result<Vec<PbuSlice<'_>>> {
let au = unwrap_au(data)?;
if au.len() < 8 {
return Err(Error::Truncated);
}
let mut pos = 4usize;
let mut pbus = Vec::new();
while pos < au.len() {
let sz_bytes = au.get(pos..pos + 4).ok_or(Error::Truncated)?;
let pbu_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
if pbu_size == 0 || pbu_size == 0xFFFF_FFFF {
return Err(Error::Invalid("invalid pbu_size"));
}
if pbu_size < 4 {
return Err(Error::Truncated);
}
pos += 4;
let pbu_end = pos.checked_add(pbu_size).ok_or(Error::Truncated)?;
let pbu = au.get(pos..pbu_end).ok_or(Error::Truncated)?;
pos = pbu_end;
let pbu_type = pbu[0];
let group_id = u16::from_be_bytes([pbu[1], pbu[2]]);
let reserved_zero_8bits = pbu[3];
if reserved_zero_8bits > 0 {
// RFC 9924 §5.3.3: Decoders MUST ignore PBU with reserved_zero_8bits > 0.
continue;
}
if group_id == 0xFFFF || (group_id == 0 && pbu_type <= 64) {
return Err(Error::Invalid("invalid PBU group_id"));
}
pbus.push(PbuSlice { pbu_type, group_id, payload: &pbu[4..] });
}
Ok(pbus)
}
/// Read `byte_alignment()` and verify all alignment bits are zero (RFC 9924 §5.3.17).
pub fn read_byte_alignment(r: &mut BitReader) -> Result<()> {
while !r.is_byte_aligned() {
let bit = r.read_bit().map_err(|_| Error::Truncated)?;
if bit {
return Err(Error::Invalid("non-zero alignment bit"));
}
}
Ok(())
}
/// Parse `frame_header()` from the payload of a frame PBU (after `pbu_header()`), per RFC 9924 §5.3.5–5.3.8.
/// Returns `Ok(None)` if any `reserved_zero_*` field is non-zero (RFC 9924 §5.3.5 requires ignoring the PBU).
pub fn parse_frame_header(payload: &[u8]) -> Result<Option<FrameHeader>> {
let mut r = BitReader::new(payload);
// frame_info() (RFC 9924 §5.3.6)
let profile_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
let level_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
let band_idc = r.read_bits(3).map_err(|_| Error::Truncated)? as u8;
let reserved_zero_5bits = r.read_bits(5).map_err(|_| Error::Truncated)? as u8;
let width = r.read_bits(24).map_err(|_| Error::Truncated)?;
let height = r.read_bits(24).map_err(|_| Error::Truncated)?;
let chroma_format_idc = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
let bit_depth_minus8 = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
let capture_time_distance = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
let fi_reserved_8 = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
// frame_header() (RFC 9924 §5.3.5)
let fh_reserved_8_a = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
if reserved_zero_5bits > 0 || fi_reserved_8 > 0 || fh_reserved_8_a > 0 {
return Ok(None);
}
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
return Err(Error::Invalid("unsupported frame dimensions"));
}
let chroma = ChromaFormat::from_idc(chroma_format_idc).ok_or(Error::Invalid("invalid chroma_format_idc"))?;
if chroma == ChromaFormat::Yuv422 && !width.is_multiple_of(2) {
return Err(Error::Invalid("4:2:2 frame_width must be even"));
}
if !(2..=8).contains(&bit_depth_minus8) {
return Err(Error::Invalid("bit_depth_minus8 out of range 2..=8"));
}
let bit_depth = bit_depth_minus8 + 8;
let color_description_present = r.read_bit().map_err(|_| Error::Truncated)?;
let color = if color_description_present {
let primaries = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
let transfer = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
let matrix = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
let full_range = r.read_bit().map_err(|_| Error::Truncated)?;
ColorInfo { primaries, transfer, matrix, full_range }
} else {
ColorInfo::default()
};
let use_q_matrix = r.read_bit().map_err(|_| Error::Truncated)?;
let mut q_matrix = [[DEFAULT_Q_MATRIX_VAL; 64]; 4];
if use_q_matrix {
for c in 0..chroma.num_comps() {
for y in 0..8 {
for x in 0..8 {
let q = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
if q == 0 {
return Err(Error::Invalid("q_matrix entry 0 is reserved"));
}
q_matrix[c][y * 8 + x] = q;
}
}
}
}
// tile_info() (RFC 9924 §5.3.8)
let tile_width_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
let tile_height_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
if tile_width_in_mbs == 0 || tile_height_in_mbs == 0 {
return Err(Error::Invalid("tile dimensions in MBs must be non-zero"));
}
let w_mbs = width.div_ceil(16);
let h_mbs = height.div_ceil(16);
let tile_cols = w_mbs.div_ceil(tile_width_in_mbs);
let tile_rows = h_mbs.div_ceil(tile_height_in_mbs);
if tile_cols == 0 || tile_rows == 0 || tile_cols > MAX_TILE_DIM || tile_rows > MAX_TILE_DIM {
return Err(Error::Invalid("too many tiles in frame"));
}
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
let mut start_mb = 0u32;
while start_mb < w_mbs {
col_starts.push(start_mb * 16);
start_mb = start_mb.saturating_add(tile_width_in_mbs);
}
col_starts.push(w_mbs * 16);
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
let mut start_mb = 0u32;
while start_mb < h_mbs {
row_starts.push(start_mb * 16);
start_mb = start_mb.saturating_add(tile_height_in_mbs);
}
row_starts.push(h_mbs * 16);
let num_tiles = (tile_cols as usize) * (tile_rows as usize);
let tile_size_present_in_fh = r.read_bit().map_err(|_| Error::Truncated)?;
let tile_sizes_in_fh = if tile_size_present_in_fh {
let mut sizes = Vec::with_capacity(num_tiles);
for _ in 0..num_tiles {
let sz = r.read_bits(32).map_err(|_| Error::Truncated)?;
if sz == 0 {
return Err(Error::Invalid("tile_size_in_fh cannot be 0"));
}
sizes.push(sz);
}
Some(sizes)
} else {
None
};
let fh_reserved_8_b = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
if fh_reserved_8_b > 0 {
return Ok(None);
}
read_byte_alignment(&mut r)?;
Ok(Some(FrameHeader {
profile_idc,
level_idc,
band_idc,
width,
height,
chroma,
bit_depth,
capture_time_distance,
color_description_present,
color,
use_q_matrix,
q_matrix,
tile_width_in_mbs,
tile_height_in_mbs,
tile_cols,
tile_rows,
col_starts,
row_starts,
tile_sizes_in_fh,
header_bytes: r.byte_pos(),
}))
}
/// Write `frame_header()` to a byte vector (RFC 9924 §5.3.5–5.3.8).
pub fn write_frame_header(h: &FrameHeader) -> Vec<u8> {
let mut w = BitWriter::new();
// frame_info()
w.write_bits(h.profile_idc as u32, 8);
w.write_bits(h.level_idc as u32, 8);
w.write_bits((h.band_idc & 7) as u32, 3);
w.write_bits(0, 5); // reserved_zero_5bits
w.write_bits(h.width, 24);
w.write_bits(h.height, 24);
w.write_bits(h.chroma.idc() as u32, 4);
w.write_bits(h.bit_depth.saturating_sub(8) as u32, 4);
w.write_bits(h.capture_time_distance as u32, 8);
w.write_bits(0, 8); // reserved_zero_8bits
// frame_header()
w.write_bits(0, 8); // reserved_zero_8bits
w.write_bit(h.color_description_present);
if h.color_description_present {
w.write_bits(h.color.primaries as u32, 8);
w.write_bits(h.color.transfer as u32, 8);
w.write_bits(h.color.matrix as u32, 8);
w.write_bit(h.color.full_range);
}
w.write_bit(h.use_q_matrix);
if h.use_q_matrix {
for c in 0..h.chroma.num_comps() {
for y in 0..8 {
for x in 0..8 {
w.write_bits(h.q_matrix[c][y * 8 + x].max(1) as u32, 8);
}
}
}
}
// tile_info()
w.write_bits(h.tile_width_in_mbs, 20);
w.write_bits(h.tile_height_in_mbs, 20);
if let Some(sizes) = &h.tile_sizes_in_fh {
w.write_bit(true);
for &sz in sizes {
w.write_bits(sz, 32);
}
} else {
w.write_bit(false);
}
w.write_bits(0, 8); // reserved_zero_8bits
w.finish()
}
/// Parse a `metadata()` PBU payload (after `pbu_header()`), extracting MDCV (`payloadType == 5`)
/// and CLL (`payloadType == 6`) if present (RFC 9924 §5.3.10 & §8).
pub fn parse_metadata_pbu(payload: &[u8], mdcv: &mut Option<MasteringDisplay>, cll: &mut Option<ContentLightLevel>) -> Result<()> {
if payload.len() < 4 {
return Err(Error::Truncated);
}
let metadata_size = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
let body = payload.get(4..4usize.checked_add(metadata_size).ok_or(Error::Truncated)?).ok_or(Error::Truncated)?;
let mut pos = 0usize;
while pos < body.len() {
let mut payload_type = 0u32;
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
payload_type = payload_type.checked_add(255).ok_or(Error::Invalid("metadata payloadType overflow"))?;
pos += 1;
}
payload_type = payload_type.checked_add(*body.get(pos).ok_or(Error::Truncated)? as u32).ok_or(Error::Invalid("metadata payloadType overflow"))?;
pos += 1;
let mut payload_size = 0usize;
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
payload_size = payload_size.checked_add(255).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
pos += 1;
}
payload_size = payload_size.checked_add(*body.get(pos).ok_or(Error::Truncated)? as usize).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
pos += 1;
let end = pos.checked_add(payload_size).ok_or(Error::Truncated)?;
let item = body.get(pos..end).ok_or(Error::Truncated)?;
pos = end;
match payload_type {
5 if item.len() >= 24 => {
let u16_at = |i: usize| u16::from_be_bytes([item[i], item[i + 1]]);
let u32_at = |i: usize| u32::from_be_bytes([item[i], item[i + 1], item[i + 2], item[i + 3]]);
*mdcv = Some(MasteringDisplay {
primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))],
white_point: (u16_at(12), u16_at(14)),
max_luminance: u32_at(16),
min_luminance: u32_at(20),
});
}
6 if item.len() >= 4 => {
*cll = Some(ContentLightLevel { max_cll: u16::from_be_bytes([item[0], item[1]]), max_fall: u16::from_be_bytes([item[2], item[3]]) });
}
// Filler metadata (§8.2.1): must be 0xFF bytes
10 if item.iter().any(|&b| b != 0xFF) => {
return Err(Error::Invalid("non-0xFF byte in filler metadata"));
}
_ => {
// RFC 9924 §10: A decoder MUST NOT try to process metadata whose type is not recognized.
}
}
}
Ok(())
}
/// Build a `metadata()` PBU (including its 4-byte `pbu_header`) carrying MDCV and/or CLL metadata.
pub fn write_metadata_pbu(group_id: u16, mdcv: Option<&MasteringDisplay>, cll: Option<&ContentLightLevel>) -> Option<Vec<u8>> {
if mdcv.is_none() && cll.is_none() {
return None;
}
let mut body = Vec::new();
if let Some(m) = mdcv {
body.push(5); // payloadType = 5 (MDCV)
body.push(24); // payloadSize = 24
for &(x, y) in &m.primaries {
body.extend_from_slice(&x.to_be_bytes());
body.extend_from_slice(&y.to_be_bytes());
}
body.extend_from_slice(&m.white_point.0.to_be_bytes());
body.extend_from_slice(&m.white_point.1.to_be_bytes());
body.extend_from_slice(&m.max_luminance.to_be_bytes());
body.extend_from_slice(&m.min_luminance.to_be_bytes());
}
if let Some(c) = cll {
body.push(6); // payloadType = 6 (CLL)
body.push(4); // payloadSize = 4
body.extend_from_slice(&c.max_cll.to_be_bytes());
body.extend_from_slice(&c.max_fall.to_be_bytes());
}
let mut pbu = Vec::with_capacity(8 + body.len());
pbu.push(PBU_METADATA);
pbu.extend_from_slice(&group_id.to_be_bytes());
pbu.push(0); // reserved_zero_8bits
pbu.extend_from_slice(&(body.len() as u32).to_be_bytes());
pbu.extend_from_slice(&body);
Some(pbu)
}
+329
View File
@@ -0,0 +1,329 @@
//! Clean-room Advanced Professional Video (APV) codec, implemented strictly from IETF RFC 9924
//! (February 2026).
//!
//! - **Decoder** ([`decode_frame`], [`decode_frame_with`], [`decode_frame_into`], [`probe`]):
//! every profile in RFC 9924 §9 (`422-10`, `422-12`, `444-10`, `444-12`, `4444-10`, `4444-12`,
//! `400-10`) at 10–16 bits per sample, custom quantization matrices, multi-tile parallel decoding
//! (behind the `threads` feature), 4:4:4:4 and auxiliary alpha PBU (`pbu_type == 27`) support,
//! and HDR static metadata (`MDCV` and `CLL`, RFC 9924 §8).
//! - **Encoder** ([`Encoder`]): progressive intra frame encoding across all 7 profiles with fixed-QP
//! or frame-budget rate control, plus `APVDecoderConfigurationRecord` (`apvC`) generation.
//!
//! See the crate README for specification edition, accuracy against external oracles, and design notes.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod dct;
mod decode;
mod encode;
pub mod header;
pub mod tables;
pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe};
pub use encode::{Encoder, EncoderConfig};
pub use header::{AU_SIGNATURE, FrameHeader, split_raw_bitstream, unwrap_au};
/// APV codec errors.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("truncated APV bitstream")]
Truncated,
#[error("invalid APV bitstream: {0}")]
Invalid(&'static str),
#[error("corrupt APV tile {0}")]
CorruptTile(usize),
#[error("invalid APV encoder input: {0}")]
Input(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Chroma format (`chroma_format_idc` in RFC 9924 §4.1 Table 2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChromaFormat {
/// `chroma_format_idc == 0`: 4:0:0 monochrome (1 component).
Monochrome,
/// `chroma_format_idc == 2`: 4:2:2 YCbCr (3 components, `SubWidthC = 2, SubHeightC = 1`).
Yuv422,
/// `chroma_format_idc == 3`: 4:4:4 YCbCr (3 components, `SubWidthC = 1, SubHeightC = 1`).
Yuv444,
/// `chroma_format_idc == 4`: 4:4:4:4 YCbCrA (4 components, `SubWidthC = 1, SubHeightC = 1`).
Yuv4444,
}
impl ChromaFormat {
pub fn from_idc(idc: u8) -> Option<Self> {
match idc {
0 => Some(Self::Monochrome),
2 => Some(Self::Yuv422),
3 => Some(Self::Yuv444),
4 => Some(Self::Yuv4444),
_ => None,
}
}
pub fn idc(self) -> u8 {
match self {
Self::Monochrome => 0,
Self::Yuv422 => 2,
Self::Yuv444 => 3,
Self::Yuv4444 => 4,
}
}
pub fn num_comps(self) -> usize {
match self {
Self::Monochrome => 1,
Self::Yuv422 | Self::Yuv444 => 3,
Self::Yuv4444 => 4,
}
}
pub fn sub_width_c(self) -> u32 {
match self {
Self::Yuv422 => 2,
_ => 1,
}
}
pub fn sub_height_c(self) -> u32 {
1
}
pub fn chroma_width(self, width: u32) -> u32 {
match self {
Self::Monochrome => 0,
Self::Yuv422 => width.div_ceil(2),
Self::Yuv444 | Self::Yuv4444 => width,
}
}
pub fn chroma_height(self, height: u32) -> u32 {
match self {
Self::Monochrome => 0,
_ => height,
}
}
pub fn has_alpha(self) -> bool {
self == Self::Yuv4444
}
}
/// APV profiles defined in RFC 9924 §9.3.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Profile {
/// `profile_idc == 33`: 422-10 profile (4:2:2, 10-bit).
#[default]
P422_10,
/// `profile_idc == 44`: 422-12 profile (4:2:2, 10–12-bit).
P422_12,
/// `profile_idc == 55`: 444-10 profile (4:2:2..4:4:4, 10-bit).
P444_10,
/// `profile_idc == 66`: 444-12 profile (4:2:2..4:4:4, 10–12-bit).
P444_12,
/// `profile_idc == 77`: 4444-10 profile (4:2:2..4:4:4:4, 10-bit).
P4444_10,
/// `profile_idc == 88`: 4444-12 profile (4:2:2..4:4:4:4, 10–12-bit).
P4444_12,
/// `profile_idc == 99`: 400-10 profile (4:0:0, 10-bit).
P400_10,
}
impl Profile {
pub const ALL: [Profile; 7] =
[Profile::P422_10, Profile::P422_12, Profile::P444_10, Profile::P444_12, Profile::P4444_10, Profile::P4444_12, Profile::P400_10];
pub fn idc(self) -> u8 {
match self {
Self::P422_10 => 33,
Self::P422_12 => 44,
Self::P444_10 => 55,
Self::P444_12 => 66,
Self::P4444_10 => 77,
Self::P4444_12 => 88,
Self::P400_10 => 99,
}
}
pub fn from_idc(idc: u8) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.idc() == idc)
}
pub fn name(self) -> &'static str {
match self {
Self::P422_10 => "APV 422-10",
Self::P422_12 => "APV 422-12",
Self::P444_10 => "APV 444-10",
Self::P444_12 => "APV 444-12",
Self::P4444_10 => "APV 4444-10",
Self::P4444_12 => "APV 4444-12",
Self::P400_10 => "APV 400-10",
}
}
pub fn short_name(self) -> &'static str {
match self {
Self::P422_10 => "422-10",
Self::P422_12 => "422-12",
Self::P444_10 => "444-10",
Self::P444_12 => "444-12",
Self::P4444_10 => "4444-10",
Self::P4444_12 => "4444-12",
Self::P400_10 => "400-10",
}
}
pub fn default_chroma(self) -> ChromaFormat {
match self {
Self::P422_10 | Self::P422_12 => ChromaFormat::Yuv422,
Self::P444_10 | Self::P444_12 => ChromaFormat::Yuv444,
Self::P4444_10 | Self::P4444_12 => ChromaFormat::Yuv4444,
Self::P400_10 => ChromaFormat::Monochrome,
}
}
pub fn default_bit_depth(self) -> u8 {
match self {
Self::P422_12 | Self::P444_12 | Self::P4444_12 => 12,
_ => 10,
}
}
/// Check whether `(chroma, bit_depth)` conforms to this profile per RFC 9924 §9.3.
pub fn supports(self, chroma: ChromaFormat, bit_depth: u8) -> bool {
let c = chroma.idc();
let b = bit_depth.saturating_sub(8);
match self {
Self::P422_10 => c == 2 && b == 2,
Self::P422_12 => c == 2 && (2..=4).contains(&b),
Self::P444_10 => (2..=3).contains(&c) && b == 2,
Self::P444_12 => (2..=3).contains(&c) && (2..=4).contains(&b),
Self::P4444_10 => (2..=4).contains(&c) && b == 2,
Self::P4444_12 => (2..=4).contains(&c) && (2..=4).contains(&b),
Self::P400_10 => c == 0 && b == 2,
}
}
/// Nominal target bytes per frame for `(width, height)`.
pub fn nominal_frame_bytes(self, width: u32, height: u32) -> u32 {
let bpp = match self {
Self::P400_10 => 1.5,
Self::P422_10 => 3.0,
Self::P422_12 => 3.8,
Self::P444_10 => 4.2,
Self::P444_12 => 5.0,
Self::P4444_10 => 5.2,
Self::P4444_12 => 6.2,
};
let bytes = ((width as f64) * (height as f64) * bpp / 8.0).round() as u32;
bytes.max(512)
}
/// Nominal data rate in Mbit/s at 1920x1080 29.97 fps.
pub fn nominal_mbps_1080p30(self) -> f64 {
(self.nominal_frame_bytes(1920, 1080) as f64) * 8.0 * 29.97 / 1e6
}
}
/// Human-readable profile name from `profile_idc` (e.g. `"APV 422-10"`).
pub fn profile_name(profile_idc: u8) -> String {
Profile::from_idc(profile_idc).map(|p| p.name().to_string()).unwrap_or_else(|| format!("APV (profile {profile_idc})"))
}
/// Colour description from `frame_header()` (ITU-T H.273 code points, RFC 9924 §5.3.5).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ColorInfo {
pub primaries: u8,
pub transfer: u8,
pub matrix: u8,
pub full_range: bool,
}
impl Default for ColorInfo {
/// Inferred values when `color_description_present_flag == 0` (RFC 9924 §5.3.5: 2, 2, 2, 0).
fn default() -> Self {
Self { primaries: 2, transfer: 2, matrix: 2, full_range: false }
}
}
impl ColorInfo {
pub const BT709: Self = Self { primaries: 1, transfer: 1, matrix: 1, full_range: false };
pub const BT2020_PQ: Self = Self { primaries: 9, transfer: 16, matrix: 9, full_range: false };
pub const BT2020_HLG: Self = Self { primaries: 9, transfer: 18, matrix: 9, full_range: false };
}
/// Mastering Display Color Volume metadata (`payloadType == 5`, RFC 9924 §8.2.3).
///
/// Chromaticities are in 0.16 fixed-point (1/65536), order `[R, G, B]`;
/// `max_luminance` is 24.8 fixed-point cd/m²; `min_luminance` is 18.14 fixed-point cd/m².
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MasteringDisplay {
pub primaries: [(u16, u16); 3],
pub white_point: (u16, u16),
pub max_luminance: u32,
pub min_luminance: u32,
}
/// Content Light-Level Information metadata (`payloadType == 6`, RFC 9924 §8.2.4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ContentLightLevel {
pub max_cll: u16,
pub max_fall: u16,
}
/// A decoded (or to-be-encoded) APV frame: planar `u16` samples, rows packed without padding.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub width: u32,
pub height: u32,
pub chroma: ChromaFormat,
/// Significant bits per sample (`10..=16`, or `8..=16` when rescaled via [`DecodeOptions`]).
pub bit_depth: u8,
pub y: Vec<u16>,
pub cb: Vec<u16>,
pub cr: Vec<u16>,
pub alpha: Option<Vec<u16>>,
pub color: ColorInfo,
pub profile_idc: u8,
pub level_idc: u8,
pub band_idc: u8,
pub mastering_display: Option<MasteringDisplay>,
pub content_light: Option<ContentLightLevel>,
}
impl Frame {
/// Allocate a black video-range frame.
pub fn new(width: u32, height: u32, chroma: ChromaFormat, bit_depth: u8, with_alpha: bool) -> Self {
let n = (width as usize) * (height as usize);
let cw = chroma.chroma_width(width) as usize;
let ch = chroma.chroma_height(height) as usize;
let cn = cw * ch;
let shift = bit_depth.saturating_sub(8) as u32;
let has_alpha = with_alpha || chroma == ChromaFormat::Yuv4444;
Self {
width,
height,
chroma,
bit_depth,
y: vec![(16u32 << shift) as u16; n],
cb: vec![(128u32 << shift) as u16; cn],
cr: vec![(128u32 << shift) as u16; cn],
alpha: has_alpha.then(|| vec![((1u32 << bit_depth) - 1) as u16; n]),
color: ColorInfo::BT709,
profile_idc: Profile::P422_10.idc(),
level_idc: 90,
band_idc: 2,
mastering_display: None,
content_light: None,
}
}
pub fn chroma_width(&self) -> u32 {
self.chroma.chroma_width(self.width)
}
pub fn chroma_height(&self) -> u32 {
self.chroma.chroma_height(self.height)
}
}
+59
View File
@@ -0,0 +1,59 @@
//! RFC 9924 tables: zig-zag scan, 8x8 transform matrix, quantization level scales, and level limits.
/// 8x8 zig-zag scan order (`ScanOrder[sPos] = yC * 8 + xC`), derived from RFC 9924 §4.4.1 Figure 5.
pub const ZIGZAG_8X8: [u8; 64] = [
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
];
/// 8x8 transform matrix (`transMatrix` in RFC 9924 §6.3.2.3 Figure 25).
pub const TRANS_MATRIX: [[i32; 8]; 8] = [
[64, 64, 64, 64, 64, 64, 64, 64],
[89, 75, 50, 18, -18, -50, -75, -89],
[84, 35, -35, -84, -84, -35, 35, 84],
[75, -18, -89, -50, 50, 89, 18, -75],
[64, -64, -64, 64, 64, -64, -64, 64],
[50, -89, 18, 75, -75, -18, 89, -50],
[35, -84, 84, -35, -35, 84, -84, 35],
[18, -50, 75, -89, 89, -75, 50, -18],
];
/// Quantization scaling list `levelScale[k]` for `k = qP % 6` (RFC 9924 §6.3.1).
pub const LEVEL_SCALE: [i32; 6] = [40, 45, 51, 57, 64, 71];
/// Default flat quantization matrix value when `use_q_matrix == 0` (RFC 9924 §5.3.7).
pub const DEFAULT_Q_MATRIX_VAL: u8 = 16;
/// General level limits (`level_idc`, max luma sample rate, and max coded data rate in Mbit/s per band)
/// from RFC 9924 §9.4.2 Table 4.
pub const LEVEL_LIMITS: [(u8, u64, [u32; 4]); 14] = [
(30, 3_041_280, [8, 11, 15, 23]),
(33, 6_082_560, [16, 21, 30, 45]),
(60, 15_667_200, [39, 54, 76, 114]),
(63, 31_334_400, [78, 108, 152, 227]),
(90, 66_846_720, [114, 159, 222, 333]),
(93, 133_693_440, [227, 317, 444, 666]),
(120, 265_420_800, [455, 637, 892, 1_338]),
(123, 530_841_600, [910, 1_274, 1_784, 2_675]),
(150, 1_061_683_200, [1_820, 2_548, 3_567, 5_350]),
(153, 2_123_366_400, [3_639, 5_095, 7_133, 10_699]),
(180, 4_777_574_400, [7_278, 10_189, 14_265, 21_397]),
(183, 8_493_465_600, [14_556, 20_378, 28_529, 42_793]),
(210, 16_986_931_200, [29_111, 40_756, 57_058, 85_586]),
(213, 33_973_862_400, [58_222, 81_511, 114_115, 171_172]),
];
/// Choose the lowest conforming `(level_idc, band_idc)` for a luma sample rate and bitrate (in kbps).
pub fn select_level_and_band(luma_samples_per_sec: u64, kbps: u32) -> (u8, u8) {
let mbps = kbps.div_ceil(1000);
for &(level_idc, max_samples, bands) in &LEVEL_LIMITS {
if luma_samples_per_sec <= max_samples {
for (band_idc, &max_mbps) in bands.iter().enumerate() {
if mbps <= max_mbps {
return (level_idc, band_idc as u8);
}
}
}
}
(213, 3)
}
+136
View File
@@ -0,0 +1,136 @@
//! Robustness: mutated, truncated, spliced, and random bitstreams must never panic (errors are fine).
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_apv::*;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (p, w, h) in
[(Profile::P422_10, 64u32, 48u32), (Profile::P422_12, 70, 38), (Profile::P444_10, 48, 32), (Profile::P4444_10, 64, 32), (Profile::P400_10, 32, 32)]
{
let chroma = p.default_chroma();
let depth = p.default_bit_depth();
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
for (i, s) in f.y.iter_mut().enumerate() {
*s = (64 + (i * 37) % 800) as u16;
}
if let Some(a) = f.alpha.as_mut() {
for (i, s) in a.iter_mut().enumerate() {
*s = ((i * 19) % 1024) as u16;
}
}
let mut cfg = EncoderConfig::new(p, w, h);
cfg.qp = Some(16);
let mut enc = Encoder::with_config(cfg).expect("seed encoder");
v.push(enc.encode(&f).expect("seed encode"));
v.push(enc.encode_raw_au(&f).expect("seed encode_raw_au"));
}
v
}
#[test]
fn mutated_streams_never_panic() {
let seeds = seeds();
for s in &seeds {
decode_frame(s).expect("seed decodes");
}
let mut rng = Rng(0x9e3779b97f4a7c15);
let mut decoded_ok = 0usize;
let iters = 3000usize;
for i in 0..iters {
let mut d = seeds[i % seeds.len()].clone();
match rng.below(6) {
0 => {
// Bit flips
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
d[p] ^= 1 << rng.below(8);
}
}
1 => {
// Random byte overwrites
for _ in 0..1 + rng.below(4) {
let p = rng.below(d.len());
d[p] = rng.next() as u8;
}
}
2 => {
// Truncation
let n = rng.below(d.len());
d.truncate(n);
}
3 => {
// Corrupt header fields
if d.len() > 8 {
let p = 4 + rng.below(24.min(d.len() - 4));
d[p] = rng.next() as u8;
}
}
4 => {
// Corrupt tile payload region
let start = 24.min(d.len().saturating_sub(1));
let off = start + rng.below(d.len().saturating_sub(start).max(1));
for b in d.iter_mut().skip(off).take(1 + rng.below(48)) {
*b = rng.next() as u8;
}
}
_ => {
// Splice random bytes
let p = rng.below(d.len());
let extra: Vec<u8> = (0..1 + rng.below(16)).map(|_| rng.next() as u8).collect();
d.splice(p..p, extra);
}
}
let res = catch_unwind(AssertUnwindSafe(|| {
for depth in [None, Some(8), Some(16)] {
let opts = DecodeOptions { bit_depth: depth, threads: i % 2 == 0 };
if let Ok(f) = decode_frame_with(&d, &opts) {
decoded_ok += 1;
assert_eq!(f.y.len(), (f.width as usize) * (f.height as usize));
}
}
let _ = probe(&d);
let _ = split_raw_bitstream(&d);
}));
assert!(res.is_ok(), "panic on mutated stream iteration {i}");
}
println!("{decoded_ok} of {} mutated decodes succeeded", iters * 3);
}
#[test]
fn random_bytes_never_panic() {
let mut rng = Rng(123456789);
for i in 0..2000 {
let n = rng.below(400);
let mut d: Vec<u8> = (0..n).map(|_| rng.next() as u8).collect();
if d.len() >= 8 && rng.below(2) == 0 {
d[..4].copy_from_slice(b"aPv1");
} else if d.len() >= 12 && rng.below(2) == 0 {
let au_sz = (n - 4) as u32;
d[..4].copy_from_slice(&au_sz.to_be_bytes());
d[4..8].copy_from_slice(b"aPv1");
}
let res = catch_unwind(AssertUnwindSafe(|| {
let _ = decode_frame(&d);
let _ = probe(&d);
let _ = split_raw_bitstream(&d);
}));
assert!(res.is_ok(), "panic on random bytes iteration {i}");
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Conformance, bit-exactness, and quality tests against ffmpeg's APV decoder (external oracle).
use std::path::{Path, PathBuf};
use std::process::Command;
use filmcraft_apv::{
ChromaFormat, ContentLightLevel, DecodeOptions, Encoder, EncoderConfig, Frame, MasteringDisplay, Profile, decode_frame, decode_frame_with, probe,
};
fn ffmpeg() -> Option<PathBuf> {
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
}
fn fixture_dir() -> PathBuf {
filmcraft_testkit::fixtures_dir("apv")
}
fn native_pix_fmt(profile: Profile) -> &'static str {
match profile {
Profile::P422_10 => "yuv422p10le",
Profile::P422_12 => "yuv422p12le",
Profile::P444_10 => "yuv444p10le",
Profile::P444_12 => "yuv444p12le",
Profile::P4444_10 => "yuva444p10le",
Profile::P4444_12 => "yuva444p12le",
Profile::P400_10 => "gray10le",
}
}
/// Decode a raw `.apv` file with ffmpeg (`-v error -xerror`) to planar `u16` samples.
fn ffmpeg_decode_raw(ff: &Path, apv_path: &Path, pix_fmt: &str) -> (Vec<u16>, String) {
let out = Command::new(ff)
.args(["-hide_banner", "-v", "error", "-xerror", "-f", "apv", "-i"])
.arg(apv_path)
.args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
.output()
.expect("run ffmpeg");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(out.status.success(), "ffmpeg failed on {:?}: {}", apv_path, stderr);
let samples = out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
(samples, stderr)
}
fn synthetic_frame(profile: Profile, w: u32, h: u32) -> Frame {
let chroma = profile.default_chroma();
let depth = profile.default_bit_depth();
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
let max_v = (1u32 << depth) - 1;
let lo = 16u32 << (depth - 8);
let hi = 235u32 << (depth - 8);
let span = hi - lo;
for y in 0..h {
for x in 0..w {
// Mix smooth gradients, diagonal edges, and high-frequency patterns
let grad = (x * 7 + y * 13) % span;
let checker = if ((x / 3) ^ (y / 5)) & 1 == 0 { 40 << (depth - 10) } else { 0 };
f.y[(y * w + x) as usize] = (lo + (grad + checker) % span).min(max_v) as u16;
}
}
let cw = f.chroma_width();
let ch = f.chroma_height();
for y in 0..ch {
for x in 0..cw {
let u = lo + ((x * 11 + y * 5 + 100) % span);
let v = lo + ((x * 3 + y * 17 + 300) % span);
f.cb[(y * cw + x) as usize] = u.min(max_v) as u16;
f.cr[(y * cw + x) as usize] = v.min(max_v) as u16;
}
}
if let Some(a) = f.alpha.as_mut() {
for y in 0..h {
for x in 0..w {
let dx = x as i32 - (w as i32) / 2;
let dy = y as i32 - (h as i32) / 2;
a[(y * w + x) as usize] =
if dx * dx + dy * dy < ((w.min(h) as i32) / 3).pow(2) { max_v as u16 } else { ((x * 19 + y * 23) % (max_v + 1)) as u16 };
}
}
}
f
}
fn flatten_frame(f: &Frame) -> Vec<u16> {
let mut v = Vec::new();
v.extend_from_slice(&f.y);
v.extend_from_slice(&f.cb);
v.extend_from_slice(&f.cr);
if let Some(a) = &f.alpha {
v.extend_from_slice(a);
}
v
}
fn psnr(a: &[u16], b: &[u16], peak: f64) -> f64 {
if a.is_empty() {
return 99.0;
}
let mse: f64 = a.iter().zip(b).map(|(&x, &y)| (x as f64 - y as f64).powi(2)).sum::<f64>() / a.len() as f64;
if mse == 0.0 { 99.0 } else { 10.0 * (peak * peak / mse).log10() }
}
#[test]
fn all_profiles_bit_exact_with_ffmpeg() {
let Some(ff) = ffmpeg() else { return };
let dir = fixture_dir();
for profile in Profile::ALL {
let (w, h) = (320u32, 192u32);
let src = synthetic_frame(profile, w, h);
let mut cfg = EncoderConfig::new(profile, w, h);
cfg.qp = Some(12);
let mut enc = Encoder::with_config(cfg).expect("encoder init");
let raw_au = enc.encode_raw_au(&src).expect("encode_raw_au");
let apv_path = dir.join(format!("oracle_{}.apv", profile.short_name()));
std::fs::write(&apv_path, &raw_au).expect("write .apv");
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
assert!(stderr.trim().is_empty(), "{profile:?}: ffmpeg stderr: {stderr}");
let ours = decode_frame(&raw_au).expect("our decode");
let our_samples = flatten_frame(&ours);
assert_eq!(our_samples.len(), ff_samples.len(), "{profile:?}: sample count mismatch");
let max_diff = our_samples.iter().zip(&ff_samples).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).max().unwrap_or(0);
assert_eq!(max_diff, 0, "{profile:?}: our decoder must be 100% bit-exact with ffmpeg (max_diff={max_diff})");
let peak = ((1u32 << profile.default_bit_depth()) - 1) as f64;
let y_psnr = psnr(&ours.y, &src.y, peak);
assert!(y_psnr > 45.0, "{profile:?}: expected high PSNR at qp=12, got {y_psnr:.2} dB");
}
}
#[test]
fn odd_dimensions_and_multi_tile_bit_exact_with_ffmpeg() {
let Some(ff) = ffmpeg() else { return };
let dir = fixture_dir();
for (profile, w, h) in [
(Profile::P422_10, 640u32, 360u32),
(Profile::P422_10, 334, 202),
(Profile::P444_10, 333, 201),
(Profile::P4444_12, 512, 288),
(Profile::P400_10, 70, 38),
] {
let mut src = synthetic_frame(profile, w, h);
src.mastering_display = Some(MasteringDisplay {
primaries: [(46400, 19136), (11141, 52232), (8585, 3015)],
white_point: (20493, 21561),
max_luminance: 1000 << 8,
min_luminance: 1,
});
src.content_light = Some(ContentLightLevel { max_cll: 1000, max_fall: 400 });
let mut enc = Encoder::new(profile, w, h).expect("encoder init");
let raw_au = enc.encode_raw_au(&src).expect("encode");
let apv_path = dir.join(format!("oracle_geom_{}_{w}x{h}.apv", profile.short_name()));
std::fs::write(&apv_path, &raw_au).expect("write .apv");
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
assert!(stderr.trim().is_empty(), "{profile:?} {w}x{h}: {stderr}");
let hdr = probe(&raw_au).expect("probe");
assert_eq!((hdr.width, hdr.height), (w, h));
let ours = decode_frame(&raw_au).expect("decode");
assert_eq!(ours.mastering_display, src.mastering_display);
assert_eq!(ours.content_light, src.content_light);
let our_samples = flatten_frame(&ours);
assert_eq!(our_samples, ff_samples, "{profile:?} {w}x{h}: bit-exact match with ffmpeg failed");
// Also verify single-threaded decode produces identical output
let ours_st = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: None, threads: false }).expect("decode single-threaded");
assert_eq!(ours, ours_st);
}
}
#[test]
fn custom_q_matrix_and_fh_tile_sizes_bit_exact_with_ffmpeg() {
let Some(ff) = ffmpeg() else { return };
let dir = fixture_dir();
let profile = Profile::P444_12;
let (w, h) = (512u32, 256u32);
let src = synthetic_frame(profile, w, h);
let mut q_matrix = [[16u8; 64]; 4];
for (c, qm) in q_matrix.iter_mut().enumerate() {
for y in 0..8 {
for x in 0..8 {
// Intentionally asymmetric (x != y weight) to verify exact (x, y) matrix orientation
qm[y * 8 + x] = (10 + c * 3 + y * 5 + x * 2) as u8;
}
}
}
let mut cfg = EncoderConfig::new(profile, w, h);
cfg.qp = Some(18);
cfg.q_matrix = Some(q_matrix);
cfg.write_tile_sizes_in_fh = true;
cfg.tile_width_in_mbs = 16;
cfg.tile_height_in_mbs = 8;
let mut enc = Encoder::with_config(cfg).expect("encoder init");
let raw_au = enc.encode_raw_au(&src).expect("encode");
let apv_path = dir.join("oracle_custom_qmat_444-12.apv");
std::fs::write(&apv_path, &raw_au).expect("write .apv");
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
assert!(stderr.trim().is_empty(), "ffmpeg stderr: {stderr}");
let hdr = probe(&raw_au).expect("probe");
assert!(hdr.use_q_matrix);
assert!(hdr.tile_sizes_in_fh.is_some());
assert_eq!(hdr.num_tiles(), 4);
let ours = decode_frame(&raw_au).expect("decode");
let our_samples = flatten_frame(&ours);
assert_eq!(our_samples, ff_samples, "asymmetric q_matrix decode must be bit-exact with ffmpeg");
// Verify 8-bit and 16-bit rescaled output
let d8 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(8), threads: true }).expect("decode 8-bit");
assert_eq!(d8.bit_depth, 8);
assert!(d8.y.iter().all(|&s| s <= 255));
let d16 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(16), threads: true }).expect("decode 16-bit");
assert_eq!(d16.bit_depth, 16);
}
+4
View File
@@ -132,6 +132,10 @@ impl Biquad {
pub fn reset(&mut self) {
*self = Biquad::new();
}
/// Exact zero state, including the sign of zero, for identity-filter fast paths.
pub(crate) fn is_clear(&self) -> bool {
self.s1.to_bits() == 0 && self.s2.to_bits() == 0
}
/// Filter a buffer in place.
pub fn process(&mut self, c: &Coeffs, buf: &mut [f32]) {
for v in buf {
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! **Channel order.** 5.1 audio is always in the SMPTE / WAVE order **L, R, C, LFE, Ls, Rs**
//! ([`L`], [`R`], [`C`], [`LFE`], [`LS`], [`RS`]); stereo is L, R; mono is one channel. Codecs with a
//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the encoder boundary.
//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the codec boundary.
//!
//! **Downmix** follows ITU-R BS.775 (Table 2, "3/2 source" rows), with `k = 1/√2` (−3 dB):
//!
+167
View File
@@ -110,6 +110,57 @@ impl BandBank {
pub(crate) fn process(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
let n = block_len(channels);
let nch = channels.len().min(self.state.len());
if n == 0 {
return;
}
if !out_gain.is_smoothing()
&& self.bands.iter().all(|b| !b.dirty && !b.freq.is_smoothing() && !b.gain.is_smoothing() && !b.q.is_smoothing() && !b.mix.is_smoothing())
{
// Keep the exact per-sample arithmetic and recursive state; only the settled
// parameter/smoother bookkeeping is hoisted out of the hot loop.
let g = out_gain.value() as f64;
for (channel, state) in channels.iter_mut().take(nch).zip(&mut self.state) {
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
if b.mix.value() <= 0.0 {
st.reset();
}
}
// A settled unit transfer with zero state is exactly transparent for finite
// f32 input. Do not discard a tail or bypass a non-finite sample. Bounded
// coefficients also ensure intermediate f64 products cannot overflow.
let transparent = self.bands.iter().zip(state.iter()).all(|(b, st)| {
b.mix.value() <= 0.0
|| (st.is_clear()
&& b.coeffs.b0 == 1.0
&& b.coeffs.b1 == b.coeffs.a1
&& b.coeffs.b2 == b.coeffs.a2
&& b.coeffs.b1.abs() <= 4.0
&& b.coeffs.b2.abs() <= 4.0)
});
if transparent && channel.iter().take(n).all(|x| x.is_finite()) {
let active = self.bands.iter().any(|b| b.mix.value() > 0.0);
for sample in channel.iter_mut().take(n) {
let x = *sample as f64;
// An active identity biquad adds +0, which normalizes negative zero.
*sample = ((if active { x + 0.0 } else { x }) * g) as f32;
}
continue;
}
for sample in channel.iter_mut().take(n) {
let mut x = *sample as f64;
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
let m = b.mix.value() as f64;
if m > 0.0 {
let y = st.tick(&b.coeffs, x);
x += m * (y - x);
}
}
*sample = (x * g) as f32;
}
}
self.counter = (self.counter + (n % COEFF_INTERVAL as usize) as u32) % COEFF_INTERVAL;
return;
}
for i in 0..n {
if self.counter == 0 {
self.update_coeffs(false);
@@ -376,3 +427,119 @@ mod tests {
assert!((x[4799] - 0.5 * crate::db_to_gain(12.0)).abs() < 1e-4);
}
}
#[cfg(test)]
impl BandBank {
fn process_reference(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
let n = block_len(channels);
let nch = channels.len().min(self.state.len());
for i in 0..n {
if self.counter == 0 {
self.update_coeffs(false);
}
self.counter = (self.counter + 1) % COEFF_INTERVAL;
for b in &mut self.bands {
b.freq.tick();
b.gain.tick();
b.q.tick();
if b.freq.is_smoothing() || b.gain.is_smoothing() || b.q.is_smoothing() {
b.dirty = true;
}
}
let g = out_gain.tick();
// Tick the band mixes once per sample (shared by all channels).
let mut mixes = [0.0f32; MAX_BANDS];
for (m, b) in mixes.iter_mut().zip(self.bands.iter_mut()) {
*m = b.mix.tick();
}
for ch in 0..nch {
let mut x = channels[ch][i] as f64;
for (k, b) in self.bands.iter().enumerate() {
let m = mixes[k] as f64;
let st = &mut self.state[ch][k];
if m > 0.0 {
let y = st.tick(&b.coeffs, x);
x += m * (y - x);
} else {
st.reset();
}
}
channels[ch][i] = (x * g as f64) as f32;
}
}
}
}
#[cfg(test)]
mod throughput_tests {
use super::*;
#[test]
fn flat_eq_fast_path_preserves_samples_and_filter_tails() {
let mut fast = BandBank::new(48000.0, 2, 1);
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
fast.reset();
let mut reference = fast.clone();
let mut gain = Smoothed::new(1.0, 0);
let mut ref_gain = gain;
for input in
[vec![-0.0, 0.0, f32::MIN_POSITIVE, f32::from_bits(1), f32::MAX, -f32::MAX, 0.7], vec![0.0; 100], vec![f32::INFINITY, f32::NEG_INFINITY, f32::NAN]]
{
let mut a = [input.clone(), input];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar], &mut gain);
reference.process_reference(&mut [bl, br], &mut ref_gain);
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
assert_eq!(a.to_bits(), b.to_bits());
}
}
// A newly flat filter must drain its old state, not drop the tail.
fast.reset();
fast.set_band(0, true, FilterType::Peaking, 1000.0, 12.0, 0.7, true);
fast.update_coeffs(true);
fast.process(&mut [&mut [0.5], &mut [0.5]], &mut gain);
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
fast.update_coeffs(true);
reference = fast.clone();
let mut a = [vec![0.0; 4096], vec![0.0; 4096]];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar], &mut gain);
reference.process_reference(&mut [bl, br], &mut ref_gain);
assert_eq!(a, b);
assert!(a[0].iter().any(|x| *x != 0.0));
}
#[test]
fn settled_and_automated_eq_matches_original_loop() {
let mut fast = BandBank::new(48000.0, 2, 3);
fast.reset();
let mut reference = fast.clone();
let mut gain = Smoothed::with_ms(1.0, 48000.0, 20.0);
let mut ref_gain = gain;
for block in 0..500 {
if matches!(block, 2 | 90 | 180 | 270 | 360) {
let on = block != 180;
let db = if block == 270 { 0.0 } else { 9.0 };
for bank in [&mut fast, &mut reference] {
bank.set_band(0, on, FilterType::Peaking, 1733.0, db, 0.7, false);
}
gain.set(if block == 360 { 1.0 } else { 0.8 });
ref_gain.set(gain.target());
}
let n = [0, 1, 17, 64, 513][block % 5];
let input: Vec<f32> = (0..n).map(|i| if block > 400 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
let mut a = [input.clone(), input.clone()];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar], &mut gain);
reference.process_reference(&mut [bl, br], &mut ref_gain);
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
}
assert_eq!(fast.counter, reference.counter);
}
}
}
+167 -10
View File
@@ -5,12 +5,26 @@ use crate::{AudioEffect, ParamSpec, ParamValues, Smoothed, Unit, block_len, flus
/// Fractional read from a circular buffer: `delay` samples behind write position `w`.
#[inline]
fn read_frac(buf: &[f32], w: usize, delay: f32) -> f32 {
let len = buf.len();
read_prepared(buf, w, prepare_delay(buf.len(), delay))
}
#[inline]
fn prepare_delay(len: usize, delay: f32) -> (usize, f32) {
let d = delay.clamp(1.0, (len - 2) as f32);
let di = d.floor();
let frac = d - di;
let i0 = (w + len - di as usize) % len;
let i1 = (i0 + len - 1) % len;
// The delay is bounded by this ring's length, so wrapping needs no integer division.
// Keep the fallback for very large rings whose f32 length rounds upward.
let distance = di as usize;
let distance = if distance <= len { distance } else { distance % len };
(distance, frac)
}
#[inline]
fn read_prepared(buf: &[f32], w: usize, (distance, frac): (usize, f32)) -> f32 {
let len = buf.len();
let i0 = if w >= distance { w - distance } else { len - (distance - w) };
let i1 = if i0 == 0 { len - 1 } else { i0 - 1 };
buf[i0] + (buf[i1] - buf[i0]) * frac
}
@@ -103,7 +117,7 @@ impl AudioEffect for Delay {
buf[self.w] = flush32(x + fb * yd);
channels[ch][i] = x + mix * (yd - x);
}
self.w = (self.w + 1) % len;
self.w = if self.w + 1 == len { 0 } else { self.w + 1 };
}
}
}
@@ -132,6 +146,7 @@ pub struct Reverb {
len: [Smoothed; FDN],
lp: [f32; FDN],
gains: [f32; FDN],
gain_key: ([f32; FDN], f32),
damp: Smoothed,
rt60: Smoothed,
mix: Smoothed,
@@ -162,6 +177,7 @@ impl Reverb {
len: [sm(1.0); FDN],
lp: [0.0; FDN],
gains: [0.0; FDN],
gain_key: ([0.0; FDN], 0.0),
damp: sm(0.0),
rt60: sm(1.0),
mix: sm(0.0),
@@ -195,6 +211,11 @@ impl Reverb {
fn update_gains(&mut self) {
let rt = self.rt60.value().max(0.01);
let lengths = self.len.map(|l| l.value());
if self.gain_key == (lengths, rt) {
return;
}
self.gain_key = (lengths, rt);
for k in 0..FDN {
// Attenuation per pass so the loop decays 60 dB in rt seconds.
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
@@ -222,13 +243,20 @@ impl AudioEffect for Reverb {
let line_len = self.lines[0].len();
let pre_len = self.pre.len();
let hh = 2.0 / FDN as f32;
// During a settled block these positions cannot change. Preserve the original path
// while smoothing, and preserve the 32-sample gain update grid in both cases.
let fixed = self.len.iter().all(|l| !l.is_smoothing());
let delays = self.len.map(|l| prepare_delay(line_len, l.value()));
let predelay = (!self.pre_len.is_smoothing()).then(|| prepare_delay(pre_len, self.pre_len.value()));
for i in 0..n {
if self.counter == 0 {
self.update_gains();
}
self.counter = (self.counter + 1) % 32;
for l in &mut self.len {
l.tick();
if !fixed {
for l in &mut self.len {
l.tick();
}
}
self.rt60.tick();
let damp = self.damp.tick();
@@ -241,13 +269,16 @@ impl AudioEffect for Reverb {
}
mono /= nch as f32;
self.pre[self.pre_w] = mono;
let input = read_frac(&self.pre, self.pre_w, pd);
self.pre_w = (self.pre_w + 1) % pre_len;
let input = match predelay {
Some(delay) => read_prepared(&self.pre, self.pre_w, delay),
None => read_frac(&self.pre, self.pre_w, pd),
};
self.pre_w = if self.pre_w + 1 == pre_len { 0 } else { self.pre_w + 1 };
let mut v = [0.0f32; FDN];
let mut sum = 0.0;
for k in 0..FDN {
let d = read_frac(&self.lines[k], self.w, self.len[k].value());
let d = if fixed { read_prepared(&self.lines[k], self.w, delays[k]) } else { read_frac(&self.lines[k], self.w, self.len[k].value()) };
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
v[k] = self.lp[k] * self.gains[k];
sum += v[k];
@@ -259,7 +290,7 @@ impl AudioEffect for Reverb {
wl += SIGN_L[k] * v[k];
wr += SIGN_R[k] * v[k];
}
self.w = (self.w + 1) % line_len;
self.w = if self.w + 1 == line_len { 0 } else { self.w + 1 };
let (wl, wr) = (wl * 0.35, wr * 0.35);
for (c, ch) in channels.iter_mut().enumerate() {
let x = ch[i];
@@ -370,3 +401,129 @@ mod tests {
}
}
}
#[cfg(test)]
fn read_frac_reference(buf: &[f32], w: usize, delay: f32) -> f32 {
let len = buf.len();
let d = delay.clamp(1.0, (len - 2) as f32);
let di = d.floor();
let frac = d - di;
let i0 = (w + len - di as usize) % len;
let i1 = (i0 + len - 1) % len;
buf[i0] + (buf[i1] - buf[i0]) * frac
}
#[cfg(test)]
impl Reverb {
fn update_gains_reference(&mut self) {
let rt = self.rt60.value().max(0.01);
for k in 0..FDN {
// Attenuation per pass so the loop decays 60 dB in rt seconds.
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
}
}
fn process_reference(&mut self, channels: &mut [&mut [f32]]) {
let n = block_len(channels);
let nch = channels.len();
if nch == 0 {
return;
}
let line_len = self.lines[0].len();
let pre_len = self.pre.len();
let hh = 2.0 / FDN as f32;
for i in 0..n {
if self.counter == 0 {
self.update_gains_reference();
}
self.counter = (self.counter + 1) % 32;
for l in &mut self.len {
l.tick();
}
self.rt60.tick();
let damp = self.damp.tick();
let mix = self.mix.tick();
let pd = self.pre_len.tick();
let mut mono = 0.0;
for ch in channels.iter() {
mono += ch[i];
}
mono /= nch as f32;
self.pre[self.pre_w] = mono;
let input = read_frac_reference(&self.pre, self.pre_w, pd);
self.pre_w = (self.pre_w + 1) % pre_len;
let mut v = [0.0f32; FDN];
let mut sum = 0.0;
for k in 0..FDN {
let d = read_frac_reference(&self.lines[k], self.w, self.len[k].value());
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
v[k] = self.lp[k] * self.gains[k];
sum += v[k];
}
let (mut wl, mut wr) = (0.0, 0.0);
for k in 0..FDN {
let fbk = v[k] - hh * sum;
self.lines[k][self.w] = flush32(fbk + input * 0.5);
wl += SIGN_L[k] * v[k];
wr += SIGN_R[k] * v[k];
}
self.w = (self.w + 1) % line_len;
let (wl, wr) = (wl * 0.35, wr * 0.35);
for (c, ch) in channels.iter_mut().enumerate() {
let x = ch[i];
let wet = if nch == 1 {
0.5 * (wl + wr)
} else if c % 2 == 0 {
wl
} else {
wr
};
ch[i] = x + mix * (wet - x);
}
}
}
}
#[cfg(test)]
mod throughput_tests {
use super::*;
#[test]
fn circular_reads_match_modulo_reference() {
for len in [3, 4, 17, 257, 14078] {
let buf: Vec<f32> = (0..len).map(|i| (i as f32 * 0.17).sin()).collect();
for w in 0..len {
for delay in [-10.0, 0.0, 1.0, 1.25, 2.75, len as f32 - 2.0, len as f32, f32::INFINITY] {
assert_eq!(read_frac(&buf, w, delay).to_bits(), read_frac_reference(&buf, w, delay).to_bits());
}
}
}
}
#[test]
fn reverb_automation_and_tail_match_original_loop() {
let mut fast = Reverb::new(48000.0, 2);
let mut reference = Reverb::new(48000.0, 2);
for block in 0..800 {
if matches!(block, 1 | 80 | 200 | 320 | 440) {
for fx in [&mut fast, &mut reference] {
fx.set_param("size", (block % 101) as f32);
fx.set_param("decay", 0.1 + (block % 20) as f32);
fx.set_param("predelay", (block % 200) as f32);
}
}
if block == 600 {
fast.reset();
reference.reset();
}
let n = [0, 1, 17, 64, 513][block % 5];
let input: Vec<f32> = (0..n).map(|i| if block > 450 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
let mut a = [input.clone(), input];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar]);
reference.process_reference(&mut [bl, br]);
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
}
}
}
}
+2
View File
@@ -7,6 +7,7 @@
//! engine and UI can build controls generically.
//! * [`biquad`] — RBJ-cookbook biquads (TDF-II) with analytic magnitude response.
//! * [`sync`] — offset between two recordings of one event (GCC-PHAT, sample-accurate).
//! * [`resample`] — streaming, block-invariant rate conversion for playback on a device at another rate.
//! * [`channels`] — channel layouts, ITU-R BS.775 up/downmix, 5.1 mixdown types, the 5.1 panner.
//!
//! Conventions: audio is **planar f32** (`&mut [&mut [f32]]`, one slice per channel, all the same
@@ -27,6 +28,7 @@ pub mod fft;
pub mod loudness;
pub mod oversample;
pub mod remix;
pub mod resample;
mod smooth;
pub mod sync;
+170
View File
@@ -0,0 +1,170 @@
//! Streaming sample-rate conversion for playback, when the output device runs at another rate
//! than the sequence (a 44.1 kHz device playing a 48 kHz sequence).
//!
//! Every output sample's source position comes from its absolute output frame, as an exact
//! rational (`frame × from / to`), and is linearly interpolated, so the output is identical however
//! the stream is cut into blocks: no phase jump at block boundaries. Source frames are pulled in
//! contiguous, non-overlapping spans, so whatever produces them (the mixer, with its effect state)
//! sees one continuous stream. Output lags the source by one source sample (≈21 µs at 48 kHz),
//! which is what lets each block interpolate without reading ahead.
/// Converts a planar stream from `from` Hz to `to` Hz, block by block.
#[derive(Debug, Clone)]
pub struct StreamResampler {
from: u32,
to: u32,
/// Source frames `[held_start, held_start + held[c].len())` kept from the last pull.
held_start: i64,
held: Vec<Vec<f32>>,
/// The output frame the next block is expected to start at (contiguous playback).
next_out: Option<i64>,
}
impl StreamResampler {
/// Rates of zero are treated as 1 Hz rather than dividing by zero.
pub fn new(from: u32, to: u32) -> Self {
Self { from: from.max(1), to: to.max(1), held_start: 0, held: Vec::new(), next_out: None }
}
pub fn rates(&self) -> (u32, u32) {
(self.from, self.to)
}
/// The source frame whose sample lands at (or just before) output frame `out`.
fn source_index(&self, out: i64) -> (i64, f32) {
let p = out as i128 * self.from as i128;
let to = self.to as i128;
(p.div_euclid(to) as i64, (p.rem_euclid(to) as f64 / to as f64) as f32)
}
/// Output frames `[out, out + n)`, planar. `pull(start, frames)` returns source frames
/// `[start, start + frames)`, planar (missing samples read as silence). A block that does not
/// continue the previous one (a seek) starts the stream afresh.
pub fn process(&mut self, out: i64, n: usize, mut pull: impl FnMut(i64, usize) -> Vec<Vec<f32>>) -> Vec<Vec<f32>> {
if n == 0 {
return vec![Vec::new(); self.held.len().max(1)];
}
let (first, _) = self.source_index(out);
let (last, _) = self.source_index(out.saturating_add(n as i64 - 1));
// interpolation reads source frames first - 1 ..= last
if self.next_out != Some(out) || self.held.is_empty() {
self.held_start = first - 1;
self.held.clear();
}
let held_end = self.held_start + self.held.first().map_or(0, Vec::len) as i64;
if last + 1 > held_end {
let want = (last + 1 - held_end) as usize;
let got = pull(held_end, want);
if self.held.is_empty() {
self.held = vec![Vec::new(); got.len().max(1)];
}
for (c, h) in self.held.iter_mut().enumerate() {
let src = got.get(c).map(Vec::as_slice).unwrap_or_default();
h.extend((0..want).map(|i| src.get(i).copied().unwrap_or(0.0)));
}
}
let mut dst = vec![vec![0.0f32; n]; self.held.len()];
for (i, frame) in (out..).take(n).enumerate() {
let (s, frac) = self.source_index(frame);
// held starts at first - 1, so the earlier frame is always held
let k = usize::try_from(s - self.held_start).unwrap_or(0);
for (d, h) in dst.iter_mut().zip(&self.held) {
let a = k.checked_sub(1).and_then(|j| h.get(j)).copied().unwrap_or(0.0);
let b = h.get(k).copied().unwrap_or(0.0);
if let Some(x) = d.get_mut(i) {
*x = a + (b - a) * frac;
}
}
}
// keep what the next block can still read: from its first frame - 1 on
let (next_first, _) = self.source_index(out.saturating_add(n as i64));
let drop = (next_first - 1 - self.held_start).clamp(0, self.held.first().map_or(0, Vec::len) as i64) as usize;
for h in &mut self.held {
h.drain(..drop);
}
self.held_start += drop as i64;
self.next_out = Some(out.saturating_add(n as i64));
dst
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A sine at `hz`, sampled at `rate`, from absolute frame `start`; logs every pull.
fn sine(hz: f64, rate: u32, pulls: &mut Vec<(i64, usize)>) -> impl FnMut(i64, usize) -> Vec<Vec<f32>> + '_ {
move |start, frames| {
pulls.push((start, frames));
let ch: Vec<f32> = (0..frames).map(|i| (2.0 * std::f64::consts::PI * hz * (start + i as i64) as f64 / rate as f64).sin() as f32).collect();
vec![ch.clone(), ch]
}
}
fn convert(from: u32, to: u32, blocks: &[usize], total: usize) -> (Vec<f32>, Vec<(i64, usize)>) {
let mut r = StreamResampler::new(from, to);
let mut pulls = Vec::new();
let mut out = Vec::new();
let mut pos = 0i64;
let mut k = 0;
while (pos as usize) < total {
let n = blocks[k % blocks.len()].min(total - pos as usize);
let b = r.process(pos, n, sine(1000.0, from, &mut pulls));
assert_eq!(b.len(), 2);
assert!(b.iter().all(|c| c.len() == n));
out.extend_from_slice(&b[0]);
pos += n as i64;
k += 1;
}
(out, pulls)
}
#[test]
fn output_is_the_same_however_the_stream_is_cut() {
for (from, to) in [(48_000, 44_100), (44_100, 48_000), (48_000, 96_000), (96_000, 48_000)] {
let (whole, _) = convert(from, to, &[20_000], 20_000);
for blocks in [&[512][..], &[333], &[1, 7, 512, 64]] {
let (cut, _) = convert(from, to, blocks, 20_000);
assert_eq!(cut, whole, "{from}→{to} in blocks {blocks:?}");
}
}
}
#[test]
fn a_tone_converts_to_the_same_tone() {
// 1 kHz at 48 kHz played on a 44.1 kHz device: within linear interpolation's error of the
// ideal tone, one source sample late
let (from, to) = (48_000u32, 44_100u32);
let (out, _) = convert(from, to, &[512], 20_000);
for (o, x) in out.iter().enumerate().skip(2) {
let t = o as f64 / to as f64 - 1.0 / from as f64;
let want = (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32;
assert!((x - want).abs() < 3e-3, "frame {o}: {x} vs {want}");
}
}
#[test]
fn the_source_is_pulled_as_one_continuous_stream() {
for (from, to) in [(48_000, 44_100), (44_100, 48_000)] {
let (_, pulls) = convert(from, to, &[512, 333], 20_000);
for w in pulls.windows(2) {
assert_eq!(w[0].0 + w[0].1 as i64, w[1].0, "{from}→{to}: {:?} then {:?}", w[0], w[1]);
}
}
}
#[test]
fn a_seek_starts_afresh_and_hostile_input_stays_finite() {
let mut r = StreamResampler::new(48_000, 44_100);
let mut pulls = Vec::new();
let _ = r.process(0, 512, sine(1000.0, 48_000, &mut pulls));
let _ = r.process(1_000_000, 512, sine(1000.0, 48_000, &mut pulls));
assert_eq!(pulls.last().map(|p| p.0), Some(1_000_000 * 48_000 / 44_100 - 1));
// a mixer returning too few channels and samples, and zero rates
let mut r = StreamResampler::new(0, 0);
let b = r.process(5, 64, |_, _| vec![vec![1.0; 3]]);
assert_eq!(b.len(), 1);
assert!(b[0].iter().all(|x| x.is_finite()));
assert!(r.process(0, 0, |_, _| Vec::new()).iter().all(Vec::is_empty));
}
}
+133
View File
@@ -10,6 +10,9 @@ use tokio::sync::Mutex;
use crate::AutomationError;
/// How often a blocking export's app job is polled.
const JOB_POLL: Duration = Duration::from_millis(250);
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
pub struct BridgeClient {
@@ -29,6 +32,43 @@ impl BridgeClient {
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
}
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
/// `wait: true`, see [`crate::long_job::LONG_COMMANDS`]) is started as an app job and polled
/// here until it finishes, so the app keeps repainting (its status bar shows the progress) and
/// answering other requests, and no request waits on the control server's 60 s reply limit
/// (#91, #92). The reply is the same as a blocking export's: `{job, path, result}`, or the
/// export's error.
pub async fn execute(&self, id: &str, mut params: Value) -> Result<Value, AutomationError> {
if !crate::long_job::is_long(id, &params) {
return self.call("engine.execute", json!({"command": id, "params": params})).await;
}
if let Some(p) = params.as_object_mut() {
p.insert("wait".into(), json!(false));
}
let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?;
let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) };
let (result, offline) = loop {
tokio::time::sleep(JOB_POLL).await;
let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?;
let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else {
break (Value::Null, None);
};
if j["finished"].as_bool() == Some(true) {
break (j["result"].clone(), j.get("offlineMedia").cloned());
}
};
if let Some(e) = result.get("error").and_then(Value::as_str) {
return Err(AutomationError::App(format!("export failed: {e}")));
}
if let Some(o) = start.as_object_mut() {
o.insert("result".into(), result);
if let Some(off) = offline {
o.insert("offlineMedia".into(), off);
}
}
Ok(start)
}
/// Call a control method; returns `result` or the app's error.
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
let mut guard = self.conn.lock().await;
@@ -74,3 +114,96 @@ impl BridgeClient {
Err(AutomationError::Bridge("unreachable".into()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tokio::net::TcpListener;
/// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports
/// running twice and then finished with `result`. Every request is recorded.
async fn fake_app(result: Value) -> (String, Arc<Mutex<Vec<Value>>>) {
fake_app_with(result, None).await
}
/// [`fake_app`] whose finished job also lists `offline` as its `offlineMedia`.
async fn fake_app_with(result: Value, offline: Option<Value>) -> (String, Arc<Mutex<Vec<Value>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(Mutex::new(Vec::new()));
let log = seen.clone();
tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let (r, mut w) = s.into_split();
let mut lines = BufReader::new(r).lines();
let mut polls = 0;
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
log.lock().await.push(req.clone());
let reply = match req["params"]["command"].as_str() {
Some("file.exportMedia") => json!({"job": 7, "path": "/tmp/out.mov"}),
Some("jobs.list") => {
polls += 1;
let finished = polls >= 3;
let mut j = json!({"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }});
if let Some(o) = offline.clone().filter(|_| finished) {
j["offlineMedia"] = o;
}
json!([j])
}
_ => json!(null),
};
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": reply}));
w.write_all(line.as_bytes()).await.unwrap();
}
});
(addr, seen)
}
/// A blocking bridge export runs as an app job (#91, #92): the app never sees `wait: true` (it
/// would encode on its UI thread and the reply would time out after 60 s), and the caller still
/// gets the finished result.
#[tokio::test]
async fn a_blocking_export_is_polled_as_a_job() {
let (addr, seen) = fake_app(json!({"frames": 642})).await;
let b = BridgeClient::new(addr).unwrap();
let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap();
assert_eq!(r, json!({"job": 7, "path": "/tmp/out.mov", "result": {"frames": 642}}));
let seen = seen.lock().await;
assert_eq!(seen[0]["params"]["params"]["wait"], json!(false));
assert!(seen.iter().all(|q| q["params"]["params"]["wait"] != json!(true)));
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "file.exportMedia").count(), 1, "started once");
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3);
}
/// The offline media a polled export drew comes back as in a blocking export's reply (#1054).
#[tokio::test]
async fn a_polled_export_reports_its_offline_media() {
let off = json!([{"item": 8, "status": "missing"}]);
let (addr, _) = fake_app_with(json!({"frames": 3}), Some(off.clone())).await;
let b = BridgeClient::new(addr).unwrap();
let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap();
assert_eq!(r["offlineMedia"], off, "{r}");
}
#[tokio::test]
async fn a_failed_blocking_export_is_an_error() {
let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await;
let b = BridgeClient::new(addr).unwrap();
let e = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap_err();
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
}
/// Everything else is forwarded unchanged.
#[tokio::test]
async fn other_commands_are_forwarded() {
let (addr, seen) = fake_app(Value::Null).await;
let b = BridgeClient::new(addr).unwrap();
b.execute("file.exportMedia", json!({"path": "/tmp/out.mov"})).await.unwrap();
b.execute("sequence.inspect", json!({})).await.unwrap();
let seen = seen.lock().await;
assert_eq!(seen.len(), 2);
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
}
}
+1
View File
@@ -11,6 +11,7 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod bridge;
pub mod long_job;
pub mod server;
pub use bridge::BridgeClient;
+242
View File
@@ -0,0 +1,242 @@
//! Long exports over MCP (docs/agents.md § Long exports): `file.exportMedia` with `wait: true`
//! runs as a background engine job; the server reports its progress as MCP
//! `notifications/progress` (when the request carried a `progressToken`) and stops it on
//! `notifications/cancelled`, deleting the partial output. The session lock is only held for
//! short polls, so other requests are answered while the export runs.
use std::path::Path;
use std::time::{Duration, SystemTime};
use rmcp::RoleServer;
use rmcp::model::{CallToolResult, ContentBlock as Content, ProgressNotificationParam, ProgressToken};
use rmcp::service::{Peer, RequestContext};
use serde_json::{Value, json};
use crate::server::FilmcraftMcp;
/// Engine commands that block until a render is written when called with `wait: true`.
pub const LONG_COMMANDS: &[&str] = &["file.exportMedia"];
/// How often the job is polled (and at most how often progress is reported).
const POLL: Duration = Duration::from_millis(100);
/// Whether `command_run {id, params}` is a long call this module runs.
pub fn is_long(id: &str, params: &Value) -> bool {
LONG_COMMANDS.contains(&id) && params.get("wait").and_then(Value::as_bool) == Some(true)
}
/// Sends `notifications/progress` for one request; progress only ever increases.
struct Reporter {
peer: Peer<RoleServer>,
token: Option<ProgressToken>,
last: f64,
}
impl Reporter {
async fn report(&mut self, done: f64, total: f64, message: &str) {
let Some(token) = self.token.clone() else { return };
if total <= 0.0 || done <= self.last {
return;
}
self.last = done;
let mut p = ProgressNotificationParam::new(token, done).with_total(total);
if !message.is_empty() {
p = p.with_message(message);
}
let _ = self.peer.notify_progress(p).await;
}
}
impl FilmcraftMcp {
/// Run `id` (a [`LONG_COMMANDS`] entry, `wait: true`) as a job, reporting progress and
/// honouring cancellation.
pub(crate) async fn run_long(&self, id: &str, mut params: Value, context: &RequestContext<RoleServer>) -> CallToolResult {
let started = SystemTime::now();
if let Some(p) = params.as_object_mut() {
p.insert("wait".into(), json!(false));
}
let start = match self.run(id, params).await {
Ok(v) => v,
Err(e) => return CallToolResult::error(vec![Content::text(e.to_string())]),
};
let Some(job) = start.get("job").and_then(Value::as_u64) else {
return CallToolResult::error(vec![Content::text(format!("{id} started no job: {start}"))]);
};
let path = start.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
let mut rep = Reporter { peer: context.peer.clone(), token: context.meta.get_progress_token(), last: 0.0 };
loop {
let state = self.job_state(job).await;
let finished = state.as_ref().is_none_or(|j| j["finished"].as_bool() == Some(true));
if let Some(j) = &state {
let (done, total) = (j["done"].as_f64().unwrap_or(0.0), j["total"].as_f64().unwrap_or(0.0));
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
}
if finished {
let offline = state.as_ref().and_then(|j| j.get("offlineMedia").cloned());
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
if let Some(e) = result.get("error").and_then(Value::as_str) {
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
}
let mut out = start;
if let Some(o) = out.as_object_mut() {
o.insert("result".into(), result);
if let Some(off) = offline {
o.insert("offlineMedia".into(), off);
}
}
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
}
tokio::select! {
_ = context.ct.cancelled() => break,
_ = tokio::time::sleep(POLL) => {}
}
}
// Cancelled: stop the encode at the next batch, wait for the worker, delete the partial file.
let _ = self.run("jobs.cancel", json!({"job": job})).await;
for _ in 0..600 {
if self.job_state(job).await.is_none_or(|j| j["finished"].as_bool() == Some(true)) {
break;
}
tokio::time::sleep(POLL).await;
}
remove_partial(&path, started);
CallToolResult::error(vec![Content::text("cancelled")])
}
/// The job's `jobs.list` entry.
async fn job_state(&self, job: u64) -> Option<Value> {
let jobs = self.run("jobs.list", json!({})).await.ok()?;
jobs.as_array()?.iter().find(|j| j["id"].as_u64() == Some(job)).cloned()
}
}
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
/// stills of an image sequence (`<stem><3+ digits>.<same extension>`, only when the output is a
/// PNG/TIFF/BMP/JPEG/Targa/DPX sequence) and the caption sidecar
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, `<stem>1.mp4` beside a movie, …) are
/// never touched, even when they were saved during the export.
pub fn remove_partial(path: &str, since: SystemTime) {
let p = Path::new(path);
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
return;
};
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default();
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let Ok(entries) = std::fs::read_dir(dir) else { return };
// file times are coarser than the clock
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
for e in entries.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if is_export_output(name, file, stem, ext) && e.metadata().is_ok_and(|m| m.is_file() && m.modified().is_ok_and(|t| t >= since)) {
let _ = std::fs::remove_file(e.path());
}
}
}
/// Whether `name` is a file an export to `file` (`stem` + `.ext`) writes.
fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
if name == file {
return true;
}
let Some(rest) = name.strip_prefix(stem) else { return false };
if let Some(sidecar) = rest.strip_prefix('.') {
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
}
// image sequence: the frame number (at least three digits, like `image_sequence_path`), then the
// output's own extension; movies and audio are a single file, so numbered neighbours are not theirs
if !is_sequence_extension(ext) {
return false;
}
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
digits >= 3 && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
}
/// Whether an export to a file with extension `ext` writes an image sequence (one still per frame).
fn is_sequence_extension(ext: &str) -> bool {
use filmcraft_engine::export::Format;
Format::from_name(ext).is_some_and(Format::is_image_sequence)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_exports_own_files_are_removed() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let started = SystemTime::now();
let ours = ["trailer.png", "trailer000.png", "trailer123.png", "trailer.srt", "trailer.vtt"];
// same stem, written during the export, but not the export's
let theirs = ["trailer.aep", "trailer2.psd", "trailer.docx", "trailer000.jpg", "trailers.png", "trailer.png.bak", "other.png"];
for f in ours.iter().chain(theirs.iter()) {
std::fs::write(dir.join(f), b"x").unwrap();
}
remove_partial(&dir.join("trailer.png").to_string_lossy(), started);
for f in ours {
assert!(!dir.join(f).exists(), "{f} should be removed");
}
for f in theirs {
assert!(dir.join(f).exists(), "{f} must not be removed");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn numbered_files_beside_a_movie_are_kept() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-movie-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let started = SystemTime::now();
// a movie export writes one file: `clip1.mp4` and `clip002.mp4` are other exports
let ours = ["clip.mp4", "clip.srt"];
let theirs = ["clip1.mp4", "clip002.mp4", "clip2.mov"];
for f in ours.iter().chain(theirs.iter()) {
std::fs::write(dir.join(f), b"x").unwrap();
}
remove_partial(&dir.join("clip.mp4").to_string_lossy(), started);
for f in ours {
assert!(!dir.join(f).exists(), "{f} should be removed");
}
for f in theirs {
assert!(dir.join(f).exists(), "{f} must not be removed");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sequence_frames_have_at_least_three_digits() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-digits-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let started = SystemTime::now();
// frames are numbered with at least three digits (`shot000.tiff`); `shot2.tiff` is not one
let ours = ["shot.tiff", "shot000.tiff", "shot1234.tiff"];
let theirs = ["shot2.tiff", "shot12.tiff"];
for f in ours.iter().chain(theirs.iter()) {
std::fs::write(dir.join(f), b"x").unwrap();
}
remove_partial(&dir.join("shot.tiff").to_string_lossy(), started);
for f in ours {
assert!(!dir.join(f).exists(), "{f} should be removed");
}
for f in theirs {
assert!(dir.join(f).exists(), "{f} must not be removed");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn files_older_than_the_export_are_kept() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("clip.mp4"), b"x").unwrap();
remove_partial(&dir.join("clip.mp4").to_string_lossy(), SystemTime::now() + Duration::from_secs(60));
assert!(dir.join("clip.mp4").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+668 -41
View File
@@ -1,6 +1,6 @@
//! The MCP server. Tool names are snake_case.
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use base64::Engine as _;
use filmcraft_engine::Session;
@@ -19,6 +19,19 @@ pub enum Backend {
Bridge(Arc<BridgeClient>),
}
/// A panic payload as text.
fn panic_message(p: Box<dyn std::any::Any + Send>) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
/// A failed blocking task: a panic is reported as an internal error and the session stays usable.
fn join_error(e: tokio::task::JoinError) -> AutomationError {
match e.try_into_panic() {
Ok(p) => AutomationError::Other(format!("internal error: {}", panic_message(p))),
Err(e) => AutomationError::Other(e.to_string()),
}
}
#[derive(Clone)]
pub struct FilmcraftMcp {
backend: Arc<Backend>,
@@ -44,6 +57,24 @@ pub struct RunParams {
pub params: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BatchStep {
/// Command id.
pub id: String,
/// Parameters object.
#[serde(default)]
pub params: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BatchParams {
/// Commands to run in order.
pub steps: Vec<BatchStep>,
/// Stop at the first failing step (default true).
#[serde(default)]
pub stop_on_error: Option<bool>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct RenderParams {
/// Timeline time in seconds (default: the playhead).
@@ -108,6 +139,30 @@ pub struct TypeParams {
pub text: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct MediaImportParams {
/// Absolute paths to media files (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).
pub paths: Vec<String>,
/// Bin ID to import into (optional).
#[serde(default)]
pub bin: Option<u64>,
/// Import numbered stills as an image sequence (optional, default false).
#[serde(default)]
pub image_sequence: Option<bool>,
}
/// `file.import` params for a `media_import` call.
fn media_import_params(p: MediaImportParams) -> Value {
let mut params = json!({"paths": p.paths});
if let Some(bin) = p.bin {
params["bin"] = json!(bin);
}
if let Some(seq) = p.image_sequence {
params["imageSequence"] = json!(seq);
}
params
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct ElementsParams {
/// Only element ids starting with this prefix (e.g. `timeline.clip.`).
@@ -148,9 +203,60 @@ impl FilmcraftMcp {
}
pub async fn serve_stdio(self) -> Result<(), AutomationError> {
let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?;
Ok(())
self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
}
/// Serve MCP over any line stream. A line that isn't JSON gets a JSON-RPC parse error
/// (`-32700`, id null) instead of being dropped silently; the rest go to the rmcp service. One
/// writer task owns `output`, so replies never interleave.
pub async fn serve_io(
self,
input: impl tokio::io::AsyncRead + Unpin + Send + 'static,
output: impl tokio::io::AsyncWrite + Unpin + Send + 'static,
) -> Result<(), AutomationError> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (mut to_service, service_in) = tokio::io::duplex(1 << 20);
let (service_out, from_service) = tokio::io::duplex(1 << 20);
let (err_tx, mut err_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(_) => {
if to_service.write_all(format!("{line}\n").as_bytes()).await.is_err() {
break;
}
}
Err(e) => {
let reply = json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("parse error: {e}")}});
let _ = err_tx.send(reply.to_string());
}
}
}
// EOF: closing the pipe ends the service
let _ = to_service.shutdown().await;
});
let writer = tokio::spawn(async move {
let mut output = output;
let mut replies = BufReader::new(from_service).lines();
loop {
let line = tokio::select! {
l = replies.next_line() => match l { Ok(Some(l)) => l, _ => break },
Some(e) = err_rx.recv() => e,
};
if output.write_all(format!("{line}\n").as_bytes()).await.is_err() || output.flush().await.is_err() {
break;
}
}
});
let running = self.serve((service_in, service_out)).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
let r = running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()));
reader.abort();
let _ = writer.await;
r.map(|_| ())
}
/// Run an engine command on whichever backend.
@@ -160,16 +266,24 @@ impl FilmcraftMcp {
let s = s.clone();
let id = id.to_string();
tokio::task::spawn_blocking(move || {
let mut g = s.lock().map_err(|_| AutomationError::Other("session lock poisoned".into()))?;
// A command that panicked earlier poisoned the lock; the session is still usable.
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
g.execute(&id, params).map_err(AutomationError::from)
})
.await
.map_err(|e| AutomationError::Other(e.to_string()))?
.map_err(join_error)?
}
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
Backend::Bridge(b) => b.execute(id, params).await,
}
}
/// The project tree plus the active sequence (null when there is none).
async fn document(&self) -> Result<Value, AutomationError> {
let project = self.run("project.inspect", json!({})).await?;
let sequence = self.run("sequence.inspect", json!({})).await.ok();
Ok(json!({"project": project, "sequence": sequence}))
}
fn bridge_client(&self) -> Option<Arc<BridgeClient>> {
match &*self.backend {
Backend::Bridge(b) => Some(b.clone()),
@@ -190,7 +304,9 @@ impl FilmcraftMcp {
#[tool_router]
impl FilmcraftMcp {
#[tool(
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids."
title = "List commands",
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
let r = self.run("command.list", json!({})).await.map(|v| {
@@ -213,76 +329,166 @@ impl FilmcraftMcp {
}
#[tool(
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History."
title = "Run a command",
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
wrap(self.run(&p.id, p.params.unwrap_or(json!({}))).await)
}
#[tool(description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.")]
#[tool(
title = "Run several commands",
description = "Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}; each edit is its own undo step.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
let stop = p.stop_on_error.unwrap_or(true);
let (mut completed, mut failed, mut results) = (0u32, 0u32, Vec::new());
for st in p.steps {
match self.run(&st.id, st.params.unwrap_or(json!({}))).await {
Ok(v) => {
completed += 1;
results.push(json!({"ok": true, "result": v}));
}
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e.to_string()}));
if stop {
break;
}
}
}
}
let v = json!({"completed": completed, "failed": failed, "results": results});
Ok(if failed > 0 { CallToolResult::error(vec![Content::text(v.to_string())]) } else { ok_json(&v) })
}
#[tool(
title = "Inspect the document",
description = "The project as JSON: the tree (as project_inspect) plus the active sequence (as sequence_inspect; null when there is none).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn doc_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.document().await)
}
#[tool(
title = "Render a preview",
description = "The active sequence's frame at `seconds` (default: the playhead) as PNG, same as render_frame (bridge mode: Program monitor screenshot).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn render_preview(&self, p: Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
self.render_frame(p).await
}
#[tool(
title = "Inspect the project tree",
description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn project_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.run("project.inspect", json!({})).await)
}
#[tool(
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second."
title = "Inspect the active sequence",
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn sequence_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.run("sequence.inspect", json!({})).await)
}
#[tool(description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).")]
async fn media_import(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
let paths: Vec<&str> = p.text.split('\n').map(str::trim).filter(|s| !s.is_empty()).collect();
wrap(self.run("file.import", json!({"paths": paths})).await)
#[tool(
title = "Import media",
description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).",
annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = false)
)]
async fn media_import(&self, Parameters(p): Parameters<MediaImportParams>) -> Result<CallToolResult, McpError> {
wrap(self.run("file.import", media_import_params(p)).await)
}
#[tool(description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.")]
#[tool(
title = "Render a frame",
description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn render_frame(&self, Parameters(p): Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
let max = p.max_side.unwrap_or(960);
match &*self.backend {
Backend::Headless(s) => {
let s = s.clone();
let r = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AutomationError> {
let mut g = s.lock().map_err(|_| AutomationError::Other("lock".into()))?;
if let Some(sec) = p.seconds {
g.set_playhead(filmcraft_time::Tick::from_seconds_f64(sec));
}
let g = s.lock().unwrap_or_else(PoisonError::into_inner);
// read-only: render at `seconds` without moving the playhead (#114)
let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64);
let (w, h) =
g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
let scale = (max as f32 / w.max(h) as f32).min(1.0);
let img = g.render_program(scale).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
let scale = render_scale(max, w, h);
let img = g.try_render_program_at(scale, t).map_err(|e| AutomationError::Other(e.to_string()))?;
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
})
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
.unwrap_or_else(|e| Err(join_error(e)));
Ok(match r {
Ok(png) => png_result(&png, "rendered frame"),
Err(e) => fail(e),
})
}
Backend::Bridge(b) => {
if let Some(sec) = p.seconds
&& let Err(e) = b.call("engine.execute", json!({"command": "playhead.set", "params": {"seconds": sec}})).await
{
let Some(sec) = p.seconds else {
return self.screenshot(&b.clone(), Some("Program".into()), max).await;
};
// The Program monitor shows the playhead's frame, so the live playhead has to move
// for the screenshot; put it (and the selection, which may follow it) back after,
// so the tool stays read-only (#114).
let before = match self.run("sequence.inspect", json!({})).await {
Ok(v) => v,
Err(e) => return Ok(fail(e)),
};
if let Err(e) = self.run("playhead.set", json!({"seconds": sec})).await {
return Ok(fail(e));
}
self.screenshot(&b.clone(), Some("Program".into()), max).await
let shot = self.screenshot(&b.clone(), Some("Program".into()), max).await;
if let Some(t) = before.get("playhead").and_then(Value::as_i64) {
let _ = self.run("playhead.set", json!({"time": t})).await;
}
// only when it changed: selecting also leaves trim mode
let now = self.run("sequence.inspect", json!({})).await.ok();
if let Some(sel) = before.get("selection").filter(|v| v.is_array())
&& now.as_ref().and_then(|n| n.get("selection")) != Some(sel)
{
let _ = self.run("timeline.select", json!({"clips": sel})).await;
}
shot
}
}
}
#[tool(description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).")]
#[tool(
title = "Live app: UI state",
description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
self.ui("ui.inspect", json!({})).await
}
#[tool(description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.")]
#[tool(
title = "Live app: on-screen elements",
description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_elements(&self, Parameters(p): Parameters<ElementsParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await
}
#[tool(description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.")]
#[tool(
title = "Live app: click",
description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_click(&self, Parameters(p): Parameters<ClickParams>) -> Result<CallToolResult, McpError> {
let mut v = json!({"button": p.button, "count": p.count, "modifiers": p.modifiers});
if let Some(id) = p.id {
@@ -295,23 +501,37 @@ impl FilmcraftMcp {
}
#[tool(
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks)."
title = "Live app: drag",
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_drag(&self, Parameters(p): Parameters<DragParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.drag", json!({"from": p.from, "to": p.to, "steps": p.steps, "modifiers": p.modifiers})).await
}
#[tool(description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).")]
#[tool(
title = "Live app: press a key",
description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_key(&self, Parameters(p): Parameters<KeyParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.key", json!({"key": p.key})).await
}
#[tool(description = "Live app: type text into the focused field.")]
#[tool(
title = "Live app: type text",
description = "Live app: type text into the focused field.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_type(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.type", json!({"text": p.text})).await
}
#[tool(description = "Live app: screenshot of the window or one panel (PNG).")]
#[tool(
title = "Live app: screenshot",
description = "Live app: screenshot of the window or one panel (PNG).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_screenshot(&self, Parameters(p): Parameters<ShotParams>) -> Result<CallToolResult, McpError> {
match self.bridge_client() {
Some(b) => self.screenshot(&b, p.panel, p.max_side.unwrap_or(1600)).await,
@@ -320,7 +540,9 @@ impl FilmcraftMcp {
}
#[tool(
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …)."
title = "Live app: control-channel call",
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_control(&self, Parameters(p): Parameters<ControlParams>) -> Result<CallToolResult, McpError> {
self.ui(&p.method, p.params.unwrap_or(json!({}))).await
@@ -350,17 +572,125 @@ impl FilmcraftMcp {
}
}
#[tool_handler(
router = self.tool_router,
name = "filmcraft",
instructions = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable). `project_inspect`/`sequence_inspect` return ids you can pass to commands; `render_frame` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`)."
)]
impl ServerHandler for FilmcraftMcp {}
impl FilmcraftMcp {
/// The first argument `tool` doesn't take, as a message naming the ones it does.
fn unknown_arg(&self, tool: &str, args: Option<&rmcp::model::JsonObject>) -> Option<String> {
let def = self.tool_router.get(tool)?;
let props = def.input_schema.get("properties").and_then(Value::as_object).cloned().unwrap_or_default();
let bad = args?.keys().find(|k| !props.contains_key(*k))?;
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
let accepted = if accepted.is_empty() { "no arguments".to_string() } else { accepted.join(", ") };
Some(format!("unknown argument \"{bad}\" for {tool}; expected: {accepted}"))
}
}
const INSTRUCTIONS: &str = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable; `command_batch` runs several). `doc_inspect` (or `project_inspect`/`sequence_inspect`) returns ids you can pass to commands; `render_preview` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`).";
/// Resources: the project (as `doc_inspect`) and the command catalog (as `command_list`).
const DOCUMENT_URI: &str = "filmcraft://document";
const COMMANDS_URI: &str = "filmcraft://commands";
/// MCP 2026-07-28 clients (current Claude Code) require `ttlMs` and `cacheScope` on list and read
/// results; rmcp fills `resultType` and leaves the cache hints to the server, as the generated
/// `tools/list` does. The resource list never changes; the document does, so reads aren't cached.
fn cache_hints(context: &rmcp::service::RequestContext<rmcp::RoleServer>, ttl_ms: u64) -> Option<(u64, rmcp::model::CacheScope)> {
context.protocol_version().is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28).then_some((ttl_ms, rmcp::model::CacheScope::Private))
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for FilmcraftMcp {
fn get_info(&self) -> rmcp::model::ServerConfig {
rmcp::model::ServerConfig::new(rmcp::model::ServerCapabilities::builder().enable_tools().enable_resources().build())
.with_server_info(rmcp::model::Implementation::new("filmcraft", env!("CARGO_PKG_VERSION")))
.with_instructions(INSTRUCTIONS.to_string())
}
/// Strict arguments and long exports in front of the tool router.
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::CallToolResponse, McpError> {
if let Some(m) = self.unknown_arg(&request.name, request.arguments.as_ref()) {
return Err(McpError::invalid_params(m, None));
}
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
// Bridge mode too: the app runs it as a job, so it stays responsive (#91, #92).
if request.name == "command_run"
&& let Some(a) = &request.arguments
&& let Some(id) = a.get("id").and_then(Value::as_str)
{
let params = a.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
if crate::long_job::is_long(id, &params) {
return Ok(self.run_long(id, params, &context).await.into());
}
}
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.tool_router.call(tcc).await
}
async fn list_resources(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListResourcesResult, McpError> {
use rmcp::model::Resource;
let mut r = rmcp::model::ListResourcesResult::with_all_items(vec![
Resource::new(DOCUMENT_URI, "document")
.with_title("Project")
.with_description("The project tree and the active sequence (same as doc_inspect).")
.with_mime_type("application/json"),
Resource::new(COMMANDS_URI, "commands")
.with_title("Command catalog")
.with_description("Every command with id, label, menu path, shortcut, params and enabled state (same as command_list).")
.with_mime_type("application/json"),
]);
if let Some((ttl, scope)) = cache_hints(&context, 600_000) {
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
}
Ok(r)
}
async fn read_resource(
&self,
request: rmcp::model::ReadResourceRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
let v = match request.uri.as_str() {
DOCUMENT_URI => self.document().await,
COMMANDS_URI => self.run("command.list", json!({})).await,
other => return Err(McpError::resource_not_found(format!("resource not found: {other}"), None)),
}
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let text = serde_json::to_string_pretty(&v).map_err(|e| McpError::internal_error(e.to_string(), None))?;
let mut r = rmcp::model::ReadResourceResult::new(vec![rmcp::model::ResourceContents::text(text, request.uri).with_mime_type("application/json")]);
if let Some((ttl, scope)) = cache_hints(&context, 0) {
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
}
Ok(r.into())
}
}
/// Render scale that fits the longest side of a `w` x `h` sequence into `max_side` (never upscales; 0 = no limit, as in `png_rgba`).
fn render_scale(max_side: u32, w: u32, h: u32) -> f32 {
if max_side == 0 {
return 1.0;
}
(max_side as f32 / w.max(h).max(1) as f32).min(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_scale_zero_means_no_limit() {
assert_eq!(render_scale(0, 1920, 1080), 1.0);
assert_eq!(render_scale(960, 1920, 1080), 0.5);
assert_eq!(render_scale(960, 640, 360), 1.0);
assert_eq!(render_scale(960, 0, 0), 1.0);
}
#[tokio::test]
async fn headless_commands_and_render() {
let mut s = Session::default();
@@ -371,4 +701,301 @@ mod tests {
let r = m.render_frame(Parameters(RenderParams { seconds: Some(1.0), max_side: Some(320) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
/// `render_frame` / `render_preview` are annotated read-only (#114): rendering at `seconds`
/// leaves the playhead, and the selection that follows it, where they were, and still renders
/// the frame at `seconds`.
#[tokio::test]
async fn rendering_at_a_time_does_not_move_the_playhead() {
let m = FilmcraftMcp::headless(demo());
m.run("sequence.selectionFollowsPlayhead", json!({"on": true})).await.unwrap();
m.run("playhead.set", json!({"seconds": 1.0})).await.unwrap();
let before = m.run("sequence.inspect", json!({})).await.unwrap();
let png = |r: CallToolResult| serde_json::to_string(&r.content).unwrap();
let at_playhead = png(m.render_frame(Parameters(RenderParams { seconds: None, max_side: Some(64) })).await.unwrap());
for seconds in [5.0, 8.0] {
let r = m.render_frame(Parameters(RenderParams { seconds: Some(seconds), max_side: Some(64) })).await.unwrap();
assert_ne!(png(r), at_playhead, "renders the frame at {seconds} s");
m.render_preview(Parameters(RenderParams { seconds: Some(seconds), max_side: Some(64) })).await.unwrap();
}
let after = m.run("sequence.inspect", json!({})).await.unwrap();
assert_eq!(after["playhead"], before["playhead"]);
assert_eq!(after["selection"], before["selection"]);
}
/// One request/response exchange over `serve_io`, as an MCP client sees it.
struct Client {
tx: tokio::io::DuplexStream,
rx: tokio::io::Lines<tokio::io::BufReader<tokio::io::DuplexStream>>,
}
impl Client {
fn start(s: Session) -> Self {
use tokio::io::AsyncBufReadExt;
let (tx, server_in) = tokio::io::duplex(1 << 22);
let (server_out, rx) = tokio::io::duplex(1 << 22);
tokio::spawn(FilmcraftMcp::headless(s).serve_io(server_in, server_out));
Self { tx, rx: tokio::io::BufReader::new(rx).lines() }
}
async fn send(&mut self, line: &str) {
use tokio::io::AsyncWriteExt;
self.tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
}
async fn next(&mut self) -> Value {
serde_json::from_str(&self.rx.next_line().await.unwrap().unwrap()).unwrap()
}
async fn ask(&mut self, msg: Value) -> Value {
self.send(&msg.to_string()).await;
self.next().await
}
async fn init(&mut self) -> Value {
let r = self.ask(json!({"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}})).await;
self.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string()).await;
r
}
async fn call(&mut self, id: u64, name: &str, arguments: Value) -> Value {
self.ask(json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":arguments}})).await
}
}
/// #499: `media_import` takes `paths` (a required array), and forwards `bin` / `image_sequence` to `file.import`.
#[test]
fn media_import_schema_and_params() {
let m = FilmcraftMcp::headless(Session::default());
let def = m.tool_router.get("media_import").unwrap();
let schema = Value::Object((*def.input_schema).clone());
assert_eq!(schema["properties"]["paths"]["type"], "array", "{schema}");
assert!(schema["required"].as_array().unwrap().iter().any(|r| r == "paths"), "{schema}");
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.png"], "bin": 7, "image_sequence": true})).unwrap();
assert_eq!(media_import_params(p), json!({"paths": ["/a.png"], "bin": 7, "imageSequence": true}));
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.wav"]})).unwrap();
assert_eq!(media_import_params(p), json!({"paths": ["/a.wav"]}));
assert!(serde_json::from_value::<MediaImportParams>(json!({"text": "/a.wav"})).is_err());
}
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
/// Every listed tool has a title and the four hints, and the core tools are listed.
#[tokio::test(flavor = "multi_thread")]
async fn tools_are_titled_and_annotated() {
let mut c = Client::start(demo());
let init = c.init().await;
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft");
let tools = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})).await["result"]["tools"].as_array().cloned().unwrap();
for t in &tools {
assert!(t["title"].is_string(), "{} has no title", t["name"]);
for h in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][h].is_boolean(), "{} lacks {h}", t["name"]);
}
}
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for want in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "project_inspect", "render_frame", "ui_click"] {
assert!(names.contains(&want), "{want} not listed");
}
let ro = |n: &str| tools.iter().find(|t| t["name"] == n).map(|t| t["annotations"]["readOnlyHint"].clone());
assert_eq!((ro("command_list"), ro("command_run")), (Some(json!(true)), Some(json!(false))));
}
/// Strict arguments, malformed JSON, batch, doc_inspect and render_preview over the raw line
/// protocol.
#[tokio::test(flavor = "multi_thread")]
async fn strict_arguments_and_parse_errors() {
let mut c = Client::start(demo());
c.init().await;
let r = c.call(1, "command_list", json!({"filter": "razor", "enabled_only": true})).await;
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
// an unknown argument names itself and the accepted ones
let r = c.call(2, "command_list", json!({"filtr": "x"})).await;
assert_eq!(r["error"]["code"], -32602, "{r}");
let m = r["error"]["message"].as_str().unwrap();
assert!(m.contains("\"filtr\"") && m.contains("filter") && m.contains("enabled_only"), "{m}");
let r = c.call(3, "doc_inspect", json!({"verbose": true})).await;
assert_eq!(r["error"]["code"], -32602, "{r}");
// command params stay free-form
let r = c.call(4, "command_run", json!({"id": "playhead.set", "params": {"seconds": 0.5}})).await;
assert_eq!(r["result"]["isError"], false, "{r}");
// malformed JSON: a parse error, and the server keeps serving
c.send("{\"jsonrpc\": \"2.0\", \"id\": 5, BROKEN").await;
let r = c.next().await;
assert_eq!((r["error"]["code"].as_i64(), r["id"].is_null()), (Some(-32700), true), "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":6,"method":"ping"})).await;
assert!(r["result"].is_object(), "{r}");
// batch: stops at the first failure and reports both
let r =
c.call(7, "command_batch", json!({"steps": [{"id": "playhead.set", "params": {"seconds": 0.5}}, {"id": "no.such"}, {"id": "playhead.set"}]})).await;
assert_eq!(r["result"]["isError"], true, "{r}");
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!((v["completed"].as_u64(), v["failed"].as_u64(), v["results"].as_array().map(Vec::len)), (Some(1), Some(1), Some(2)), "{v}");
let r = c.call(8, "doc_inspect", json!({})).await;
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
let r = c.call(9, "render_preview", json!({"max_side": 64})).await;
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
}
/// `filmcraft://document` and `filmcraft://commands` are listed and readable as JSON.
#[tokio::test(flavor = "multi_thread")]
async fn resources_document_and_commands() {
let mut c = Client::start(demo());
let init = c.init().await;
assert!(init["result"]["capabilities"]["resources"].is_object(), "{init}");
assert!(init["result"]["instructions"].as_str().unwrap().contains("doc_inspect"), "{init}");
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"resources/list"})).await;
let uris: Vec<&str> = r["result"]["resources"].as_array().unwrap().iter().filter_map(|x| x["uri"].as_str()).collect();
assert_eq!(uris, [DOCUMENT_URI, COMMANDS_URI], "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":DOCUMENT_URI}})).await;
assert_eq!(r["result"]["contents"][0]["mimeType"], "application/json", "{r}");
let v: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
let r = c.ask(json!({"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":COMMANDS_URI}})).await;
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"filmcraft://nope"}})).await;
assert!(r["error"]["code"].is_i64(), "{r}");
}
/// MCP 2026-07-28 clients (current Claude Code) negotiate per request and reject list and read
/// results without `resultType`, `ttlMs` and `cacheScope`; sessions that negotiated an older
/// revision through `initialize` get the old shape.
#[tokio::test(flavor = "multi_thread")]
async fn modern_clients_get_result_type_and_cache_hints() {
let mut c = Client::start(Session::default());
let meta = json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {},
});
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":meta}})).await;
assert!(r["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2026-07-28")), "{r}");
for (id, method, params) in [
(2, "tools/list", json!({"_meta": meta})),
(3, "resources/list", json!({"_meta": meta})),
(4, "resources/read", json!({"_meta": meta, "uri": DOCUMENT_URI})),
] {
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
assert_eq!(r["result"]["resultType"], "complete", "{method}: {r}");
assert!(r["result"]["ttlMs"].is_u64(), "{method}: {r}");
assert!(matches!(r["result"]["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
}
let mut c = Client::start(Session::default());
c.init().await;
for (id, method, params) in [(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": DOCUMENT_URI}))] {
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
assert!(r["result"].is_object() && r["result"].get("resultType").is_none() && r["result"].get("ttlMs").is_none(), "{method}: {r}");
}
}
/// docs/agents.md § Long exports: a blocking export reports `notifications/progress` for its
/// token (none without one), answers `ping` meanwhile, and `notifications/cancelled` stops
/// it, deletes the partial file and sends no response.
#[tokio::test(flavor = "multi_thread")]
async fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("filmcraft-mcp-progress-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut c = Client::start(demo());
c.init().await;
let export = |id: u64, name: &str, token: Option<Value>, seconds: f64| {
let path = dir.join(name).to_string_lossy().to_string();
let params = json!({"path": path, "format": "h264", "width": 640, "height": 360, "audio": false, "range": "custom", "startSeconds": 0, "endSeconds": seconds, "wait": true});
let mut call =
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"file.exportMedia","params":params}}});
if let Some(t) = token {
call["params"]["_meta"] = json!({"progressToken": t});
}
call.to_string()
};
// no token: no notifications, the same response as before
c.send(&export(1, "quiet.mp4", None, 0.5)).await;
let m = c.next().await;
assert_eq!((m["id"].as_u64(), &m["result"]["isError"]), (Some(1), &json!(false)), "{m}");
// progress, strictly increasing, with a total; ping answered while the export runs. The
// job is polled every 100 ms, so a fast machine finishes a short export within one poll:
// a longer one is tried until the export outlasts a few polls.
let (mut notes, mut pong) = (0, false);
for (attempt, seconds) in [1.0, 4.0, 16.0].into_iter().enumerate() {
let (id, ping, name) = (20 + attempt as u64, 30 + attempt as u64, format!("a{attempt}.mp4"));
c.send(&export(id, &name, Some(json!("tok")), seconds)).await;
let (mut last, mut pinged) = (-1.0, false);
(notes, pong) = (0, false);
let done = loop {
let m = c.next().await;
if m["method"] == "notifications/progress" {
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
let p = m["params"]["progress"].as_f64().unwrap();
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
last = p;
notes += 1;
if !pinged {
pinged = true;
c.send(&json!({"jsonrpc": "2.0", "id": ping, "method": "ping"}).to_string()).await;
}
} else if m["id"] == ping {
pong = true;
} else if m["id"] == id {
break m;
}
};
assert_eq!(done["result"]["isError"], false, "{done}");
assert!(notes >= 1, "a finished export reports its progress at least once");
assert!(dir.join(&name).exists());
if notes >= 2 && pong {
break;
}
// the ping's answer is still on its way: read it so it cannot be taken for a later reply
while pinged && !pong {
pong = c.next().await["id"] == ping;
}
pong = false;
}
assert!(notes >= 2 && pong, "{notes} notifications, ping answered during the export: {pong}");
// cancel after the first notification: no response, no partial file
c.send(&export(4, "b.mp4", Some(json!(7)), 20.0)).await;
loop {
let m = c.next().await;
assert_ne!(m["id"], 4, "finished before the cancel: {m}");
if m["method"] == "notifications/progress" {
break;
}
}
c.send(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}}"#).await;
let t0 = std::time::Instant::now();
while dir.join("b.mp4").exists() || t0.elapsed() < std::time::Duration::from_millis(500) {
assert!(t0.elapsed().as_secs() < 30, "partial file still there");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
c.send(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"jobs.list"}}}"#).await;
let m = loop {
let m = c.next().await;
assert_ne!(m["id"], 4, "no response for a cancelled request: {m}");
if m["id"] == 5 {
break m;
}
};
let jobs: Value = serde_json::from_str(m["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
let last = jobs.as_array().and_then(|a| a.last()).cloned().unwrap_or_default();
assert_eq!(last["result"]["error"], "cancelled", "{jobs}");
assert!(!dir.join("b.mp4").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A command that panicked used to poison the session lock, and every later call failed with
/// "session lock poisoned" until the server was restarted.
#[tokio::test(flavor = "multi_thread")]
async fn a_poisoned_session_lock_keeps_serving() {
let m = FilmcraftMcp::headless(demo());
let Backend::Headless(s) = &*m.backend else { panic!("headless backend") };
let s = s.clone();
let _ = std::thread::spawn(move || {
let _g = s.lock().unwrap();
panic!("a command panicked");
})
.join();
let v = m.run("sequence.inspect", json!({})).await.unwrap();
assert!(v.is_object(), "{v}");
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
}
+6 -3
View File
@@ -70,12 +70,15 @@ with libdav1d: **bit-exact** on all 6 fixtures.
wasm32, or without the default `threads` feature, everything runs on the calling thread). Headers are parsed in order; each frame is
then decoded by a frame worker once the reference frames it reads are finished, so independent
frames (hierarchical GOP layers) decode concurrently; tiles of a frame decode in parallel into
private buffers that are merged afterwards. Results never depend on scheduling (the oracle tests
compare 1 thread, many threads and libdav1d). With frame threads, pictures may be returned by a
private buffers that are merged afterwards; the in-loop filters (deblocking, CDEF, loop
restoration) each run in parallel bands of 64 rows (`postfilter.rs` explains why the bands are
independent). Results never depend on scheduling (the oracle tests compare 1 thread, many threads
and libdav1d). With frame threads, pictures may be returned by a
later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it.
`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
(`Decoder::stats()`) and all-core fps for 1080p fixtures. The hot loops (sub-pixel filters,
(`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames,
GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters,
CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation.
Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p
+34 -19
View File
@@ -1,35 +1,45 @@
//! CDEF (7.15).
use crate::frame::{FrameBuf, Plane};
use crate::frame::{FrameBuf, RowBand};
use crate::par::Pool;
use crate::postfilter::bands;
use crate::spec_tables::*;
use crate::state::FrameState;
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
/// equals CurrFrame).
pub(crate) fn apply(fs: &FrameState) -> Option<FrameBuf> {
/// equals CurrFrame). Bands of 64 luma rows (whole 8x8 blocks) are filtered in parallel: every
/// block reads only CurrFrame and writes only its own samples.
pub(crate) fn apply(fs: &FrameState, pool: &Pool) -> Option<FrameBuf> {
let fh = &fs.fh;
if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef {
return None;
}
let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize);
let mut out = fs.cur.clone();
let mut any = false;
let mut r = 0;
while r < mi_rows {
let mut c = 0;
while c < mi_cols {
let idx = fs.cdef_idx(r & !15, c & !15);
if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) {
any = true;
let ranges = bands(pool, mi_rows * 4);
let mut planes = out.row_bands(&ranges);
let any = pool.map_mut(&mut planes, |b, band| {
let (start, end) = ranges[b];
let mut any = false;
let mut r = start / 4;
while r < mi_rows.min(end / 4) {
let mut c = 0;
while c < mi_cols {
let idx = fs.cdef_idx(r & !15, c & !15);
if idx >= 0 && cdef_block(fs, band, r, c, idx as usize) {
any = true;
}
c += 2;
}
c += 2;
r += 2;
}
r += 2;
}
any.then_some(out)
any
});
drop(planes);
any.contains(&true).then_some(out)
}
fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool {
fn cdef_block(fs: &FrameState, out: &mut [RowBand; 3], r: usize, c: usize, idx: usize) -> bool {
let mi = &fs.mi;
let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } };
let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1);
@@ -125,7 +135,7 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
}
#[allow(clippy::too_many_arguments)]
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
if pri == 0 && sec == 0 {
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
return;
@@ -169,7 +179,7 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
let dst = &mut out.planes[plane];
let dst = &mut out[plane];
if all_avail {
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
@@ -230,7 +240,12 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
/// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12
/// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width.
#[inline(always)]
fn filter_rows<const W: usize>(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(isize, i32, i32, u32); 12], dst: &mut Plane, (y0, x0, h): (usize, usize, usize)) {
fn filter_rows<const W: usize>(
buf: &[i32; PAD_STRIDE * PAD_STRIDE],
taps: &[(isize, i32, i32, u32); 12],
dst: &mut RowBand,
(y0, x0, h): (usize, usize, usize),
) {
let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE];
for (d, &s) in b16.iter_mut().zip(buf.iter()) {
*d = s as i16;
+89
View File
@@ -106,6 +106,48 @@ impl Plane {
dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s);
}
}
/// Disjoint mutable bands of whole rows, for filtering in parallel: one per `[start, end)`
/// range of rows (relative to `oy`; ascending and not overlapping, else the later band is
/// cut to what is left). Ranges are clipped to the stored rows.
pub fn row_bands(&mut self, ranges: &[(usize, usize)]) -> Vec<RowBand<'_>> {
let (stride, oy) = (self.stride.max(1), self.oy);
let mut out = Vec::with_capacity(ranges.len());
let mut rest = self.data.as_mut_slice();
// First row of `rest`.
let mut y = 0usize;
for &(start, end) in ranges {
let skip = start.saturating_sub(y).saturating_mul(stride).min(rest.len());
rest = std::mem::take(&mut rest).split_at_mut(skip).1;
y = y.saturating_add(skip / stride);
let len = end.saturating_sub(y).saturating_mul(stride).min(rest.len());
let (band, tail) = std::mem::take(&mut rest).split_at_mut(len);
out.push(RowBand { data: band, stride, y0: oy.saturating_add(y) });
y = y.saturating_add(len / stride);
rest = tail;
}
out
}
}
/// Mutable whole rows [y0, y0 + data.len() / stride) of a plane, addressed with the plane's
/// coordinates (see [`Plane::row_bands`]).
pub struct RowBand<'a> {
pub data: &'a mut [u16],
pub stride: usize,
pub y0: usize,
}
impl RowBand<'_> {
#[inline(always)]
pub fn set(&mut self, x: usize, y: usize, v: u16) {
self.data[(y - self.y0) * self.stride + x] = v;
}
#[inline(always)]
pub fn row_mut(&mut self, y: usize) -> &mut [u16] {
let o = (y - self.y0) * self.stride;
&mut self.data[o..o + self.stride]
}
}
/// A frame's three planes, sized to the macroblock-aligned coded area plus margins.
@@ -158,6 +200,16 @@ impl FrameBuf {
}
}
/// [`Plane::row_bands`] of all three planes for ranges of luma rows (chroma rows scaled
/// down): element `i` holds the bands of range `i`.
pub fn row_bands(&mut self, luma: &[(usize, usize)]) -> Vec<[RowBand<'_>; 3]> {
let ssy = self.subsampling_y;
let chroma: Vec<(usize, usize)> = luma.iter().map(|&(s, e)| (s >> ssy, e >> ssy)).collect();
let [p0, p1, p2] = &mut self.planes;
let (b1, b2) = (p1.row_bands(&chroma), p2.row_bands(&chroma));
p0.row_bands(luma).into_iter().zip(b1).zip(b2).map(|((y, u), v)| [y, u, v]).collect()
}
pub fn plane_width(&self, plane: usize) -> usize {
if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x }
}
@@ -285,3 +337,40 @@ impl MiInfo {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rows(b: &RowBand) -> (usize, usize) {
(b.y0, b.data.len() / b.stride)
}
#[test]
fn row_bands_split_whole_rows() {
let mut p = Plane::new(8, 20);
for (i, v) in p.data.iter_mut().enumerate() {
*v = (i / 8) as u16;
}
let bands = p.row_bands(&[(0, 8), (8, 16), (16, usize::MAX)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 8), (8, 8), (16, 4)]);
// Each band's row y holds the samples of plane row y.
for mut b in bands {
let y = b.y0 + 1;
assert!(b.row_mut(y).iter().all(|&v| v as usize == y));
}
// Gaps between bands (the loop filter's even / odd turns).
let bands = p.row_bands(&[(0, 4), (10, 14), (18, 30)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 4), (10, 4), (18, 2)]);
}
#[test]
fn row_bands_never_overlap_or_panic() {
let mut p = Plane::new(4, 10);
// Overlapping, descending and out-of-range ranges are cut to what is left.
let bands = p.row_bands(&[(2, 6), (4, 8), (1, 3), (9, 2), (50, 60), (usize::MAX, usize::MAX)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(2, 4), (6, 2), (8, 0), (9, 0), (10, 0), (10, 0)]);
let mut empty = Plane::default();
assert!(empty.row_bands(&[(0, 64), (64, usize::MAX)]).iter().all(|b| b.data.is_empty()));
}
}
+138 -27
View File
@@ -1,20 +1,33 @@
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
//! super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::RowBand;
use crate::spec_tables::*;
use crate::state::FrameState;
use crate::stats::{DecodeStats, Stage, Timer};
/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used).
/// `draft`: only super-resolution (which sets the output size) runs.
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) {
/// Luma rows per band of the row-parallel filters: whole 64x64 CDEF filter blocks, and no
/// transform block crosses a band edge.
const BAND_ROWS: usize = 64;
/// The [start, end) luma rows of the bands covering `rows`: [`BAND_ROWS`] each (the last one
/// runs to the end of the plane), or one band for everything when `pool` is sequential.
pub(crate) fn bands(pool: &crate::par::Pool, rows: usize) -> Vec<(usize, usize)> {
let n = if pool.is_parallel() { rows.div_ceil(BAND_ROWS).max(1) } else { 1 };
(0..n).map(|b| (b * BAND_ROWS, if b + 1 == n { usize::MAX } else { (b + 1) * BAND_ROWS })).collect()
}
/// The post-filters run on the frame worker, each in row bands on `pool` (the result does not
/// depend on how the bands are scheduled). `draft`: only super-resolution (which sets the
/// output size) runs.
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, pool: &crate::par::Pool, draft: bool) {
let mut t = Timer::start();
let lvl = fs.fh.lf.level;
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
loop_filter(fs);
loop_filter(fs, pool);
}
stats.add(Stage::LoopFilter, t.lap());
let cdef = if draft { None } else { crate::cdef::apply(fs) };
let cdef = if draft { None } else { crate::cdef::apply(fs, pool) };
stats.add(Stage::Cdef, t.lap());
let (up_cur, up_cdef) = if fs.fh.use_superres {
let c = crate::restoration::upscale(fs, &fs.cur);
@@ -25,7 +38,7 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
};
stats.add(Stage::Superres, t.lap());
fs.cur = if fs.fh.lr.uses_lr && !draft {
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur), pool)
} else {
up_cdef.unwrap_or(up_cur)
};
@@ -35,28 +48,102 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
// ---------------------------------------------------------------------------------------------
// Loop filter (7.14)
fn loop_filter(fs: &mut FrameState) {
let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize);
for plane in 0..fs.num_planes {
if plane == 0 || fs.fh.lf.level[1 + plane] != 0 {
for pass in 0..2 {
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
let mut row = 0;
while row < mi_rows {
let mut col = 0;
while col < mi_cols {
edge(fs, plane, pass, row, col);
col += col_step;
}
row += row_step;
/// Luma rows above a band that its horizontal edges read or write (p6 .. p0 of the widest
/// filter, rounded up to whole 4x4 rows); chroma: `LF_MARGIN >> subsampling_y`.
const LF_MARGIN: usize = 8;
/// The loop filter in row bands, all of a frame's in one parallel pass (each join costs a
/// worker wake-up, which is slow on a busy machine).
///
/// Vertical edges (pass 0) only touch the 4 rows of their own edge. A horizontal edge (pass 1)
/// reads and writes at most `filter_size / 2` samples on each side, never more than the
/// transform blocks on either side are high, so no two edges touch the same sample and
/// any order gives the spec's raster-order result. Transform blocks never cross a band edge, so
/// the only edges reaching outside a band are those on its top row, whose p side lies in the
/// band above. Each band therefore also filters the vertical edges of a copy of the
/// `LF_MARGIN` rows above it (the same work as the band above does on them), runs its top
/// horizontal edges against that copy, and afterwards the samples those edges changed are
/// written back; the band above never touches them.
fn loop_filter(fs: &mut FrameState, pool: &crate::par::Pool) {
let luma = bands(pool, fs.fh.mi_rows as usize * 4);
let planes: Vec<usize> = (0..fs.num_planes).filter(|&p| p == 0 || fs.fh.lf.level[1 + p] != 0).collect();
let mut cur = std::mem::take(&mut fs.cur);
let f: &FrameState = fs;
// Unfiltered copies of the rows above each band but the first.
let margins: Vec<[Vec<u16>; 3]> = luma
.iter()
.map(|&(start, _)| {
std::array::from_fn(|p| {
let sub_y = if p == 0 { 0 } else { f.ssy };
let pl = &cur.planes[p];
let (y0, y1) = ((start.saturating_sub(LF_MARGIN)) >> sub_y, start >> sub_y);
let rows = pl.data.get(y0 * pl.stride..y1 * pl.stride);
if start == 0 || !planes.contains(&p) { Vec::new() } else { rows.map(<[u16]>::to_vec).unwrap_or_default() }
})
})
.collect();
let mut bands: Vec<([RowBand; 3], [Vec<u16>; 3])> = cur.row_bands(&luma).into_iter().zip(margins).collect();
let changed = pool.map_mut(&mut bands, |b, (band, margin)| {
let mut changed: [Vec<(usize, u16)>; 3] = Default::default();
for &p in &planes {
changed[p] = filter_band(f, p, luma[b], &mut band[p], &mut margin[p]);
}
changed
});
drop(bands);
for (&(start, _), ch) in luma.iter().zip(&changed) {
for (p, list) in ch.iter().enumerate() {
let sub_y = if p == 0 { 0 } else { f.ssy };
let pl = &mut cur.planes[p];
let first = (start.saturating_sub(LF_MARGIN) >> sub_y) * pl.stride;
for &(i, v) in list {
if let Some(d) = pl.data.get_mut(first + i) {
*d = v;
}
}
}
}
fs.cur = cur;
}
fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) {
/// Both loop filter passes over the band of `plane` holding luma rows [start, end); `margin` is
/// an unfiltered copy of the rows above the band (empty for the first band). Returns the samples
/// of `margin` that the band's top horizontal edges changed, as (index, value).
fn filter_band(fs: &FrameState, plane: usize, (start, end): (usize, usize), band: &mut RowBand, margin: &mut [u16]) -> Vec<(usize, u16)> {
let mi_rows = fs.fh.mi_rows as usize;
let (r0, r1) = (start / 4, mi_rows.min(end / 4));
let sub_y = if plane == 0 { 0 } else { fs.ssy };
let edges = |band: &mut RowBand, mut above: Option<&mut RowBand>, pass: usize, rows: std::ops::Range<usize>| {
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
let mi_cols = fs.fh.mi_cols as usize;
let mut row = rows.start;
while row < rows.end {
let mut col = 0;
while col < mi_cols {
edge(fs, band, above.as_deref_mut(), plane, pass, row, col);
col += col_step;
}
row += row_step;
}
};
edges(band, None, 0, r0..r1);
if margin.is_empty() {
edges(band, None, 1, r0..r1);
return Vec::new();
}
let stride = band.stride;
let y0 = start.saturating_sub(LF_MARGIN) >> sub_y;
let mut above = RowBand { data: margin, stride, y0 };
edges(&mut above, None, 0, r0.saturating_sub(LF_MARGIN / 4)..r0);
let before = above.data.to_vec();
edges(band, Some(&mut above), 1, r0..r1);
above.data.iter().zip(&before).enumerate().filter(|(_, (a, b))| a != b).map(|(i, (&a, _))| (i, a)).collect()
}
/// One loop filter edge (7.14.2) at mode info position (row, col): 4 lines of samples across
/// it. `above` holds the rows above `band` for horizontal edges on the band's top row.
fn edge(fs: &FrameState, band: &mut RowBand, above: Option<&mut RowBand>, plane: usize, pass: usize, row: usize, col: usize) {
let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) };
let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) };
let x = col * 4;
@@ -102,16 +189,40 @@ fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize)
return;
}
let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth);
let pl = &mut fs.cur.planes[plane];
let stride = pl.stride;
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
let base = yp * stride + xp;
let chroma = plane > 0;
let stride = band.stride;
if let Some(above) = above.filter(|_| pass == 1 && yp == band.y0) {
// The p side is in the rows above the band: filter each line in a copy that spans both.
let (n_above, n_band) = (above.data.len() / stride.max(1), band.data.len() / stride.max(1));
for k in 0..4 {
let x = xp + k;
let mut line = [0u16; 2 * LF_LINE];
for j in 1..=n_above.min(LF_LINE) {
line[LF_LINE - j] = above.data[(n_above - j) * stride + x];
}
for j in 0..n_band.min(LF_LINE) {
line[LF_LINE + j] = band.data[j * stride + x];
}
sample_filter(&mut line, LF_LINE, 1, chroma, filter_size, t);
for j in 1..=n_above.min(LF_LINE) {
above.data[(n_above - j) * stride + x] = line[LF_LINE - j];
}
for j in 0..n_band.min(LF_LINE) {
band.data[j * stride + x] = line[LF_LINE + j];
}
}
return;
}
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
let base = (yp - band.y0) * stride + xp;
for k in 0..4 {
sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t);
sample_filter(band.data, base + k * along, step, chroma, filter_size, t);
}
}
/// Samples on each side of an edge that the sample filter can read (at most p6 .. q6).
const LF_LINE: usize = 8;
/// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh).
fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) {
let mi = &fs.mi;
+51 -39
View File
@@ -1,6 +1,8 @@
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::{FrameBuf, Plane};
use crate::frame::{FrameBuf, Plane, RowBand};
use crate::par::Pool;
use crate::postfilter::bands;
use crate::spec_tables::*;
use crate::state::FrameState;
@@ -70,56 +72,66 @@ impl LrCtx<'_> {
}
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
/// Bands of 64 luma rows are restored in parallel: every unit reads only the two input frames
/// and writes only its own samples.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, pool: &Pool) -> FrameBuf {
let fh = &fs.fh;
let mut lr = cdef.clone();
let up_w = fh.upscaled_width as i32;
let fr_h = fh.frame_height as i32;
let bd = fs.bit_depth;
for plane in 0..fs.num_planes {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = 0;
while row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
_ => {}
let ranges = bands(pool, fh.frame_height as usize);
let mut planes = lr.row_bands(&ranges);
pool.map_mut(&mut planes, |b, band| {
// Rows of 4 luma samples in this band.
let (start, end) = ranges[b];
let (row0, row1) = ((start / 4) as i32, (end / 4).min(fh.frame_height as usize) as i32);
for (plane, out) in band.iter_mut().enumerate().take(fs.num_planes) {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = row0;
while row < row1 && row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, plane, ui, x, y, w, h, bd),
_ => {}
}
}
col += 1;
}
col += 1;
row += 1;
}
row += 1;
}
}
});
drop(planes);
lr
}
#[allow(clippy::too_many_arguments)]
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
let round0: u32 = if bd == 12 { 5 } else { 3 };
let round1: u32 = if bd == 12 { 9 } else { 11 };
let coef = &fs.lr.lr_wiener[plane][ui];
@@ -163,7 +175,7 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
}
#[allow(clippy::too_many_arguments)]
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
let mut flt0 = [[0i32; 4]; 4];
let mut flt1 = [[0i32; 4]; 4];
+1 -1
View File
@@ -7,7 +7,7 @@ use common::*;
#[test]
#[ignore]
fn adhoc_compare() {
let Some(ff) = ffmpeg() else { return };
let Some(ff) = ffmpeg_dav1d() else { return };
let Some(path) = std::env::var_os("AV1_IVF") else { return };
let path = std::path::PathBuf::from(path);
let pics = decode_all(&path).unwrap();
+35 -4
View File
@@ -6,8 +6,32 @@ use filmcraft_av1::{Decoder, Picture};
use std::path::{Path, PathBuf};
use std::process::Command;
/// ffmpeg with libsvtav1 (fixtures) and libdav1d (reference), or `None` after reporting a skip.
pub fn ffmpeg() -> Option<PathBuf> {
filmcraft_testkit::ffmpeg_or_skip("av1 oracle")
ffmpeg_with(&[("-encoders", "libsvtav1"), ("-decoders", "libdav1d")])
}
/// ffmpeg with libdav1d, for checks that decode existing files only.
pub fn ffmpeg_dav1d() -> Option<PathBuf> {
ffmpeg_with(&[("-decoders", "libdav1d")])
}
/// ffmpeg with libaom (4:2:2 and 4:4:4 fixtures, which SVT-AV1 cannot make) and libdav1d.
pub fn ffmpeg_libaom() -> Option<PathBuf> {
ffmpeg_with(&[("-encoders", "libaom-av1"), ("-decoders", "libdav1d")])
}
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
for &(list, lib) in needs {
let out = Command::new(&ff).args(["-hide_banner", list]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
if !out.contains(lib) {
filmcraft_testkit::skip("av1 oracle", &format!("ffmpeg has no {lib} (see `ffmpeg {list}`)"));
return None;
}
}
Some(ff)
}
pub fn fixture_dir() -> PathBuf {
@@ -19,9 +43,11 @@ pub fn run(ff: &Path, args: &[&str]) {
assert!(out.status.success(), "ffmpeg {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
}
/// An AV1 fixture: a lavfi source encoded by ffmpeg's libsvtav1 into IVF.
/// An AV1 fixture: a lavfi source encoded by ffmpeg (libsvtav1 unless [`Spec::encoder`] says
/// otherwise) into IVF.
pub struct Spec {
pub name: &'static str,
pub encoder: &'static str,
pub lavfi: String,
pub frames: u32,
pub pix_fmt: &'static str,
@@ -31,7 +57,12 @@ pub struct Spec {
impl Spec {
pub fn new(name: &'static str, lavfi: impl Into<String>, frames: u32, pix_fmt: &'static str, enc: &[&str]) -> Spec {
Spec { name, lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
Spec { name, encoder: "libsvtav1", lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
}
pub fn encoder(mut self, encoder: &'static str) -> Spec {
self.encoder = encoder;
self
}
}
@@ -41,7 +72,7 @@ pub fn make(ff: &Path, spec: &Spec) -> PathBuf {
let tmp = filmcraft_testkit::temp_path(&path);
let frames = spec.frames.to_string();
let t = tmp.to_str().unwrap().to_string();
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", "libsvtav1"];
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", spec.encoder];
args.extend(spec.enc.iter().map(String::as_str));
args.extend(["-f", "ivf", t.as_str()]);
run(ff, &args);
+2 -2
View File
@@ -9,7 +9,7 @@ const VECTORS: &[&str] =
#[test]
fn conformance_vectors() {
let Some(ff) = ffmpeg() else { return };
let Some(ff) = ffmpeg_dav1d() else { return };
for name in VECTORS {
let Some(path) = test_vector(name) else { return };
check_file(&ff, name, &path);
@@ -21,7 +21,7 @@ fn conformance_vectors() {
#[test]
#[ignore]
fn conformance_vectors_extended() {
let Some(ff) = ffmpeg() else { return };
let Some(ff) = ffmpeg_dav1d() else { return };
let list: Vec<String> = match std::env::var("AV1_VECTORS") {
Ok(v) => v.split(',').map(str::to_string).collect(),
Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(),
+16
View File
@@ -101,3 +101,19 @@ fn inter_1080p() {
check_bit_exact(&ff, &s);
}
}
/// 4:2:2 and 4:4:4 (libaom; SVT-AV1 only makes 4:2:0): the post-filters run in row bands whose
/// chroma rows depend on the vertical subsampling, so these check the band edges there too.
#[test]
fn inter_chroma_444_422() {
let Some(ff) = ffmpeg_libaom() else { return };
let p = ["-cpu-used", "8", "-crf", "40", "-enable-restoration", "1"];
let src = "testsrc2=s=416x400:r=25,noise=alls=8:allf=t";
for s in [
Spec::new("inter_aom_444", src, 6, "yuv444p", &p).encoder("libaom-av1"),
Spec::new("inter_aom_422", src, 6, "yuv422p", &p).encoder("libaom-av1"),
Spec::new("inter_aom_444_10bit", src, 6, "yuv444p10le", &p).encoder("libaom-av1"),
] {
check_bit_exact(&ff, &s);
}
}
+31 -16
View File
@@ -51,8 +51,8 @@ fn run(frames: &[Vec<u8>], threads: usize) -> Run {
}
fn bench(spec: &Spec) {
// AV1_PERF_ONLY=<substring> limits the run to matching fixtures.
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !spec.name.contains(f.as_str())) {
// AV1_PERF_ONLY=<substring>[,<substring>...] limits the run to matching fixtures.
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !f.split(',').any(|f| spec.name.contains(f))) {
return;
}
let Some(ff) = ffmpeg() else { return };
@@ -68,7 +68,6 @@ fn bench(spec: &Spec) {
}
}
let b = best.unwrap();
let n = b.stats.frames.max(1) as f64;
let cpu_fps = b.cpu.map(|c| format!("{:6.1}", b.shown as f64 / c)).unwrap_or_else(|| " ?".into());
println!(
"{:<26} {:>3} shown {:>3} decoded {:>3} tiles/frame | 1 thread: {} fps (thread CPU) {:6.1} fps (wall)",
@@ -79,24 +78,35 @@ fn bench(spec: &Spec) {
cpu_fps,
b.shown as f64 / b.wall
);
let total = b.stats.total_secs();
let mut line = String::from(" ms/frame:");
println!("{}", stage_line(" ms/frame:", &b.stats));
// All cores: best of 3 by wall time.
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let mut best: Option<Run> = None;
for _ in 0..3 {
let r = run(&frames, threads);
assert_eq!(r.shown, b.shown);
if best.as_ref().is_none_or(|b| r.wall < b.wall) {
best = Some(r);
}
}
let m = best.unwrap();
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / m.wall, load_average());
// Per-stage wall time on the frame workers (stages of different frames overlap).
println!("{}", stage_line(&format!(" {threads} threads, ms/frame:"), &m.stats));
}
/// Per-stage milliseconds per frame and share of the total.
fn stage_line(label: &str, stats: &DecodeStats) -> String {
let n = stats.frames.max(1) as f64;
let total = stats.total_secs();
let mut line = String::from(label);
for s in Stage::ALL {
let v = b.stats.secs(s);
let v = stats.secs(s);
if v > 0.0005 {
line += &format!(" {} {:.2} ({:.0}%)", s.name(), 1e3 * v / n, 100.0 * v / total);
}
}
println!("{line}");
// All cores: best of 3 by wall time.
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let mut best_wall = f64::INFINITY;
for _ in 0..3 {
let r = run(&frames, threads);
assert_eq!(r.shown, b.shown);
best_wall = best_wall.min(r.wall);
}
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / best_wall, load_average());
line
}
#[test]
@@ -110,4 +120,9 @@ fn decode_throughput() {
// Higher quality (more coefficients, finer partitions) and a multi-tile stream (4x2 tiles).
bench(&Spec::new("perf_1080p_gop_hq", src, 60, "yuv420p", &p("18", "keyint=60")));
bench(&Spec::new("perf_1080p_gop_tiles", src, 60, "yuv420p", &p("30", "keyint=60:tile-columns=2:tile-rows=1")));
// Low delay (each frame references the previous one, as screen and camera recorders encode):
// frames cannot overlap, so a frame's own parallelism decides the speed.
bench(&Spec::new("perf_1080p_lowdelay", src, 60, "yuv420p", &p("30", "keyint=60:pred-struct=1")));
// One tile at 2160p: the post-filters' row bands are most of the parallelism there.
bench(&Spec::new("perf_2160p_gop", "testsrc2=s=3840x2160:r=25,noise=alls=6:allf=t", 30, "yuv420p", &p("30", "keyint=30")));
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "filmcraft-avi"
description = "Clean-room AVI (RIFF) and OpenDML demuxer"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+43
View File
@@ -0,0 +1,43 @@
# filmcraft-avi
A clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the
OpenDML AVI File Format Extensions 1.02. Layer L0: no dependencies beyond `std`, no `unsafe`, builds
for `wasm32-unknown-unknown`. No FFmpeg, libavformat or other AVI implementation was read; ffmpeg
and ffprobe are used only as external **test oracles**.
## API
```rust
use filmcraft_avi::{open, StreamKind};
let file = open(&bytes)?; // any ByteSource: a slice, a file, a web Blob…
for (i, s) in file.streams.iter().enumerate() {
match s.kind {
StreamKind::Video => println!("{:?} {} fps", s.video.as_ref().map(|b| b.compression), s.rate as f64 / s.scale as f64),
StreamKind::Audio => println!("format 0x{:04x}", s.audio.as_ref().map_or(0, |w| w.format_tag)),
_ => {}
}
let first = file.read_chunk(&bytes, i, 0)?; // frame data, on demand
}
```
## What it reads
| Part | |
|---|---|
| Headers | `avih`; per stream `strh`, `strf` (`BITMAPINFOHEADER`, `WAVEFORMATEX` / `WAVEFORMATEXTENSIBLE`), `strn` |
| Index | OpenDML `indx` super index → `ix##` standard indexes (field indexes too), else `idx1` (offsets relative to `movi` or absolute), else a scan of the `movi` lists |
| Large files | `RIFF AVIX` extensions: through the OpenDML index, or scanned after `idx1`, which only covers the first RIFF |
| Key frames | from the index; a scan marks every chunk and says so (`keyframes_known`) |
| Damage | sizes clamped to the file (a cut-off recording opens with what is there), `rec ` lists nested at most 3 deep, tables bounded by what the file could hold |
`filmcraft-codecs` maps the streams onto decoders (`AviSource`): Motion JPEG, H.264, HEVC and
uncompressed RGB / YUV video; PCM, MP3, MP2 and AC-3 audio.
## Tests
`cargo test -p filmcraft-avi`: hand-built files for every index path (`idx1` relative and absolute,
`rec ` lists, OpenDML standard indexes, a scan), truncation, and every single-byte mutation and
truncation of them (never a panic). `tests/ffmpeg_oracle.rs` checks ffmpeg-made files (Motion JPEG,
H.264 with B-frames, BGR24, YUY2; PCM and MP3) against ffprobe; the ignored `opendml_file_past_1_gb`
checks a 1.16 GB OpenDML file with `AVIX` extensions.
+542
View File
@@ -0,0 +1,542 @@
//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the
//! OpenDML AVI File Format Extensions 1.02.
//!
//! [`open`] reads the `hdrl` header list (`avih`, and per stream `strh` / `strf` / `strn` /
//! `indx`) of the first `RIFF AVI ` and builds a per-stream table of [`Chunk`]s from the best index
//! the file has: the OpenDML super index (`indx` → `ix##` standard indexes, which also cover the
//! `RIFF AVIX` extensions of files past 1 GB), else the AVI 1.0 `idx1` (plus a scan of any `AVIX`
//! extension it cannot see), else a scan of the `movi` lists. Frame data is read on demand with
//! [`AviFile::read_chunk`]. Truncated files (a recording cut off) open with what is there.
//!
//! Every size and count comes from the file and is treated as hostile: reads are bounded by the
//! file length, tables by the number of chunks the file could hold, and nesting by a fixed depth.
//!
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod source;
#[cfg(test)]
mod tests;
pub use source::ByteSource;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// Not an AVI file (no `RIFF` … `AVI ` header).
NotAvi,
/// A read failed.
Io(String),
/// Structurally invalid data.
Invalid(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NotAvi => f.write_str("not an AVI file"),
Error::Io(e) => write!(f, "I/O error: {e}"),
Error::Invalid(e) => write!(f, "invalid AVI: {e}"),
}
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
/// Whether `head` starts like an AVI file: `RIFF` <size> `AVI `.
pub fn sniff(head: &[u8]) -> bool {
head.get(..4) == Some(b"RIFF") && head.get(8..12) == Some(b"AVI ")
}
/// `avih`: the main header (frame period, flags, frame count, size).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MainHeader {
pub micro_sec_per_frame: u32,
pub flags: u32,
pub total_frames: u32,
pub streams: u32,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StreamKind {
Video,
Audio,
/// Text, MIDI or anything else (`fccType`).
Other([u8; 4]),
}
/// `strf` of a video stream: a `BITMAPINFOHEADER`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BitmapInfo {
pub width: i32,
/// Positive: rows stored bottom-up (uncompressed RGB); negative: top-down.
pub height: i32,
pub bit_count: u16,
/// `biCompression` as four bytes: a FourCC (`MJPG`, `H264`, `YUY2`…) or 0 / 3 (`BI_RGB`,
/// `BI_BITFIELDS`) in little-endian.
pub compression: [u8; 4],
pub size_image: u32,
/// Bytes after the 40-byte header (codec setup, palette).
pub extra: Vec<u8>,
}
impl BitmapInfo {
/// Uncompressed RGB (`BI_RGB` / `BI_BITFIELDS`).
pub fn is_rgb(&self) -> bool {
matches!(u32::from_le_bytes(self.compression), 0 | 3)
}
}
/// `strf` of an audio stream: a `WAVEFORMATEX` (`WAVEFORMATEXTENSIBLE` is resolved to its sub-format).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WaveFormat {
/// 1 PCM, 3 IEEE float, 0x50 MPEG audio, 0x55 MP3, 0x2000 AC-3, 0xFF AAC…
pub format_tag: u16,
pub channels: u16,
pub sample_rate: u32,
pub avg_bytes_per_sec: u32,
pub block_align: u16,
pub bits_per_sample: u16,
/// `cbSize` bytes after the 18-byte header.
pub extra: Vec<u8>,
}
/// One stored frame or audio chunk.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Chunk {
/// Offset of the data (after the 8-byte chunk header).
pub offset: u64,
pub size: u32,
/// A key frame (always true for audio and when the index does not say).
pub key: bool,
}
/// Where the chunk tables came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IndexSource {
/// OpenDML `indx` super index and `ix##` standard indexes.
OpenDml,
/// The AVI 1.0 `idx1` (plus a scan of the `AVIX` extensions).
Idx1,
/// No usable index: the `movi` lists were scanned (key frames unknown).
Scan,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stream {
pub kind: StreamKind,
/// `fccHandler`.
pub handler: [u8; 4],
/// Time base: `rate / scale` units per second (frames per second for video).
pub scale: u32,
pub rate: u32,
/// Start time in `scale / rate` units.
pub start: u32,
/// Length in `scale / rate` units.
pub length: u32,
/// Bytes per sample for fixed-size samples (PCM block align), 0 when samples vary.
pub sample_size: u32,
pub video: Option<BitmapInfo>,
pub audio: Option<WaveFormat>,
/// `strn`.
pub name: Option<String>,
pub chunks: Vec<Chunk>,
/// Whether [`Chunk::key`] comes from an index (a scan cannot tell).
pub keyframes_known: bool,
/// The OpenDML super index entries (offsets of the `ix##` chunks).
super_index: Vec<u64>,
}
impl Stream {
/// Stream index of a `movi` chunk id (`00dc`, `01wb`…): its first two characters.
fn number(id: &[u8; 4]) -> Option<usize> {
let d = |b: u8| (b as char).to_digit(16).map(|v| v as usize);
Some(d(id[0])? * 16 + d(id[1])?)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AviFile {
pub header: MainHeader,
pub streams: Vec<Stream>,
pub index: IndexSource,
}
/// Options for [`open_with`] (tests reach the fallback indexes with them).
#[derive(Clone, Copy, Debug, Default)]
pub struct OpenOptions {
pub ignore_odml: bool,
pub ignore_idx1: bool,
}
/// Headers larger than this are refused (a real `hdrl` is a few kilobytes).
const MAX_HEADER: u64 = 16 << 20;
/// Streams beyond this are ignored.
const MAX_STREAMS: usize = 64;
/// `LIST rec ` inside `movi` nests once; deeper is refused.
const MAX_DEPTH: u32 = 3;
fn io(e: std::io::Error) -> Error {
Error::Io(e.to_string())
}
fn read<S: ByteSource + ?Sized>(src: &S, at: u64, n: u64) -> Result<Vec<u8>> {
let end = at.checked_add(n).filter(|e| *e <= src.len()).ok_or_else(|| Error::Invalid(format!("{n} bytes at {at} run past the end")))?;
let len = usize::try_from(end - at).map_err(|_| Error::Invalid("chunk too large".into()))?;
let mut buf = vec![0; len];
src.read_at(at, &mut buf).map_err(io)?;
Ok(buf)
}
fn fourcc(b: &[u8], at: usize) -> [u8; 4] {
let mut f = [0; 4];
if let Some(s) = b.get(at..at + 4) {
f.copy_from_slice(s);
}
f
}
fn le16(b: &[u8], at: usize) -> u16 {
b.get(at..at + 2).map_or(0, |s| u16::from_le_bytes([s[0], s[1]]))
}
fn le32(b: &[u8], at: usize) -> u32 {
b.get(at..at + 4).map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn le64(b: &[u8], at: usize) -> u64 {
u64::from(le32(b, at)) | (u64::from(le32(b, at + 4)) << 32)
}
/// A chunk header at `at`: (id, size, data offset). The size is clamped to the file (a truncated
/// recording keeps what is there).
fn header_at<S: ByteSource + ?Sized>(src: &S, at: u64) -> Result<([u8; 4], u64, u64)> {
let h = read(src, at, 8)?;
let data = at + 8;
let size = u64::from(le32(&h, 4)).min(src.len().saturating_sub(data));
Ok((fourcc(&h, 0), size, data))
}
/// Iterate the chunks of `[from, to)`: (id, size, data offset). Stops at the first unreadable
/// header; every step moves forward, so it ends.
fn children<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Vec<([u8; 4], u64, u64)> {
let mut out = Vec::new();
let mut at = from;
while at.checked_add(8).is_some_and(|e| e <= to) {
let Ok((id, size, data)) = header_at(src, at) else { break };
out.push((id, size, data));
// chunks are padded to an even size
let Some(next) = data.checked_add(size).and_then(|e| e.checked_add(size & 1)) else { break };
if next <= at {
break;
}
at = next;
}
out
}
/// Open an AVI file.
pub fn open<S: ByteSource + ?Sized>(src: &S) -> Result<AviFile> {
open_with(src, OpenOptions::default())
}
pub fn open_with<S: ByteSource + ?Sized>(src: &S, opts: OpenOptions) -> Result<AviFile> {
let head = read(src, 0, 12.min(src.len())).map_err(|_| Error::NotAvi)?;
if !sniff(&head) {
return Err(Error::NotAvi);
}
// the RIFF forms: the first `AVI `, then any `AVIX` extensions (OpenDML)
let mut hdrl = None;
let mut movis: Vec<(u64, u64, u64)> = Vec::new(); // (position of the `movi` id, data start, data end)
let mut idx1 = None;
for (id, size, data) in children(src, 0, src.len()) {
if id != *b"RIFF" || size < 4 {
continue;
}
let end = data + size;
let form = fourcc(&read(src, data, 4)?, 0);
if form != *b"AVI " && form != *b"AVIX" {
continue;
}
for (cid, csize, cdata) in children(src, data + 4, end) {
match &cid {
b"LIST" if csize >= 4 => {
let ltype = fourcc(&read(src, cdata, 4)?, 0);
if ltype == *b"hdrl" && hdrl.is_none() && form == *b"AVI " {
hdrl = Some((cdata + 4, cdata + csize));
} else if ltype == *b"movi" {
movis.push((cdata, cdata + 4, cdata + csize));
}
}
b"idx1" if form == *b"AVI " => idx1 = Some((cdata, csize)),
_ => {}
}
}
}
let (hfrom, hto) = hdrl.ok_or_else(|| Error::Invalid("no hdrl header list".into()))?;
if hto - hfrom > MAX_HEADER {
return Err(Error::Invalid("header list too large".into()));
}
let (header, mut streams) = parse_hdrl(src, hfrom, hto)?;
if streams.is_empty() {
return Err(Error::Invalid("no streams".into()));
}
// chunk tables from the best index
let odml = !opts.ignore_odml && streams.iter().any(|s| !s.super_index.is_empty());
let index = if odml && read_odml(src, &mut streams) {
IndexSource::OpenDml
} else if let (false, Some((at, size)), Some(first)) = (opts.ignore_idx1, idx1, movis.first()) {
clear(&mut streams);
read_idx1(src, &mut streams, at, size, first.0);
// idx1 only covers the first RIFF: the AVIX extensions are scanned
for &(_, from, to) in movis.iter().skip(1) {
scan(src, &mut streams, from, to, 0);
}
if streams.iter().all(|s| s.chunks.is_empty()) { scan_all(src, &mut streams, &movis) } else { IndexSource::Idx1 }
} else {
scan_all(src, &mut streams, &movis)
};
Ok(AviFile { header, streams, index })
}
fn clear(streams: &mut [Stream]) {
for s in streams.iter_mut() {
s.chunks.clear();
s.keyframes_known = true;
}
}
fn scan_all<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream], movis: &[(u64, u64, u64)]) -> IndexSource {
clear(streams);
for &(_, from, to) in movis {
scan(src, streams, from, to, 0);
}
for s in streams.iter_mut() {
s.keyframes_known = false;
}
IndexSource::Scan
}
fn parse_hdrl<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec<Stream>)> {
let mut header = MainHeader::default();
let mut streams = Vec::new();
for (id, size, data) in children(src, from, to) {
match &id {
b"avih" => {
let b = read(src, data, size.min(64))?;
header = MainHeader {
micro_sec_per_frame: le32(&b, 0),
flags: le32(&b, 12),
total_frames: le32(&b, 16),
streams: le32(&b, 24),
width: le32(&b, 32),
height: le32(&b, 36),
};
}
b"LIST" if size >= 4 && streams.len() < MAX_STREAMS && fourcc(&read(src, data, 4)?, 0) == *b"strl" => {
streams.push(parse_strl(src, data + 4, data + size)?);
}
_ => {}
}
}
Ok((header, streams))
}
fn parse_strl<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<Stream> {
let mut s = Stream {
kind: StreamKind::Other([0; 4]),
handler: [0; 4],
scale: 1,
rate: 1,
start: 0,
length: 0,
sample_size: 0,
video: None,
audio: None,
name: None,
chunks: Vec::new(),
keyframes_known: true,
super_index: Vec::new(),
};
let mut strf = None;
for (id, size, data) in children(src, from, to) {
match &id {
b"strh" => {
let b = read(src, data, size.min(64))?;
s.kind = match &fourcc(&b, 0) {
b"vids" => StreamKind::Video,
b"auds" => StreamKind::Audio,
other => StreamKind::Other(*other),
};
s.handler = fourcc(&b, 4);
s.scale = le32(&b, 20);
s.rate = le32(&b, 24);
s.start = le32(&b, 28);
s.length = le32(&b, 32);
s.sample_size = le32(&b, 44);
}
b"strf" => strf = Some(read(src, data, size.min(1 << 20))?),
b"strn" => {
let b = read(src, data, size.min(4096))?;
let name = String::from_utf8_lossy(b.split(|&c| c == 0).next().unwrap_or_default()).trim().to_string();
s.name = (!name.is_empty()).then_some(name);
}
b"indx" => s.super_index = super_index(&read(src, data, size.min(MAX_HEADER))?),
_ => {}
}
}
if let Some(b) = strf {
match s.kind {
StreamKind::Video => {
s.video = Some(BitmapInfo {
width: le32(&b, 4) as i32,
height: le32(&b, 8) as i32,
bit_count: le16(&b, 14),
compression: fourcc(&b, 16),
size_image: le32(&b, 20),
extra: b.get(40.min(b.len())..).unwrap_or_default().to_vec(),
});
}
StreamKind::Audio => {
let cb = usize::from(le16(&b, 16));
let extra = b.get(18.min(b.len())..(18 + cb).min(b.len())).unwrap_or_default().to_vec();
let mut tag = le16(&b, 0);
// WAVE_FORMAT_EXTENSIBLE: the sub-format GUID starts with the real tag
if tag == 0xFFFE && extra.len() >= 22 {
tag = le16(&extra, 6);
}
s.audio = Some(WaveFormat {
format_tag: tag,
channels: le16(&b, 2),
sample_rate: le32(&b, 4),
avg_bytes_per_sec: le32(&b, 8),
block_align: le16(&b, 12),
bits_per_sample: le16(&b, 14),
extra,
});
}
StreamKind::Other(_) => {}
}
}
if s.scale == 0 || s.rate == 0 {
(s.scale, s.rate) = (1, 1);
}
Ok(s)
}
/// The `ix##` chunk offsets of an `indx` super index (`AVI_INDEX_OF_INDEXES`, 4 longs per entry).
fn super_index(b: &[u8]) -> Vec<u64> {
let longs = usize::from(le16(b, 0));
let (subtype, itype, n) = (b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0xFF), le32(b, 4) as usize);
if itype != 0 || subtype != 0 || longs != 4 {
return Vec::new();
}
// header 24 bytes, entries of 16: qwOffset, dwSize, dwDuration
let n = n.min(b.len().saturating_sub(24) / 16);
(0..n).map(|k| le64(b, 24 + k * 16)).filter(|&o| o > 0).collect()
}
/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream
/// that has data lacks one or none could be read.
fn read_odml<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream]) -> bool {
let limit = src.len() / 8;
let mut total = 0u64;
for s in streams.iter_mut() {
s.chunks.clear();
s.keyframes_known = true;
for &at in &s.super_index {
let Ok((id, size, data)) = header_at(src, at) else { continue };
if &id[..2] != b"ix" {
continue;
}
let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue };
let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0));
// AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD)
if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) {
continue;
}
let base = le64(&b, 12);
let step = longs * 4;
let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step);
for k in 0..n {
let e = 24 + k * step;
let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4));
let size = raw & 0x7FFF_FFFF;
let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue };
total += 1;
if total > limit {
return false;
}
s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 });
}
}
}
let ok = streams.iter().any(|s| !s.chunks.is_empty()) && streams.iter().all(|s| !s.super_index.is_empty() || matches!(s.kind, StreamKind::Other(_)));
if !ok {
clear(streams);
}
ok
}
/// Fill the chunk tables from `idx1` (16-byte entries: id, flags, offset, size). Offsets are
/// relative to the `movi` list's type id, or (some writers) absolute: the first entry decides.
fn read_idx1<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream], at: u64, size: u64, movi: u64) {
let Ok(b) = read(src, at, size.min(src.len() / 2)) else { return };
let entries: Vec<&[u8]> = b.as_chunks::<16>().0.iter().map(|e| e.as_slice()).collect();
let id_at = |pos: u64| read(src, pos, 4).ok().map(|v| fourcc(&v, 0));
let first = entries.iter().find(|e| le32(e, 4) & 1 == 0);
let relative = match first {
Some(e) => {
let (id, off) = (fourcc(e, 0), u64::from(le32(e, 8)));
id_at(movi + off) == Some(id) || id_at(off) != Some(id)
}
None => true,
};
let base = if relative { movi } else { 0 };
for e in entries {
let (id, flags, off, len) = (fourcc(e, 0), le32(e, 4), u64::from(le32(e, 8)), le32(e, 12));
// AVIIF_LIST entries (`rec ` lists) and palette changes carry no samples
if flags & 1 != 0 || &id[2..] == b"pc" {
continue;
}
let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) else { continue };
let Some(offset) = base.checked_add(off).and_then(|o| o.checked_add(8)).filter(|o| o.checked_add(u64::from(len)).is_some_and(|end| end <= src.len()))
else {
continue;
};
s.chunks.push(Chunk { offset, size: len, key: flags & 0x10 != 0 || s.kind != StreamKind::Video });
}
}
/// Append the data chunks of a `movi` list (`[from, to)`) to the streams' tables, in file order.
fn scan<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream], from: u64, to: u64, depth: u32) {
if depth >= MAX_DEPTH {
return;
}
for (id, size, data) in children(src, from, to) {
if id == *b"LIST" {
if size >= 4 {
scan(src, streams, data + 4, data + size, depth + 1);
}
continue;
}
if &id[..2] == b"ix" || &id[2..] == b"pc" {
continue;
}
if let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) {
s.chunks.push(Chunk { offset: data, size: u32::try_from(size).unwrap_or(u32::MAX), key: true });
}
}
}
impl AviFile {
/// The bytes of chunk `i` of stream `stream`.
pub fn read_chunk<S: ByteSource + ?Sized>(&self, src: &S, stream: usize, i: usize) -> Result<Vec<u8>> {
let c = self.streams.get(stream).and_then(|s| s.chunks.get(i)).ok_or_else(|| Error::Invalid(format!("no chunk {i} in stream {stream}")))?;
read(src, c.offset, u64::from(c.size))
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Random-access byte sources for the demuxer (same shape as the other container crates', so one
//! adapter serves them all).
use std::io;
/// Random-access, read-only byte source (file, in-memory buffer, web Blob…).
pub trait ByteSource {
/// Total length in bytes.
fn len(&self) -> u64;
/// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end.
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>;
/// True if the source holds no bytes.
fn is_empty(&self) -> bool {
self.len() == 0
}
}
fn eof() -> io::Error {
io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source")
}
impl ByteSource for [u8] {
fn len(&self) -> u64 {
<[u8]>::len(self) as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
let start = usize::try_from(offset).map_err(|_| eof())?;
let end = start.checked_add(buf.len()).ok_or_else(eof)?;
buf.copy_from_slice(self.get(start..end).ok_or_else(eof)?);
Ok(())
}
}
impl ByteSource for Vec<u8> {
fn len(&self) -> u64 {
self.as_slice().len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
self.as_slice().read_at(offset, buf)
}
}
impl<T: ByteSource + ?Sized> ByteSource for &T {
fn len(&self) -> u64 {
(**self).len()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
(**self).read_at(offset, buf)
}
}
impl<T: ByteSource + ?Sized> ByteSource for std::sync::Arc<T> {
fn len(&self) -> u64 {
(**self).len()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
(**self).read_at(offset, buf)
}
}
+269
View File
@@ -0,0 +1,269 @@
use super::*;
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut v = id.to_vec();
v.extend_from_slice(&(data.len() as u32).to_le_bytes());
v.extend_from_slice(data);
if data.len() % 2 == 1 {
v.push(0);
}
v
}
fn list(ltype: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut d = ltype.to_vec();
d.extend_from_slice(body);
chunk(b"LIST", &d)
}
fn strl(video: bool) -> Vec<u8> {
let mut strh = vec![0u8; 56];
strh[..4].copy_from_slice(if video { b"vids" } else { b"auds" });
strh[4..8].copy_from_slice(if video { b"MJPG" } else { &[0; 4] });
let (scale, rate, ss) = if video { (1u32, 25u32, 0u32) } else { (4, 192_000, 4) };
strh[20..24].copy_from_slice(&scale.to_le_bytes());
strh[24..28].copy_from_slice(&rate.to_le_bytes());
strh[44..48].copy_from_slice(&ss.to_le_bytes());
let strf = if video {
let mut b = vec![0u8; 40];
b[..4].copy_from_slice(&40u32.to_le_bytes());
b[4..8].copy_from_slice(&64i32.to_le_bytes());
b[8..12].copy_from_slice(&48i32.to_le_bytes());
b[14..16].copy_from_slice(&24u16.to_le_bytes());
b[16..20].copy_from_slice(b"MJPG");
b
} else {
let mut b = vec![0u8; 18];
b[..2].copy_from_slice(&1u16.to_le_bytes());
b[2..4].copy_from_slice(&2u16.to_le_bytes());
b[4..8].copy_from_slice(&48_000u32.to_le_bytes());
b[8..12].copy_from_slice(&192_000u32.to_le_bytes());
b[12..14].copy_from_slice(&4u16.to_le_bytes());
b[14..16].copy_from_slice(&16u16.to_le_bytes());
b
};
let mut body = chunk(b"strh", &strh);
body.extend(chunk(b"strf", &strf));
body.extend(chunk(b"strn", if video { b"Camera\0" } else { b"Mic\0" }));
list(b"strl", &body)
}
/// A small AVI: 3 video frames (the second a delta, the third odd-sized) and 2 audio chunks,
/// optionally inside a `rec ` list, with an `idx1` whose offsets are relative to `movi` or absolute.
fn avi(idx1: bool, absolute: bool, rec: bool) -> Vec<u8> {
let mut avih = vec![0u8; 56];
avih[0..4].copy_from_slice(&40_000u32.to_le_bytes());
avih[12..16].copy_from_slice(&0x10u32.to_le_bytes());
avih[16..20].copy_from_slice(&3u32.to_le_bytes());
avih[24..28].copy_from_slice(&2u32.to_le_bytes());
avih[32..36].copy_from_slice(&64u32.to_le_bytes());
avih[36..40].copy_from_slice(&48u32.to_le_bytes());
let mut hdrl = chunk(b"avih", &avih);
hdrl.extend(strl(true));
hdrl.extend(strl(false));
let hdrl = list(b"hdrl", &hdrl);
let samples: [(&[u8; 4], Vec<u8>, bool); 5] =
[(b"00dc", vec![1; 10], true), (b"01wb", vec![2; 16], true), (b"00dc", vec![3; 6], false), (b"01wb", vec![4; 8], true), (b"00dc", vec![5; 7], true)];
let mut body = Vec::new();
let mut entries = Vec::new();
for (id, data, key) in &samples {
entries.push((**id, body.len() as u32 + 4, data.len() as u32, *key));
body.extend(chunk(id, data));
}
let movi_body = if rec {
let r = list(b"rec ", &body);
// the samples moved 12 bytes in (the rec list's header and type)
for e in &mut entries {
e.1 += 12;
}
r
} else {
body
};
let movi = list(b"movi", &movi_body);
let mut riff = b"AVI ".to_vec();
riff.extend(hdrl);
// position of the `movi` type id: after `RIFF` <size>, what `riff` holds so far, `LIST` <size>
let movi_at = (8 + riff.len() + 8) as u32;
riff.extend(movi);
if idx1 {
let mut ix = Vec::new();
for (id, off, len, key) in entries {
ix.extend_from_slice(&id);
ix.extend_from_slice(&(if key { 0x10u32 } else { 0 }).to_le_bytes());
ix.extend_from_slice(&(if absolute { off + movi_at } else { off }).to_le_bytes());
ix.extend_from_slice(&len.to_le_bytes());
}
riff.extend(chunk(b"idx1", &ix));
}
chunk(b"RIFF", &riff)
}
fn table(f: &AviFile, s: usize) -> Vec<(u32, bool)> {
f.streams[s].chunks.iter().map(|c| (c.size, c.key)).collect()
}
#[test]
fn headers_and_every_index_path_agree() {
for (idx1, absolute, rec, want) in [
(true, false, false, IndexSource::Idx1),
(true, true, false, IndexSource::Idx1),
(true, false, true, IndexSource::Idx1),
(false, false, false, IndexSource::Scan),
(false, false, true, IndexSource::Scan),
] {
let file = avi(idx1, absolute, rec);
let f = open(&file).unwrap();
let what = format!("idx1 {idx1} absolute {absolute} rec {rec}");
assert_eq!(f.index, want, "{what}");
assert_eq!((f.header.width, f.header.height, f.header.total_frames, f.header.micro_sec_per_frame), (64, 48, 3, 40_000));
let (v, a) = (&f.streams[0], &f.streams[1]);
assert_eq!((v.kind, v.handler, v.scale, v.rate, v.name.as_deref()), (StreamKind::Video, *b"MJPG", 1, 25, Some("Camera")));
assert_eq!(v.video.as_ref().map(|b| (b.width, b.height, b.bit_count, b.compression)), Some((64, 48, 24, *b"MJPG")));
let w = a.audio.as_ref().unwrap();
assert_eq!((w.format_tag, w.channels, w.sample_rate, w.block_align, w.bits_per_sample, a.sample_size), (1, 2, 48_000, 4, 16, 4));
// the index knows the delta frame; a scan cannot
let keys = if idx1 { [true, false, true] } else { [true; 3] };
assert_eq!(table(&f, 0), vec![(10, keys[0]), (6, keys[1]), (7, keys[2])], "{what}");
assert_eq!(f.streams[0].keyframes_known, idx1);
assert_eq!(table(&f, 1), vec![(16, true), (8, true)], "{what}");
assert_eq!(f.read_chunk(&file, 0, 1).unwrap(), vec![3; 6], "{what}");
assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7], "{what}");
assert_eq!(f.read_chunk(&file, 1, 1).unwrap(), vec![4; 8], "{what}");
assert!(f.read_chunk(&file, 0, 3).is_err() && f.read_chunk(&file, 5, 0).is_err());
}
// the idx1 path can be skipped
let f = open_with(&avi(true, false, false), OpenOptions { ignore_idx1: true, ..Default::default() }).unwrap();
assert_eq!(f.index, IndexSource::Scan);
}
#[test]
fn not_avi_and_truncated_recordings() {
assert_eq!(open(&b"RIFF\0\0\0\0WAVEfmt ".to_vec()), Err(Error::NotAvi));
assert_eq!(open(&Vec::new()), Err(Error::NotAvi));
assert!(sniff(&avi(true, false, false)) && !sniff(b"RIFF"));
// a recording cut off in its last frame: the headers and the whole frames are there
let full = avi(false, false, false);
let cut = full[..full.len() - 4].to_vec();
let f = open(&cut).unwrap();
assert_eq!(f.streams[0].chunks.len(), 3);
assert!(f.read_chunk(&cut, 0, 0).is_ok());
}
/// Every single-byte change and every truncation of a valid file opens or fails, never panics, and
/// whatever opens reads its chunks without panicking.
#[test]
fn mutations_never_panic() {
for base in [avi(true, false, true), avi(false, false, false), avi(true, true, false)] {
for cut in 0..base.len() {
let b = base[..cut].to_vec();
if let Ok(f) = open(&b) {
for (s, st) in f.streams.iter().enumerate() {
for i in 0..st.chunks.len() {
let _ = f.read_chunk(&b, s, i);
}
}
}
}
for at in 0..base.len() {
for v in [0x00u8, 0xFF, 0x7F, 0x80] {
let mut b = base.clone();
b[at] = v;
if let Ok(f) = open(&b) {
for (s, st) in f.streams.iter().enumerate() {
for i in 0..st.chunks.len() {
let _ = f.read_chunk(&b, s, i);
}
}
}
}
}
}
}
/// An OpenDML file: each stream's `indx` super index points at an `ix##` standard index (base
/// offset + offsets to the data, bit 31 of the size marking delta frames). Built twice: the second
/// pass knows where the `ix##` chunks landed.
fn avi_odml(ix_at: [u64; 2]) -> (Vec<u8>, [u64; 2]) {
let indx = |stream: u8, at: u64| {
let mut b = Vec::new();
b.extend_from_slice(&4u16.to_le_bytes()); // wLongsPerEntry
b.extend_from_slice(&[0, 0]); // subtype, AVI_INDEX_OF_INDEXES
b.extend_from_slice(&1u32.to_le_bytes());
b.extend_from_slice(&[b'0', b'0' + stream, if stream == 0 { b'd' } else { b'w' }, if stream == 0 { b'c' } else { b'b' }]);
b.extend_from_slice(&[0; 12]);
b.extend_from_slice(&at.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes());
chunk(b"indx", &b)
};
let mut hdrl = chunk(b"avih", &[0; 56]);
for (s, at) in ix_at.iter().enumerate() {
let l = strl(s == 0);
// strl = LIST <size> strl …: append indx inside it
let mut body = l[12..].to_vec();
body.extend(indx(s as u8, *at));
hdrl.extend(list(b"strl", &body));
}
let mut riff = b"AVI ".to_vec();
riff.extend(list(b"hdrl", &hdrl));
// movi: video 10 (key) 6 (delta) 7 (key), audio 16 8; then ix00, ix01
let movi_data_at = 8 + riff.len() as u64 + 12; // RIFF hdr, so far, LIST hdr + `movi`
let mut body = Vec::new();
let mut at: [Vec<(u64, u32, bool)>; 2] = [Vec::new(), Vec::new()];
for (s, data, key) in [(0usize, vec![1u8; 10], true), (1, vec![2; 16], true), (0, vec![3; 6], false), (1, vec![4; 8], true), (0, vec![5; 7], true)] {
let id = if s == 0 { b"00dc" } else { b"01wb" };
at[s].push((movi_data_at + body.len() as u64 + 8, data.len() as u32, key));
body.extend(chunk(id, &data));
}
let mut where_ix = [0u64; 2];
for (s, entries) in at.iter().enumerate() {
let base = 100u64; // offsets are relative to qwBaseOffset
let mut b = Vec::new();
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&[0, 1]); // subtype 0, AVI_INDEX_OF_CHUNKS
b.extend_from_slice(&(entries.len() as u32).to_le_bytes());
b.extend_from_slice(if s == 0 { b"00dc" } else { b"01wb" });
b.extend_from_slice(&base.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes());
for &(off, size, key) in entries {
b.extend_from_slice(&((off - base) as u32).to_le_bytes());
b.extend_from_slice(&(size | if key { 0 } else { 0x8000_0000 }).to_le_bytes());
}
where_ix[s] = movi_data_at + body.len() as u64;
body.extend(chunk(if s == 0 { b"ix00" } else { b"ix01" }, &b));
}
riff.extend(list(b"movi", &body));
(chunk(b"RIFF", &riff), where_ix)
}
#[test]
fn opendml_standard_indexes() {
let (_, at) = avi_odml([0, 0]);
let (file, again) = avi_odml(at);
assert_eq!(at, again, "the layout does not depend on the offsets");
let f = open(&file).unwrap();
assert_eq!(f.index, IndexSource::OpenDml);
assert_eq!(table(&f, 0), vec![(10, true), (6, false), (7, true)]);
assert_eq!(table(&f, 1), vec![(16, true), (8, true)]);
assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7]);
// ignoring the OpenDML index, the scan finds the same chunks (and skips the ix## chunks)
let s = open_with(&file, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap();
assert_eq!(s.index, IndexSource::Scan);
assert_eq!(s.streams[0].chunks.iter().map(|c| c.offset).collect::<Vec<_>>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::<Vec<_>>());
// a broken super index (pointing nowhere) falls back to a scan
let (bad, _) = avi_odml([3, 5]);
assert_eq!(open(&bad).unwrap().index, IndexSource::Scan);
// and mutations of the OpenDML file never panic
for i in 0..file.len() {
let mut b = file.clone();
b[i] ^= 0xFF;
if let Ok(f) = open(&b) {
for (s, st) in f.streams.iter().enumerate() {
for k in 0..st.chunks.len() {
let _ = f.read_chunk(&b, s, k);
}
}
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! ffmpeg-made AVI files (skipped without ffmpeg): the chunk tables match ffprobe's packet counts,
//! and every index the file has (`idx1`; OpenDML once a file passes 1 GB) and a `movi` scan describe
//! the same chunks.
use std::path::{Path, PathBuf};
use std::process::Command;
use filmcraft_avi::{IndexSource, OpenOptions, StreamKind, open, open_with};
fn fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("avi oracle")?;
let out = filmcraft_testkit::fixtures_dir("avi").join(name);
filmcraft_testkit::fixtures::generate(&out, |tmp: &Path| {
let mut c = Command::new(&ffmpeg);
c.args(["-v", "error", "-y"]).args(args).args(["-f", "avi"]).arg(tmp);
c.status().is_ok_and(|s| s.success())
})
}
fn packets(path: &Path, stream: usize) -> usize {
let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg");
let out = Command::new(ffprobe)
.args(["-v", "error", "-select_streams", &stream.to_string(), "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0"])
.arg(path)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0)
}
fn packet_bytes(path: &Path, stream: usize) -> u64 {
let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg");
let out = Command::new(ffprobe)
.args(["-v", "error", "-select_streams", &stream.to_string(), "-show_entries", "packet=size", "-of", "csv=p=0"])
.arg(path)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.trim().parse::<u64>().ok()).sum()
}
const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"];
fn cases() -> Vec<(&'static str, Vec<&'static str>, [u8; 4])> {
let pic = vec!["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"];
let with = |v: &[&'static str], a: &[&'static str]| -> Vec<&'static str> {
let mut x = pic.clone();
x.extend_from_slice(&TONE);
x.extend_from_slice(v);
x.extend_from_slice(a);
x
};
vec![
("mjpeg_pcm.avi", with(&["-c:v", "mjpeg", "-q:v", "4", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]), *b"MJPG"),
("h264_mp3.avi", with(&["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]), *b"H264"),
("bgr24_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "bgr24"], &["-c:a", "pcm_s16le"]), [0; 4]),
("yuyv_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "yuyv422"], &["-c:a", "pcm_s16le"]), *b"YUY2"),
]
}
#[test]
fn ffmpeg_files_match_ffprobe_on_every_index_path() {
for (name, args, fourcc) in cases() {
let Some(path) = fixture(name, &args) else { return };
let bytes = std::fs::read(&path).unwrap();
let f = open(&bytes).unwrap();
// ffmpeg reserves room for the OpenDML index but writes only idx1 under 1 GB
assert_eq!(f.index, IndexSource::Idx1, "{name}");
assert_eq!(f.streams.len(), 2, "{name}");
let (v, a) = (&f.streams[0], &f.streams[1]);
assert_eq!((v.kind, a.kind), (StreamKind::Video, StreamKind::Audio), "{name}");
let bmp = v.video.as_ref().unwrap();
assert_eq!((bmp.width, bmp.height.abs(), bmp.compression), (160, 120, fourcc), "{name}");
assert_eq!((v.rate as f64 / v.scale as f64).round(), 25.0, "{name}");
assert_eq!(a.audio.as_ref().map(|w| (w.sample_rate, w.channels)), Some((48_000, 1)), "{name}");
// the tables hold what ffprobe reads
assert_eq!(v.chunks.len(), packets(&path, 0), "{name}: video");
// PCM chunks are packets; ffprobe re-splits MP3 into its frames, which AVI chunks need not
// follow, so compressed audio compares by bytes
if a.audio.as_ref().is_some_and(|w| w.format_tag == 1) {
assert_eq!(a.chunks.len(), packets(&path, 1), "{name}: audio");
} else {
let ours: u64 = a.chunks.iter().map(|c| u64::from(c.size)).sum();
assert_eq!(ours, packet_bytes(&path, 1), "{name}: audio bytes");
}
assert!(v.chunks[0].key, "{name}: the first frame is a key frame");
if fourcc == *b"H264" {
assert!(v.chunks.iter().any(|c| !c.key), "{name}: the index marks the delta frames");
}
// a scan finds the same chunks (it cannot know key frames)
let scanned = open_with(&bytes, OpenOptions { ignore_odml: true, ignore_idx1: true }).unwrap();
assert_eq!(scanned.index, IndexSource::Scan, "{name}");
for s in 0..2 {
assert_eq!(sizes(&scanned, s), sizes(&f, s), "{name}: scan stream {s}");
}
}
}
fn sizes(f: &filmcraft_avi::AviFile, s: usize) -> Vec<(u64, u32)> {
f.streams[s].chunks.iter().map(|c| (c.offset, c.size)).collect()
}
/// A file past 1 GB, as long recordings are: ffmpeg writes `RIFF AVIX` extensions and the
/// OpenDML index. Needs ffmpeg and 1.2 GB of disk: `cargo test -p filmcraft-avi --test
/// ffmpeg_oracle -- --ignored`.
#[test]
#[ignore]
fn opendml_file_past_1_gb() {
let args = [
"-f",
"lavfi",
"-i",
"testsrc2=s=640x480:r=25:d=50",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000:d=50",
"-c:v",
"rawvideo",
"-pix_fmt",
"bgr24",
"-c:a",
"pcm_s16le",
];
let Some(path) = fixture("large_opendml.avi", &args) else { return };
let file = std::fs::File::open(&path).unwrap();
let src = FileSource(file);
let f = open(&src).unwrap();
assert_eq!(f.index, IndexSource::OpenDml);
assert_eq!(f.streams[0].chunks.len(), packets(&path, 0));
assert_eq!(f.streams[1].chunks.len(), packets(&path, 1));
assert_eq!(f.streams[0].chunks.len(), 1250);
assert!(f.streams[0].chunks.last().unwrap().offset > 1 << 30, "the last frames are in an AVIX extension");
// idx1 covers the first RIFF only; the AVIX extensions are scanned: the same chunks
let idx1 = open_with(&src, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap();
assert_eq!(idx1.index, IndexSource::Idx1);
for s in 0..2 {
assert_eq!(sizes(&idx1, s), sizes(&f, s), "stream {s}");
}
assert_eq!(f.read_chunk(&src, 0, 1249).unwrap().len(), 640 * 480 * 3);
}
/// A file on disk as a byte source, read where asked.
struct FileSource(std::fs::File);
impl filmcraft_avi::ByteSource for FileSource {
fn len(&self) -> u64 {
self.0.metadata().map(|m| m.len()).unwrap_or(0)
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ and writes caption files, converts them to and from caption tracks, and draws ca
| Scenarist SCC `.scc` | yes | yes | CEA-608 at 29.97 fps, drop-frame or not. Reads pop-on, roll-up and paint-on (channel 1); writes pop-on. |
| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. |
| EBU STL `.stl` | yes | yes | EBU Tech 3264-E: GSI block + 128-byte TTI blocks, `STL25.01` (25 fps) or `STL30.01` (read/written as 29.97 fps, non-drop labels), ISO/IEC 6937 Latin text (accents as diacritic + letter), italics / underline codes as `<i>` / `<u>`, extension blocks for long subtitles, start-of-programme (TCP) subtracted. |
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers, timed spans, referenced styles. |
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers (`body` / `div` begins offset and ends clip their contents; `seq` containers are read as `par` with a warning), timed spans, referenced styles. |
| DFXP `.dfxp` | yes | yes | TTML1 with the DFXP presentation profile; clock times in milliseconds (frame-exact after `snap_to_frames`). |
All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 000 ticks); SCC
+32 -13
View File
@@ -49,13 +49,10 @@ 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))
/// The caption face: the track's font family (Inter when empty or not installed) in SemiBold at `px`.
fn caption_style(font: &str, px: f32) -> TextStyle {
let family = if font.trim().is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY } else { font.trim() };
TextStyle { family: family.into(), style: "SemiBold".into(), size: px, ..Default::default() }
}
fn lin(c: [u8; 4]) -> [f32; 4] {
@@ -64,12 +61,12 @@ fn lin(c: [u8; 4]) -> [f32; 4] {
}
/// Word-wrap one line to `max_w` pixels.
fn wrap(line: &str, px: f32, max_w: f32) -> Vec<String> {
fn wrap(line: &str, ts: &TextStyle, max_w: f32) -> Vec<String> {
let mut out = Vec::new();
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() && measure(&cand, px) > max_w {
if !cur.is_empty() && filmcraft_text::measure(&cand, ts) > max_w {
out.push(std::mem::take(&mut cur));
cur = word.to_string();
} else {
@@ -115,16 +112,17 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
let scale = h as f32 / 1080.0;
let px = (style.size * scale).max(4.0);
let max_w = w as f32 * 0.9;
let ts = caption_style(&style.font, px);
let mut lines: Vec<String> = Vec::new();
// (speaker names are metadata; like Premiere, they are not burned in)
for l in c.plain_lines() {
lines.extend(wrap(l.trim(), px, max_w));
lines.extend(wrap(l.trim(), &ts, max_w));
}
lines.retain(|l| !l.is_empty());
if lines.is_empty() {
return None;
}
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve("Inter", "SemiBold").face).metrics(px);
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).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();
@@ -141,7 +139,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| measure(l, px)).collect();
let widths: Vec<f32> = lines.iter().map(|l| filmcraft_text::measure(l, &ts)).collect();
let xs: Vec<f32> = widths
.iter()
.map(|&lw| match align {
@@ -176,7 +174,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
}
}
let baseline = lt + (lh - (asc + desc)) / 2.0 + asc;
let l = layout(line, &caption_style(px), &ParagraphStyle::default());
let l = layout(line, &ts, &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;
@@ -305,6 +303,27 @@ mod tests {
assert!(o.px.chunks(4).any(|p| p[0] > 0.9 && p[1] < 0.05 && p[3] > 0.9), "red outline pixels");
}
#[test]
fn track_font_is_used() {
// the style's font used to be ignored (always Inter)
let inter = render_caption(&cap("Hello world"), &CaptionStyle::default(), 1920, 1080).unwrap();
let serif = CaptionStyle { font: "Noto Serif".into(), ..Default::default() };
let serif = render_caption(&cap("Hello world"), &serif, 1920, 1080).unwrap();
assert_ne!((inter.w, &inter.px), (serif.w, &serif.px), "Noto Serif renders like Inter");
let mono = CaptionStyle { font: "jetbrains mono".into(), ..Default::default() };
let mono = render_caption(&cap("Hello world"), &mono, 1920, 1080).unwrap();
assert_ne!((inter.w, &inter.px), (mono.w, &mono.px), "JetBrains Mono renders like Inter");
}
#[test]
fn unknown_or_empty_font_falls_back_to_inter() {
let inter = render_caption(&cap("Hello"), &CaptionStyle::default(), 1280, 720).unwrap();
for font in ["", " ", "No Such Font 123"] {
let st = CaptionStyle { font: font.into(), ..Default::default() };
assert_eq!(render_caption(&cap("Hello"), &st, 1280, 720), Some(inter.clone()), "font {font:?}");
}
}
#[test]
fn hidden_track_draws_nothing() {
let mut t = CaptionTrack::new(TrackId(1), "S".into(), CaptionFormat::Subtitle);
+4 -1
View File
@@ -307,7 +307,8 @@ pub fn parse_clock(s: &str) -> Option<Tick> {
/// Format ticks as `HH:MM:SS<sep>mmm` (rounded to the nearest millisecond).
pub fn format_clock(t: Tick, sep: char) -> String {
let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS;
// The rounding addition must not overflow for an input-derived Tick near i64::MAX.
let ms = (i128::from(t.0.max(0)) + i128::from(TICKS_PER_MS) / 2) / i128::from(TICKS_PER_MS);
let (h, m, s, f) = (ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000);
format!("{h:02}:{m:02}:{s:02}{sep}{f:03}")
}
@@ -368,6 +369,8 @@ mod tests {
assert_eq!(parse_clock("00:61:00,000"), None);
assert_eq!(parse_clock("aa:00:00,000"), None);
assert_eq!(format_clock(Tick(TICKS_PER_SECOND * 3723 + TICKS_PER_MS * 45), ','), "01:02:03,045");
assert!(format_clock(Tick(i64::MAX), ',').ends_with(",077"));
assert_eq!(format_clock(Tick(i64::MIN), '.'), "00:00:00.000");
}
#[test]
+10 -2
View File
@@ -17,7 +17,8 @@
//! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence
//! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two
//! bytes)), optional time code / service info sections, footer `74`, the counter again and a
//! checksum that makes the packet's byte sum 0 mod 256.
//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte
//! (low 8 bits of DID + SDID + data count + CDP bytes).
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption
//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608
//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708
@@ -161,6 +162,9 @@ pub fn build_cdp(seq: u16, data: &FrameData) -> Vec<u8> {
cdp.push(((256 - sum % 256) % 256) as u8);
let mut out = vec![0x61, 0x01, cdp.len() as u8];
out.extend(cdp);
// SMPTE ST 291 ancillary checksum: low 8 bits of the sum of DID, SDID, data count and the user data words.
let anc_sum: u32 = out.iter().map(|&b| b as u32).sum();
out.push((anc_sum & 0xff) as u8);
out
}
@@ -661,7 +665,11 @@ mod tests {
let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] };
let b = build_cdp(7, &d);
assert_eq!(&b[..3], &[0x61, 0x01, 73]);
let cdp = &b[3..];
// prefix (3) + CDP (73) + ancillary checksum byte (1)
assert_eq!(b.len(), 77);
let sum: u32 = b[..76].iter().map(|&x| x as u32).sum();
assert_eq!(b[76], (sum & 0xff) as u8, "ancillary checksum");
let cdp = &b[3..76];
assert_eq!(cdp.len(), 73);
assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]);
assert_eq!(cdp.iter().map(|&x| x as u32).sum::<u32>() % 256, 0, "checksum");
+31 -8
View File
@@ -215,15 +215,22 @@ fn encode_text(text: &str) -> Vec<u8> {
if ch == '<'
&& let Some(end) = rest.find('>')
{
match rest[1..end].trim().to_ascii_lowercase().as_str() {
"i" => out.push(0x80),
"/i" => out.push(0x81),
"u" => out.push(0x82),
"/u" => out.push(0x83),
_ => {}
let tag = &rest[1..end];
// Only a syntactically plausible tag is markup. Comparisons such as
// "2 < 3 > 1" and "x < y > z" must not lose their middle text.
let name = tag.strip_prefix('/').unwrap_or(tag);
let is_tag = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if is_tag {
match tag.trim().to_ascii_lowercase().as_str() {
"i" => out.push(0x80),
"/i" => out.push(0x81),
"u" => out.push(0x82),
"/u" => out.push(0x83),
_ => {}
}
rest = &rest[end + 1..];
continue;
}
rest = &rest[end + 1..];
continue;
}
if ch == '\n' {
out.push(0x8a);
@@ -451,6 +458,22 @@ mod tests {
assert!(parse(&b[..500]).is_err());
}
#[test]
fn literal_angle_brackets_are_not_discarded_as_markup() {
let literal = "2 < 3 > 1, x < y > z, and 5 <7> 6";
let doc = Document {
cues: vec![Cue { start: FrameRate::FPS_25.tick_of(25), end: FrameRate::FPS_25.tick_of(75), text: literal.into(), ..Default::default() }],
..Default::default()
};
let encoded = write(&doc, Some(FrameRate::FPS_25));
let decoded = parse(&encoded).expect("valid EBU STL");
assert_eq!(decoded.cues.len(), 1);
assert_eq!(decoded.cues[0].text, literal);
// Recognized styling still becomes STL control bytes.
assert_eq!(encode_text("<i>Hi</i>"), vec![0x80, b'H', b'i', 0x81]);
assert_eq!(encode_text("<i >Hi</i >"), vec![0x80, b'H', b'i', 0x81]);
}
#[test]
fn programme_start_is_subtracted() {
let doc = Document {
+178 -44
View File
@@ -10,8 +10,8 @@
//!
//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read):
//! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`,
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested time containers
//! (`body` / `div` begins add up), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time
//! containers (`body` / `div` begins add up and their ends clip what is inside), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
//! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`.
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
@@ -184,28 +184,45 @@ struct Timing {
tick_rate: i64,
}
fn positive_integer(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<i64>().ok().filter(|v| *v > 0)
}
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
let mut it = s.split_whitespace().map(|x| x.parse::<i64>().ok());
let mut it = s.split_whitespace().map(positive_integer);
let a = it.next()??;
let b = it.next().flatten().unwrap_or(1);
(a > 0 && b > 0).then_some((a, b))
let b = it.next()??;
it.next().is_none().then_some((a, b))
}
fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == local).map(|a| a.value())
}
fn timing(root: roxmltree::Node) -> Timing {
let base = attr(root, "frameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(30);
let (mn, md) = attr(root, "frameRateMultiplier").and_then(parse_ratio).unwrap_or((1, 1));
let rate = FrameRate::new(base * mn, md);
let sub = attr(root, "subFrameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(1);
let tick_rate = attr(root, "tickRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(if attr(root, "frameRate").is_some() {
base * sub
} else {
1
});
Timing { rate, base, sub_frame_rate: sub, tick_rate }
fn timing(root: roxmltree::Node) -> Result<Timing> {
let invalid =
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
let integer = |name: &str, default| match attr(root, name) {
Some(v) => positive_integer(v).ok_or_else(|| invalid(name)),
None => Ok(default),
};
let base = integer("frameRate", 30)?;
let (mn, md) = match attr(root, "frameRateMultiplier") {
Some(v) => parse_ratio(v).ok_or_else(|| invalid("frameRateMultiplier"))?,
None => (1, 1),
};
let rate = FrameRate::new(base.checked_mul(mn).ok_or_else(|| invalid("frameRateMultiplier"))?, md);
let sub = integer("subFrameRate", 1)?;
let tick_rate = match attr(root, "tickRate") {
Some(v) => positive_integer(v).ok_or_else(|| invalid("tickRate"))?,
None if attr(root, "frameRate").is_some() => base.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?,
None => 1,
};
Ok(Timing { rate, base, sub_frame_rate: sub, tick_rate })
}
/// Seconds as an exact decimal string → ticks.
@@ -235,6 +252,10 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
let (h, m) = (num(parts.first()?)?, num(parts.get(1)?)?);
let sec_part = parts.get(2)?;
let (si, sf) = sec_part.split_once('.').unwrap_or((sec_part, ""));
let seconds = num(si)?;
if m >= 60 || seconds >= 60 {
return None;
}
let mut t = (h * 3600 + m * 60) * tps + decimal_ticks(si, sf, tps)?;
if let Some(fp) = parts.get(3) {
if parts.len() > 4 {
@@ -242,13 +263,15 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
}
let (fi, fs) = fp.split_once('.').unwrap_or((fp, ""));
let frames = num(fi)?;
let _ = tm.base;
let sub: i128 = if fs.is_empty() { 0 } else { num(fs)? };
if frames >= i128::from(tm.base) || sub >= i128::from(tm.sub_frame_rate) {
return None;
}
// frames at the effective rate; sub-frames divide a frame
let ft = tm.rate.tick_of(1).0 as i128;
t += frames * ft + sub * ft / tm.sub_frame_rate.max(1) as i128;
}
return Some(Tick(t as i64));
return i64::try_from(t).ok().map(Tick);
}
let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?;
let (v, unit) = s.split_at(unit_start);
@@ -264,8 +287,12 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
"f" => {
let n: i128 = vi.parse().ok()?;
let fd = tm.rate.tick_of(1).0 as i128;
let whole = tm.rate.tick_of(n as i64).0 as i128;
whole + decimal_ticks("0", vf, fd)?
let whole = tm.rate.tick_of(i64::try_from(n).ok()?);
// tick_of saturates; a saturated count overflowed rather than named a real time
if whole == Tick(i64::MAX) {
return None;
}
whole.0 as i128 + decimal_ticks("0", vf, fd)?
}
"t" => {
let n: i128 = vi.parse().ok()?;
@@ -273,7 +300,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
}
_ => return None,
};
Some(Tick(t as i64))
i64::try_from(t).ok().map(Tick)
}
#[derive(Clone, Copy, Default, PartialEq)]
@@ -383,7 +410,7 @@ pub fn parse(text: &str) -> Result<Document> {
if root.tag_name().name() != "tt" {
return Err(Error::NotFormat("TTML"));
}
let tm = timing(root);
let tm = timing(root)?;
let mut styles = std::collections::HashMap::new();
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
@@ -396,44 +423,84 @@ pub fn parse(text: &str) -> Result<Document> {
let Some(body) = root.children().find(|n| n.is_element() && n.tag_name().name() == "body") else {
return Ok(doc);
};
walk(body, Tick::ZERO, &tm, &styles, Fmt::default(), &mut doc);
if root.descendants().any(|n| attr(n, "timeContainer").is_some_and(|v| v.trim() == "seq")) {
doc.warnings.push("timeContainer=\"seq\" is not supported; its children are timed as in a par container".into());
}
// the body is a time container like a div: its begin offsets, and its end clips, everything inside
let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc);
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, Fmt::default(), &mut doc);
doc.cues.sort_by_key(|c| (c.start, c.end));
Ok(doc)
}
/// Begin and end of a timed element relative to `parent` (None when it has no timing).
fn times(n: roxmltree::Node, parent: Tick, tm: &Timing, doc: &mut Document) -> (Option<Tick>, Option<Tick>) {
let p = |k: &str, doc: &mut Document| -> Option<Tick> {
/// The active interval of the enclosing time containers (`par`): child times are offsets from
/// `begin`, and nothing inside outlasts `end` (None when no ancestor sets an end).
#[derive(Clone, Copy)]
struct Scope {
begin: Tick,
end: Option<Tick>,
}
/// Whether `n` has timing attributes of its own.
fn has_timing(n: roxmltree::Node) -> bool {
["begin", "end", "dur"].into_iter().any(|k| attr(n, k).is_some())
}
/// Timing of an element in its parent container.
struct Times {
/// The begin, when the element sets one.
begin: Option<Tick>,
/// Its own end (`end`, or `begin` + `dur`, the earlier of the two) clipped to the parent's
/// end, else the parent's end.
end: Option<Tick>,
/// A timing attribute did not parse (and was warned about).
bad: bool,
}
fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> Times {
let mut bad = false;
let mut p = |k: &str, doc: &mut Document| -> Option<Tick> {
let v = attr(n, k)?;
let t = parse_time(v, *tm);
let t = parse_time(v, tm);
if t.is_none() {
bad = true;
doc.warnings.push(format!("bad time expression \"{v}\""));
}
t
};
let begin = p("begin", doc).map(|b| parent + b);
let end = match (p("end", doc), p("dur", doc)) {
(Some(e), _) => Some(parent + e),
(None, Some(d)) => Some(begin.unwrap_or(parent) + d),
_ => None,
let begin = p("begin", doc).map(|b| parent.begin + b);
let own_end = match (p("end", doc).map(|e| parent.begin + e), p("dur", doc).map(|d| begin.unwrap_or(parent.begin) + d)) {
(Some(e), Some(d)) => Some(e.min(d)),
(e, d) => e.or(d),
};
(begin, end)
let end = match (own_end, parent.end) {
(Some(e), Some(pe)) => Some(e.min(pe)),
(e, pe) => e.or(pe),
};
Times { begin, end, bad }
}
fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, doc: &mut Document) {
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, doc: &mut Document) {
for c in n.children().filter(|c| c.is_element()) {
let f = style_of(c, styles, fmt);
match c.tag_name().name() {
"div" => {
let (b, _) = times(c, parent, tm, doc);
walk(c, b.unwrap_or(parent), tm, styles, f, doc);
let t = times(c, parent, *tm, doc);
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc);
}
"p" => {
let (b, e) = times(c, parent, tm, doc);
let t = times(c, parent, *tm, doc);
if t.bad {
continue;
}
let (b, e) = (t.begin.unwrap_or(parent.begin), t.end);
let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " "));
let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string());
match (b, e) {
(Some(b), Some(e)) => {
let spans: Vec<roxmltree::Node> = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect();
// a timed paragraph, or one without timed spans in a container with an end, is one cue
let whole = if has_timing(c) || spans.is_empty() { e } else { None };
match whole {
Some(e) => {
let mut o = Out { s: String::new(), pending: false };
let tags: Vec<&str> = [(f.italic, "i"), (f.bold, "b"), (f.underline, "u")].into_iter().filter(|x| x.0).map(|x| x.1).collect();
for t in &tags {
@@ -448,12 +515,13 @@ fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
}
}
_ => {
None => {
// untimed paragraph: its timed spans are the cues
let base = b.unwrap_or(parent);
for sp in c.children().filter(|x| x.is_element() && x.tag_name().name() == "span") {
let (sb, se) = times(sp, base, tm, doc);
if let (Some(sb), Some(se)) = (sb, se) {
let scope = Scope { begin: b, end: e };
for sp in spans {
let st = times(sp, scope, *tm, doc);
let sb = st.begin.unwrap_or(scope.begin);
if let (false, Some(se)) = (st.bad, st.end) {
let mut o = Out { s: String::new(), pending: false };
collect(sp, styles, style_of(sp, styles, f), &mut o);
let text = clean(&o.s);
@@ -478,6 +546,44 @@ mod tests {
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: 1 }
}
#[test]
fn invalid_timing_parameters_return_an_error() {
for params in [
r#"ttp:frameRate="0""#,
r#"ttp:frameRate="-25""#,
r#"ttp:frameRate="""#,
r#"ttp:subFrameRate="0""#,
r#"ttp:tickRate="-1""#,
r#"ttp:frameRateMultiplier="1 0""#,
r#"ttp:frameRateMultiplier="1""#,
r#"ttp:frameRateMultiplier="1 2 3""#,
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#
);
assert!(crate::parse(xml.as_bytes(), crate::Format::Ttml).is_err(), "{params}");
}
}
#[test]
fn missing_timing_parameters_keep_defaults() {
for (params, begin, end) in [
("", "1s", "2s"),
(r#"ttp:frameRate="25""#, "25f", "50f"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2""#, "60t", "120t"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10""#, "10t", "20t"),
(r#"ttp:frameRate="25" ttp:frameRateMultiplier="2 1""#, "50f", "100f"),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="{begin}" end="{end}">Hello</p></div></body></tt>"#
);
let doc = crate::parse(xml.as_bytes(), crate::Format::Ttml).unwrap();
assert_eq!(doc.cues.len(), 1, "{params}");
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND), "{params}");
assert_eq!(doc.cues[0].end, Tick(2 * TICKS_PER_SECOND), "{params}");
}
}
#[test]
fn time_expressions() {
let t = tm(FrameRate::FPS_25);
@@ -497,6 +603,34 @@ mod tests {
assert_eq!(parse_time("1:2", t), None);
}
#[test]
fn invalid_and_overflowed_time_expressions_are_ignored() {
let normal = tm(FrameRate::FPS_25);
for expr in [
"999999999999999h",
"999999999999999m",
"999999999999999s",
"999999999999999ms",
"999999999999999f",
"999999999999999t",
"00:60:00",
"00:00:60",
"00:00:01:25",
] {
assert_eq!(parse_time(expr, normal), None, "{expr}");
}
let subframes = Timing { sub_frame_rate: 2, ..normal };
assert!(parse_time("00:00:01:12.1", subframes).is_some());
assert_eq!(parse_time("00:00:01:12.2", subframes), None);
// Bad cues must not wrap to a plausible but unrelated timeline position.
let xml =
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="999999999999999s" end="2s">Bad</p><p begin="1s" end="2s">Good</p></div></body></tt>"#;
let doc = parse(xml).expect("well-formed TTML");
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].text, "Good");
assert!(!doc.warnings.is_empty());
}
#[test]
fn reads_styles_spans_and_nesting() {
let x = r#"<?xml version="1.0"?>
+92
View File
@@ -0,0 +1,92 @@
//! TTML / DFXP inherited timing: `begin`, `end` and `dur` on `body` and `div` time containers
//! offset and clip the paragraphs inside them (#895).
use filmcraft_captions::{Format, WriteOptions, parse, write};
/// (start, end, text) of every cue, in seconds; panics on a parse error.
fn cues(xml: &str, format: Format) -> Vec<(f64, f64, String)> {
let doc = parse(xml.as_bytes(), format).unwrap();
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
doc.cues.iter().map(|c| (c.start.seconds(), c.end.seconds(), c.text.clone())).collect()
}
fn both(xml: &str, want: &[(f64, f64, &str)]) {
let want: Vec<(f64, f64, String)> = want.iter().map(|(s, e, t)| (*s, *e, t.to_string())).collect();
for format in [Format::Ttml, Format::Dfxp] {
assert_eq!(cues(xml, format), want, "{format:?}: {xml}");
}
}
#[test]
fn body_begin_is_inherited() {
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
}
#[test]
fn div_begin_is_inherited() {
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="5s"><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
}
#[test]
fn untimed_containers_leave_times_unchanged() {
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(1.0, 2.0, "x")]);
}
#[test]
fn nested_begins_add_up() {
both(
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="00:00:10.000"><div begin="1s"><p begin="1s" end="2s">deep</p></div><p begin="0s" dur="1s">mid</p></div><p begin="1s" end="2s">top</p></body></tt>"#,
&[(6.0, 7.0, "top"), (15.0, 16.0, "mid"), (17.0, 18.0, "deep")],
);
}
#[test]
fn body_begin_offsets_timed_spans() {
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p><span begin="1s" end="2s">x</span></p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
}
#[test]
fn container_ends_clip_descendants() {
// div active 11 s to 13 s: a later paragraph is dropped, a longer one is cut at 13 s
both(
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="1s"><div begin="10s" end="12s"><p begin="1s" end="5s">cut</p><p begin="3s" end="4s">gone</p></div></body></tt>"#,
&[(12.0, 13.0, "cut")],
);
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body dur="3s"><div><p begin="1s" end="5s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
}
#[test]
fn paragraphs_without_an_end_last_until_their_container_ends() {
both(
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="2s" end="6s"><p begin="1s">open</p></div><div begin="10s" dur="2s"><p>whole</p></div></body></tt>"#,
&[(3.0, 6.0, "open"), (10.0, 12.0, "whole")],
);
}
#[test]
fn end_and_dur_take_the_earlier() {
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="5s" dur="2s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
}
#[test]
fn seq_containers_warn() {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"><body><div timeContainer="seq"><p begin="1s" end="2s">x</p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert!(doc.warnings.iter().any(|w| w.contains("seq")), "{:?}", doc.warnings);
}
}
#[test]
fn inherited_times_survive_export() {
// the writer uses untimed body / div and absolute paragraph times, so the resolved times stay
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="1s" end="4s"><p begin="1s" end="9s">x</p></div></body></tt>"#;
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap();
let times = |d: &filmcraft_captions::Document| d.cues.iter().map(|c| (c.start, c.end, c.text.clone())).collect::<Vec<_>>();
assert_eq!(times(&back), times(&doc), "{format:?}");
assert_eq!((back.cues[0].start.seconds(), back.cues[0].end.seconds()), (7.0, 9.0), "{format:?}");
}
}
+10 -5
View File
@@ -84,7 +84,6 @@ impl<'a> CompoundFile<'a> {
let mini_cutoff = u32_at(data, 56) as u64;
let first_minifat = u32_at(data, 60);
let first_difat = u32_at(data, 68);
let n_difat = u32_at(data, 72) as usize;
let mut header_clsid = [0u8; 16];
header_clsid.copy_from_slice(&data[8..24]);
let max_sectors = data.len() / sector_size + 1;
@@ -108,8 +107,10 @@ impl<'a> CompoundFile<'a> {
let mut seen = 0;
while d <= MAXREGSECT && fat_sectors.len() < n_fat {
seen += 1;
if seen > n_difat.max(1) + max_sectors {
return Err(invalid("DIFAT chain loops"));
// a DIFAT chain cannot have more sectors than the file holds; the header's DIFAT sector
// count (up to 2^32 - 1) is not trusted, or a self-pointing sector loops that often
if seen > max_sectors {
return Err(invalid(format!("DIFAT chain loops: more than {max_sectors} sectors in a {}-byte file", data.len())));
}
let s = cf.sector(d)?;
let per = sector_size / 4 - 1;
@@ -169,13 +170,17 @@ impl<'a> CompoundFile<'a> {
let mut out = Vec::new();
let mut s = start;
let mut steps = 0usize;
// A chain cannot visit more sectors than the file holds. Not `self.fat.len()`: the FAT can
// be far longer (one FAT sector named by many header / DIFAT slots), and a looping chain
// would then append a whole sector per FAT entry (hundreds of MB, or an abort).
let max_steps = self.data.len() / self.sector_size + 1;
while s != ENDOFCHAIN {
if s > MAXREGSECT {
return Err(invalid(format!("bad sector {s:#x} in a chain")));
}
steps += 1;
if steps > self.fat.len().max(1) {
return Err(invalid("sector chain loops"));
if steps > max_steps {
return Err(invalid(format!("sector chain loops: more than {max_steps} sectors in a {}-byte file", self.data.len())));
}
let b = self.sector(s)?;
out.extend_from_slice(b);
+92
View File
@@ -199,3 +199,95 @@ fn mutation_never_panics_or_hangs() {
}
}
}
fn put_u32(b: &mut [u8], at: usize, v: u32) {
b[at..at + 4].copy_from_slice(&v.to_le_bytes());
}
/// A 512-byte header (§2.2) with sector shift `shift`: no directory, mini FAT or DIFAT chain, and
/// all 109 FAT slots free. Tests fill in the fields they attack.
fn bare_header(shift: u16) -> Vec<u8> {
let mut h = vec![0u8; 512];
h[..8].copy_from_slice(&SIGNATURE);
h[24..26].copy_from_slice(&0x3Eu16.to_le_bytes());
h[26..28].copy_from_slice(&(if shift == 9 { 3u16 } else { 4 }).to_le_bytes());
h[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes());
h[30..32].copy_from_slice(&shift.to_le_bytes());
h[32..34].copy_from_slice(&6u16.to_le_bytes());
for at in [48, 60, 68] {
put_u32(&mut h, at, ENDOFCHAIN);
}
put_u32(&mut h, 56, MINI_STREAM_CUTOFF);
h[76..].fill(0xFF);
h
}
/// A DIFAT sector that names no FAT sector and points to itself. The loop guard used to be the
/// header's DIFAT sector count (up to 2^32 - 1), so this 1 KB file kept `open` (and so AAF
/// sniffing) busy for 14-16 s here (minutes with `u32::MAX`), reading a sector each pass. A DIFAT
/// chain cannot be longer than the file, so the walk now stops after as many passes as the file
/// has sectors (1024 / 512 + 1 = 3 here) and fails on the 4th.
#[test]
fn difat_self_loop_fails_fast() {
let mut b = bare_header(9);
put_u32(&mut b, 44, 1); // one FAT sector, to be found through the DIFAT
put_u32(&mut b, 68, 0); // first DIFAT sector
put_u32(&mut b, 72, 100_000_000); // claimed DIFAT sector count (inflates the old loop bound)
let mut difat = vec![0xFF; 512]; // no FAT sector numbers...
put_u32(&mut difat, 508, 0); // ...and the next DIFAT sector is this one
b.extend_from_slice(&difat);
// The message names the bound the walk stopped at: the file's 3 sectors, not the header's
// 100,000,000 (the old guard allowed 100,000,003 passes).
let expect = format!("DIFAT chain loops: more than 3 sectors in a {}-byte file", b.len());
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
}
/// All 109 header slots name the same FAT sector, so the FAT is 109 copies of it, and the
/// directory chain loops (sector 0 → 0). The loop guard used to be the FAT length, so this 450 KB
/// file appended a sector per FAT entry before failing: a 457 MB buffer per open (117 GB with
/// 64 KB sectors in a 7 MB file). A chain cannot visit more sectors than the file has.
#[test]
fn duplicated_fat_sector_chain_loop_fails_fast() {
let ss = 4096;
let mut b = bare_header(12);
put_u32(&mut b, 44, 109); // FAT sectors: the 109 header slots...
for i in 0..109 {
put_u32(&mut b, 76 + i * 4, 0); // ...all naming sector 0
}
put_u32(&mut b, 48, 0); // the directory starts at sector 0
b.resize(ss, 0);
let mut fat = vec![0xFF; ss];
put_u32(&mut fat, 0, 0); // FAT[0] = 0: sector 0 is followed by itself
b.extend_from_slice(&fat);
b.resize(110 * ss, 0); // big enough for 109 FAT sectors to pass the size check
// The message names the bound the walk stopped at: 111, this file's 110 sectors + 1, not the
// 109 * 1024 = 111,616 entries of the inflated FAT that the old guard allowed (a 457 MB buffer).
let expect = format!("sector chain loops: more than 111 sectors in a {}-byte file", b.len());
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
}
/// Positive control for the chain bound: the longest chains a file can hold still read back, in
/// both versions: a directory (read without a size limit) and a stream, each filling every sector
/// but the header and the FAT (and the directory, for the stream).
#[test]
fn chains_filling_the_file_still_read() {
for v in [Version::V3, Version::V4] {
let ss = v_sector(v);
let mut w = Writer::new(v);
for i in 0..1000 {
w.stream(Writer::ROOT, &format!("e{i}"), Vec::new()).unwrap();
}
let b = w.finish();
assert!(b.len() / ss <= (1001 * 128usize).div_ceil(ss) + 3, "header, directory, FAT");
let cf = CompoundFile::open(&b).unwrap();
assert_eq!(cf.root().children.len(), 1000);
assert_eq!(cf.read_path("e999").unwrap(), Vec::<u8>::new());
let mut w = Writer::new(v);
let data = pattern(64 * ss, 3);
w.stream(Writer::ROOT, "big", data.clone()).unwrap();
let b = w.finish();
assert_eq!(b.len(), (1 + 64 + 2) * ss, "header, stream, directory, FAT");
assert_eq!(CompoundFile::open(&b).unwrap().read_path("big").unwrap(), data);
}
}
Loaded 100 of 777 files, more files were not shown because too many files have changed in this diff. Show more