Compare commits

...
186 Commits
Author SHA1 Message Date
Brandon Thomas cee1feb185 Merge pull request #1433 from vangel76/fix-mpegts-hardening
#1432: mpegts: bounds-check frame_info, cap chunks, units and allocations, drop non-PES data
2026-10-13 03:00:25 -04:00
Brandon ThomasandClaude Opus 5.5 fccddb6ea8 Merge origin/main into #1433; NAL streams keep their chunks (#1440 locates access units inside a PES)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 03:00:21 -04:00
Brandon Thomas 753c9a99e7 Merge pull request #1389 from Nai-ITA/main
Ita Tranlsate
2026-10-13 02:58:42 -04:00
Brandon ThomasandClaude Opus 5.5 c34b9de976 #1389: Italian Ripple Delete Head Frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:58:38 -04:00
Brandon Thomas 002b3d0fb6 Merge remote-tracking branch 'origin/main' into fix-1389 2026-10-13 02:54:24 -04:00
Brandon ThomasandClaude Opus 5.5 fae7f8a9be i18n: translate Ripple Delete Head Frame (#1427) so the catalog coverage tests pass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:54:22 -04:00
Brandon ThomasandClaude Opus 5.5 48cae3e5a3 Merge origin/main into #1389 (Italian beside Korean: 12 languages)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:40:02 -04:00
Brandon Thomas 5e1ded3301 Merge pull request #1502 from CokeRoach/korean-ui
i18n: Korean (ko) interface catalog (#508)
2026-10-13 02:34:38 -04:00
Brandon Thomas 23c9cdb33d Merge pull request #1427 from bfenty/fix/ripple-head-1357
Add edit.rippleDeleteHead (Ripple Delete Head Frame)
2026-10-13 02:34:37 -04:00
Brandon Thomas ff2739f9ea Merge pull request #1457 from bfenty/fix/pps-validation-1441
h264: validate malformed PPS fields instead of panicking (#1441)
2026-10-13 02:34:36 -04:00
Brandon Thomas 81e2d083fc Merge pull request #1303 from LloydNicholson/fix-1292-y4m-ale
Import YUV4MPEG2 (.y4m) video; stop offering .ale for import (#1292)
2026-10-13 02:34:36 -04:00
Brandon Thomas 1d3b4f2bab Merge pull request #1288 from LloydNicholson/linux-mime-types
packaging: Linux file managers offer FilmCraft for .fcproj projects and video files
2026-10-13 02:34:35 -04:00
Brandon Thomas 13355a77a9 Merge pull request #1225 from haranishi/fix/1191-locked-track-feedback
fix: トラックロックの斜線表示とドラッグ保護を修正 (#1191)
2026-10-13 02:34:34 -04:00
Brandon Thomas bdc10001fe Merge pull request #1240 from Ni-zav/fix/1163-alt-marquee-select-unlinked-s20261012T0220Z-a3
timeline: Alt marquee selects clips without linked partners (#1163)
2026-10-13 02:34:33 -04:00
Brandon Thomas 044483689b Merge pull request #1233 from diegozpy/i18n/es-review
i18n(es): fix mistranslations and terms, add 4 missing rows
2026-10-13 02:34:33 -04:00
Brandon Thomas 25585fa84a Merge pull request #1258 from gyroninja/fix/transitions-follow-clip-drag
Fix clip drags leaving transitions behind
2026-10-13 02:34:32 -04:00
Brandon Thomas 5e55b144a5 Merge pull request #1404 from vangel76/gap-exr-pxr24
#828: import PXR24-compressed OpenEXR
2026-10-13 02:34:31 -04:00
Brandon ThomasandClaude Opus 5.5 7c9da91184 Media Browser drag test: count the dragged files' clips by name (#1442)
The drop lands inside the demo's long Ambient_Score.wav on A2, and overwriting there splits it,
so the total clip count went up by 3, not 2, and the test failed deterministically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:33:47 -04:00
Brandon ThomasandClaude Opus 5.5 08d500a5c9 #1389: Italian rows for strings added on main since (Open Recent, 32-bit audio, Help hints)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:07:54 -04:00
Brandon Thomas 6b2a36e2dd Merge remote-tracking branch 'origin/main' into fix-1389 2026-10-13 02:04:51 -04:00
Brandon ThomasandClaude Opus 5.5 c3f931d0c4 #1502: English fallback without a Hangul font (as for Chinese); doc comment back on available(); drop the old Korean ROADMAP row; Korean rows for strings added on main since
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:04:44 -04:00
Brandon Thomas 64a2391ddc Merge remote-tracking branch 'origin/main' into fix-1502 2026-10-13 01:43:02 -04:00
Brandon ThomasandClaude Opus 5.5 e6973f5957 #1427: ripple sequence markers by the removed head (they didn't move); params name clips/clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:42:53 -04:00
Brandon Thomas 5ae8d50168 Merge remote-tracking branch 'origin/main' into fix-1427 2026-10-13 01:34:47 -04:00
Brandon ThomasandClaude Opus 5.5 39f60de2f4 #1457: bound slice-group map type 6 by the picture's map units (CIF FMO is 396), drop pr1441.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:34:27 -04:00
Brandon Thomas 62e94fb2dd Merge remote-tracking branch 'origin/main' into fix-1457 2026-10-13 01:29:24 -04:00
Brandon ThomasandClaude Opus 5.5 62aed1acc2 #1303: frame times in i128 via Tick::from_rational (a hostile F1:i64::MAX rate can't overflow)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:29:19 -04:00
Brandon Thomas ee414e8b9e Merge remote-tracking branch 'origin/main' into fix-1303 2026-10-13 01:22:02 -04:00
Brandon ThomasandClaude Opus 5.5 6c3a7ed4ef #1288: drop the committed .pyc; ignore __pycache__
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:53 -04:00
Brandon Thomas 99547d183b Merge remote-tracking branch 'origin/main' into fix-1288 2026-10-13 01:21:53 -04:00
Brandon ThomasandClaude Opus 5.5 d77ab61994 Merge origin/main into #1225 (lock check wraps #1232's new-track move)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:50 -04:00
Brandon ThomasandClaude Opus 5.5 676ce6e8ff Merge origin/main into #1240 (the marquee release keeps Alt's linked:false in main's restructured drag end)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:01 -04:00
Brandon ThomasandClaude Opus 5.5 dd92e46583 #1233: drop the stale Hardware decoding row (main already has the current one)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:20:20 -04:00
Brandon Thomas 9261608e1f Merge remote-tracking branch 'origin/main' into fix-1233 2026-10-13 01:20:11 -04:00
Brandon ThomasandClaude Opus 5.5 154bc148e3 #1258: lands.first() instead of lands[0] (a transition with no endpoints can't panic)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:20:04 -04:00
Brandon Thomas 3feaf7390f Merge remote-tracking branch 'origin/main' into fix-1258 2026-10-13 01:19:58 -04:00
Brandon ThomasandClaude Opus 5.5 9c00138b4b Merge origin/main into #1404 (gaps.md: keep only the PXR24 edits)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:19:55 -04:00
Brandon Thomas ade0f36544 Merge pull request #1271 from n-delic/fix-1241-select-all-keyframes
#1241: Select All in Effect Controls selects its keyframes
2026-10-13 01:19:18 -04:00
Brandon ThomasandClaude Opus 5.5 a469a4aeb7 Merge origin/main into #1271
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:10:44 -04:00
Brandon Thomas 1267e4e172 Merge pull request #1304 from LloydNicholson/fix-1293-ycbcr-tiff
media: decode uncompressed and PackBits YCbCr TIFF stills (#1293)
2026-10-13 01:10:35 -04:00
Brandon ThomasandClaude Opus 5.5 192174c9ee #1304: rustfmt the module list after the merge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:10:32 -04:00
Brandon ThomasandClaude Opus 5.5 c163983fc1 Merge origin/main into #1304
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:01:21 -04:00
Brandon Thomas 9418816580 Merge pull request #1527 from marcoarnulfo/fix/adjustment-layer-transform-replaces-below
Adjustment layers: a Transform replaces the picture below instead of drawing over it
2026-10-13 01:01:09 -04:00
Brandon ThomasandClaude Opus 5.5 0cfc02a933 Merge origin/main into #1527: the Normal-blend replace moves into adjustment_layer (after #1327)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:49:14 -04:00
Brandon Thomas dc5e488454 Merge pull request #1287 from Silvertree2010/fix/transform-shutter-frame-rate
Transform shutter: use the sequence frame rate on adjustment layers (#1088)
2026-10-13 00:48:09 -04:00
Brandon ThomasandClaude Opus 5.5 1990835de9 #1287: frame_rate in the Sharpen preview-resolution test's FxCtx (after #1361)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:26:53 -04:00
Brandon ThomasandClaude Opus 5.5 138e972010 Merge origin/main into #1287 (frame_rate on the adjustment layer's FxCtx after #1327)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:10:18 -04:00
Brandon ThomasandClaude Opus 5.5 234b3454ef Export: as_chunks in the float-audio oracle test (clippy manual_chunks)
Follow-up to #1245.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:17 -04:00
Brandon Thomas 4678c68d8e Merge pull request #1476 from itrycodinganditsbad/fix-1393
Keep transform handles in reach when the picture is past the monitor edge
2026-10-13 00:03:45 -04:00
Brandon Thomas cc7cec89f8 Merge pull request #1474 from sanyamsingh001/codex/inspectors-selection-oct12
ui: keep Metadata and Info on the selected source item
2026-10-13 00:03:45 -04:00
Brandon Thomas 629e5e9ee1 Merge pull request #1472 from txiki739/fix/linux-async-dialog-filters
#262: Linux file dialogs shown off the UI thread keep case-insensitive filters
2026-10-13 00:03:44 -04:00
Brandon Thomas 7bc739bfae Merge pull request #1471 from marcoarnulfo/fix/recent-projects
fix(home): list recent projects on Home and in File ▸ Open Recent
2026-10-13 00:03:43 -04:00
Brandon Thomas f2ad4b9c94 Merge pull request #1467 from marcoarnulfo/fix/dock-to-window-edge
fix(dock): dock a panel along the edge of the whole workspace
2026-10-13 00:03:42 -04:00
Brandon Thomas e35a828c73 Merge pull request #1454 from vangel76/refactor-mpegts-simplify-optimize
mpegts: simplify and avoid redundant work
2026-10-13 00:03:42 -04:00
Brandon Thomas eb27d5efff Merge pull request #1444 from marcoarnulfo/fix/se-golomb-bounds
fix(bitstream): map se(v) through the 64-bit codeNum so i32::MIN round-trips
2026-10-13 00:03:41 -04:00
Brandon Thomas 9247622550 Merge pull request #1442 from MarekSpodBiedry/issue-1360
fix(media-browser): drag every selected file to the Timeline, not just one (#1360)
2026-10-13 00:03:41 -04:00
Brandon Thomas 5f3b54cf44 Merge pull request #1434 from vangel76/fix-mpegts-ts-resync
#1430: mpegts: resync inside the loaded block, bad adaptation field is not lost sync
2026-10-13 00:03:40 -04:00
Brandon Thomas bf96b1dcc1 Merge pull request #1431 from vangel76/fix-mpegts-ps-truncated
#1429: MPEG-1 program stream cut inside a pack header no longer panics
2026-10-13 00:03:39 -04:00
Brandon Thomas 524f49d994 Merge pull request #1548 from marcoarnulfo/fix/mcp-bridge-slow-command
fix(mcp): a slow bridge command no longer blocks every later call
2026-10-13 00:03:39 -04:00
Brandon Thomas 4f2a4e9981 Merge pull request #1547 from sanyamsingh001/codex/caption-sidecar-range-oct12
export: clip caption sidecars to the exported range
2026-10-13 00:03:38 -04:00
Brandon Thomas ba72c8944b Merge pull request #1534 from HEOJUNFO/fix/settings-text-overflow
fix(settings): keep long translations on the Preferences pages (#1517)
2026-10-13 00:03:37 -04:00
Brandon Thomas 76f423db77 Merge pull request #1532 from JeremyYao/fix/windows-duplicate-vulkan-driver
graphics: draw with the newer driver when Windows lists one GPU through two versions of it (#609 on the #827 start-up)
2026-10-13 00:03:37 -04:00
Brandon Thomas 1c7aa21957 Merge pull request #1526 from marcoarnulfo/fix/timeline-fourth-video-track
Timeline: scrolling the video tracks up leaves room to drop a new track
2026-10-13 00:03:35 -04:00
Brandon Thomas e6ba11e286 Merge pull request #1518 from sanyamsingh001/codex/ttml-speaker-metadata-oct12
captions: preserve speaker names in TTML metadata
2026-10-13 00:03:35 -04:00
Brandon Thomas 4e1f673ce0 Merge pull request #1516 from jnohclee-rgb/dharma/preserve-subclip-sources
fix(engine): preserve offline sources referenced through subclips
2026-10-13 00:03:34 -04:00
Brandon Thomas 5f60d980f6 Merge pull request #1512 from CokeRoach/fx-folder-names
Effects panel: keep the whole name of a folder that contains a slash
2026-10-13 00:03:33 -04:00
Brandon Thomas e565d4a2fd Merge pull request #1353 from marcoarnulfo/fix/mask-handles-selection
fix(masks): hide the mask handles while the clip is not selected
2026-10-13 00:03:32 -04:00
Brandon Thomas aa8d808a48 Merge pull request #1367 from marcoarnulfo/fix/pen-mask-undo-point
fix(masks): Delete / Backspace and Cmd+Z take back the last pen point while drawing
2026-10-13 00:03:32 -04:00
Brandon Thomas fae6adc577 Merge pull request #1386 from marcoarnulfo/fix/nested-audio-preview-hash
fix(previews): a nest's audio preview follows the sound of its sequence (#1370)
2026-10-13 00:03:31 -04:00
Brandon Thomas bd5261c8a3 Merge pull request #1383 from vangel76/fix-1371-list-presets
#1371: export preset query also matches the format id
2026-10-13 00:03:30 -04:00
Brandon Thomas 20487653ce Merge pull request #1378 from vangel76/fix-1368-nudge-track
#1368: nudge clip selection up/down works for linked video+audio
2026-10-13 00:03:29 -04:00
Brandon Thomas 1ee3f85c4c Merge pull request #1245 from JordyUSA/feat/audio-32bit-export
Export: 32-bit integer and 32-bit float audio
2026-10-13 00:03:28 -04:00
Brandon Thomas 37c17d7073 Merge pull request #1232 from haranishi/fix/1187-drag-clips-new-track
fix: 新規トラック移動のリンクと編集履歴を保持 (#1187)
2026-10-13 00:03:27 -04:00
Brandon Thomas 2ee1734de4 Merge pull request #1224 from liangforstudy/fix-help-link-tooltips
Help menu: say where each item goes; keep the address when no browser opens
2026-10-13 00:03:26 -04:00
Brandon Thomas 4046ff85f6 Merge pull request #1223 from webdevtodayjason/fix/ttml-fractional-ticks
Captions: preserve fractional TTML tick offsets
2026-10-13 00:03:26 -04:00
Brandon Thomas bb2f5958b6 Merge pull request #1218 from haranishi/fix/1184-clip-menu-marker
fix(timeline): クリップの右クリックにマーカー追加を用意する (#1184)
2026-10-13 00:03:25 -04:00
Brandon Thomas 0f174ab0d6 Merge pull request #1257 from AkashBhat14/fix-1249-apv-oracle-skip-old-ffmpeg
fix(apv): skip oracle tests when ffmpeg predates APV support
2026-10-13 00:03:24 -04:00
Brandon Thomas 0199bf3f37 Merge pull request #1259 from MarekSpodBiedry/issue-1251
test(i18n): skip the Japanese language step when no Japanese font is available (#1251)
2026-10-13 00:03:23 -04:00
Brandon Thomas a4ec057c4b Merge pull request #1310 from vbasky/fix/1229-fcproj-document
Let Finder open .fcproj files
2026-10-13 00:03:23 -04:00
Brandon Thomas 18dba32df7 Merge pull request #1309 from vbasky/fix/1295-column-padding
Pad sorted project-panel column headers
2026-10-13 00:03:22 -04:00
Brandon Thomas e61900cd3a Merge pull request #1302 from LloydNicholson/fix-1291-adts-aac-import
codecs: import raw ADTS .aac files (#1291)
2026-10-13 00:03:21 -04:00
Brandon Thomas dac0219ba8 Merge pull request #1298 from gyroninja/fix/macos-ctrl-click-context-menu
fix(ui): open the context menu on macOS Ctrl+Click
2026-10-13 00:03:20 -04:00
Brandon Thomas c2363399b9 Merge pull request #1275 from n-delic/fix-1081-align-video-clips
#1081: Align to Video Frame works on selected video and image clips
2026-10-13 00:03:19 -04:00
Brandon Thomas 1df794b882 Merge pull request #1274 from n-delic/fix-1023-eyedropper-transform-handles
#1023: the eyedropper picks from the Program monitor again
2026-10-13 00:03:18 -04:00
Brandon Thomas f25156c720 Merge pull request #1510 from Narmjep/fix/time-ruler
fix(timeline): stop Ruler timestamps disappearing while zooming (#1397)
2026-10-13 00:03:17 -04:00
Brandon Thomas 133f503851 Merge pull request #1507 from marcoarnulfo/fix/menubar-hover-switch
fix(menus): hovering another title switches the open menu
2026-10-13 00:03:17 -04:00
Brandon Thomas 5ecb74c73e Merge pull request #1496 from marcoarnulfo/fix/timeline-middle-button-pans
timeline: a middle-button drag pans, whatever the tool
2026-10-13 00:03:16 -04:00
Brandon Thomas c931005aca Merge pull request #1493 from fabian-co/fix/intel-metal-indirect-validation
macOS: open the window on Intel Metal by skipping indirect-call validation
2026-10-13 00:03:15 -04:00
Brandon Thomas 6c06ab14ca Merge pull request #1492 from marcoarnulfo/fix/hevc-pps-trailing-zero
fix(codecs): in-band parameter sets compare without trailing zero bytes
2026-10-13 00:03:15 -04:00
Brandon Thomas 5b0eaa9c30 Merge pull request #1489 from marcoarnulfo/fix/space-after-panel-drag
fix(ui): Space and other shortcuts keep working after dragging a pane divider
2026-10-13 00:03:14 -04:00
Brandon Thomas 2259e2ba15 Merge pull request #1481 from sanyamsingh001/codex/ingest-collisions-oct12
ingest: preserve same-named camera takes during ingest
2026-10-13 00:03:13 -04:00
Brandon Thomas 70031d5050 Merge pull request #1479 from marcoarnulfo/fix/audio-transitions
fix(audio): crossfade audio transitions through the clip handles
2026-10-13 00:03:13 -04:00
Brandon Thomas f5b8f11271 Merge pull request #1416 from vangel76/fix-1322-properties-scroll
#1322: the Properties panel scrolls
2026-10-13 00:03:12 -04:00
Brandon Thomas e8dd5ffc45 Merge pull request #1410 from vangel76/gap-radiance-hdr
#1407: import Radiance HDR stills and image sequences
2026-10-13 00:03:11 -04:00
Brandon Thomas 4ac34587b0 Merge pull request #1409 from vangel76/feat-gap-slot1
#1408: Shift constrains mask vertex and whole-mask drags to 45°
2026-10-13 00:03:11 -04:00
Brandon Thomas 2d2e701078 Merge pull request #1403 from vangel76/gap-mkv-mp1
#1402: import MP1 (A_MPEG/L1) audio from Matroska
2026-10-13 00:03:10 -04:00
Brandon Thomas 22ba7dd5c2 Merge pull request #1401 from webdevtodayjason/fix/vtt-tab-voices
Captions: preserve tab-separated WebVTT voices
2026-10-13 00:03:09 -04:00
Brandon Thomas 12c9f13d73 Merge pull request #1366 from jahvari/fix/windows-baseline-win03
WIN-03: Make AVI oracle file reads portable
2026-10-13 00:03:09 -04:00
Brandon Thomas 6bbd389f41 Merge pull request #1361 from marcoarnulfo/fix/sharpen-playback
render: Sharpen stays visible at reduced playback resolution
2026-10-13 00:03:08 -04:00
Brandon Thomas 3b177c9033 Merge pull request #1340 from marcoarnulfo/fix/clear-default-shortcut
fix(shortcuts): Clear and reassign also drop a command's panel copy of its key (part of #1333)
2026-10-13 00:03:07 -04:00
Brandon Thomas c7cc3088bd Merge pull request #1328 from marcoarnulfo/fix/mp4-nearest-frame
codecs: MP4 frames stamped just after the frame grid show on their own frame
2026-10-13 00:03:07 -04:00
Brandon Thomas 475aafe033 Merge pull request #1327 from slimwerken/adjustment-layer-transitions
Adjustment layers: a transition fades their effects in and out instead of cutting them in after it
2026-10-13 00:03:06 -04:00
Marco Arnulfo 3a78436665 fix(mcp): a slow bridge command no longer blocks every later call (part of #1538) 2026-10-13 05:40:35 +02:00
sanyamsingh001andCodex 13c89bf9df export: clip caption sidecars to the exported range
Co-authored-by: Codex <codex@openai.com>
2026-10-13 13:32:11 +10:00
heojunfoandClaude Opus 5.5 c11f7b5e09 fix(settings): keep long translations on the Preferences pages (#1517)
Field labels wrap in their column instead of running left over the
category list, dropdowns cut their current choice to their width,
checkbox labels wrap at the page edge, folder fields leave room for
Browse…, and category names too long for the list end in an ellipsis
with the full name in a tooltip. The page rect is registered as
settings.page; long_translations_stay_on_the_page checks every page
in eight languages against it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 11:45:59 +09:00
Jeremy YaoandClaude Opus 5.5 1d1cec80b1 graphics: draw with the newer driver when Windows lists one GPU through two versions of it (#609)
A PC whose GPUs were installed from different driver packages of one vendor
(an ASUS G513QY: the integrated Radeon on the 2021 package, the RX 6800M on a
2026 one) gives the Vulkan loader two versions of that vendor's driver, and
the RX 6800M is listed once through each. wgpu's request_adapter takes the
first discrete entry, the 2021 driver's, and the window drawn through it dies
inside amdvlk64.dll (0xc0000005) before it paints: the main thread is in the
2021 driver, the fault on a thread of the 2026 one, no Rust frame anywhere.
Left to the #820 ladder, two crashed launches end in DirectX 12 only.

filmcraft_gpu::adapter::choose is wgpu's own rule (the adapters that can draw
to the surface, by device type for WGPU_POWER_PREF, then as enumerated) with
one addition: when the adapter that rule picks is listed again by the same
driver (same backend, device type, PCI vendor and device and driver name; the
same bus id when both report one), the entry exposing the most wgpu features
is used, i.e. the newer version (68 against 60 here, a strict superset).
Without such a duplicate the choice is wgpu's own, and entries of one GPU from
different drivers (RADV and AMDVLK, a vendor's driver and Dozen) are left
alone.

graphics::apply installs it as egui-wgpu's native_adapter_selector in the
default mode on Windows, as #609 was asked to be rebased onto the #827
start-up. DirectX 12 only, software mode and the other platforms keep their
adapter choice, and the ladder is untouched. The adapters and the choice go
to the log.

Tests: the laptop's listing (wgpu's rule picks the entry that crashed, choose
the other one), listings without two versions of one driver over every power
preference (choose equals wgpu's rule), surface, bus id and driver cases, and
one against the adapters of the machine running the tests (the rule is what
request_adapter returns there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:31:02 -04:00
Jeremy YaoandClaude Opus 5.5 654b2b8362 Merge main into #609: the #827 graphics start-up replaces this branch's selector
#827 rewrote apps/filmcraft/src/graphics.rs and the start-up in main.rs, so
the selector of this branch no longer applies. main's tree is taken whole
(graphics.rs, main.rs and the README note about WGPU_ADAPTER_NAME included);
the next commit hooks the selector into the GraphicsStart flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:31:02 -04:00
Marco Arnulfo 4ad860cb69 Adjustment layers: a Transform replaces the picture below instead of drawing over it (fixes #1524) 2026-10-13 04:23:52 +02:00
Marco Arnulfo fe7efdaffc Timeline: scrolling the video tracks up leaves room to drop a new track (fixes #1522) 2026-10-13 04:22:50 +02:00
jnohclee-rgb e4a1a7fee3 fix(engine): retain surviving subclip chains and discard unused sources
Signed-off-by: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com>
2026-10-13 09:30:49 +08:00
sanyamsingh001andCodex 2343fc8fa3 captions: preserve speaker names in TTML metadata
Co-authored-by: Codex <codex@openai.com>
2026-10-13 11:07:57 +10:00
jnohclee-rgb bcb7902f42 fix(engine): preserve offline sources referenced through subclips
Signed-off-by: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com>
2026-10-13 08:56:52 +08:00
CokeRoachandClaude Opus 5.5 9203732393 Effects panel: keep the whole name of a folder that contains a slash
The Effects panel drew a folder's name from the last `/`-separated part of
its key (`<top>/<folder>`), so Audio Effects ▸ Noise Reduction/Restoration
showed as "Restoration" — truncated in English and untranslated in every
other language, since the catalogs translate the full name. Split once,
after the top folder, instead.

Test: effects_panel_lists_the_premiere_transition_folders checks the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XWUReif4gqcjU5GsjYioc
2026-10-13 09:40:52 +09:00
NarmjepandCopilot 3b3599d11c fix(timeline): stop Ruler timestamps disappearing while zooming
The Ruler walked one grid (the tick stride) and labelled a frame only when it
was also a multiple of the label stride. Chosen independently, the two need not
divide: at 24 fps and 100 pt a second, 5-frame ticks against 48-frame labels
meet only every lcm(5, 48) = 240 frames — one timestamp per 10 s, the rest
returning at the next zoom step, where the ladder happens to divide.

Take the tick stride as the finest division of the label stride that still
leaves ticks 12 pt apart, so both are one grid. Every label then falls on a
tick and can no longer be skipped, and the ticks stay evenly spaced around it
instead of bunching either side. Labels are >= 110 pt apart, so a divisor of at
most 9 always meets the minimum. The labelled stride, and so every timecode and
the frame it is drawn at, is unchanged.

Strides are clamped to >= 1 (the loop advances by one and tests the other for
divisibility), and the ladder's products saturate, as FrameRate is plain serde
data from the project file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-13 02:27:47 +02:00
Marco Arnulfo f87796f0c8 fix(menus): hovering another title switches the open menu (fixes #1500) 2026-10-13 02:07:14 +02:00
CokeRoachandClaude Opus 5.5 b97bb74330 i18n: Korean (ko) interface catalog (#508)
Adds Korean as an interface language: Edit ▸ Language ▸ 한국어, Settings ▸
General ▸ Interface Language, and System Language for ko / ko-KR locales.

- crates/ui-egui/src/i18n/ko.tsv: 3,573 entries, the Spanish catalog's sources
  plus the macOS Settings item, written clean-room from the plain meaning of
  the English text (sidecar ko.tsv.attribution, ATTRIBUTION.md row).
- Hangul is drawn with the installed system face cjk.rs already adds (Malgun
  Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…); switching is refused with a
  message when there is none, like Chinese.
- Test: korean_covers_the_menus_and_has_no_dead_entries.
- Docs: localization-parity, ROADMAP, ui-egui README.

Native-speaker review welcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XWUReif4gqcjU5GsjYioc
2026-10-13 08:44:01 +09:00
Marco Arnulfo 95297477fd timeline: a middle-button drag pans, whatever the tool (fixes #1490)
The monitors already pan with the middle button (#824). In the Timeline a middle-button drag went
down the left button's path and selected, moved, marqueed, scrubbed or cut, depending on the tool.
It now starts the Hand tool's pan from where the button went down.
2026-10-13 01:31:43 +02:00
Marco Arnulfo c1b1e86046 fix(codecs): in-band parameter sets compare without trailing zero bytes (fixes #1483)
ffmpeg keeps a trailing zero byte after the hvcC PPS that the in-band PPS lacks, so the byte-for-byte comparison read it as a parameter-set change and sent the stream to the software decoder from its first sample. A NAL unit never ends with 0x00 (H.264 7.4.1, H.265 7.4.2): those bytes are trailing_zero_8bits, so both sides are compared without them. The platform fallback test used a trailing zero byte as its changed SPS; it now changes nal_ref_idc.
2026-10-13 01:16:52 +02:00
Marco Arnulfo bef9a6e127 fix(ui): Space and other shortcuts keep working after dragging a pane divider (fixes #1482) 2026-10-13 01:12:57 +02:00
fabian-coandClaude Opus 5.5 eb81d531eb macOS: open the window on Intel Metal by skipping indirect-call validation
wgpu validates indirect draw and dispatch arguments with a compute shader it
compiles when the device is created. Metal on an Intel Iris Pro (MacBookPro11,3,
macOS 12.7.6) fails to compile it ("Compiler encountered an internal error" in
MTLCompilerService), the device is lost, and the window never opens.

FilmCraft draws and dispatches nothing indirectly, so leave the check out of the
instance flags. WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. VectorCraft
does the same (storytold/vectorcraft#651).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:45 -05:00
sanyamsingh001andCodex fa7c6ff8ab ingest: preserve same-named camera takes during ingest
Co-authored-by: Codex <codex@openai.com>
2026-10-13 08:42:29 +10:00
Marco Arnulfo e22a49db1c fix(audio): crossfade audio transitions through the clip handles (fixes #1473) 2026-10-13 00:25:27 +02:00
Fabio LussiandClaude Opus 5.5 ddaf78c221 Keep transform handles in reach when the picture is past the monitor edge
The Program monitor's transform box drew and hit-tested its handles at
their true positions only, and the painter and the ui clip stop at the
picture area. A still moved to or past the frame edge left its resize
handles off the monitor. Handles beyond the monitor now stop at its
edge (the picture itself is not moved), and the box is drawn over the
whole monitor rather than only the picture.

Fixes #1393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:21:48 +02:00
sanyamsingh001andCodex d91a7b3122 ui: keep Metadata and Info on the selected source item
Co-authored-by: Codex <codex@openai.com>
2026-10-13 08:11:35 +10:00
txiki739andClaude Opus 5.5 55c583f62b #262: Linux file dialogs shown off the UI thread keep case-insensitive filters
#260 moved Linux file dialogs to a worker thread (run_dialog_off_thread),
which built its rfd filters from the plain extension lists. The
case-insensitive patterns from file_filters::extensions (#262, #390) were
only applied in the synchronous pickers, so on Linux every dialog hid
CLIP.MP4, clip.Mp4 and EDIT.FCPROJ again: import, relink, open file,
open/save project and save as.

prepare_dialog now turns every extension list into those patterns (and
translates the filter names, as before); the project dialogs use
project_patterns(). The literal extension stays first for save suffixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:08:20 +02:00
Marco Arnulfo 59506d9b4a fix(home): list recent projects on Home and in File ▸ Open Recent (fixes #1461) 2026-10-12 23:59:49 +02:00
Marco Arnulfo e5b1ed90a9 fix(dock): dock a panel along the edge of the whole workspace (fixes #1463) 2026-10-12 23:55:59 +02:00
bfenty 1ef2960514 h264: validate PPS fields before arithmetic; conceal weighted_bipred_idc 3 (#1441) 2026-10-12 21:15:13 +00:00
vangel76andClaude Sonnet 5.5 d034c58edd #1450: cargo fmt for the new mpegts tests and window field
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:05:40 +02:00
vangel76andClaude Sonnet 5.5 5198059a7c #1450: borrow program stream packets from the window instead of copying them
scan and read copied the 32-byte head of every packet and every pack, PSM
and PES packet (up to 64 KB) with to_vec before looking at it. They now
borrow the window; the borrows end before the next window access. Output is
unchanged: the new large-stream test (two streams, over 2 MiB, MPEG-1 and
MPEG-2 packs, cuts inside the last packets) passes on the old and the new
code, as do the existing round-trip and truncation tests. The allocation
saving follows from the removed to_vec calls; it is not measured (the
workspace forbids unsafe, so there is no counting allocator).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:05:29 +02:00
vangel76andClaude Sonnet 5.5 afc54a5f38 #1450: do not zero-fill the program stream window past the end of the file
A window refill resized the buffer to at least 1 MiB although read_some can
only fill what the file still holds. Near the end of the file, where the
previous window was shorter, that zeroed about a megabyte for every packet
whose length ran past the end. The buffer is now capped at the bytes left.
Window contents are unchanged: proved against the old refill for many
lengths, positions and sizes, and the zeroed byte count is asserted (at most
1000 against about 500 MiB for the old refill in the tail scenario).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:04:44 +02:00
vangel76andClaude Sonnet 5.5 da13964a82 #1450: table-driven CRC-32 for PSI sections
One table lookup per byte instead of eight shift steps. Proved equal to the
old bitwise loop (kept in the test as the reference) for every single byte,
random inputs of 0..4096 bytes, the CRC-32/MPEG-2 check value and the
section-plus-own-CRC-is-zero property.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:04:44 +02:00
vangel76andClaude Sonnet 5.5 1990bde6d3 #1450: fix two stale doc comments in mpegts
Unit.pos for transport streams is a byte offset (scan stores off + k *
packet_size), not a packet index; frame_bytes carried the doc line of
frame_info above its own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:02:55 +02:00
vangel76andClaude Sonnet 5.5 5b76035c3d #1450: drop the unread PesHeader.stream_id and packet_length
Nothing outside one test read them. No behaviour change; the existing
header tests cover parse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:02:55 +02:00
Marco Arnulfo 4d58c67745 fix(bitstream): map se(v) through the 64-bit codeNum so i32::MIN round-trips (fixes #1428) 2026-10-12 22:48:18 +02:00
MarekSpodBiedry 542300e911 fix(media-browser): drag every selected file to the Timeline, not just one (#1360) 2026-10-12 22:30:00 +02:00
vangel76andClaude Sonnet 5.5 393284d78f #1430: mpegts: resync inside the loaded block, bad adaptation field is not lost sync
Problem: in ts::scan every packet-parse failure re-read a whole 4096-packet
block from an offset that advanced about one packet. packet() returned None
both for a bad sync byte and for an adaptation_field_length that overruns the
packet, so aligned packets with a bad adaptation field cost one ~770 KB read
each (a 564 KB file made ByteSource::read_at fetch 846 MB). The fallback
offset (`pos + got`) also overshot by k packets, skipping unread data, and the
five-packet sync window only looked at the first packet_size start positions.

Fix: packet() distinguishes Bad::Sync from Bad::Adaptation. A bad adaptation
field means "no payload, grid intact": count a warning and go on with the next
packet. A real sync loss is repaired inside the loaded block (resync(): first
start whose five sync bytes line up, any distance); the block is refilled
from the file at the exact new offset only when it is used up. read() skips a
bad-adaptation packet instead of ending the unit.

Tests: read-volume tests with a counting ByteSource (bad adaptation fields,
sync byte every 189 bytes), and a resync test with a garbage prefix and
mid-stream garbage around the block boundary for 188- and 192-byte packets.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:09:27 +02:00
vangel76andClaude Sonnet 5.5 c6b5943e7e #1432: mpegts: bounds-check frame_info, cap chunks, units and allocations, drop non-PES data
Problem: frame_info indexed byte 6 of an AC-3 header that is only guaranteed
6 bytes long (panic), and gave E-AC-3 fscod2 3 a sample rate of 0. The PES
modes of Splitter never pruned their chunk list. read_unit reserved up to
4 GiB up front, the unit table was uncapped and 4-byte E-AC-3 "frames"
amplified input 16x. PesAsm kept passing continuation packets of a non-PES
unit start, and an adaptation-field-only unit start destroyed the PES in
progress.

Fix: b.get(6)? and None for the reserved rate; no chunk bookkeeping where
positions come from feed(); EAC3_MIN_FRAME = 8; MAX_UNITS (2^23) per stream
with a warning; read_unit preallocates at most 1 MiB and refuses units over
128 MiB; PesAsm gets a dropping state and ignores payload-less unit starts.
Regression tests for each.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:08:40 +02:00
vangel76andClaude Sonnet 5.5 cf9ad66732 #1429: MPEG-1 program stream cut inside a pack header no longer panics
Problem: packet_len accepted an MPEG-1 pack header (b[4]>>4==2) with
only 6 bytes available, but scan reads the SCR from head[4..9]. A file
ending inside the last pack header (00 00 01 BA 21 00 00) panicked with
"range end index 9 out of range".

Fix: packet_len requires the full 12-byte MPEG-1 pack header; a shorter
tail is not a packet and takes the normal resync path. The MPEG-2 branch
already needs 14 bytes (b.get(13)) before scan reads 10, so it is fine.
Tests: truncate a valid MPEG-1 and MPEG-2 program stream at every byte
length, plus the minimal 7-byte input; both panicked before the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:07:25 +02:00
bfenty db6f5194fe Add edit.rippleDeleteHead (Ripple Delete Head Frame)
Remove the selected clips' heads - from each in point through the
playhead - and ripple the sequence closed. Linked clips act as one
group through edit::ripple_trim_group; a playhead past every member's
out point deletes the group whole via edit::ripple_delete_items.
Refused at or before a member's in point, and on locked members.

Tests: crates/engine/src/ripple_head_tests.rs (4 tests, red on main,
green with the patch, negative control by stash round-trip).

Fixes #1357
2026-10-12 19:52:29 +00:00
vangel76andClaude Sonnet 5.5 8457e7bf31 #1322: the Properties panel scrolls
Problem: the Properties panel laid its sections out in a plain child ui, so
when the panel was shorter than its content (Transform, Crop, Audio, the
speed button) the lower part was cut off and the wheel did nothing.

Fix: put everything below the clip header in a vertical ScrollArea, keeping the
rows 14 px clear of the scrollbar so the keyframe arrows at the row end stay
clickable. The speed button gets the automation id `properties.speed`.
Regression test properties_scroll_ui scrolls a short panel and checks the
speed button moves; it fails without the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:19:59 +02:00
vangel76andClaude Sonnet 5.5 595f94f303 #1407: import Radiance HDR stills and image sequences
Problem: Premiere reads Radiance HDR (.hdr) pictures; FilmCraft listed
the extension nowhere and could not open them.

Fix: clean-room RGBE decoder in filmcraft-media from the public format
description: header with FORMAT, all eight axis orders, flat, old
run-length and new per-channel run-length scan lines. Frames are linear
float (Rec.709), like EXR. Allocations are bounded by the data present;
hostile headers and truncated data return errors. "hdr" joins the
importable still extensions; stills and image sequences share the decoder.
Output equals ffmpeg's decode exactly for flat and RLE files; EXPOSURE and
PRIMARIES lines are ignored and XYZE is refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:12:44 +02:00
vangel76andClaude Sonnet 5.5 9deba4a830 #1408: Shift constrains mask vertex and whole-mask drags to 45 degrees
Problem: Shift snapped pen segments (#853) but dragging an existing mask
vertex or the whole mask still moved freely.

Fix: while Shift is held the movement since the press is constrained to
horizontal/vertical/diagonal steps (accumulated raw vs applied totals per
gesture, so each frame's delta stays a single undo step). Unit test for
the pure step function; gaps.md G18 updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:12:19 +02:00
vangel76andClaude Sonnet 5.5 af5d29310e #828: import PXR24-compressed OpenEXR
Problem: OpenEXR files with PXR24 compression (a common lossy 24-bit
float format from renderers and compositors) were refused with "not
supported yet".

Fix: decode PXR24 chunks (16 lines, deflated byte planes of per-line
sample differences; floats keep their top 24 bits) into the existing
scan-line pipeline. Bounds-checked, no panics on damaged data. Tests:
hand-built chunks with half/float/uint channels and a partial last
chunk, damaged-file cases, and an OpenImageIO-written oracle comparing
our decode with OpenEXR's own (skips without oiiotool). Docs updated;
stale AVI / FLAC-in-MP4 entries removed from G10.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:56:26 +02:00
vangel76andClaude Sonnet 5.5 3516207de7 #1402: import MP1 (A_MPEG/L1) audio from Matroska
Problem: the Matroska demuxer mapped A_MPEG/L2 and A_MPEG/L3 only, so a
Layer I track was reported as an unsupported codec even though the MP1/MP2
decoder (#809) reads Layer I.

Fix: add Codec::Mp1 for A_MPEG/L1 and route it through the same packet
duration and decoder path as Mp2. Test remuxes synthetic Layer I frames
into Matroska with ffmpeg and compares our decode (max error 3e-5).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:55:35 +02:00
webdevtodayjason 20d196401a Captions: preserve tab-separated WebVTT voices 2026-10-12 13:51:26 -05:00
Marco Arnulfo 6192214def fix(previews): a nest's audio preview follows the sound of its sequence (#1370)
The content hash of a nested sequence covered only its video tracks, for
audio segments too. Changing the sound inside a nest (a clip's gain, a
muted track, a new clip) left the outer sequence's rendered audio preview
valid, so playback kept the old mix, or silence. Audio segments now hash
the nested sequence's mix (audio tracks, their clips recursively, master
and submixes) instead of its picture, so editing the picture inside a nest
no longer invalidates its sound either.
2026-10-12 19:45:00 +02:00
vangel76andClaude Sonnet 5.5 5553e26e12 #1371: export preset query also matches the format id
Problem: `export --list-presets h264` returned nothing because the query
only matched name, description and category, while `h264` is the format
id the CLI takes everywhere else.

Fix: also match the preset's format id and label, compared with
punctuation stripped, so `h264`, `h.264` and `H.264` are equivalent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:39:44 +02:00
vangel76andClaude Sonnet 5.5 1d62db0172 #1368: nudge clip selection up/down no longer fails for linked video+audio
Problem: timeline.nudgeUp/nudgeDown returned an error for the whole selection when any selected clip was already on the outermost track in that direction. A video clip with its linked audio (A1 has no track above) therefore could never be nudged.

Fix: clips with no destination track stay where they are; the command only errors when nothing could move. Regression test covers a linked selection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:35:54 +02:00
Nai-ITA 88675faf58 Merge pull request #1 from Nai-ITA/Nai-ITA-patch-1
Add files via upload
2026-10-12 18:57:14 +02:00
Nai-ITA d2ae945c30 Add files via upload 2026-10-12 18:56:50 +02:00
Marco Arnulfo c580406234 fix(masks): Delete / Backspace and Cmd+Z take back the last pen point while drawing (fixes #1345) 2026-10-12 18:45:56 +02:00
jahvariandCodex 44baf6c0f4 WIN-03: Make AVI oracle file reads portable
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-10-12 11:43:18 -05:00
NetterSilver a6a7f95579 Docs: update the progress docs for this change 2026-10-12 18:36:19 +02:00
Marco Arnulfo 67f1ea1ae3 render: Sharpen stays visible at reduced playback resolution (fixes #1351) 2026-10-12 18:28:53 +02:00
Marco Arnulfo dadbfe0bf8 fix(masks): hide the mask handles while the clip is not selected (fixes #1341) 2026-10-12 18:17:42 +02:00
Marco Arnulfo 528e525d67 fix(shortcuts): Clear and reassign also drop a command's panel copy of its key (part of #1333) 2026-10-12 17:44:34 +02:00
Marco Arnulfo 3f61c036ff codecs: MP4 frames stamped just after the frame grid show on their own frame (fixes #1282)
Phone footage stamps its frames up to a millisecond after the frame grid
(a first frame 34.1 ms long at 30 fps). The frame shown at a time is the
last one starting at or before it, so a sequence frame asked at its exact
start got the picture before: the clip ran one frame behind and showed
frame 0 twice. The request is already rounded to the track timescale
(#73, as WebM in #83), but at 90 kHz a millisecond is 90 units.

When opening an MP4, the video samples of a file that is a constant rate
gone slightly off its grid are put back on it: the grid starts at the
first picture and steps by the nominal frame duration, and the file is
snapped only when every picture is less than a quarter of a frame from
its own grid point and no two pictures share a grid point. Anything
further off is variable frame rate and keeps all its times; a picture one
unit off is left where it is, since that can't be told from timescale
rounding. Only the video sample times move: the audio, the first picture
and the clip duration are unchanged.
2026-10-12 16:50:49 +02:00
Bart BoonstraandClaude Opus 5.5 6bc526b5e1 Adjustment layers: a transition fades their effects in and out instead of cutting them in after it
During a transition on its track an adjustment layer rendered as nothing, because it has no picture
of its own: Cmd+D at its start left the effects off for the whole dissolve and then cut them in.
In a transition the layer is now the frame below with its effects (opacity, masks and Motion region
included), so a dissolve fades the effects. A fade from or to nothing keeps the layer's blend mode.
Frames where a transition touches an adjustment layer render on the CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:28:00 +02:00
Vikram Bhaskaran b5e05a6501 Register .fcproj as a document the macOS app can open from Finder. 2026-10-13 00:39:15 +11:00
Vikram Bhaskaran 7fdccec8d7 Keep the project-panel sort arrow from covering a narrow column name. 2026-10-13 00:39:09 +11:00
gyro f21434cf9f fix(ui): open the context menu on macOS Ctrl+Click
On macOS, AppKit delivers Ctrl+Click to the winit view as a primary mouse press carrying the Control modifier — rightMouseDown only fires for a real right button or a two-finger trackpad click. winit 0.30 (button 0 → Left) and egui-winit 0.36 (Left → PointerButton::Primary) pass it through untouched, so egui's secondary_clicked() never fires and no context menu opens.

Context menus subscribe to egui's secondary click, so the translation is done once at the input boundary, as the platform convention requires: winit deliberately leaves Ctrl+Click handling to the application (rust-windowing/winit#4469, wontfix), and macOS apps translate it themselves (Firefox does the same in nsCocoaWindow.mm).

raw_input_hook runs before egui begins its pass, when pointer click state is computed — the only seam where a rewritten event has the right effect. Primary presses carrying the Control modifier become Secondary presses; the press and its release are paired so releasing Control mid-click still closes as a context click and never leaks a stray primary click, and a plain press or PointerGone clears any stale pairing. Real right clicks and other buttons pass through unchanged; Windows and Linux keep Ctrl+Click as a selection modifier, and control-channel/automation input is exempt.

Unit tests cover the transformation through egui's real pointer state: a rewritten Ctrl+Click makes pointer.secondary_clicked() true, plain clicks are untouched, and a stale pairing cannot corrupt the next click.
2026-10-12 05:21:42 -07:00
lloydandClaude Opus 5.5 b6866db1b2 Import YUV4MPEG2 (.y4m) video; stop offering .ale for import (#1292)
Both were offered for import but could never be imported.

`.y4m` is in VIDEO_EXTENSIONS (and file-format-parity.md lists Y4M as
read), but nothing read it. `codecs::y4m` is a YUV4MPEG2 source written
from the format's public documentation (yuv4mpeg(5)): the W/H/F/A/I/C/X
stream header, FRAME lines with their own parameters, 8-bit 4:2:0 / 4:2:2
/ 4:4:4 / 4:4:4 + alpha / mono and 9–16-bit little-endian
(`420p10`, `422p12`, `mono16`, …) as Yuv8 / Yuv16 frames, XCOLORRANGE=FULL
and the pixel aspect. Frames are indexed once from their header lines and
read on demand (a reader opener too), so large files aren't loaded whole.
Sizes, rates and offsets are checked; 4:1:1 and unknown colour spaces are
an "unsupported" error.

The Media Browser listed `.ale` among project files, but FilmCraft only
writes ALE (File ▸ Export; Premiere doesn't import it either, per
file-format-parity.md), so importing one always failed. It is no longer
listed.

Tests: hand-built Y4M files (4:2:0 frame by frame, 10-bit 4:2:2 with frame
parameters, aspect and full range, mono, the reader path), truncated /
bit-flipped / hostile headers under catch_unwind, and an ffmpeg oracle
(8-bit 4:2:0 and 10-bit 4:2:2 files equal ffmpeg's rawvideo output byte
for byte); the browser no longer classifies `.ale`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:12:21 +02:00
lloydandClaude Opus 5.5 d6df5c6abd media: decode uncompressed and PackBits YCbCr TIFF stills (#1293)
TIFFs stored as YCbCr (PhotometricInterpretation 6) without JPEG
compression failed with "does not support the color type Unknown(24)":
the image crate reads YCbCr only inside JPEG compression. ffmpeg writes
these whenever the source is YUV, so an ordinary frame grab
(`ffmpeg -i clip.mp4 -frames:v 1 grab.tiff`) couldn't be imported.

`still::ycbcr_tiff` decodes them from TIFF 6.0 (§21 YCbCr images, §20
ReferenceBlackWhite, §9 PackBits), reading the directory with
filmcraft-dng's TIFF parser: 8-bit chunky data units with subsampling 1, 2
or 4 each way (the image padded to whole data units), converted with the
file's YCbCrCoefficients and ReferenceBlackWhite (or the §21 defaults).
Every size, offset and count is checked, the picture is capped at 2^28
pixels, and separate planes, tiles and other bit depths are an
"unsupported" error. JPEG-compressed and RGB TIFFs still go to the image
crate.

Tests: hand-built files for data-unit placement (4:2:0), video-range
reference levels with PackBits (4:4:4), sizes that aren't whole data
units, and truncated/bit-flipped/hostile files under catch_unwind; an
ffmpeg oracle compares ffmpeg-written 4:2:0, 4:2:2 and 4:4:4 TIFFs (95×63)
with ffmpeg's own RGB decode (every channel within 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:06:04 +02:00
lloydandClaude Opus 5.5 52b36f9764 codecs: import raw ADTS .aac files (#1291)
File ▸ Import and the Media Browser offer `.aac` (it is in AUDIO_EXTENSIONS),
and Export ▸ AAC Audio writes ADTS `.aac` (#814), but no opener read them:
`audio::opener` only accepted mp3/mp2/flac/ogg/aif by extension, so every
`.aac` failed with "unrecognised format".

`AudioFileSource` now decodes a standalone ADTS stream with our AAC decoder
(ISO/IEC 13818-7 §6.2): frame after frame from the first header (after an
ID3v2 tag), resyncing past damage, skipping frames with another
configuration, keeping a frame that doesn't decode as silence, and
upsampling HE-AAC to its output rate. Decoding stops with an error past
4 GiB of samples instead of allocating without bound.

Tests encode a stereo tone with our encoder's ADTS output and import it
(rate, channels, duration, level), and run truncated, bit-flipped and
bad-length streams under catch_unwind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:01:33 +02:00
lloydandClaude Opus 5.5 69a6c4b625 packaging: Linux file managers offer FilmCraft for .fcproj projects and video files
The desktop entry declared no MimeType and the shared-mime-info file was an
empty stub, so no file manager listed FilmCraft under "Open With" for any
file, and a double-clicked .fcproj had no type to open with. The app already
handles both from the command line (`%F`): a project opens, media is
imported (#850).

- mime.xml: define application/x-filmcraft for *.fcproj, a JSON subclass
  with the app icon; the same name the macOS UTI declares in Info.plist.in.
- Desktop entry: MimeType lists the project type and the video containers
  FilmCraft imports (MP4/M4V, QuickTime, Matroska, WebM, AVI, MXF,
  MPEG-TS, MPEG-PS, 3GP). Audio and stills are left out so an NLE doesn't
  crowd every image and song's Open With list.
- AppStream: the metainfo <provides> the project type.
- Flatpak bundle: the comment no longer says the MIME file has no types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:09:57 +02:00
NetterSilver 4a75bc05fa Add my contributor entry 2026-10-12 12:11:07 +02:00
NetterSilver eb3afad326 Transform shutter: use the sequence frame rate on adjustment layers (#1088)
Adjustment layers render their effects without an FxEnv, so Transform's
shutter fell back to 24 fps. In a 60 fps sequence a 360° shutter then
spanned 2.5 frames, blurring one frame before the move starts and one
after it ends. FxCtx now carries the sequence frame rate, set wherever a
sequence is known, and Transform uses it.
2026-10-12 12:11:03 +02:00
NDelicandClaude Opus 5.5 ec1570a4d8 #1081: Align to Video Frame works on selected video and image clips
Graphics ▸ Align to Video Frame (Left, Center Horizontally, Right, Top,
Center Vertically, Bottom) only reached layers inside a graphic clip. With
video or image clips selected instead, it now aligns each clip's scaled and
rotated bounds to the frame by moving Motion ▸ Position only, at the
playhead (a keyframe only when Position is already animated, as typing the
value would). Scale, rotation and other keyframes stay untouched; one undo
step. A selected or named graphic clip still aligns its layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:34:58 +02:00
NDelicandClaude Opus 5.5 d466995942 #1023: the eyedropper picks from the Program monitor again
With a colour parameter's eyedropper armed (Color Key, Ultra Key, …), a
click on the Program picture did nothing: the selected clip's on-monitor
transform handles (#639) are drawn over the picture and took the click.
The clip is always selected while its effect shows in Effect Controls, so
the eyedropper never fired. While an eyedropper is armed the picture
overlays (transform handles, masks, graphics) are not drawn, so the click
reaches it; they come back once the colour is picked or Esc disarms it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:30:31 +02:00
NDelicandClaude Opus 5.5 d74ed39c24 Contributors: add n-delic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:18:07 +02:00
NDelicandClaude Opus 5.5 37a845bc68 #1241: Select All in Effect Controls selects its keyframes
With Effect Controls focused, Edit ▸ Select All (⌘A / Ctrl+A) selected
every clip in the Timeline. It now selects every keyframe the panel shows
(effect parameters and mask parameters of the shown clips) and leaves the
Timeline selection alone. With no keyframes shown, or another panel
focused, Select All behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:17:58 +02:00
Ikki Nishihara 8cd44f205b fix: ロックしたトラックの斜線表示とドラッグ保護を修正 (#1191) 2026-10-12 17:26:18 +09:00
gyro 25aeb4d8a4 Fix clip drags leaving transitions behind
Dragging a clip kept its transitions on their old spots, so they jumped to the
dropped position on release instead of moving with the clip. The drag preview now
draws each transition where the drop will leave it, applying the same rule the
edit does: a fade travels with its clip, and a cut transition travels only when
every clip it sits on moves to one track by one offset. A cut transition whose
one side stays behind is dropped on release; the preview keeps it on its cut
while dragging, and Option-drag copies show a ghost transition beside the
copies, duplicating only the transitions whose every clip is copied.

- edit: carried_transitions keeps only all-clips-move transitions; move_items
  and copy_items drop or skip the rest
- ui: move_transition_preview draws carried transitions where they land
- tests: timeline_transition_move_ui (fade, both-clips cut, XYZ partial drop,
  cross-track drop, Option-drag ghost); transitions_travel_with_moved_clips
  updated to the drop-on-partial rule
2026-10-12 00:41:05 -07:00
MarekSpodBiedry fe3c60b97a test(i18n): skip the Japanese language step when no Japanese font is available (#1251) 2026-10-12 09:38:51 +02:00
Akash Bhat 50dd69ce32 fix(apv): skip oracle tests when ffmpeg predates APV support
The APV demuxer is new in ffmpeg 8.0, so ubuntu-24.04 runners failed instead of skipping. testkit gains ffmpeg_with_format_or_skip: when ffmpeg -formats does not list apv it SKIPs naming the detected version, failing only under FILMCRAFT_REQUIRE_ORACLES=1. The apv oracle uses it; docs/testing.md notes ffmpeg >= 8.0.

Fixes #1249.
2026-10-12 13:02:48 +05:30
Ikki Nishihara 917d23fdff fix(timeline): クリップの右クリックにマーカー追加を用意する (#1184) 2026-10-12 15:10:24 +09:00
Claude 6c13dda2bf export: 32-bit integer and 32-bit float audio; Sample Size lists every size and greys out the unsupported ones
- AudioSettings gains `float`; `bits` takes 16, 24 or 32. `SampleFormat` (Int16 / Int24 /
  Int32 / Float32) and `ExportSettings::supports_sample_format` decide what each codec and
  container stores: WAV, AIFF and QuickTime PCM all four, MXF integers, FLAC integers.
  `validate` refuses float without 32 bits and float in FLAC or MXF.
- WAV float is WAVE_FORMAT_IEEE_FLOAT (EXTENSIBLE for more than two channels) with a `fact`
  chunk; AIFF float is AIFF-C `fl32`; QuickTime float is `lpcm` with the float flag; MXF writes
  32-bit integers. Float keeps the mix as it is, peaks above 0 dBFS included; 16 and 24 bit
  write the same bytes as before.
- FLAC encoder: 8-32 bits. 32-bit stereo is coded independently (a side channel would need 33
  bits), the sample range check no longer overflows, and 32-bit frames point at STREAMINFO for
  the size, which symphonia 0.5 (FilmCraft's own FLAC import) and ffmpeg both read.
- Export mode: Sample Size shows 16 bit, 24 bit, 32 bit and 32 bit float like Audio Format
  does, greying out what the format cannot store with the reason on hover; a size that stops
  being available (float after switching to FLAC) becomes 32-bit integer. es, fr, pt-br, id,
  ja and zh-cn strings.
- Tests: writer layouts and ffprobe codecs for every new format, round trips through ffmpeg
  and FilmCraft's own readers in WAV, AIFF, MOV, MXF and FLAC, headroom of a +6 dB tone,
  refused combinations, full-scale 32-bit FLAC through ffmpeg (MD5 too) and symphonia, and a
  UI test of the selector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nzevSsTNZxHPpUngfGTAw
2026-10-12 05:42:44 +00:00
Ikki Nishihara aa9d78cd9c fix: 新規トラック移動のリンクと編集履歴を保持 (#1187) 2026-10-12 13:40:32 +09:00
Diego Ospina 369882123f i18n(es): fix mistranslations and terms, add 4 missing rows
Scratch disks, sidecar files, burn-in, legal range, through edits, nudge
units, butt cap and camera shake were mistranslated; render, view preset,
transport and focus now match the rest of the catalog; four macOS menu
and Preferences rows were missing.

AI-assisted, native review welcome.

Models: Opus 5.5, Terra 5.6, DeepSeek V4 Flash, Mistral Large 4
2026-10-11 23:08:20 -05:00
liangforstudyandClaude Opus 5.5 d73196f3f7 Help menu: a dialog keeps the address when no browser opens; shorter hints
When the web browser can't be opened, a dialog now shows the reason and
the address in a read-only field you can select from, with Copy, and
stays until Close or Escape. The status bar message it replaces cleared
itself after 8 seconds and claimed the address was on the clipboard,
which egui can't confirm (a failed copy is only logged), so the address
could be gone after switching to the browser window.

The Help hints lose their "(no web page)" suffix, and System
Compatibility Report's hint is just "Shows a report on this computer."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:21:44 +08:00
webdevtodayjason 518df2fb92 Captions: preserve fractional TTML tick offsets 2026-10-11 22:13:01 -05:00
webdevtodayjason aff5e7f194 Contributors: add webdevtodayjason (Jason Brashear) 2026-10-11 22:13:01 -05:00
liangforstudyandClaude Opus 5.5 3f5d4385dd Help menu: say where each item goes; keep the address when no browser opens
Hovering a Help menu item now tells you what it does: the links (ArtCraft
Discord, website, app page, GitHub, Report an Issue, FilmCraft Help) show
"Opens in your web browser: <address>", and Reveal Log Files, System
Compatibility Report and About FilmCraft say they stay in FilmCraft (no
web page).

When the host can't open a browser, the click was only logged. Now the
address is copied to the clipboard and the status bar names the reason
and the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:25:53 +08:00
Nigel Ihza Firdausy 1186b242ba timeline: Alt marquee selects clips without linked partners (#1163) 2026-10-12 09:23:06 +07:00
Jeremy YaoandClaude Fable 5.1 c733040a5c Windows: draw with the newest driver when one GPU is listed through two Vulkan drivers
A PC whose GPUs were installed from different driver packages (an ASUS G513QY:
the integrated Radeon on the 2021 package, the RX 6800M on the 2026 one, each with
its own Vulkan ICD) lists the RX 6800M twice. wgpu's default takes the first
discrete adapter, the stale ICD, and drawing through it dies with 0xc0000005
inside amdvlk64.dll before any Rust frame can catch it.

apps/filmcraft/src/graphics.rs installs an egui-wgpu native_adapter_selector that
keeps wgpu's ranking (device type for WGPU_POWER_PREF, then enumeration order)
between different GPUs but, among entries that are one physical GPU (same backend,
vendor and device id; bus id when both drivers report one), prefers the entry
exposing the most wgpu features, i.e. the newer driver. Adapters that cannot draw
to the window are skipped. With one driver per GPU the rule has nothing to merge
and the choice is wgpu's own. WGPU_ADAPTER_NAME (part of the name, backend or
driver text) picks an adapter by hand; candidates, duplicates and the choice are
logged.

The choice is a pure function over plain candidate data, with regression tests
built from this machine's vulkaninfo listing (wgpu's rule picked the crashing
entry 0; the selector picks entry 1) plus wgpu-order, surface, two-cards and
override cases. Verified on the affected laptop: the release build now opens,
answers the control channel, screenshots and quits (exit 0); it crashed on every
launch before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 04:25:34 -04:00
172 changed files with 16149 additions and 791 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ plan/
/log
.DS_Store
.claude/worktrees/
__pycache__/
+2
View File
@@ -9,7 +9,9 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| Asset | Author | Source | Licence |
|---|---|---|---|
| `crates/ui-egui/src/i18n/it.tsv` | FilmCraft contributors | Original work: clean-room Italian interface translations | MIT OR Apache-2.0 |
| `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/ko.tsv` | FilmCraft contributors | Original work: clean-room Korean 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 |
+7
View File
@@ -347,6 +347,13 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
A PC whose GPUs were installed from different driver packages of one vendor (a laptop with an
integrated and a discrete AMD GPU, for example) can list one GPU twice, once through each installed
version of the Vulkan driver, and a window drawn through the stale one crashes inside the driver
(`amdvlk64.dll`, exit code `0xc0000005`). FilmCraft draws with the newer one and says so in
`%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for every GPU removes the
duplicate. With one driver per GPU the adapter is the one wgpu picks, as before.
### macOS
| Build | File | Notes |
+4 -1
View File
@@ -84,9 +84,10 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| 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% |
| Italian | partial (whole interface; native review welcome) | ~100% |
| Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | menus only (native-reviewed) | 10.6% |
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
@@ -106,6 +107,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
## Progress log
- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
- **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).
@@ -164,6 +166,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
| 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 |
+51 -2
View File
@@ -5,6 +5,13 @@
//! `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.
//!
//! # The adapter
//! In the default mode on Windows the adapter is wgpu's own choice, except for a GPU that Vulkan
//! lists through two installed versions of one driver: there the newer one draws, because a
//! window drawn through the stale one dies inside the driver (`amdvlk64.dll`; see
//! `filmcraft_gpu::adapter`). That case is avoided up front instead of left to the fallback
//! below, which would end in DirectX 12 only.
//!
//! # 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`,
@@ -427,6 +434,11 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<B
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
return;
};
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
// Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns
// the check back on.
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
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
@@ -435,7 +447,16 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<B
// 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 if windows => {
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
// wgpu's own choice of adapter, except for a GPU that Windows lists through two
// versions of one driver: the first entry can be a stale one that crashes inside the
// driver (`filmcraft_gpu::adapter`). A selector replaces `power_preference`
// (`WGPU_POWER_PREF`), so it takes that along.
let power = create.power_preference;
create.native_adapter_selector =
Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| filmcraft_gpu::adapter::pick(adapters, surface, power)));
}
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,
@@ -484,6 +505,22 @@ mod tests {
(create.instance_descriptor.backends, create.native_adapter_selector.is_some())
}
#[test]
fn no_mode_validates_indirect_calls() {
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
return;
}
for mode in [Mode::Default, Mode::Dx12, Mode::Software] {
let mut options = NativeOptions::default();
apply_for(&mut options, mode, None, false);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
// Intel Metal can't compile the validation's compute shader, and the device is lost.
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "{mode:?}");
}
}
#[test]
fn windows_default_leaves_out_gl() {
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
@@ -495,7 +532,19 @@ mod tests {
assert_eq!(backends, Backends::PRIMARY);
assert!(!backends.contains(Backends::GL));
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
assert!(!selector);
// and chooses the adapter itself: a GPU listed through two versions of one driver
assert!(selector);
}
#[test]
fn only_the_windows_default_and_software_modes_choose_the_adapter() {
for backend_override in [None, Some(Backends::VULKAN), Some(Backends::DX12)] {
assert!(configured(Mode::Default, backend_override, true).1);
// elsewhere, and in DirectX 12 only mode, the adapter is wgpu's own choice
assert!(!configured(Mode::Default, backend_override, false).1);
assert!(!configured(Mode::Dx12, backend_override, true).1);
}
assert!(configured(Mode::Software, None, true).1 && configured(Mode::Software, None, false).1);
}
#[test]
+54 -7
View File
@@ -447,11 +447,8 @@ fn run_dialog_off_thread(dialog: 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 project_exts = project_patterns();
let dialog = prepare_dialog(dialog);
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 || {
@@ -462,9 +459,9 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
}
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::OpenProject => rfd::FileDialog::new().add_filter(&project, &project_exts).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()
rfd::FileDialog::new().add_filter(&project, &project_exts).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()
@@ -492,6 +489,29 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog)
rx
}
/// A dialog as the Linux dialog thread shows it: filter names translated (here, on the UI thread:
/// the interface language is per thread) and every extension list turned into the
/// case-insensitive patterns the synchronous pickers use, so `CLIP.MP4` stays visible (#262).
#[cfg(target_os = "linux")]
fn prepare_dialog(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> filmcraft_ui_egui::native_dialogs::FileDialog {
use filmcraft_ui_egui::native_dialogs::FileDialog;
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
let patterns = |exts: Vec<String>| file_filters::extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>());
match dialog {
FileDialog::OpenFiles { exts } => FileDialog::OpenFiles { exts: patterns(exts) },
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts: patterns(exts) },
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts: patterns(exts), name },
FileDialog::Relink { exts, hint } => FileDialog::Relink { exts: patterns(exts), hint },
other => other,
}
}
/// The project dialogs' filter patterns (`EDIT.FCPROJ` included).
#[cfg(target_os = "linux")]
fn project_patterns() -> Vec<String> {
file_filters::extensions(&["fcproj"])
}
#[cfg(test)]
mod tests {
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
@@ -505,4 +525,31 @@ mod tests {
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
}
/// #262 on Linux: the dialogs shown off the UI thread (#260) filter with the same
/// case-insensitive patterns as the synchronous pickers, so upper- and mixed-case files show.
#[cfg(target_os = "linux")]
#[test]
fn off_thread_dialogs_keep_case_insensitive_filters() {
use filmcraft_ui_egui::RelinkHint;
use filmcraft_ui_egui::native_dialogs::FileDialog;
let mp4 = || vec!["mp4".to_string()];
let hint = RelinkHint { command: "media.relink".into(), params: serde_json::json!({}) };
for dialog in [
FileDialog::OpenFiles { exts: mp4() },
FileDialog::OpenFile { filter: "Media".into(), exts: mp4() },
FileDialog::SaveAs { filter: "Media".into(), exts: mp4(), name: "clip.mp4".into() },
FileDialog::Relink { exts: mp4(), hint },
] {
let exts = match super::prepare_dialog(dialog) {
FileDialog::OpenFiles { exts } | FileDialog::OpenFile { exts, .. } | FileDialog::SaveAs { exts, .. } | FileDialog::Relink { exts, .. } => exts,
other => panic!("the dialog changed kind: {other:?}"),
};
assert_eq!(exts.first().map(String::as_str), Some("mp4"), "the literal extension stays first (save suffix)");
assert!(exts.iter().any(|e| e == "[mM][pP]4"), "{exts:?}");
}
let project = super::project_patterns();
assert_eq!(project.first().map(String::as_str), Some("fcproj"));
assert!(project.iter().any(|e| e == "[fF][cC][pP][rR][oO][jJ]"), "{project:?}");
}
}
+4
View File
@@ -131,6 +131,10 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
filmcraft_ui_egui::i18n::Language::Fr
} else if checked("app.language.indonesian") {
filmcraft_ui_egui::i18n::Language::Id
} else if checked("app.language.italian") {
filmcraft_ui_egui::i18n::Language::It
} else if checked("app.language.korean") {
filmcraft_ui_egui::i18n::Language::Ko
} else {
filmcraft_ui_egui::i18n::Language::En
};
+11
View File
@@ -77,3 +77,14 @@ sync_github_name = true
real_name = "Valentin Klein"
display_name = "Valentin Klein"
sync_github_name = false
[people.n-delic]
display_name = "n-delic"
sync_github_name = false
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
+3 -1
View File
@@ -8,7 +8,9 @@ use filmcraft_apv::{
};
fn ffmpeg() -> Option<PathBuf> {
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
// The APV demuxer is new in ffmpeg 8.0; older toolchains are found but
// cannot read the fixtures, so they skip like a missing tool (#1249).
filmcraft_testkit::ffmpeg_with_format_or_skip("apv oracle", "apv", "8.0")
}
fn fixture_dir() -> PathBuf {
+95 -19
View File
@@ -1,4 +1,5 @@
//! Client for the desktop app's JSON-lines control protocol. One connection, reconnect on failure.
//! Client for the desktop app's JSON-lines control protocol. A connection per request in flight,
//! reused once its reply is in; reconnect on failure.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
@@ -12,12 +13,17 @@ use crate::AutomationError;
/// How often a blocking export's app job is polled.
const JOB_POLL: Duration = Duration::from_millis(250);
/// Idle connections kept for reuse; the app serves at most 16 at once.
const MAX_IDLE: usize = 4;
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
pub struct BridgeClient {
addr: String,
conn: Mutex<Option<Conn>>,
/// Connections with no request in flight. A request takes one (or opens one) for its whole
/// round trip and puts it back only once the reply is read, so a slow or abandoned request
/// never holds up the others or leaves its late reply for the next one to read (#1538).
idle: Mutex<Vec<Conn>>,
next_id: AtomicU64,
}
@@ -29,7 +35,7 @@ impl BridgeClient {
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
}
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
}
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
@@ -71,20 +77,14 @@ impl BridgeClient {
/// 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;
for attempt in 0..2 {
if guard.is_none() {
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
.await
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
let (r, w) = s.into_split();
*guard = Some((BufReader::new(r), w));
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let Some(conn) = guard.as_mut() else {
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
// A retry gets a fresh connection: the failed one may have been closed by an app restart.
let idle = if attempt == 0 { self.idle.lock().await.pop() } else { None };
let mut conn = match idle {
Some(c) => c,
None => self.connect().await?,
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
let res: Result<Value, AutomationError> = async {
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
@@ -98,21 +98,32 @@ impl BridgeClient {
.await;
match res {
Ok(v) => {
let mut idle = self.idle.lock().await;
if idle.len() < MAX_IDLE {
idle.push(conn);
}
drop(idle);
return if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()))
};
}
Err(e) if attempt == 0 => {
*guard = None;
let _ = e;
}
Err(_) if attempt == 0 => {}
Err(e) => return Err(e),
}
}
Err(AutomationError::Bridge("unreachable".into()))
}
async fn connect(&self) -> Result<Conn, AutomationError> {
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
.await
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
let (r, w) = s.into_split();
Ok((BufReader::new(r), w))
}
}
#[cfg(test)]
@@ -195,6 +206,71 @@ mod tests {
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
}
/// A stand-in for the app that serves any number of connections and answers each request on
/// its own, as the app does: `slow` after `slow_reply` (never when `None`), anything else at
/// once with its command id. The ids of the requests it receives are sent to the returned channel.
async fn slow_app(slow_reply: Option<Duration>) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (seen, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((s, _)) = listener.accept().await {
let seen = seen.clone();
tokio::spawn(async move {
let (r, w) = s.into_split();
let w = Arc::new(Mutex::new(w));
let mut lines = BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let cmd = req["params"]["command"].as_str().unwrap_or_default().to_string();
let _ = seen.send(cmd.clone());
let delay = if cmd == "slow" { slow_reply } else { Some(Duration::ZERO) };
let w = w.clone();
tokio::spawn(async move {
let Some(d) = delay else { return };
tokio::time::sleep(d).await;
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": cmd}));
let _ = w.lock().await.write_all(line.as_bytes()).await;
});
}
});
}
});
(addr, rx)
}
/// A command the app is slow to answer holds up only itself: a later call is answered at once
/// instead of queueing behind it, and behind every call queued after it (#1538).
#[tokio::test]
async fn a_slow_command_does_not_block_later_calls() {
let (addr, mut seen) = slow_app(None).await;
let b = Arc::new(BridgeClient::new(addr).unwrap());
let slow = tokio::spawn({
let b = b.clone();
async move { b.execute("slow", json!({})).await }
});
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
let fast = tokio::time::timeout(Duration::from_secs(5), b.execute("fast", json!({}))).await;
assert_eq!(fast.expect("the later call waited on the slow one").unwrap(), json!("fast"));
slow.abort();
}
/// A call given up while it waits leaves no late reply on a connection for the next call to read
/// as its own (#1538).
#[tokio::test]
async fn an_abandoned_call_leaves_no_late_reply_behind() {
let (addr, mut seen) = slow_app(Some(Duration::from_millis(100))).await;
let b = Arc::new(BridgeClient::new(addr).unwrap());
let slow = tokio::spawn({
let b = b.clone();
async move { b.execute("slow", json!({})).await }
});
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
slow.abort();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(b.execute("fast", json!({})).await.unwrap(), json!("fast"));
}
/// Everything else is forwarded unchanged.
#[tokio::test]
async fn other_commands_are_forwarded() {
+45 -4
View File
@@ -122,7 +122,7 @@ fn opendml_file_past_1_gb() {
];
let Some(path) = fixture("large_opendml.avi", &args) else { return };
let file = std::fs::File::open(&path).unwrap();
let src = FileSource(file);
let src = FileSource(std::sync::Mutex::new(file));
let f = open(&src).unwrap();
assert_eq!(f.index, IndexSource::OpenDml);
assert_eq!(f.streams[0].chunks.len(), packets(&path, 0));
@@ -139,13 +139,54 @@ fn opendml_file_past_1_gb() {
}
/// A file on disk as a byte source, read where asked.
struct FileSource(std::fs::File);
struct FileSource(std::sync::Mutex<std::fs::File>);
impl filmcraft_avi::ByteSource for FileSource {
fn len(&self) -> u64 {
self.0.metadata().map(|m| m.len()).unwrap_or(0)
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).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)
use std::io::{Read, Seek, SeekFrom};
if buf.is_empty() {
return Ok(());
}
let mut file = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
file.seek(SeekFrom::Start(offset))?;
file.read_exact(buf)
}
}
#[test]
fn file_source_reads_exact_offsets_and_reports_eof() {
use filmcraft_avi::ByteSource as _;
use std::io::Write as _;
let path = filmcraft_testkit::fixtures::temp_path(&filmcraft_testkit::fixtures_dir("avi").join("read-at-test.bin"));
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
file.write_all(b"0123456789abcdef").unwrap();
drop(file);
let source = FileSource(std::sync::Mutex::new(std::fs::File::open(&path).unwrap()));
assert_eq!(source.len(), 16);
let mut bytes = [0; 4];
source.read_at(7, &mut bytes).unwrap();
assert_eq!(&bytes, b"789a");
source.read_at(1, &mut bytes).unwrap();
assert_eq!(&bytes, b"1234");
assert_eq!(source.read_at(15, &mut bytes).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof);
assert!(source.read_at(u64::MAX, &mut bytes).is_err());
source.read_at(u64::MAX, &mut []).unwrap();
std::thread::scope(|scope| {
for (offset, expected) in [(0, b"0123"), (8, b"89ab")] {
let source = &source;
scope.spawn(move || {
for _ in 0..64 {
let mut bytes = [0; 4];
source.read_at(offset, &mut bytes).unwrap();
assert_eq!(&bytes, expected);
}
});
}
});
drop(source);
std::fs::remove_file(path).unwrap();
}
+54 -9
View File
@@ -117,8 +117,8 @@ impl<'a> BitReader<'a> {
Ok(())
}
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
/// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33).
fn read_code_num(&mut self) -> Result<u64> {
let mut zeros = 0u32;
while !self.read_bit()? {
zeros += 1;
@@ -130,14 +130,20 @@ impl<'a> BitReader<'a> {
return Ok(0);
}
let v = self.read_bits_u64(zeros)?;
let r = (1u64 << zeros) - 1 + v;
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
Ok((1u64 << zeros) - 1 + v)
}
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb)
}
/// Signed Exp-Golomb `se(v)`.
pub fn read_se(&mut self) -> Result<i32> {
let k = self.read_ue()? as i64;
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
// codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits.
let k = self.read_code_num()? as i64;
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
}
/// Truncated Exp-Golomb `te(v)` with range `max`.
@@ -196,7 +202,11 @@ impl BitWriter {
self.write_bits(b as u32, 1);
}
pub fn write_ue(&mut self, v: u32) {
let x = v as u64 + 1;
self.write_code_num(u64::from(v));
}
/// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31).
fn write_code_num(&mut self, v: u64) {
let x = v + 1;
let len = 64 - x.leading_zeros();
let zeros = len - 1;
for _ in 0..zeros {
@@ -210,8 +220,8 @@ impl BitWriter {
}
}
pub fn write_se(&mut self, v: i32) {
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
self.write_ue(k);
let m = u64::from(v.unsigned_abs()) * 2;
self.write_code_num(if v > 0 { m - 1 } else { m });
}
pub fn bit_len(&self) -> usize {
self.buf.len() * 8 + self.nbits as usize
@@ -377,6 +387,41 @@ mod tests {
}
}
#[test]
fn signed_exp_golomb_extremes_roundtrip() {
let vals = [0, 1, -1, i32::MAX, i32::MIN];
let mut w = BitWriter::new();
for v in vals {
w.write_se(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in vals {
assert_eq!(r.read_se(), Ok(v));
}
}
#[test]
fn signed_exp_golomb_reads_minimum_from_spec_bits() {
// 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31.
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN));
}
#[test]
fn signed_exp_golomb_rejects_positive_overflow() {
// 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32.
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0];
assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb));
}
#[test]
fn unsigned_exp_golomb_keeps_u32_range() {
// codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32.
let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80];
assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb));
}
#[test]
fn emulation_prevention() {
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
+21
View File
@@ -23,6 +23,27 @@ All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 00
frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's
frame grid (the engine does this on import) and removes overlaps.
### TTML speaker metadata
TTML and DFXP exports define each distinct speaker label once in the document head and refer to
it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved
whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is
`other` because a caption's speaker label does not say whether it denotes a person or a group.
On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed
span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006
TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified
`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent`
attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred,
otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in
reference order; the project does not retain the individual agent identities. Older FilmCraft
files without agent definitions remain readable through the previous underscore-to-space
fallback. Other agent metadata, including actor relationships, is not retained.
This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent).
The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/)
defines its earlier metadata namespace and agent ID form.
### SCC writer
Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab
+118 -19
View File
@@ -8,12 +8,14 @@
//! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without
//! one, and always for DFXP (for older players), times are clock times with milliseconds.
//!
//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read):
//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2:
//! 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 `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 std::collections::{HashMap, HashSet};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use crate::{Cue, Document, Error, Result, TICKS_PER_MS};
@@ -31,6 +33,9 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml";
const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter";
const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling";
const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata";
const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata";
const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata";
const NS_XML: &str = "http://www.w3.org/XML/1998/namespace";
const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text";
const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation";
@@ -122,6 +127,10 @@ fn is_ncname(s: &str) -> bool {
c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.'))
}
fn cue_id(cue: &Cue, index: usize) -> String {
cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1))
}
/// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the
/// document language (`xml:lang`).
pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str) -> String {
@@ -140,13 +149,38 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
}
}
o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang })));
// Keep agent identifiers distinct from the IDs already emitted for cues, style and region.
let mut ids: HashSet<String> = ["s1".into(), "r1".into()].into_iter().collect();
ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i)));
let mut speakers = Vec::new();
let mut speaker_ids = HashMap::new();
for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) {
if speaker_ids.contains_key(speaker) {
continue;
}
let mut id = format!("agent{}", speakers.len() + 1);
// Each collision consumes a distinct reserved ID, so this is bounded by ids.len().
while ids.contains(&id) {
id.push('_');
}
ids.insert(id.clone());
speaker_ids.insert(speaker, id.clone());
speakers.push((speaker, id));
}
o.push_str(" <head>\n");
for (speaker, id) in speakers {
o.push_str(&format!(
" <ttm:agent xml:id=\"{id}\" type=\"other\"><ttm:name type=\"full\" xml:space=\"preserve\">{}</ttm:name></ttm:agent>\n",
// XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve".
esc(speaker).replace('\r', "&#13;")
));
}
o.push_str(" <styling>\n <style xml:id=\"s1\" tts:color=\"white\" tts:fontFamily=\"proportionalSansSerif\" tts:fontSize=\"100%\" tts:textAlign=\"center\"/>\n </styling>\n");
o.push_str(" <layout>\n <region xml:id=\"r1\" tts:origin=\"10% 10%\" tts:extent=\"80% 80%\" tts:displayAlign=\"after\"/>\n </layout>\n");
o.push_str(" </head>\n <body region=\"r1\" style=\"s1\">\n <div>\n");
for (i, c) in doc.cues.iter().enumerate() {
let id = c.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", i + 1));
let agent = c.speaker.as_deref().map(|s| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{}\"", esc(&agent_id(s)))).unwrap_or_default();
let id = cue_id(c, i);
let agent = c.speaker.as_deref().and_then(|s| speaker_ids.get(s)).map(|id| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{id}\"")).unwrap_or_default();
o.push_str(&format!(
" <p xml:id=\"{}\" begin=\"{}\" end=\"{}\"{agent}>{}</p>\n",
esc(&id),
@@ -159,12 +193,6 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
o
}
/// `ttm:agent` refers to an agent id; we write the speaker name itself (made an NCName).
fn agent_id(s: &str) -> String {
let v: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect();
if v.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { v } else { format!("_{v}") }
}
fn gcd(a: i64, b: i64) -> i64 {
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
}
@@ -203,6 +231,56 @@ fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == local).map(|a| a.value())
}
fn metadata_namespace(namespace: Option<&str>) -> bool {
matches!(namespace, Some(NS_TTM) | Some(NS_TTM_DRAFT) | Some(NS_TTM_DRAFT_APRIL))
}
/// Significant TTML agents are children of head or of head's metadata children (TTML1 §12.1.5).
fn speaker_names(root: roxmltree::Node) -> HashMap<String, String> {
let mut out = HashMap::new();
let content_namespace = root.tag_name().namespace();
let Some(head) = root.children().find(|n| n.is_element() && n.tag_name().name() == "head" && n.tag_name().namespace() == content_namespace) else {
return out;
};
for agent in head.descendants().filter(|n| n.is_element() && n.tag_name().name() == "agent" && metadata_namespace(n.tag_name().namespace())) {
if !agent
.parent()
.is_some_and(|p| p == head || (p.tag_name().name() == "metadata" && p.tag_name().namespace() == content_namespace && p.parent() == Some(head)))
{
continue;
}
let id = if agent.tag_name().namespace() == Some(NS_TTM_DRAFT_APRIL) {
// The April 2006 draft defines an unqualified id, before TTML adopted xml:id.
agent.attributes().find(|a| a.name() == "id" && a.namespace().is_none()).map(|a| a.value())
} else {
agent.attribute((NS_XML, "id"))
};
let Some(id) = id else { continue };
let names: Vec<_> = agent
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "name" && metadata_namespace(n.tag_name().namespace()))
.map(|n| {
let kind = n.attributes().find(|a| a.name() == "type" && a.namespace().is_none()).map(|a| a.value());
(kind, n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>())
})
.collect();
let name = names.iter().find(|(kind, _)| *kind == Some("full")).or_else(|| names.iter().find(|(_, text)| !text.is_empty())).or_else(|| names.first());
if let Some((_, name)) = name {
// An explicitly empty full name remains empty, including our own Some("") speakers.
out.insert(id.to_string(), name.clone());
}
}
out
}
fn speaker(n: roxmltree::Node, names: &HashMap<String, String>) -> Option<String> {
let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent"));
refs.map(|refs| {
// The project stores one speaker label; multiple agents become an ordered display label.
refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::<Vec<_>>().join(", ")
})
}
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}") };
@@ -294,10 +372,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
}
whole.0 as i128 + decimal_ticks("0", vf, fd)?
}
"t" => {
let n: i128 = vi.parse().ok()?;
n * tps / tm.tick_rate.max(1) as i128
}
"t" => decimal_ticks(vi, vf, tps)? / tm.tick_rate.max(1) as i128,
_ => return None,
};
i64::try_from(t).ok().map(Tick)
@@ -411,6 +486,7 @@ pub fn parse(text: &str) -> Result<Document> {
return Err(Error::NotFormat("TTML"));
}
let tm = timing(root)?;
let speakers = speaker_names(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()) {
@@ -428,7 +504,7 @@ pub fn parse(text: &str) -> Result<Document> {
}
// 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);
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, &speakers, Fmt::default(), &mut doc);
doc.cues.sort_by_key(|c| (c.start, c.end));
Ok(doc)
}
@@ -480,13 +556,13 @@ fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> T
Times { begin, end, bad }
}
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, doc: &mut Document) {
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, 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 t = times(c, parent, *tm, doc);
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc);
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, speakers, f, doc);
}
"p" => {
let t = times(c, parent, *tm, doc);
@@ -494,7 +570,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
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 cue_speaker = speaker(c, speakers);
let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string());
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
@@ -512,7 +588,7 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
}
let text = clean(&o.s);
if e > b && !text.is_empty() {
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
}
}
None => {
@@ -526,7 +602,13 @@ fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collection
collect(sp, styles, style_of(sp, styles, f), &mut o);
let text = clean(&o.s);
if se > sb && !text.is_empty() {
doc.cues.push(Cue { start: sb, end: se, text, speaker: speaker.clone(), ..Default::default() });
doc.cues.push(Cue {
start: sb,
end: se,
text,
speaker: speaker(sp, speakers).or_else(|| cue_speaker.clone()),
..Default::default()
});
}
}
}
@@ -631,6 +713,23 @@ mod tests {
assert!(!doc.warnings.is_empty());
}
#[test]
fn fractional_tick_offsets_keep_exact_caption_times() {
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:tickRate="10"><body><div><p begin="1.5t" end="12.5t">Hello</p></div></body></tt>"#;
let doc = crate::parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND * 3 / 20));
assert_eq!(doc.cues[0].end, Tick(TICKS_PER_SECOND * 5 / 4));
assert!(doc.warnings.is_empty());
}
let timing = Timing { tick_rate: 3, ..tm(FrameRate::FPS_25) };
assert_eq!(parse_time("0.5t", timing), Some(Tick(TICKS_PER_SECOND / 6)));
for expr in ["1.xt", "1.1234567890123t", "999999999999999.5t"] {
assert_eq!(parse_time(expr, timing), None, "{expr}");
}
}
#[test]
fn reads_styles_spans_and_nesting() {
let x = r#"<?xml version="1.0"?>
+14 -1
View File
@@ -38,7 +38,7 @@ pub fn split_voice(text: &str) -> (Option<String>, String) {
Some(_) => return (None, text.to_string()),
None => body.to_string(),
};
if body.contains("<v ") || body.contains("<v.") {
if body.contains("<v ") || body.contains("<v.") || body.contains("<v\t") {
return (None, text.to_string());
}
return ((!name.is_empty()).then(|| name.to_string()), body);
@@ -177,6 +177,19 @@ mod tests {
assert_eq!(split_voice("<vx>hi"), (None, "<vx>hi".into()));
}
#[test]
fn multiple_voices_with_tab_separator() {
let text = "<v Ann>Hello <v\tBob>there";
assert_eq!(split_voice(text), (None, text.to_string()));
let source = format!("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n{text}\n");
let doc = parse(&source).unwrap();
assert_eq!(doc.cues[0].speaker, None);
assert_eq!(doc.cues[0].text, text);
assert_eq!(parse(&write(&doc)).unwrap().cues, doc.cues);
let closed = "<v Ann>Hi <v\tBob>x</v>";
assert_eq!(split_voice(closed), (None, closed.to_string()));
}
#[test]
fn without_signature() {
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();
+219
View File
@@ -0,0 +1,219 @@
use std::collections::{HashMap, HashSet};
use filmcraft_captions::{Cue, Document, Format, WriteOptions, parse, write};
use filmcraft_time::FrameRate;
const TT: &str = "http://www.w3.org/ns/ttml";
const META: &str = "http://www.w3.org/ns/ttml#metadata";
const XML: &str = "http://www.w3.org/XML/1998/namespace";
#[test]
fn imports_speaker_names_from_significant_ttml_metadata() {
for format in [Format::Ttml, Format::Dfxp] {
for metadata in [
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="alias">Ann</ttm:name><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#
.to_string(),
r#"<metadata><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></metadata>"#.to_string(),
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann<!-- editorial note --> Lee</ttm:name></ttm:agent>"#.to_string(),
] {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>{metadata}</head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
assert_eq!(doc.cues[0].text, "Hello");
}
}
}
#[test]
fn exported_speakers_have_unique_resolvable_ids_and_exact_names() {
let rate = FrameRate::FPS_25;
let speakers =
["Ann Lee", "Ann_Lee", "O’Connor & <Guest>", "_Ann", "山田 花子", "Ann Lee", "", " Ann Lee ", "Ann\rLee", "Ann\r\nLee", "Ann\nLee", "Ann\tLee"];
let ids = ["agent1", "agent2", "speaker1", "speaker2", "c5", "c6", "c7", "c8", "c9", "c10", "c11", "c12"];
let doc = Document {
cues: speakers
.iter()
.enumerate()
.map(|(i, name)| Cue {
start: rate.tick_of(i as i64 * 50),
end: rate.tick_of(i as i64 * 50 + 25),
text: format!("Line {}", i + 1),
speaker: Some((*name).into()),
id: Some(ids[i].into()),
..Default::default()
})
.collect(),
..Default::default()
};
for format in [Format::Ttml, Format::Dfxp] {
let bytes = write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() });
assert_eq!(bytes, write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() }), "stable output");
let text = std::str::from_utf8(&bytes).unwrap();
let xml = roxmltree::Document::parse(text).unwrap();
let mut all_ids = HashSet::new();
for id in xml.descendants().filter_map(|n| n.attribute((XML, "id"))) {
assert!(all_ids.insert(id), "duplicate xml:id {id}");
}
let mut agents = HashMap::new();
for agent in xml.descendants().filter(|n| n.has_tag_name((META, "agent"))) {
assert_eq!(agent.attribute("type"), Some("other"));
let name = agent.children().find(|n| n.has_tag_name((META, "name"))).unwrap();
assert_eq!(name.attribute("type"), Some("full"));
assert_eq!(name.attribute((XML, "space")), Some("preserve"));
agents.insert(agent.attribute((XML, "id")).unwrap(), name.text().unwrap_or(""));
}
assert_eq!(agents.len(), 11, "each exact name is defined once");
let paragraphs: Vec<_> = xml.descendants().filter(|n| n.has_tag_name((TT, "p"))).collect();
assert_eq!(paragraphs.len(), speakers.len());
for (paragraph, expected) in paragraphs.iter().zip(speakers) {
let id = paragraph.attribute((META, "agent")).unwrap();
assert_eq!(agents.get(id).copied(), Some(expected), "reference {id}");
}
let back = parse(&bytes, format).unwrap();
assert_eq!(back.cues, doc.cues, "{format:?} names and cue content survive");
}
}
#[test]
fn imports_multiple_agent_names_in_reference_order() {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
<ttm:agent xml:id="guest" type="person"><ttm:name type="alias">O’Connor</ttm:name></ttm:agent>
</head><body><div><p begin="1s" end="2s" ttm:agent="guest ann">Together</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("O’Connor, Ann Lee"));
}
#[test]
fn keeps_legacy_filmcraft_speaker_references_readable() {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><body><div>
<p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p>
</div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
}
#[test]
fn timed_span_can_name_its_own_speaker() {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
<ttm:agent xml:id="guest" type="person"><ttm:name type="full">Guest</ttm:name></ttm:agent>
</head><body><div><p ttm:agent="ann"><span begin="1s" end="2s" ttm:agent="guest">Hello</span>
<span begin="3s" end="4s">Welcome</span></p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues.len(), 2);
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Guest"));
assert_eq!(doc.cues[1].speaker.as_deref(), Some("Ann Lee"));
}
#[test]
fn foreign_metadata_does_not_replace_ttml_speakers() {
let real = r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#;
for (metadata, attributes) in [
(
format!(r#"{real}<metadata><app:agent id="speaker1"><app:name type="full">Library editor</app:name></app:agent></metadata>"#),
r#"ttm:agent="speaker1""#,
),
(real.into(), r#"app:agent="library" ttm:agent="speaker1""#),
] {
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head>{metadata}</head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "{xml}");
}
// A TTML agent nested inside another application's metadata container is not significant.
let xml = format!(
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head><app:metadata>{real}</app:metadata></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("speaker1"));
}
#[test]
fn imports_draft_metadata_and_legacy_unqualified_speaker_references() {
for (tt, metadata, attributes) in
[("http://www.w3.org/2006/10/ttaf1", "http://www.w3.org/2006/10/ttaf1#metadata", r#"ttm:agent="speaker1""#), (TT, META, r#"agent="speaker1""#)]
{
let xml = format!(
r#"<tt xmlns="{tt}" xmlns:ttm="{metadata}"><head><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
);
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
}
}
#[test]
fn keeps_april_2006_draft_speaker_references_readable() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
<head><metadata>
<ttm:agent id="Ann_Lee" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
</metadata></head>
<body><div><p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</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_eq!(doc.cues[0].text, "Hello");
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "the documented draft keeps its previously readable speaker");
}
}
#[test]
fn reads_april_2006_agent_definitions_with_unqualified_ids() {
let xml = r#"<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
<head><ttm:agent id="speaker1" type="person"><ttm:name type="full">Ann-Marie Lee</ttm:name></ttm:agent></head>
<body><div><p begin="1s" end="2s" ttm:agent="speaker1">Welcome</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_eq!(doc.cues[0].text, "Welcome");
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann-Marie Lee"), "the name resolves through the draft's unqualified id");
}
}
fn assert_full_speaker_name_with_type(attributes: &str) {
let xml = format!(
r#"<tt xml:lang="en" xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library">
<head><ttm:agent xml:id="speaker1" type="person">
<ttm:name type="alias">Ann</ttm:name><ttm:name {attributes}>Ann Lee</ttm:name>
</ttm:agent></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
);
let mut speakers = Vec::new();
for format in [Format::Ttml, Format::Dfxp] {
let doc = parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1);
assert_eq!(doc.cues[0].text, "Hello");
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
speakers.push(doc.cues[0].speaker.clone());
}
assert_eq!(speakers, [Some("Ann Lee".into()), Some("Ann Lee".into())], "TTML and DFXP prefer the full name: {attributes}");
}
#[test]
fn name_type_without_application_attribute_prefers_full_name() {
assert_full_speaker_name_with_type(r#"type="full""#);
}
#[test]
fn name_type_after_application_attribute_prefers_full_name() {
assert_full_speaker_name_with_type(r#"app:type="contact" type="full""#);
}
#[test]
fn name_type_before_application_attribute_prefers_full_name() {
assert_full_speaker_name_with_type(r#"type="full" app:type="contact""#);
}
+98 -9
View File
@@ -814,6 +814,21 @@ impl AudioFileSource {
};
return Ok(Self { info, rate, samples });
}
if ext == "aac" {
let (rate, samples) = decode_adts(&bytes)?.ok_or_else(|| CodecError::Decode(format!("{name}: no ADTS AAC frames")))?;
let frames = samples.first().map_or(0, Vec::len);
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::AudioOnly,
duration: Tick::from_units(frames as i64, rate as i64),
video: None,
audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: samples.len() as u32, codec: "AAC".into(), bits_per_sample: None }],
container: "ADTS".into(),
start_timecode: None,
file_size: Some(bytes.len() as u64),
};
return Ok(Self { info, rate, samples });
}
let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.clone())), Default::default());
let mut hint = Hint::new();
hint.with_extension(&ext);
@@ -871,20 +886,94 @@ impl AudioFileSource {
}
}
/// The length of an ID3v2 tag at the start of `bytes` (0 without one): a 10-byte header, a
/// syncsafe size, and a 10-byte footer when flagged (ID3v2.4 §3).
fn id3v2_len(bytes: &[u8]) -> usize {
match bytes {
[b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => {
let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize);
size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 })
}
_ => 0,
}
}
/// Most samples (all channels together) a standalone ADTS file decodes into memory: 4 GiB of f32,
/// about three hours of 48 kHz stereo. Longer files are refused rather than allocated.
const MAX_ADTS_SAMPLES: usize = 1 << 30;
/// The length of the ADTS frame starting `b` (ISO/IEC 13818-7 §6.2: 12-bit sync word, layer `00`,
/// 13-bit `frame_length` including the header), when it starts with a header and fits in `b`.
fn adts_frame_len(b: &[u8]) -> Option<usize> {
let [0xFF, b1, _, b3, b4, b5, ..] = *b else { return None };
if b1 & 0xF6 != 0xF0 {
return None;
}
let len = ((b3 as usize & 3) << 11) | (b4 as usize) << 3 | (b5 as usize) >> 5;
let hdr = if b1 & 1 != 0 { 7 } else { 9 };
(len > hdr && len <= b.len()).then_some(len)
}
/// A raw ADTS stream (`.aac`) decoded with our AAC decoder: (output sample rate, planar samples).
/// Frames follow each other from the first header (after an ID3v2 tag); damaged data is skipped
/// up to the next header, frames with another configuration than the first are skipped, and a
/// frame that does not decode keeps its duration as silence. HE-AAC is upsampled to its output
/// rate. `Ok(None)` when no frame decodes.
fn decode_adts(bytes: &[u8]) -> Result<Option<(u32, Vec<Vec<f32>>)>> {
let mut frames: Vec<(Vec<u8>, &[u8])> = Vec::new();
let mut p = id3v2_len(bytes);
while let Some(rest) = bytes.get(p..).filter(|r| r.len() >= 7) {
match adts_frame_len(rest).and_then(|len| Some((len, adts_split(rest.get(..len)?)?))) {
Some((len, f)) => {
frames.push(f);
p += len;
}
None => p += 1,
}
}
let Some(asc) = frames.first().map(|f| f.0.clone()) else { return Ok(None) };
let Some(rate) = aac_output_rate(&asc, frames.iter().take(8).map(|f| f.1)) else { return Ok(None) };
let mut dec = PacketDecoder::aac(&asc, rate)?;
let mut out: Vec<Vec<f32>> = Vec::new();
let mut total = 0usize;
let mut last = 0usize;
for (cfg, payload) in frames {
if cfg != asc {
continue;
}
let decoded = dec.decode(payload, 0).ok().filter(|c| out.is_empty() || c.len() == out.len());
match decoded {
Some(c) if out.is_empty() => {
last = c.first().map_or(0, Vec::len);
out = c;
}
Some(c) => {
last = c.first().map_or(0, Vec::len);
for (o, s) in out.iter_mut().zip(c) {
o.extend(s);
}
}
None => {
for o in out.iter_mut() {
o.resize(o.len() + last, 0.0);
}
}
}
total = total.saturating_add(last.saturating_mul(out.len()));
if total > MAX_ADTS_SAMPLES {
return Err(CodecError::Unsupported("AAC file too long to import as raw ADTS (over 4 GiB decoded): remux it to .m4a".into()));
}
}
Ok(out.first().is_some_and(|c| !c.is_empty()).then_some((rate, out)))
}
/// A raw MPEG audio Layer I / II stream (`.mp2`) decoded with our decoder: (layer, sample rate,
/// planar samples). Frames follow each other from the first frame header (after an ID3v2 tag);
/// damaged data is skipped up to the next frame header of the same layer and rate, and a frame
/// with another channel count keeps its duration as silence. `None` when the stream does not start
/// with a Layer I / II frame followed by another frame (or the end), e.g. MP3.
fn decode_mpeg_layer12(bytes: &[u8]) -> Option<(u8, u32, Vec<Vec<f32>>)> {
let mut p = match bytes {
// ID3v2: 10-byte header, a syncsafe size, and a 10-byte footer when flagged
[b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => {
let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize);
size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 })
}
_ => 0,
};
let mut p = id3v2_len(bytes);
let frame_at = |p: usize| bytes.get(p..).and_then(|b| filmcraft_mpa::parse_header(b).ok()).filter(|h| h.frame_bytes > 0);
let first = frame_at(p)?;
let next = p.saturating_add(first.frame_bytes);
@@ -928,7 +1017,7 @@ impl MediaSource for AudioFileSource {
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<std::result::Result<SharedSource, MediaError>> {
let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
if !matches!(ext.as_str(), "mp3" | "mp2" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") {
if !matches!(ext.as_str(), "mp3" | "mp2" | "aac" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") {
return None;
}
Some(AudioFileSource::decode(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
+38 -8
View File
@@ -175,9 +175,10 @@ impl StreamInfo {
/// with (parameter sets, a sequence header, a key frame of another format).
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
match self {
Self::Nal(i) => {
i.nals(sample).into_iter().any(|n| n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && !i.parameter_sets.iter().any(|p| p == n))
}
Self::Nal(i) => i.nals(sample).into_iter().any(|n| {
n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h)))
&& !i.parameter_sets.iter().any(|p| without_trailing_zeros(p) == without_trailing_zeros(n))
}),
Self::Frame(i) => i.parameters_changed(sample),
}
}
@@ -199,6 +200,14 @@ impl StreamInfo {
}
}
/// A NAL unit without the zero bytes after its last byte: a NAL unit never ends with 0x00
/// (H.264 7.4.1, H.265 7.4.2), so they are trailing_zero_8bits some muxers keep (ffmpeg in `hvcC`), not
/// part of the parameter set.
fn without_trailing_zeros(nal: &[u8]) -> &[u8] {
let end = nal.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
nal.get(..end).unwrap_or_default()
}
/// Length-prefixed parameter-set entries (`u16` length + bytes) starting at `pos`.
fn read_sets(rec: &[u8], pos: &mut usize, count: usize, out: &mut Vec<Vec<u8>>) -> Result<()> {
for _ in 0..count {
@@ -402,13 +411,12 @@ mod tests {
}
}
#[test]
fn nal_splitting_is_bounded() {
let info = NalStreamInfo {
fn hevc_info(parameter_sets: Vec<Vec<u8>>) -> NalStreamInfo {
NalStreamInfo {
codec: NalCodec::Hevc,
length_size: 4,
highest_tid: None,
parameter_sets: Vec::new(),
parameter_sets,
coded: (16, 16),
crop: (0, 0, 16, 16),
chroma_format_idc: 1,
@@ -419,11 +427,33 @@ mod tests {
color: filmcraft_color::ColorInfo::REC709,
par: (1, 1),
reorder: 0,
};
}
}
#[test]
fn nal_splitting_is_bounded() {
let info = hevc_info(Vec::new());
// CRA (21) then a length that runs past the end
let s = [0, 0, 0, 2, 21 << 1, 1, 0xff, 0xff, 0xff, 0xff, 1];
assert_eq!(info.nal_types(&s), vec![21]);
assert!(info.is_irap(&s));
assert!(!info.is_irap(&[0, 0, 0, 2, 1 << 1, 1]));
}
#[test]
fn parameter_sets_compare_without_trailing_zero_bytes() {
// the hvcC PPS of an ffmpeg-muxed file and the in-band one of its first sample (#1483)
let hvcc_pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90, 0x00];
let info = StreamInfo::Nal(hevc_info(vec![hvcc_pps.clone()]));
let sample = |nal: &[u8]| {
let mut v = (nal.len() as u32).to_be_bytes().to_vec();
v.extend_from_slice(nal);
v
};
assert!(!info.parameters_changed(&sample(&hvcc_pps)), "the same PPS");
assert!(!info.parameters_changed(&sample(&hvcc_pps[..7])), "a trailing zero byte is not part of the NAL unit");
assert!(!info.parameters_changed(&sample(&[&hvcc_pps[..], &[0, 0]].concat())), "nor are several");
assert!(info.parameters_changed(&sample(&hvcc_pps[..6])), "a missing byte that is not zero is a change");
assert!(info.parameters_changed(&sample(&[0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x91])), "another PPS is a change");
}
}
+6 -2
View File
@@ -16,6 +16,7 @@
//! - [`AviSource`]: AVI (OpenDML included): Motion JPEG, H.264, HEVC and uncompressed video; PCM,
//! MP3 / MP2 and AC-3 audio.
//! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …).
//! - [`Y4mSource`]: uncompressed YUV4MPEG2 (`.y4m`) video, frames read on demand.
//! - [`openers`]: the openers to register with the engine's media pool.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
@@ -34,6 +35,7 @@ pub mod ogg;
mod stream_color;
pub mod video;
mod webm_alpha;
pub mod y4m;
use std::sync::{Arc, RwLock};
@@ -47,6 +49,7 @@ pub use mpeg::MpegSource;
pub use mxf::MxfSource;
pub use ogg::OggSource;
pub use video::{DecodedFrame, VideoDecoder, VideoDecoderFactory};
pub use y4m::Y4mSource;
#[derive(Debug, thiserror::Error)]
pub enum CodecError {
@@ -128,9 +131,9 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<
}
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
/// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio).
/// MPEG-1/2 video elementary streams, APV raw bitstreams, YUV4MPEG2, standalone audio).
pub fn openers() -> Vec<filmcraft_media::Opener> {
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener]
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, y4m::opener, audio::opener]
}
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
@@ -143,6 +146,7 @@ fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
ogg::reader_opener,
mpeg::reader_opener,
apv::reader_opener,
y4m::reader_opener,
avi::reader_opener,
])
})
+3 -2
View File
@@ -83,7 +83,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec
let out_rate = u32::try_from(rate).unwrap_or(0);
let frame = match &t.codec {
Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate),
Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
Codec::Mp3 | Codec::Mp2 | Codec::Mp1 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate),
Codec::Eac3 => fixed_packet_samples(FixedFrames::Eac3, &first(), out_rate),
_ => None,
@@ -317,7 +317,7 @@ fn codec_label(c: &Codec) -> String {
Codec::Flac { .. } => "FLAC".into(),
Codec::Pcm { bits, float, .. } => format!("PCM {bits}-bit{}", if *float { " float" } else { "" }),
Codec::Mp3 => "MP3".into(),
Codec::Mp2 => "MPEG Audio".into(),
Codec::Mp2 | Codec::Mp1 => "MPEG Audio".into(),
Codec::Other(id) if id == "V_MPEG2" => "MPEG-2 Video".into(),
Codec::Other(id) if id == "V_MPEG1" => "MPEG-1 Video".into(),
other => other.name().to_string(),
@@ -486,6 +486,7 @@ impl MkvSource {
Codec::Opus { head } => PacketDecoder::opus(filmcraft_opus::OpusHead::parse(head).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?),
Codec::Mp3 => PacketDecoder::new(CODEC_TYPE_MP3, rate, None),
Codec::Mp2 => PacketDecoder::mpeg_audio(2, rate),
Codec::Mp1 => PacketDecoder::mpeg_audio(1, rate),
Codec::Ac3 | Codec::Eac3 => PacketDecoder::ac3(),
// symphonia wants the STREAMINFO block body: skip `fLaC` + the 4-byte block header.
Codec::Flac { private } => PacketDecoder::new(CODEC_TYPE_FLAC, rate, private.get(8..42).map(<[u8]>::to_vec)),
+61
View File
@@ -347,6 +347,7 @@ impl Mp4Source {
Some(Mp4Audio { track: i, state, starts, offset, preroll })
})
.collect();
snap_to_frame_grid(&mut file, vtrack);
rebase_unedited_video(&mut file, vtrack, &mut audios);
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios })
}
@@ -666,6 +667,66 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, Media
Some(Mp4Source::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
/// Put the video samples of a constant-rate file whose pictures sit just off its frame grid back
/// on the grid (#1282). Phone footage stamps frames up to a millisecond after where they belong (a
/// first frame 34.1 ms long at 30 fps), and the frame shown at a time is the last one that starts at
/// or before it: a sequence frame asked at the exact grid time got the picture before.
///
/// The grid starts at the first picture and steps by the nominal frame duration. A file is snapped
/// only when every picture is less than a quarter of a frame from its own grid point and no two land
/// on the same one: a frame that far off was placed there (variable frame rate), so a file with one
/// such picture keeps all its times. A picture at most one unit off is left where it is: that can't
/// be told from the timescale's rounding. Snapping keeps the samples' presentation order. Only the video samples move: the first picture, which the audio and
/// `rebase_unedited_video` go by, is the grid's origin, and the clip's duration comes from the
/// track header.
fn snap_to_frame_grid(file: &mut Mp4File, vtrack: Option<usize>) {
let Some(t) = vtrack.and_then(|i| file.tracks.get_mut(i)) else { return };
let Some(first) = t.samples.iter().map(|s| s.pts).min() else { return };
// The nominal duration: the median sample duration, averaged with the durations one unit from
// it. Those are the same duration rounded to the timescale (1501 and 1502 at 59.94 fps over
// 90 kHz): the median alone drifts half a unit a frame, enough to snap a short clip onto the
// wrong points.
let mut durs: Vec<u32> = t.samples.iter().map(|s| s.duration).collect();
durs.sort_unstable();
let Some(&median) = durs.get(durs.len() / 2) else { return };
let near: Vec<f64> = durs.iter().filter(|&&d| d.abs_diff(median) <= 1).map(|&d| f64::from(d)).collect();
let mean = near.iter().sum::<f64>() / near.len().max(1) as f64;
if mean <= 0.0 {
return;
}
let rate = FrameRate::from_f64(f64::from(t.timescale) / mean);
// one frame lasts `step_num / step_den` track units
let (step_num, step_den) = (i128::from(t.timescale) * i128::from(rate.den), i128::from(rate.num));
if step_num <= 0 || step_den <= 0 {
return;
}
// a sample's grid index and its new time
let snap = |pts: i64| -> Option<(i128, i64)> {
let off = i128::from(pts) - i128::from(first);
// the nearest grid index, then its grid point (both rounded half up)
let n = (2 * off * step_den + step_num).div_euclid(2 * step_num);
let at = i128::from(first) + (2 * n * step_num + step_den).div_euclid(2 * step_den);
let off_grid = (i128::from(pts) - at).abs();
if off_grid * 4 * step_den >= step_num {
return None;
}
Some((n, if off_grid <= 1 { pts } else { i64::try_from(at).ok()? }))
};
let Some(snapped) = t.samples.iter().map(|s| snap(s.pts)).collect::<Option<Vec<_>>>() else { return };
// Two pictures by one grid point: not a constant rate. Checked on the index, not the new time:
// one picture kept a unit after the point and another moved onto it would be distinct times in
// the wrong order.
let mut points: Vec<i128> = snapped.iter().map(|&(n, _)| n).collect();
points.sort_unstable();
points.dedup();
if points.len() != snapped.len() {
return;
}
for (s, (_, p)) in t.samples.iter_mut().zip(snapped) {
s.pts = p;
}
}
/// Start the file at its first picture when its video track has no edit list (#714). Without an
/// edit list the media timeline is the presentation, so a B-frame stream's first picture sits at its
/// composition delay (two frames on an x264 MP4 written without `elst`, and on fragmented MP4s):
+101
View File
@@ -134,3 +134,104 @@ fn webm_with_1ms_timestamps_shows_every_frame_once() {
fn mp4_with_a_1ms_timescale_shows_every_frame_once() {
check("mov", mp4_1ms);
}
/// An MP4 at a 90 kHz timescale whose frames after the first start `late` units after the frame
/// grid, as phone footage does (issue #1282): the first sample lasts `late` units longer.
fn mp4_90k_late(rate: FrameRate, late: i64) -> Arc<[u8]> {
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
let start = |k: usize| {
let grid = (k as i128 * 90_000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128;
grid as i64 + if k > 0 { late } else { 0 }
};
for k in 0..frame_count(rate) {
let duration = (start(k + 1) - start(k)) as u32;
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
}
w.finish().unwrap().into_inner().into()
}
fn check_90k_late(late: i64) {
let mut failures = Vec::new();
for rate in RATES {
let src = crate::open_bytes("synthetic.mov", mp4_90k_late(rate, late)).unwrap();
for p in problems(src.as_ref(), rate) {
failures.push(format!("{rate}, frames {late}/90000 s late: {p}"));
}
}
assert!(failures.is_empty(), "mov:\n{}", failures.join("\n"));
}
#[test]
fn mp4_with_frames_1ms_after_the_grid_shows_every_frame_once() {
check_90k_late(90);
}
#[test]
fn mp4_with_frames_on_the_grid_shows_every_frame_once() {
check_90k_late(0);
}
/// An MP4 at 90 kHz with these sample durations.
fn mp4_90k(durations: &[u32]) -> Arc<[u8]> {
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap();
for (k, &duration) in durations.iter().enumerate() {
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
}
w.finish().unwrap().into_inner().into()
}
/// Files that are not a constant rate gone slightly off its grid keep their own times: whatever is
/// asked (each frame's start, the unit before it, every 30 fps grid point), the frame shown is the
/// last one starting at or before it.
#[test]
fn mp4_variable_rate_and_on_grid_frame_times_stay_put() {
let cases: [(&str, Vec<u32>); 5] = [
// 1/30 s and 1/20 s in turn
("alternating", (0..60).map(|k| if k % 2 == 0 { 3000 } else { 4500 }).collect()),
// 30 fps, then every frame 0.4 frame later (one frame lasts 1.4 frames)
("shifted", (0..80).map(|k| if k == 40 { 4200 } else { 3000 }).collect()),
// 30 fps with a frame shown for a fifth of a frame: it and the next are both near one grid point
(
"stutter",
(0..80)
.map(|k| match k {
40 => 600,
41 => 5400,
_ => 3000,
})
.collect(),
),
// 30 fps with two frames by one grid point, one a unit after it and one five units after
(
"one unit and five after one point",
(0..80)
.map(|k| match k {
39 => 3001,
40 => 4,
41 => 2995,
_ => 3000,
})
.collect(),
),
// 59.94 fps on its grid, stamps truncated to the timescale (1501, 1502, …)
("59.94 truncated", (0..120).map(|k: u64| ((k + 1) * 1_501_500 / 1000 - k * 1_501_500 / 1000) as u32).collect()),
];
let mut failures = Vec::new();
for (name, durations) in cases {
let src = crate::open_bytes("synthetic.mov", mp4_90k(&durations)).unwrap();
let starts: Vec<i64> = durations.iter().scan(0i64, |t, &d| Some(std::mem::replace(t, *t + i64::from(d)))).collect();
let end = *starts.last().unwrap();
let mut asked: Vec<i64> = starts.iter().flat_map(|&s| [s, s - 1]).filter(|&s| s >= 0).collect();
asked.extend((0..).map(|k| k * 3000).take_while(|&t| t <= end));
for t in asked {
let want = starts.iter().rposition(|&s| s <= t).unwrap();
let got = index_of(&src.video_frame(FrameRequest::full(Tick::from_rational(t, 1, 90_000))).unwrap());
if got != want {
failures.push(format!("{name}: at {t}/90000 s wanted frame {want}, got {got}"));
}
}
}
assert!(failures.is_empty(), "{}", failures[..failures.len().min(8)].join("\n"));
}
+299
View File
@@ -0,0 +1,299 @@
//! YUV4MPEG2 (`.y4m`) files: uncompressed planar video, as described by the format's public
//! documentation (the `yuv4mpeg(5)` manual page of mjpegtools, and the `C` colour space names
//! other tools added for high bit depths).
//!
//! The file starts with `YUV4MPEG2` and space-separated tagged parameters up to a newline: `W` and
//! `H` (size), `F` (frame rate, `n:d`), `A` (pixel aspect, `n:d`, `0:0` unknown), `I` (interlacing),
//! `C` (colour space; `420jpeg` when absent) and `X` (extensions; `XCOLORRANGE=FULL` is read). Each
//! frame is a `FRAME` line (with optional parameters of its own) followed by the Y, Cb and Cr planes
//! (and an alpha plane for `444alpha`); samples above 8 bits are 16-bit little-endian.
//!
//! Frames are found once at open (their header lines only) and read on demand, so a large file
//! is never loaded whole. Every number comes from the file and is checked before use.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Range};
use filmcraft_frame::{AudioBuffer, Chroma, PixelData, VideoFrame};
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
use filmcraft_time::{FrameRate, Tick};
use crate::CodecError;
const SIGNATURE: &[u8] = b"YUV4MPEG2";
/// Longest stream header or `FRAME` line read.
const MAX_LINE: usize = 64 * 1024;
/// Largest picture (luma samples) accepted.
const MAX_PIXELS: u64 = 1 << 28;
pub fn sniff(head: &[u8]) -> bool {
head.starts_with(SIGNATURE) && matches!(head.get(SIGNATURE.len()), Some(b' ' | b'\n'))
}
/// The sample layout a `C` parameter names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Layout {
/// `None` for luma only (`mono`).
chroma: Option<Chroma>,
bits: u32,
alpha: bool,
}
impl Layout {
fn parse(c: &str) -> Option<Layout> {
let (base, bits) = match c.split_once('p') {
Some((base, bits)) if !bits.is_empty() && bits.bytes().all(|b| b.is_ascii_digit()) => (base, bits.parse().ok()?),
_ => (c, 8),
};
let (base, bits) = match base.strip_prefix("mono") {
Some("") => ("mono", bits),
Some(digits) if bits == 8 => ("mono", digits.parse().ok()?),
_ => (base, bits),
};
if !(8..=16).contains(&bits) {
return None;
}
let (chroma, alpha) = match base {
"420" | "420jpeg" | "420paldv" | "420mpeg2" => (Some(Chroma::C420), false),
"422" => (Some(Chroma::C422), false),
"444" => (Some(Chroma::C444), false),
"444alpha" if bits == 8 => (Some(Chroma::C444), true),
"mono" => (None, false),
_ => return None,
};
Some(Layout { chroma, bits, alpha })
}
fn label(self) -> String {
let sub = match (self.chroma, self.alpha) {
(None, _) => "Monochrome",
(Some(_), true) => "YUVA 4:4:4:4",
(Some(c), false) => return format!("YUV {} {}-bit", c.label(), self.bits),
};
format!("{sub} {}-bit", self.bits)
}
}
/// Sizes of one frame's planes in samples: luma, each chroma plane (0 for mono), alpha.
fn plane_samples(w: u32, h: u32, l: Layout) -> (usize, usize, usize) {
let luma = w as usize * h as usize;
let chroma = l.chroma.map_or(0, |c| {
let (sx, sy) = c.shifts();
w.div_ceil(1 << sx) as usize * h.div_ceil(1 << sy) as usize
});
(luma, chroma, if l.alpha { luma } else { 0 })
}
pub struct Y4mSource {
info: MediaInfo,
reader: filmcraft_media::SharedReader,
/// File offset of each frame's planes.
frames: Vec<u64>,
frame_bytes: usize,
layout: Layout,
rate: FrameRate,
}
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
let (n, d) = s.split_once(':')?;
Some((n.parse().ok()?, d.parse().ok()?))
}
/// The line starting at `pos` (without its newline) and the offset after it.
fn line_at(reader: &dyn filmcraft_media::reader::ByteReader, pos: u64) -> Option<(Vec<u8>, u64)> {
let mut want = 128usize;
loop {
let len = (reader.len().saturating_sub(pos)).min(want as u64) as usize;
let buf = filmcraft_media::reader::read_range(reader, pos, len).ok()?;
if let Some(end) = buf.iter().position(|&b| b == b'\n') {
let mut line = buf;
line.truncate(end);
return Some((line, pos + end as u64 + 1));
}
if len < want || want >= MAX_LINE {
return None;
}
want = (want * 8).min(MAX_LINE);
}
}
impl Y4mSource {
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
}
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
let bad = |what: &str| CodecError::Container(format!("{name}: Y4M: {what}"));
let (header, mut pos) = line_at(&*reader, 0).ok_or_else(|| bad("no stream header"))?;
let header = String::from_utf8_lossy(&header).into_owned();
let mut params = header.split(' ');
if params.next() != Some("YUV4MPEG2") {
return Err(bad("not a YUV4MPEG2 file"));
}
let (mut w, mut h, mut rate, mut par, mut colour, mut full) = (0u32, 0u32, None, (1u32, 1u32), "420jpeg".to_string(), false);
for p in params.filter(|p| !p.is_empty()) {
let (tag, v) = p.split_at(1);
match tag {
"W" => w = v.parse().map_err(|_| bad("invalid width"))?,
"H" => h = v.parse().map_err(|_| bad("invalid height"))?,
"F" => match parse_ratio(v) {
Some((n, d)) if n > 0 && d > 0 => rate = Some(FrameRate::new(n, d)),
_ => return Err(bad("invalid frame rate")),
},
"A" => {
if let Some((n, d)) = parse_ratio(v).filter(|&(n, d)| n > 0 && d > 0)
&& let (Ok(n), Ok(d)) = (u32::try_from(n), u32::try_from(d))
{
par = (n, d);
}
}
"C" => colour = v.to_string(),
"X" => full |= v.eq_ignore_ascii_case("COLORRANGE=FULL"),
_ => {}
}
}
if w == 0 || h == 0 {
return Err(bad("missing or zero size"));
}
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(CodecError::Unsupported(format!("{name}: Y4M frames larger than 268 million pixels")));
}
let layout = Layout::parse(&colour).ok_or_else(|| CodecError::Unsupported(format!("{name}: Y4M colour space {colour}")))?;
// ffmpeg's default when the file names no rate
let rate = rate.unwrap_or(FrameRate::FPS_25);
let (luma, chroma, alpha) = plane_samples(w, h, layout);
let per_sample = if layout.bits > 8 { 2 } else { 1 };
let frame_bytes = (luma + 2 * chroma + alpha) * per_sample;
let len = reader.len();
let mut frames = Vec::new();
while pos < len {
let Some((line, data)) = line_at(&*reader, pos) else { break };
if !line.starts_with(b"FRAME") {
break;
}
let Some(end) = data.checked_add(frame_bytes as u64).filter(|&e| e <= len) else { break };
frames.push(data);
pos = end;
}
if frames.is_empty() {
return Err(bad("no complete frames"));
}
let color = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), range: if full { Range::Full } else { Range::Limited }, ..ColorInfo::REC709 };
let duration = Tick::from_rational(frames.len() as i64, rate.den, rate.num);
let bitrate = (frame_bytes as u64 * 8).checked_mul(rate.num as u64).map(|b| b / rate.den as u64);
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Movie,
duration,
video: Some(VideoStreamInfo {
width: w,
height: h,
frame_rate: rate,
par,
codec: "Uncompressed YUV".into(),
pixel_format: layout.label(),
color,
has_alpha: layout.alpha,
bitrate,
hdr: None,
}),
audio_streams: Vec::new(),
container: "Y4M".into(),
start_timecode: None,
file_size: Some(len),
};
Ok(Self { info, reader, frames, frame_bytes, layout, rate })
}
fn frame(&self, idx: usize) -> crate::Result<VideoFrame> {
let (Some(&at), Some(v)) = (self.frames.get(idx), self.info.video.as_ref()) else {
return Err(CodecError::Container("Y4M: frame index out of range".into()));
};
let data = filmcraft_media::reader::read_range(&*self.reader, at, self.frame_bytes).map_err(|e| CodecError::Container(e.to_string()))?;
let (luma, chroma, alpha) = plane_samples(v.width, v.height, self.layout);
// mono: neutral chroma at 4:2:0
let mono = Layout { chroma: Some(Chroma::C420), ..self.layout };
let grey = plane_samples(v.width, v.height, mono).1;
let pixels = if self.layout.bits == 8 {
let plane = |i: usize, n: usize| Arc::new(data.get(i..i + n).map(<[u8]>::to_vec).unwrap_or_else(|| vec![0; n]));
let (y, cb, cr) = match self.layout.chroma {
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
None => (plane(0, luma), Arc::new(vec![128; grey]), Arc::new(vec![128; grey])),
};
let a = self.layout.alpha.then(|| plane(luma + 2 * chroma, alpha));
PixelData::Yuv8 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), alpha: a }
} else {
let max = (1u32 << self.layout.bits) - 1;
let plane = |i: usize, n: usize| {
Arc::new(match data.get(i * 2..(i + n) * 2) {
Some(b) => b.as_chunks::<2>().0.iter().map(|&s| u32::from(u16::from_le_bytes(s)).min(max) as u16).collect(),
None => vec![0; n],
})
};
let mid = 1u16 << (self.layout.bits - 1);
let (y, cb, cr) = match self.layout.chroma {
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
None => (plane(0, luma), Arc::new(vec![mid; grey]), Arc::new(vec![mid; grey])),
};
PixelData::Yuv16 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), bits: self.layout.bits, alpha: None }
};
Ok(VideoFrame {
width: v.width,
height: v.height,
data: pixels,
color: v.color,
par: v.par,
pts: Tick::from_rational(idx as i64, self.rate.den, self.rate.num),
})
}
}
impl MediaSource for Y4mSource {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
let last = self.frames.len().saturating_sub(1);
let idx = usize::try_from(self.rate.frame_at(req.time.max(Tick::ZERO))).unwrap_or(last).min(last);
Ok(Arc::new(self.frame(idx)?))
}
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer, MediaError> {
Err(MediaError::NoStream("audio"))
}
}
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
if !sniff(&bytes) {
return None;
}
Some(Y4mSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
if !sniff(head) {
return None;
}
Some(Y4mSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colour_space_names() {
let l = |c: Option<Chroma>, bits, alpha| Some(Layout { chroma: c, bits, alpha });
assert_eq!(Layout::parse("420jpeg"), l(Some(Chroma::C420), 8, false));
assert_eq!(Layout::parse("420p10"), l(Some(Chroma::C420), 10, false));
assert_eq!(Layout::parse("422p12"), l(Some(Chroma::C422), 12, false));
assert_eq!(Layout::parse("444alpha"), l(Some(Chroma::C444), 8, true));
assert_eq!(Layout::parse("mono"), l(None, 8, false));
assert_eq!(Layout::parse("mono16"), l(None, 16, false));
for bad in ["411", "420p", "420p7", "420p17", "mono10p12", "444alphap10", "", "p10"] {
assert_eq!(Layout::parse(bad), None, "{bad}");
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Standalone ADTS `.aac` files (#1291): File ▸ Import offers them, and FilmCraft's own AAC export
//! writes them (#814), so they must open as audio. The streams are made with our AAC encoder, which
//! prefixes each access unit with an ADTS header (ISO/IEC 13818-7 §6.2).
use std::sync::Arc;
use filmcraft_aac::{Encoder, EncoderConfig};
const RATE: u32 = 48_000;
/// `secs` of a 440 Hz tone at half scale in every channel, as an ADTS stream.
fn adts_tone(channels: usize, secs: u32) -> Vec<u8> {
let tone: Vec<f32> = (0..RATE * secs).map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / RATE as f32).sin() * 0.5).collect();
let planar: Vec<&[f32]> = (0..channels).map(|_| tone.as_slice()).collect();
let mut enc = Encoder::new(EncoderConfig { adts: true, ..EncoderConfig::cbr(RATE, channels, 128_000) }).unwrap();
let mut out = Vec::new();
for au in enc.encode(&planar).into_iter().chain(enc.flush()) {
out.extend(au);
}
out
}
fn rms(s: &[f32]) -> f32 {
(s.iter().map(|v| v * v).sum::<f32>() / s.len().max(1) as f32).sqrt()
}
#[test]
fn adts_aac_files_import_as_audio() {
let src = filmcraft_codecs::open_bytes("tone.aac", Arc::from(adts_tone(2, 1))).unwrap();
let info = src.info();
let a = info.audio().unwrap();
assert_eq!((a.sample_rate, a.channels), (RATE, 2));
// One second plus the encoder's priming and the last frame's padding (at most two frames).
let secs = info.duration.seconds();
assert!((1.0..=1.0 + 2048.0 / RATE as f64).contains(&secs), "duration {secs}");
let buf = src.audio(RATE as i64 / 4, RATE as usize / 2, RATE).unwrap();
for (c, ch) in buf.channels.iter().enumerate() {
let level = rms(ch);
assert!((level - 0.5 / 2f32.sqrt()).abs() < 0.05, "channel {c} rms {level}");
}
}
#[test]
fn damaged_adts_files_fail_without_panicking() {
let good = adts_tone(1, 1);
let mut cases: Vec<Vec<u8>> = (0..good.len().min(4096)).step_by(7).map(|n| good[..n].to_vec()).collect();
for i in (0..good.len()).step_by(97) {
let mut b = good.clone();
b[i] ^= 1 << (i % 8);
cases.push(b);
}
// A frame length of zero, and one running past the end of the file.
let mut zero = good.clone();
zero[3] &= !3;
zero[4] = 0;
zero[5] &= 0x1F;
cases.push(zero);
let mut long = good[..64].to_vec();
long[3] |= 3;
long[4] = 0xFF;
cases.push(long);
for (i, b) in cases.into_iter().enumerate() {
let r = std::panic::catch_unwind(|| filmcraft_codecs::open_bytes("damaged.aac", Arc::from(b)).map(|s| s.audio(0, 4800, RATE).is_ok()));
assert!(r.is_ok(), "case {i} panicked");
}
}
+89
View File
@@ -0,0 +1,89 @@
//! MPEG audio Layer I (MP1) in Matroska (`A_MPEG/L1`, #1402) against ffmpeg's decode. No
//! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames,
//! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes.
mod common;
use common::*;
struct Bits(Vec<u8>, usize);
impl Bits {
fn put(&mut self, v: u32, n: u32) {
for i in (0..n).rev() {
if self.1.is_multiple_of(8) {
self.0.push(0);
}
if (v >> i) & 1 == 1 {
let last = self.0.len() - 1;
self.0[last] |= 0x80 >> (self.1 % 8);
}
self.1 += 1;
}
}
}
/// Mono Layer I frames, MPEG-1, 48 kHz, 192 kbit/s (192-byte frames): allocations on the first 8
/// subbands only, so every frame fits.
fn layer1_stream(frames: usize) -> Vec<u8> {
let mut seed = 0x2545_F491u32;
let mut rnd = |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed % n
};
let mut out = Vec::new();
for _ in 0..frames {
let mut b = Bits(Vec::new(), 0);
for byte in [0xFFu32, 0xFF, 6 << 4 | 1 << 2, 3 << 6] {
b.put(byte, 8);
}
let alloc: Vec<u32> = (0..32).map(|sb| if sb < 8 { 1 + rnd(8) } else { 0 }).collect();
for &a in &alloc {
b.put(a, 4);
}
for &a in &alloc {
if a != 0 {
b.put(10 + rnd(30), 6);
}
}
for _ in 0..12 {
for &a in &alloc {
if a != 0 {
b.put(rnd((1 << (a + 1)) - 1), a + 1);
}
}
}
let mut f = b.0;
f.resize(192, 0);
out.extend(f);
}
out
}
#[test]
fn mp1_in_matroska_matches_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let raw = dir().join("synthetic_layer1.mp1");
std::fs::create_dir_all(dir()).unwrap();
std::fs::write(&raw, layer1_stream(250)).unwrap();
let Some(mkv) = fixture(&ff, "synthetic_layer1.mkv", &["-f", "mp3", "-i", raw.to_str().unwrap(), "-c:a", "copy"]) else {
eprintln!("SKIPPED: this ffmpeg can't remux Layer I into Matroska");
return;
};
let m = filmcraft_matroska::open(bytes(&mkv)).unwrap();
assert!(matches!(m.tracks.first().map(|t| &t.codec), Some(filmcraft_matroska::Codec::Mp1)), "A_MPEG/L1 maps to Codec::Mp1");
let src = filmcraft_codecs::open_bytes("synthetic_layer1.mkv", bytes(&mkv)).unwrap();
let a = src.info().audio().cloned().unwrap();
assert_eq!((a.sample_rate, a.channels), (48_000, 1));
let want = ffmpeg_audio_f32(&ff, &mkv, &[]);
let n = want.len().min(250 * 384) - 800;
let got = src.audio(0, n, 48_000).unwrap();
let peak = want.iter().fold(0f32, |m, x| m.max(x.abs()));
// ffmpeg's CLI may or may not drop the decoder delay of a stream copy; search a few lags
let (worst, lag) = (-2i64..=2)
.map(|lag| ((2..n - 2).map(|i| (want[(i as i64 + lag) as usize] - got.channels[0][i]).abs()).fold(0f32, f32::max), lag))
.fold((f32::INFINITY, 0), |m, x| if x.0 < m.0 { x } else { m });
println!("mkv Layer I: max error {worst} at lag {lag}, peak {peak}");
assert!(peak > 0.01 && worst <= 2e-4, "peak {peak}, max error {worst} at lag {lag}");
}
+171
View File
@@ -0,0 +1,171 @@
//! YUV4MPEG2 (`.y4m`) files (#1292): File ▸ Import and the Media Browser offer them, so they must
//! open. Files are built here: a stream header of tagged parameters, then `FRAME` lines each
//! followed by the planes (Y, Cb, Cr, 16-bit little-endian above 8 bits).
use std::sync::Arc;
use filmcraft_color::Range;
use filmcraft_frame::{Chroma, PixelData};
use filmcraft_media::{FrameRequest, MediaKind, SharedSource};
use filmcraft_time::{FrameRate, Tick};
/// A Y4M file: `header` after the signature, `frames` as raw plane bytes, each after `frame_line`.
fn y4m(header: &str, frame_line: &str, frames: &[Vec<u8>]) -> Vec<u8> {
let mut out = format!("YUV4MPEG2 {header}\n").into_bytes();
for f in frames {
out.extend_from_slice(frame_line.as_bytes());
out.push(b'\n');
out.extend_from_slice(f);
}
out
}
/// One 8-bit frame: every Y sample `y`, every Cb `cb`, every Cr `cr`.
fn planes8(luma: usize, chroma: usize, y: u8, cb: u8, cr: u8) -> Vec<u8> {
[vec![y; luma], vec![cb; chroma], vec![cr; chroma]].concat()
}
fn open(name: &str, bytes: Vec<u8>) -> SharedSource {
filmcraft_codecs::open_bytes(name, Arc::from(bytes)).unwrap()
}
/// The middle of frame `k` at `fps`.
fn at(k: i64, fps: i64) -> FrameRequest {
FrameRequest::full(Tick::from_rational(2 * k + 1, 1, 2 * fps))
}
#[test]
fn y4m_420_opens_as_a_movie_frame_by_frame() {
// 6 × 4, 4:2:0 (chroma 3 × 2), three frames at 25 fps.
let frames: Vec<Vec<u8>> = (0..3).map(|k| planes8(24, 6, 16 + 50 * k, 100 + k, 200 - k)).collect();
let src = open("clip.y4m", y4m("W6 H4 F25:1 Ip A1:1 C420jpeg", "FRAME", &frames));
let info = src.info();
assert_eq!(info.kind, MediaKind::Movie);
let v = info.video.as_ref().unwrap();
assert_eq!((v.width, v.height, v.frame_rate), (6, 4, FrameRate::new(25, 1)));
assert_eq!(info.duration, Tick::from_units(3, 25));
for k in 0..3u8 {
let f = src.video_frame(at(i64::from(k), 25)).unwrap();
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!(*chroma, Chroma::C420);
assert_eq!((planes[0].len(), planes[1].len(), planes[2].len()), (24, 6, 6));
assert!(planes[0].iter().all(|&s| s == 16 + 50 * k) && planes[1].iter().all(|&s| s == 100 + k) && planes[2].iter().all(|&s| s == 200 - k));
}
}
#[test]
fn y4m_high_bit_depth_frame_parameters_aspect_and_range() {
// 4 × 2, 4:2:2 10-bit (little-endian), 30000:1001, 4:3 pixels, full range, and frame lines
// carrying their own parameters.
let mut frame = Vec::new();
for (n, v) in [(8, 940u16), (4, 512), (4, 64)] {
for _ in 0..n {
frame.extend(v.to_le_bytes());
}
}
let src = open("ten.y4m", y4m("W4 H2 F30000:1001 It A4:3 C422p10 XCOLORRANGE=FULL", "FRAME Ip XFOO=1", &[frame.clone(), frame]));
let v = src.info().video.clone().unwrap();
assert_eq!((v.frame_rate, v.par, v.color.range), (FrameRate::new(30000, 1001), (4, 3), Range::Full));
let f = src.video_frame(at(1, 30)).unwrap();
let PixelData::Yuv16 { planes, chroma, bits, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!((*chroma, *bits), (Chroma::C422, 10));
assert!(planes[0].iter().all(|&s| s == 940) && planes[1].iter().all(|&s| s == 512) && planes[2].iter().all(|&s| s == 64));
}
#[test]
fn y4m_mono_has_neutral_chroma() {
let src = open("grey.y4m", y4m("W2 H2 F24:1 Cmono", "FRAME", &[vec![77; 4]]));
let f = src.video_frame(at(0, 24)).unwrap();
let PixelData::Yuv8 { planes, .. } = &f.data else { panic!("{:?}", f.data) };
assert!(planes[0].iter().all(|&s| s == 77) && planes[1].iter().chain(planes[2].iter()).all(|&s| s == 128));
}
#[test]
fn large_y4m_files_open_from_a_reader() {
let bytes: Arc<[u8]> = Arc::from(y4m("W2 H2 F25:1 C444", "FRAME", &[planes8(4, 4, 50, 60, 70), planes8(4, 4, 90, 60, 70)]));
let reader: filmcraft_media::SharedReader = Arc::new(filmcraft_media::reader::MemReader(bytes));
let src = filmcraft_media::reader::open_reader("big.y4m", reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()).unwrap();
let f = src.video_frame(at(1, 25)).unwrap();
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!((*chroma, planes[0][0]), (Chroma::C444, 90));
}
#[test]
fn damaged_and_hostile_y4m_files_fail_without_panicking() {
let good = y4m("W6 H4 F25:1 C420jpeg", "FRAME", &[planes8(24, 6, 1, 2, 3), planes8(24, 6, 4, 5, 6)]);
let mut cases: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
for i in 0..good.len() {
let mut b = good.clone();
b[i] ^= 0x41;
cases.push(b);
}
for header in [
"W99999999 H99999999 F25:1",
"W0 H4 F25:1",
"W6 H4 F0:0",
"W6 H4 F25:0",
"W6 H4 F25:1 C411",
"W6 H4 F25:1 Cbogus",
"W6 H4 A0:0 F-5:1",
"H4 F25:1",
"W6 H4 F1:9223372036854775807",
] {
cases.push(y4m(header, "FRAME", &[vec![0; 36]]));
}
for (i, b) in cases.into_iter().enumerate() {
let r = std::panic::catch_unwind(|| {
filmcraft_codecs::open_bytes("damaged.y4m", Arc::from(b)).map(|s| (0..3).filter(|&k| s.video_frame(at(k, 25)).is_ok()).count())
});
assert!(r.is_ok(), "case {i} panicked");
}
}
/// ffmpeg (an external oracle) writes `.y4m` files; every frame we read must equal, byte for byte,
/// the planes ffmpeg's own rawvideo output gives for the same file.
#[test]
fn ffmpeg_y4m_files_match_ffmpeg_rawvideo() {
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("Y4M oracle") else { return };
let dir = filmcraft_testkit::fixtures::fixtures_dir("codecs/y4m");
for (pix_fmt, bytes_per_sample) in [("yuv420p", 1usize), ("yuv422p10le", 2)] {
let y4m = dir.join(format!("testsrc2_{pix_fmt}.y4m"));
let made = filmcraft_testkit::fixtures::generate(&y4m, |tmp| {
std::process::Command::new(&ff)
.args([
"-y",
"-v",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=size=94x62:rate=25",
"-frames:v",
"5",
"-pix_fmt",
pix_fmt,
"-strict",
"-1",
"-f",
"yuv4mpegpipe",
])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
});
let Some(y4m) = made else { continue };
let raw =
std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&y4m).args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]).output().unwrap().stdout;
let src = open("oracle.y4m", std::fs::read(&y4m).unwrap());
assert_eq!(src.info().duration, Tick::from_units(5, 25), "{pix_fmt}");
let frame_len = raw.len() / 5;
for k in 0..5 {
let f = src.video_frame(at(k as i64, 25)).unwrap();
let ours: Vec<u8> = match &f.data {
PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(),
PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(),
other => panic!("{other:?}"),
};
assert_eq!(ours.len(), frame_len, "{pix_fmt} frame {k}");
assert!(ours == raw[k * frame_len..(k + 1) * frame_len], "{pix_fmt} frame {k} differs ({bytes_per_sample} bytes/sample)");
}
}
}
+14 -7
View File
@@ -559,7 +559,8 @@ pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
it.start = (*start).max(Tick::ZERO);
placed.push((*dest, it));
}
// worked out on the original ids, then moved over to the copies
// worked out on the original ids, then moved over to the copies (only transitions whose every
// clip is copied travel; one whose partner stays with the originals is not duplicated)
let carried = carried_transitions(&work, &placed);
let mut ids = HashMap::new();
let mut links = HashMap::new();
@@ -596,9 +597,10 @@ pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
Ok(out)
}
/// The transitions that travel with a move (#227, #374): those whose clips all move, onto one
/// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move
/// together), shifted to where those clips land.
/// The transitions that travel with a move (#227, #374): only those whose every clip moves, onto
/// one track and by one offset (a fade at a moved clip's edge, or the cut between two clips that
/// move together), shifted to where those clips land. A transition whose partner stays behind is
/// dropped, as in Premiere (the drag preview leaves it where it is until the release).
fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(TrackId, Transition)> {
let landing = |c: ClipId| {
let (dest, it) = placed.iter().find(|(_, it)| it.id == c)?;
@@ -607,9 +609,14 @@ fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(
};
let mut carried = Vec::new();
for tr in seq.all_tracks().filter(|t| !t.locked).flat_map(|t| t.transitions.iter()) {
let lands: Option<Vec<(TrackId, Tick)>> = [tr.from, tr.to].into_iter().flatten().map(landing).collect();
let Some((&(dest, delta), rest)) = lands.as_deref().and_then(<[_]>::split_first) else { continue };
if rest.iter().all(|l| *l == (dest, delta)) {
let endpoints: Vec<ClipId> = [tr.from, tr.to].into_iter().flatten().collect();
let lands: Vec<(TrackId, Tick)> = endpoints.iter().filter_map(|c| landing(*c)).collect();
// every clip it sits on must move, onto one track by one offset, or the transition is
// dropped with the delete below (a partner that stayed behind ends the move)
if lands.len() == endpoints.len()
&& let Some(&(dest, delta)) = lands.first()
&& lands.iter().skip(1).all(|l| *l == (dest, delta))
{
carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() }));
}
}
+1 -1
View File
@@ -554,7 +554,7 @@ fn transitions_travel_with_moved_clips() {
// a cut whose two clips move together keeps its transition, in an insert move as well
move_items(&mut fx.seq, &[(b, v1, f(60)), (c, v1, f(70))], true, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(at(&fx, v1), vec![(2, 68), (3, 76)]);
// a crossfade whose partner stays behind is dropped; the other clip's fade stays with it
// a crossfade whose partner stays behind is dropped; the moving clip's own fade stays with it
move_items(&mut fx.seq, &[(c, v1, f(90))], false, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(at(&fx, v1), vec![(3, 96)]);
fx.seq.check().unwrap();
+126 -12
View File
@@ -345,8 +345,23 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "timeline.move";
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
let mut new_moves = Vec::new();
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() {
let track = track_p(s, m, "track", CMD)?;
let track = if str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")) {
let clip = clip_p(m, "clip").ok_or_else(|| bad(CMD, "new track needs a clip"))?;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (source, _) = q.find_item(clip).ok_or(edit::EditError::NoItem(clip))?;
if q.track(source).is_some_and(|t| t.locked) {
return Err(edit::EditError::Locked.into());
}
if m.get("time").and_then(Value::as_i64).is_none() {
return Err(bad(CMD, "new track needs a time"));
}
new_moves.push((clip, source));
Some(source)
} else {
track_p(s, m, "track", CMD)?
};
if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) {
listed.push((clip, track, Tick(time)));
}
@@ -391,16 +406,48 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect()
};
let before = spots(s);
// Allocate destinations in the same transaction as the move: an invalid edit leaves
// neither empty tracks nor history entries. Each source track keeps its clips together.
let prepare = |q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>| -> Result<Vec<(ClipId, TrackId, Tick)>> {
let sources: Vec<_> = q.all_tracks().filter(|t| new_moves.iter().any(|m| m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect();
for kind in [TrackKind::Video, TrackKind::Audio] {
let added = sources.iter().filter(|s| s.1 == kind).count();
if added > 0 && q.tracks(kind).len().saturating_add(added) > crate::sequence_tools::MAX_TRACKS {
return Err(bad(CMD, "new tracks exceed the sequence track limit"));
}
}
let mut destinations = Vec::new();
for (source, kind, channels) in sources {
let tracks = if kind == TrackKind::Video { &mut q.video_tracks } else { &mut q.audio_tracks };
let prefix = if kind == TrackKind::Video { "Video" } else { "Audio" };
let id = TrackId(ctx.alloc());
let mut track = Track::new(id, kind, format!("{prefix} {}", tracks.len() + 1));
track.channels = channels;
tracks.push(track);
destinations.push((source, id));
}
Ok(moves
.iter()
.map(|&(clip, track, time)| {
let dest = new_moves.iter().find(|m| m.0 == clip).and_then(|m| destinations.iter().find(|d| d.0 == m.1)).map_or(track, |d| d.1);
(clip, dest, time)
})
.collect())
};
let copied = if copy {
let label = if ins { "Copy (Insert)" } else { "Copy" };
s.edit_sequence(label, |q, ctx, st| {
let ids = edit::copy_items(q, &moves, ins, ctx)?;
let destinations = prepare(q, ctx)?;
let ids = edit::copy_items(q, &destinations, ins, ctx)?;
st.selection = ids.clone();
st.transition_selection.clear();
Ok(ids)
})?
} else {
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| {
let destinations = prepare(q, ctx)?;
Ok(edit::move_items(q, &destinations, ins, ctx)?)
})?;
Vec::new()
};
// An overwrite shortens, splits or removes whatever already sits where a clip lands, and a
@@ -1630,6 +1677,58 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("edit.rippleDeleteHead", "Ripple Delete Head Frame", ["Edit"], None, r#"{"clips":[id]?,"clip":id?}"#, has_selection, |s, p| {
// #1357: remove the head — from each selected clip's in point through the playhead —
// and ripple the sequence closed. Linked clips ripple as one group (the engine's
// group trim moves every member's in point by one delta and shifts the synced
// tracks once). The playhead past every member's out point deletes the group whole.
let ph = s.playhead();
let clips = with_links(s, &clips_p(s, p));
// groups keyed by link id (unlinked clips: their own id)
let mut groups: Vec<(u64, Vec<ClipId>)> = Vec::new();
{
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
for c in &clips {
let (_, item) = q.find_item(*c).ok_or_else(|| EngineError::Other("clip not in the active sequence".into()))?;
if (ph - item.start) <= Tick::ZERO {
return Err(EngineError::Other("the playhead is at or before the clip's in point".into()));
}
let key = item.link.unwrap_or(c.0);
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(*c),
None => groups.push((key, vec![*c])),
}
}
}
s.edit_sequence("Ripple Delete Head Frame", |q, ctx, st| {
for (_, members) in &groups {
let live: Vec<(ClipId, Tick, Tick)> = members.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| (*c, i.start, i.duration))).collect();
if live.is_empty() {
continue;
}
let head = live.iter().map(|(_, s, _)| (ph - *s).max(Tick::ZERO)).max().unwrap_or(Tick::ZERO);
let all_whole = live.iter().all(|(_, _, d)| head >= *d);
let ids: Vec<ClipId> = live.iter().map(|(c, _, _)| *c).collect();
if all_whole {
let spans = edit::ripple_delete_items(q, &ids)?;
if st.ripple_sequence_markers {
for r in spans.iter().rev() {
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
}
}
continue;
}
let start = live.iter().map(|(_, s, _)| *s).min().unwrap_or(ph);
edit::ripple_trim_group(q, &ids, Edge::In, head, ctx)?;
if st.ripple_sequence_markers {
// the head [start, start + head) closes: later markers move back by it
crate::sequence_tools::ripple_markers(&mut q.markers, start + head, Tick::ZERO - head);
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| {
let all: Vec<ClipId> = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default();
s.state.selection = all;
@@ -2528,12 +2627,7 @@ fn build() -> Vec<CommandSpec> {
None,
r#"{"moves":[{"clip":id,"track":id|"V2"|"new" (after the last of its kind),"time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#,
has_seq,
|s, p| {
if crate::sequence_tools::move_wants_new_track(p) {
return crate::sequence_tools::move_to_new_tracks(s, p);
}
move_clips(s, p)
}
move_clips
),
cmd!(
"timeline.trim",
@@ -3148,20 +3242,40 @@ fn build() -> Vec<CommandSpec> {
items.sort_by_key(|i| i.0);
items.dedup();
let mut used = std::collections::HashSet::new();
for it in s.project.sequences() {
for it in s.project.sequences().filter(|it| !items.contains(&it.id)) {
if let Some(q) = it.as_sequence() {
for t in q.all_tracks() {
used.extend(t.items.iter().map(|x| x.item));
for clip in t
.items
.iter()
.filter(|clip| !items.contains(&clip.item) || s.project.item(clip.item).is_some_and(|item| matches!(item.kind, ItemKind::Media(_))))
{
// Preserve the whole valid file-backed chain, not just its root.
if !s.project.resolve_media(clip.item).is_some_and(|(_, media, _)| matches!(media.media, MediaRef::File { .. })) {
continue;
}
let mut id = clip.item;
for _ in 0..16 {
used.insert(id);
match s.project.item(id).map(|item| &item.kind) {
Some(ItemKind::Subclip { parent, .. }) => id = *parent,
_ => break,
}
}
}
}
}
}
let mut gone = Vec::new();
let mut offlined = Vec::new();
let mut retained_subclips = Vec::new();
for i in items {
let keep = used.contains(&i)
&& s.project.item(i).and_then(filmcraft_project::ProjectItem::as_media).is_some_and(|m| matches!(m.media, MediaRef::File { .. }));
if keep {
offlined.push(i);
} else if used.contains(&i) && s.project.item(i).is_some_and(|item| matches!(item.kind, ItemKind::Subclip { .. })) {
retained_subclips.push(i);
} else {
gone.push(i);
}
@@ -3175,7 +3289,7 @@ fn build() -> Vec<CommandSpec> {
for b in &bins {
pr.root.remove_bin(*b);
}
for i in &offlined {
for i in offlined.iter().chain(&retained_subclips) {
if pr.root.parent_of(*i).is_none() {
pr.root.children.push(filmcraft_project::BinEntry::Item(*i));
}
+111 -2
View File
@@ -360,6 +360,13 @@ fn user_presets_persist_with_favourites_import_and_export() {
assert_eq!(favs, 2);
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
// #1371: the query also matches the format id and the normalised label
let h264_only = s.execute("export.presets.list", json!({"format": "h264"})).unwrap()["presets"].as_array().unwrap().len();
let mut count = |q: &str| s.execute("export.presets.list", json!({"query": q})).unwrap()["presets"].as_array().unwrap().len();
assert!(count("h264") > 0, "format id `h264` finds the H.264 presets");
assert!(count("h264") >= h264_only, "every h264 preset is found by its format id");
assert_eq!(count("h.264"), count("H.264"));
assert!(count("h.264") >= h264_only);
// persisted: a fresh session with the same data directory sees both
let mut s2 = demo();
s2.export_presets.set_dir(&dir.0);
@@ -661,8 +668,11 @@ fn ranges_resolve_and_sidecar_captions_are_written() {
let out = dir.path("cap.wav");
s.execute("file.exportMedia", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": out, "captionSidecar": "srt", "wait": true})).unwrap();
let srt = std::fs::read_to_string(dir.path("cap.srt")).unwrap();
// timed from the start of the export range (In at 1 s; the caption starts on frame 35)
assert!(srt.contains("Hello sidecar") && srt.contains("00:00:00,460 --> 00:00:01,461"), "{srt}");
// Timed from In at 1 s (the caption starts on frame 35), clipped to the export duration.
let resolved = s.execute("export.resolve", json!({"preset": "Waveform Audio 48 kHz 16-bit"})).unwrap();
let duration = filmcraft_time::Tick(resolved["range"]["duration"].as_i64().unwrap());
let timing = format!("00:00:00,460 --> {}", filmcraft_captions::format_clock(duration, ','));
assert!(srt.contains("Hello sidecar") && srt.contains(&timing), "{srt}");
s.execute("file.exportMedia", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": out, "captionSidecar": "vtt", "wait": true})).unwrap();
assert!(std::fs::read_to_string(dir.path("cap.vtt")).unwrap().starts_with("WEBVTT"));
// a bad preset name is a clean error
@@ -706,3 +716,102 @@ fn unknown_settings_keys_are_errors_not_ignored() {
assert_eq!(ok.effects.loudness.target_lufs, -14.0, "{key}");
}
}
#[test]
fn caption_sidecars_keep_only_the_exported_interval() {
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use filmcraft_captions::{Document, Format};
use filmcraft_media::MediaSource;
use filmcraft_time::{TICKS_PER_SECOND, Tick};
#[derive(Default)]
struct MemoryFiles(Mutex<BTreeMap<String, Vec<u8>>>);
impl crate::Services for MemoryFiles {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::ErrorKind::NotFound.into())
}
fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> {
self.0.lock().unwrap().insert(path.to_owned(), bytes.to_vec());
Ok(())
}
fn export_in_memory(&self) -> bool {
true
}
}
fn session(text: &str) -> (Session, Arc<MemoryFiles>) {
let files = Arc::new(MemoryFiles::default());
files.0.lock().unwrap().insert("input.vtt".into(), text.as_bytes().to_vec());
let mut s = Session::new(files.clone());
s.execute("file.newSequence", json!({"name":"Caption range", "fps":25, "width":16, "height":16, "video":0, "audio":0})).unwrap();
s.execute("captions.import", json!({"path":"input.vtt"})).unwrap();
s.execute("markers.markIn", json!({"time":2 * TICKS_PER_SECOND})).unwrap();
// The UI Out point includes that frame; the export range ends at exactly five seconds.
s.execute("markers.markOut", json!({"time":5 * TICKS_PER_SECOND - TICKS_PER_SECOND / 25})).unwrap();
(s, files)
}
fn sidecar(s: &mut Session, files: &MemoryFiles, format: Format, range: Value, duration: Tick) -> Document {
let ext = format.extension();
let mut params = json!({"format":"wav", "path":"output.wav", "captionSidecar":ext, "wait":true});
params.as_object_mut().unwrap().extend(range.as_object().unwrap().clone());
let result = s.execute("file.exportMedia", params).unwrap();
assert_eq!(result["result"]["path"], "output.wav");
assert!(result["result"]["bytes"].as_u64().is_some_and(|n| n > 0), "{result}");
let wav = files.0.lock().unwrap().get("output.wav").unwrap().clone();
let audio = filmcraft_media::wav::WavSource::parse("output.wav", wav.into()).unwrap();
assert_eq!(audio.info().duration, duration, "exported audio duration");
let bytes = files.0.lock().unwrap().get(&format!("output.{ext}")).unwrap().clone();
let doc = filmcraft_captions::parse(&bytes, format).unwrap();
assert!(doc.warnings.is_empty(), "{doc:?}");
doc
}
// Every time is both a millisecond and an exact frame boundary at 25 fps.
let fixture = "WEBVTT\n\nSTYLE\n::cue { color: yellow }\n\nREGION\nid:bottom\n\nNOTE original fixture\n\nbefore\n00:00:00.000 --> 00:00:01.000\nBefore\n\nin\n00:00:01.600 --> 00:00:02.400 line:90% align:center\n<v Ann>Crossing In\n\ninside\n00:00:03.000 --> 00:00:04.000\nInside\n\nout\n00:00:04.600 --> 00:00:05.400\nCrossing Out\n\nafter\n00:00:06.000 --> 00:00:07.000\nAfter\n";
let (mut s, files) = session(fixture);
let original = serde_json::to_value(&s.project).unwrap();
let history = s.execute("history.list", json!({})).unwrap();
let ticks = |tenths: i64| Tick(tenths * (TICKS_PER_SECOND / 10));
let expected = [(ticks(0), ticks(4), "Crossing In"), (ticks(10), ticks(20), "Inside"), (ticks(26), ticks(30), "Crossing Out")];
for format in [Format::Srt, Format::WebVtt] {
for range in [json!({"range":"custom", "startTime":2 * TICKS_PER_SECOND, "endTime":5 * TICKS_PER_SECOND}), json!({"range":"inOut"}), json!({})] {
let doc = sidecar(&mut s, &files, format, range, ticks(30));
let actual: Vec<_> = doc.cues.iter().map(|c| (c.start, c.end, c.text.as_str())).collect();
assert_eq!(actual, expected, "{format:?}: exclude later cues and clip both crossing boundaries");
if format == Format::WebVtt {
assert_eq!(doc.cues[0].id.as_deref(), Some("in"));
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann"));
assert_eq!(doc.cues[0].settings, "line:90% align:center");
assert_eq!(doc.blocks, ["STYLE\n::cue { color: yellow }", "REGION\nid:bottom", "NOTE original fixture"]);
}
}
let all = sidecar(&mut s, &files, format, json!({"range":"entire"}), ticks(70));
let actual: Vec<_> = all.cues.iter().map(|c| (c.start, c.end, c.text.as_str())).collect();
assert_eq!(
actual,
[
(ticks(0), ticks(10), "Before"),
(ticks(16), ticks(24), "Crossing In"),
(ticks(30), ticks(40), "Inside"),
(ticks(46), ticks(54), "Crossing Out"),
(ticks(60), ticks(70), "After")
]
);
// A standalone caption export still exports the whole track even with sequence In/Out set.
let path = format!("standalone.{}", format.extension());
s.execute("captions.export", json!({"path":path})).unwrap();
let standalone = filmcraft_captions::parse(files.0.lock().unwrap().get(&path).unwrap(), format).unwrap();
assert_eq!(standalone, all);
}
assert_eq!(serde_json::to_value(&s.project).unwrap(), original, "export must not trim the project captions");
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
let (mut edge, edge_files) = session("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nEnds at In\n\n00:00:05.000 --> 00:00:06.000\nStarts at Out\n");
for format in [Format::Srt, Format::WebVtt] {
let doc = sidecar(&mut edge, &edge_files, format, json!({"range":"inOut"}), ticks(30));
assert!(doc.cues.is_empty(), "half-open export excludes cues touching either boundary: {doc:?}");
}
}
+15 -1
View File
@@ -449,7 +449,11 @@ fn write_sidecar(s: &Session, project: &Project, seq: ItemId, settings: &ExportS
let Some(track) = q.caption_tracks.first() else { return Ok(None) };
let format = filmcraft_captions::Format::from_name(kind).ok_or_else(|| bad("export", format!("caption sidecar `{kind}`: srt | vtt")))?;
let range = filmcraft_export::export_range(project, seq, settings).map_err(|e| EngineError::Other(e.to_string()))?;
let doc = filmcraft_captions::document_from_track(track, range.start);
let mut doc = filmcraft_captions::document_from_track(track, range.start);
doc.cues.retain_mut(|cue| {
cue.end = cue.end.min(range.duration);
cue.start < cue.end
});
let bytes =
filmcraft_captions::write(&doc, format, filmcraft_captions::WriteOptions { drop_frame: q.settings.drop_frame, rate: Some(q.settings.frame_rate) });
let ext = if format == filmcraft_captions::Format::WebVtt { "vtt" } else { kind };
@@ -953,8 +957,14 @@ fn preset_json(s: &Session, pr: &ExportPreset, full: bool) -> Value {
v
}
/// Lower-cased alphanumerics only, so `h264`, `h.264` and `H.264` compare equal.
fn squash_alnum(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
fn presets_list(s: &mut Session, p: &Value) -> Result<Value> {
let query = str_p(p, "query").unwrap_or("").to_ascii_lowercase();
let squashed = squash_alnum(&query);
let cat = str_p(p, "category").map(str::to_ascii_lowercase);
let fav = bool_p(p, "favorites").unwrap_or(false);
let fmt = str_p(p, "format").and_then(Format::from_name);
@@ -967,6 +977,10 @@ fn presets_list(s: &mut Session, p: &Value) -> Result<Value> {
|| x.name.to_ascii_lowercase().contains(&query)
|| x.description.to_ascii_lowercase().contains(&query)
|| x.category.to_ascii_lowercase().contains(&query)
// The format id (`h264`) and label (`H.264`) are equivalent spellings.
|| (!squashed.is_empty()
&& (squash_alnum(x.settings.format.id()).contains(&squashed)
|| squash_alnum(x.settings.format.label()).contains(&squashed)))
})
.filter(|x| cat.as_ref().is_none_or(|c| x.category.to_ascii_lowercase() == *c))
.filter(|x| !fav || s.export_presets.is_favorite(&x.name))
+79 -8
View File
@@ -514,8 +514,79 @@ fn union_box(boxes: &[(usize, Vec2, [f64; 4])]) -> [f64; 4] {
boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])])
}
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection.
/// Video clips (not graphics) the align commands move by their Motion ▸ Position (#1081): `clip`,
/// else the selected ones. Empty when a graphic clip is named or selected: its layers align.
fn align_target_clips(s: &Session, p: &Value) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return Vec::new() };
let video = |c: &ClipId| seq.find_item(*c).is_some_and(|(t, _)| seq.track(t).is_some_and(|t| t.kind == TrackKind::Video)) && !is_graphic(s, seq, *c);
match u64_p(p, "clip").map(ClipId) {
Some(c) => [c].into_iter().filter(video).collect(),
None if s.state.selection.iter().any(|c| is_graphic(s, seq, *c)) => Vec::new(),
None => s.state.selection.iter().copied().filter(video).collect(),
}
}
fn has_graphic_or_clip(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if align_target_clips(s, &Value::Null).is_empty() { has_graphic(s).map_err(|_| "select a graphic or video clip".into()) } else { Ok(()) }
}
/// Align video clips to the frame (each, as a group, or to their union) by moving Motion ▸
/// Position only, from the clip's scaled and rotated bounds at the playhead. Position gets a
/// keyframe there only when it is already animated, as typing a value would.
fn align_clips(s: &mut Session, clips: &[ClipId], how: &str, mode: AlignTo) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let t = s.playhead();
let at = |it: &filmcraft_project::TrackItem| it.source_time_at(t.min(it.end() - Tick(1)).max(it.start));
let frame = [0.0, 0.0, seq.settings.width as f64, seq.settings.height as f64];
let mut boxes = Vec::new();
for (i, &c) in clips.iter().enumerate() {
let Some((_, it)) = seq.find_item(c) else { continue };
let Some(src) = s.project.source_size(it.item) else { continue };
let Some(motion) = it.effect("motion") else { continue };
let mt = at(it);
let m = filmcraft_render::motion_matrix(seq, it, src, s.project.source_par(it.item), mt);
let (w, h) = (src.0 as f64, src.1 as f64);
let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| m.apply(Vec2::new(x, y)));
let b = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.x), b[1].min(p.y), b[2].max(p.x), b[3].max(p.y)]);
let pos = motion.vec2_at("position", mt);
let pos = if pos.x.is_nan() { Vec2::new(frame[2] / 2.0, frame[3] / 2.0) } else { pos };
boxes.push((i, pos, b));
}
if boxes.is_empty() {
return Err(bad("graphics.align", "the selected clips have no picture to align"));
}
let union = union_box(&boxes);
let group = align_delta(how, frame, union)?;
let mut moves = Vec::new();
for (i, pos, b) in &boxes {
let (dx, dy) = match mode {
AlignTo::Frame => align_delta(how, frame, *b)?,
AlignTo::FrameGroup => group,
AlignTo::Selection => align_delta(how, union, *b)?,
};
moves.extend(clips.get(*i).map(|c| (*c, Vec2::new(pos.x + dx, pos.y + dy))));
}
s.edit_sequence("Align Clips", |q, _, _| {
for (c, pos) in &moves {
let Some((_, it)) = q.find_item_mut(*c) else { continue };
let mt = at(it);
if let Some(prm) = it.effects.iter_mut().find(|e| e.effect == "motion").and_then(|e| e.params.get_mut("position")) {
prm.set_at(mt, ParamValue::Vec2(*pos));
}
}
Ok(())
})?;
Ok(json!({"clips": moves.iter().map(|(c, _)| c.0).collect::<Vec<_>>()}))
}
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection. With video
/// clips selected instead of a graphic, the clips align (#1081).
fn align_layers(s: &mut Session, p: &Value, how: &str, mode: AlignTo) -> Result<Value> {
let clips = align_target_clips(s, p);
if !clips.is_empty() {
return align_clips(s, &clips, how, mode);
}
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (boxes, size) = layer_boxes(s, clip, &layers)?;
@@ -669,7 +740,7 @@ fn has_graphics_in_seq(s: &Session) -> std::result::Result<(), String> {
if graphic_clips(s).is_empty() { Err("the sequence has no graphics".into()) } else { Ok(()) }
}
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers)}"#;
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers; selected video clips move by Motion Position)}"#;
macro_rules! align_cmd {
($id:literal, $label:literal, $menu:expr, $how:literal, $mode:expr, $en:expr) => {
@@ -787,12 +858,12 @@ fn menu_commands() -> Vec<CommandSpec> {
Ok(json!({"clip": clip.0, "item": item.0}))
},
),
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic_or_clip),
align_cmd!("graphics.alignGroup.left", "Left", G, "left", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.hcenter", "Center Horizontally", G, "hcenter", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.right", "Right", G, "right", AlignTo::FrameGroup, has_two_layers),
+31
View File
@@ -658,3 +658,34 @@ fn a_layer_saved_before_the_box_height_existed_can_still_get_one() {
s.execute("graphics.set", json!({"clip": clip.0, "props": {"box_height": 100}})).unwrap();
assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["box"], json!([500.0, 100.0]));
}
#[test]
fn align_to_frame_moves_selected_video_clips_by_position_only() {
// #1081
let mut s = demo();
let q = s.active_sequence().unwrap();
let (fw, fh) = (q.settings.width as f64, q.settings.height as f64);
let it = q.video_tracks[0].items[0].clone();
let (sw, _) = s.project.source_size(it.item).unwrap();
let c = it.id.0;
assert!(!s.is_enabled("graphics.alignFrame.hcenter"), "nothing selected");
s.execute("timeline.select", json!({"clips": [c]})).unwrap();
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": 50.0})).unwrap();
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "position", "value": [300.0, 200.0]})).unwrap();
let pos = |s: &Session| {
let it = s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.clone();
let m = it.effect("motion").unwrap();
(m.vec2_at("position", Tick::ZERO), m.f64_at("scale", Tick::ZERO))
};
s.execute("graphics.alignFrame.hcenter", json!({})).unwrap();
let (p, scale) = pos(&s);
assert!((p.x - fw / 2.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6, "{p:?}");
assert_eq!(scale, 50.0, "scale untouched");
s.execute("graphics.alignFrame.left", json!({})).unwrap();
assert!((pos(&s).0.x - sw as f64 * 0.25).abs() < 1e-6, "left edge on the frame's: {:?}", pos(&s).0);
s.execute("graphics.alignFrame.bottom", json!({})).unwrap();
let h = s.project.source_size(it.item).unwrap().1 as f64;
assert!((pos(&s).0.y - (fh - h * 0.25)).abs() < 1e-6, "{:?}", pos(&s).0);
s.execute("edit.undo", json!({})).unwrap();
assert!((pos(&s).0.y - 200.0).abs() < 1e-6, "one undo step per alignment");
}
+9 -2
View File
@@ -225,6 +225,7 @@ fn nudge_time(s: &mut Session, frames: i64) -> Result<Value> {
fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let mut moves = Vec::new();
let mut blocked = 0usize;
for c in &s.state.selection {
let Some((tid, it)) = seq.find_item(*c) else { continue };
let Some(tr) = seq.track(tid) else { continue };
@@ -234,11 +235,17 @@ fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
(TrackKind::Video, true) | (TrackKind::Audio, false) => 1,
_ => -1,
};
let dest = usize::try_from(idx + step).ok().and_then(|i| list.get(i)).ok_or_else(|| EngineError::Other("no track to move to".into()))?;
// a clip already on the outermost track stays put (a linked video+audio selection must not
// fail because its audio has nowhere to go)
let Some(dest) = usize::try_from(idx + step).ok().and_then(|i| list.get(i)) else {
blocked += 1;
continue;
};
moves.push((*c, dest.id, it.start));
}
if moves.is_empty() {
return Err(EngineError::Other("no clips selected".into()));
let why = if blocked > 0 { "no track to move to" } else { "no clips selected" };
return Err(EngineError::Other(why.into()));
}
s.edit_sequence("Nudge", |q, ctx, _| Ok(edit::move_items(q, &moves, false, ctx)?))?;
Ok(json!({"moved": moves.len()}))
+8
View File
@@ -190,6 +190,14 @@ fn nudge_slip_and_slide_the_selection_with_undo() {
s.execute("timeline.nudgeDown", json!({})).unwrap();
assert_eq!(track_of(&s, last.0), v1_id);
assert!(s.execute("timeline.nudgeDown", json!({})).is_err(), "there is no track below V1");
// a linked video+audio selection: the video goes up, the audio (already on the outermost
// track in that direction) stays, instead of the whole nudge failing (#1368)
s.execute("timeline.select", json!({"clips": [last.0]})).unwrap();
assert!(s.state.selection.len() > 1, "the linked audio is selected too");
s.execute("timeline.nudgeUp", json!({})).unwrap();
assert_eq!(track_of(&s, last.0), s.active_sequence().unwrap().video_tracks[1].id);
s.execute("timeline.nudgeDown", json!({})).unwrap();
assert_eq!(track_of(&s, last.0), v1_id);
// slip: the source In moves, the clip stays
let mid = v1[1];
s.execute("timeline.select", json!({"clips": [mid.0]})).unwrap();
+2
View File
@@ -1288,6 +1288,8 @@ mod remix_tests;
#[cfg(test)]
mod ripple_delete_tests;
#[cfg(test)]
mod ripple_head_tests;
#[cfg(test)]
mod roll_linked_tests;
#[cfg(test)]
mod scopes_tests;
+3 -1
View File
@@ -73,7 +73,9 @@ pub const ALL_COLUMNS: [&str; 10] =
["Name", "Media Type", "Size", "Date Modified", "Frame Rate", "Media Duration", "Video Info", "Audio Info", "Video Codec", "Audio Codec"];
pub const DEFAULT_COLUMNS: [&str; 5] = ["Name", "Frame Rate", "Media Duration", "Video Info", "Size"];
const PROJECT_EXTENSIONS: [&str; 6] = ["fcproj", "edl", "xml", "fcpxml", "otio", "ale"];
/// Project and interchange files the browser can import. Avid Log Exchange (`.ale`) is left out:
/// FilmCraft writes it (File ▸ Export) but, like Premiere, does not read it (#1292).
const PROJECT_EXTENSIONS: [&str; 5] = ["fcproj", "edl", "xml", "fcpxml", "otio"];
const CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"];
const RECENT_MAX: usize = 10;
+9
View File
@@ -318,3 +318,12 @@ fn probing_large_unstreamable_files_does_not_read_them_whole() {
assert_eq!(r["items"].as_array().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
/// #1292: an Avid Log Exchange file is something FilmCraft writes (File ▸ Export), not a project
/// it can import, so the browser doesn't list it with the project files.
#[test]
fn ale_logs_are_not_offered_for_import() {
assert_eq!(media_browser::kind_of("clips.ale"), None);
assert!(!media_browser::extensions().contains(&"ale"));
assert_eq!(media_browser::kind_of("cut.edl"), Some("project"));
}
+56 -14
View File
@@ -378,6 +378,56 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"job": job, "preset": pr.id, "outputs": outputs, "skipped": skipped}))
}
/// Copy to a new file, numbering collisions without replacing another take. Open the input
/// first and create the output exclusively, so a failed read or a competing copy cannot replace
/// an existing destination. Relink only after the copy's identity has been checked.
fn copy_for_ingest(s: &mut Session, item: ItemId, path: &str, dir: &Path) -> Result<String> {
let source = Path::new(path);
let name = source.file_name().ok_or_else(|| bad("ingest", "source has no file name"))?;
let mut input = std::fs::File::open(source).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let mut number = 1u64;
let (out, mut output) = loop {
let mut next = if number == 1 { name.to_os_string() } else { source.file_stem().unwrap_or(name).to_os_string() };
if number != 1 {
next.push(format!(" {number}"));
if let Some(extension) = source.extension() {
next.push(".");
next.push(extension);
}
}
let out = dir.join(next);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&out) {
Ok(output) => break (out, output),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
number = number.checked_add(1).ok_or_else(|| bad("ingest", "no unused destination name"))?;
}
Err(e) => return Err(EngineError::Other(format!("{}: {e}", out.display()))),
}
};
let copied = std::io::copy(&mut input, &mut output).and_then(|_| output.set_permissions(input.metadata()?.permissions()));
drop(output);
let out_s = out.to_string_lossy().into_owned();
let linked = copied.map_err(|e| EngineError::Other(format!("copying {path}: {e}"))).and_then(|_| {
let a = crate::relink::identity_of(&*s.services, path).map_err(|e| EngineError::Other(e.to_string()))?;
let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?;
if a != b {
return Err(EngineError::Other(format!("the copy of {} doesn't match the original", name.to_string_lossy())));
}
// The generated suffix may change the name; every other relink check still applies.
crate::relink::relink(s, &json!({"item": item.0, "path": out_s, "relinkOthers": false, "match": {"fileName": false}}))?;
Ok(())
});
if let Err(error) = linked {
// Only this operation's exclusively created file can be removed on failure.
if let Err(cleanup) = std::fs::remove_file(&out) {
return Err(EngineError::Other(format!("{error}; removing incomplete copy {}: {cleanup}", out.display())));
}
return Err(error);
}
Ok(out_s)
}
/// Ingest freshly imported items per the project's ingest settings.
pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
let ing = s.project.settings.ingest.clone();
@@ -393,21 +443,10 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
Some(d) if !d.is_empty() => PathBuf::from(d),
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
};
let fname = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let out = dir.join(&fname);
if Path::new(&path) == out {
if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) {
continue;
}
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
std::fs::copy(&path, &out).map_err(|e| EngineError::Other(format!("copying {fname}: {e}")))?;
let out_s = out.to_string_lossy().into_owned();
// verify the copy before switching to it
let a = crate::relink::identity_of(&*s.services, &path).map_err(|e| EngineError::Other(e.to_string()))?;
let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?;
if a != b {
return Err(EngineError::Other(format!("the copy of {fname} doesn't match the original")));
}
crate::relink::relink(s, &json!({"item": i.0, "path": out_s, "relinkOthers": false}))?;
let out_s = copy_for_ingest(s, i, &path, &dir)?;
copied.push(json!({"item": i.0, "path": out_s}));
}
}
@@ -423,6 +462,7 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
let pr =
preset(if ing.preset.is_empty() { DEFAULT_TRANSCODE_PRESET } else { ing.preset.as_str() }).ok_or_else(|| bad("ingest", "unknown preset"))?;
let mut work = Vec::new();
let mut taken = Vec::new();
for &i in items {
let Some((clip, path, name)) = media_path(&s.project, i) else { continue };
if clip.info.video.is_none() {
@@ -433,7 +473,9 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
};
let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name);
work.push((i, unique_path(&dir, &stem, "", pr.extension, &[]).to_string_lossy().into_owned(), pr));
let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned();
taken.push(out.clone());
work.push((i, out, pr));
}
if !work.is_empty() {
let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?;
+149
View File
@@ -124,6 +124,155 @@ fn ingest_copies_and_creates_proxies_on_import() {
let _ = std::fs::remove_dir_all(&root);
}
/// Import two different camera takes with the same basename into a folder that already has
/// that name. Check the saved project with a new media pool so cached decoders cannot hide a
/// replaced file.
fn check_ingest_collision(action: &str) {
use filmcraft_media::FrameRequest;
use filmcraft_project::{ItemId, MediaRef};
use filmcraft_time::FrameRate;
use std::collections::BTreeSet;
use std::sync::atomic::Ordering;
let root = tmp_dir(&format!("ingest-collision-{action}"));
let paths = [root.join("Card A/A001.mov"), root.join("Card B/A001.mov")];
for (path, scene) in paths.iter().zip([DemoScene::Aurora, DemoScene::Plasma]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
make_movie(path, scene, 160, 96, 4);
}
let original: Vec<Vec<u8>> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect();
assert_ne!(original[0], original[1], "different source takes");
let dest = root.join("Ingest");
std::fs::create_dir_all(&dest).unwrap();
let existing = dest.join("A001.mov");
let existing_bytes = b"existing destination must survive ingest";
std::fs::write(&existing, existing_bytes).unwrap();
let mut s = Session::default();
s.execute("project.ingestSettings", json!({"enabled": true, "action": action, "destination": dest.to_string_lossy(), "preset": "prores_proxy_half"}))
.unwrap();
let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>()})).unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
let items: Vec<ItemId> = result["items"].as_array().unwrap().iter().map(|i| ItemId(i.as_u64().unwrap())).collect();
assert_eq!(items.len(), 2, "both takes imported");
if let Some(id) = result["ingest"]["job"]["job"].as_u64() {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let job = s.jobs.iter().find(|j| j.id == id).unwrap();
if job.progress.finished.load(Ordering::Relaxed) && job.result.lock().unwrap().is_some() {
assert!(job.result.lock().unwrap().as_ref().unwrap().is_ok(), "ingest job failed");
break;
}
assert!(std::time::Instant::now() < deadline, "ingest job did not finish");
std::thread::sleep(std::time::Duration::from_millis(25));
}
s.poll_persistence();
}
assert_eq!(std::fs::read(&existing).unwrap(), existing_bytes, "ingest must preserve existing destination files");
let project_path = root.join("ingested.fcproj");
s.execute("file.save", json!({"path": project_path.to_string_lossy()})).unwrap();
let mut reopened = Session::default();
reopened.execute("file.open", json!({"path": project_path.to_string_lossy()})).unwrap();
let request = FrameRequest { time: FrameRate::FPS_24.tick_of(2), scale: 1.0 };
let mut linked = BTreeSet::new();
let mut proxies = BTreeSet::new();
for (index, item) in items.iter().enumerate() {
let media = reopened.project.item(*item).unwrap().as_media().unwrap();
let MediaRef::File { path } = &media.media else { panic!("ingest must retain file media") };
assert!(linked.insert(path.clone()), "different takes must have different ingest paths");
assert_eq!(std::path::Path::new(path).parent(), Some(dest.as_path()));
assert_eq!(std::fs::read(&paths[index]).unwrap(), original[index], "source card is unchanged");
if action != "transcode" {
assert_eq!(std::fs::read(path).unwrap(), original[index], "each copy retains its own take");
assert_eq!(media.identity, Some(crate::relink::identity_of_bytes(&original[index])));
}
let source = reopened.media.open_file(&paths[index].to_string_lossy(), &*reopened.services).unwrap();
let expected = source.video_frame(request).unwrap();
let ingested = reopened.source(*item).unwrap().video_frame(request).unwrap();
let quality = |frame: &filmcraft_frame::VideoFrame| {
let rgba = image::RgbaImage::from_raw(frame.width, frame.height, frame.to_rgba8()).unwrap();
let expected = image::RgbaImage::from_raw(expected.width, expected.height, expected.to_rgba8()).unwrap();
let expected = image::imageops::resize(&expected, frame.width, frame.height, image::imageops::FilterType::Triangle);
psnr(rgba.as_raw(), expected.as_raw())
};
assert!(quality(&ingested) > 30.0, "saved project retains the correct picture for take {index}");
if action == "copyAndCreateProxies" {
let Some(MediaRef::File { path }) = &media.proxy else { panic!("copy should have its proxy attached") };
assert!(proxies.insert(path.clone()), "different takes must have different proxy paths");
let proxy = reopened.media.open_file(path, &*reopened.services).unwrap().video_frame(request).unwrap();
assert!(quality(&proxy) > 30.0, "proxy retains the correct picture for take {index}");
}
}
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn ingest_copy_preserves_same_named_takes_and_existing_files() {
check_ingest_collision("copy");
}
#[test]
fn ingest_copy_and_proxy_preserves_same_named_takes_and_existing_files() {
check_ingest_collision("copyAndCreateProxies");
}
#[test]
fn ingest_transcode_preserves_same_named_takes_and_existing_files() {
check_ingest_collision("transcode");
}
#[test]
fn ingest_copy_keeps_sources_already_at_destination_and_extensionless_names() {
use filmcraft_project::{ItemId, MediaRef};
for (name, numbered) in [("Take.wav", "Take 2.wav"), ("Take", "Take 2")] {
let root = tmp_dir("ingest-copy-existing-source");
let dest = root.join("Ingest");
let card = root.join("Card");
std::fs::create_dir_all(&dest).unwrap();
std::fs::create_dir_all(&card).unwrap();
let paths = [dest.join(name), card.join(name)];
let takes = [filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000), filmcraft_media::wav::write_wav16(&[-0.25; 960], 1, 48_000)];
for (path, bytes) in paths.iter().zip(&takes) {
std::fs::write(path, bytes).unwrap();
}
let mut s = Session::default();
s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap();
let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::<Vec<_>>()})).unwrap();
assert_eq!(result["errors"], json!([]), "{result}");
assert_eq!(result["items"].as_array().unwrap().len(), 2);
assert_eq!(result["ingest"]["copied"].as_array().unwrap().len(), 1, "source already at destination is kept");
for (index, path) in [dest.join(name), dest.join(numbered)].iter().enumerate() {
let item = ItemId(result["items"][index].as_u64().unwrap());
let media = s.project.item(item).unwrap().as_media().unwrap();
assert_eq!(media.media, MediaRef::File { path: path.to_string_lossy().into_owned() });
assert_eq!(std::fs::read(path).unwrap(), takes[index]);
assert_eq!(std::fs::read(&paths[index]).unwrap(), takes[index]);
}
assert_eq!(std::fs::read_dir(&dest).unwrap().count(), 2, "no unnecessary copies or trailing-dot filenames");
let _ = std::fs::remove_dir_all(root);
}
}
#[test]
fn ingest_copy_does_not_create_a_destination_when_the_source_is_missing() {
use filmcraft_project::ItemId;
let root = tmp_dir("ingest-copy-missing-source");
let source = root.join("Take.wav");
std::fs::write(&source, filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000)).unwrap();
let mut s = Session::default();
let imported = s.execute("file.import", json!({"paths": [source.to_string_lossy()]})).unwrap();
let item = ItemId(imported["items"][0].as_u64().unwrap());
let dest = root.join("Ingest");
s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap();
let before = s.project.clone();
std::fs::rename(&source, root.join("Moved.wav")).unwrap();
assert!(crate::proxies::ingest(&mut s, &[item]).is_err());
assert_eq!(s.project, before, "failed copy does not relink the item");
assert!(!dest.exists(), "no destination is created before the source can be opened");
let _ = std::fs::remove_dir_all(root);
}
/// Measure: Program-monitor playback cost of 4K H.264 with and without proxies (sequential frames
/// at ½ resolution). Run with `RAYON_NUM_THREADS=1` so wall time ≈ CPU time:
/// `cargo test --release -p filmcraft-engine proxy_playback_perf_4k -- --ignored --nocapture`.
+97
View File
@@ -270,3 +270,100 @@ fn jobs_list_reports_an_exports_offline_media() {
assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn deleting_bin_keeps_subclip_source_but_removes_unused_media() {
let root = tmp_dir("delete-bin-used-media");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let p = std::sync::Arc::make_mut(&mut s.project);
let seq = s.state.active_sequence.unwrap();
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
let sub =
p.add_item("Subclip", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
for track in p.sequence_mut(seq).unwrap().all_tracks_mut() {
track.items.retain(|clip| clip.item != items[1]);
for clip in &mut track.items {
clip.item = sub;
}
}
let bin = p.add_bin("Sources", None);
p.move_to_bin(&items, Some(bin)).unwrap();
let before = s.active_sequence().unwrap().clone();
s.execute("project.delete", json!({"items": [bin.0, items[0].0, bin.0]})).unwrap();
assert_eq!(s.active_sequence().unwrap(), &before);
assert!(s.project.root.find_bin(bin).is_none());
assert!(s.project.item(items[0]).unwrap().as_media().unwrap().offline);
assert!(s.project.item(items[1]).is_none());
assert!(s.project.resolve_media(sub).unwrap().1.offline);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.root.parent_of(items[0]), Some(bin));
assert!(s.project.item(items[1]).is_some());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn deleting_sequence_and_its_media_does_not_keep_an_unused_source() {
let root = tmp_dir("delete-sequence-and-media");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let seq = s.state.active_sequence.unwrap();
s.execute("project.delete", json!({"items": [seq.0, items[0].0]})).unwrap();
assert!(s.project.item(seq).is_none());
assert!(s.project.item(items[0]).is_none());
assert!(s.project.item(items[1]).is_some());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn deleting_subclip_and_source_does_not_keep_an_unused_source() {
let root = tmp_dir("delete-subclip-and-source");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let seq = s.state.active_sequence.unwrap();
let p = std::sync::Arc::make_mut(&mut s.project);
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
let sub =
p.add_item("Subclip", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
for c in &mut t.items {
if c.item == items[0] {
c.item = sub;
}
}
}
s.execute("project.delete", json!({"items": [sub.0, items[0].0]})).unwrap();
assert!(s.project.item(sub).is_none());
assert!(s.project.item(items[0]).is_none(), "a removed timeline subclip must not retain its unused source");
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn deleting_bin_preserves_intermediate_subclip_needed_by_surviving_clip() {
let root = tmp_dir("delete-intermediate-subclip");
let (path, items) = saved_project(&root);
let mut s = open(&path);
let seq = s.state.active_sequence.unwrap();
let p = std::sync::Arc::make_mut(&mut s.project);
let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration());
let parent =
p.add_item("Parent", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
let outer = p.add_item("Outer", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent, range, restrict_trims: false }, None);
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
for c in &mut t.items {
if c.item == items[0] {
c.item = outer;
}
}
}
let bin = p.add_bin("Sources", None);
p.move_to_bin(&[items[0], parent], Some(bin)).unwrap();
let before = s.active_sequence().unwrap().clone();
s.execute("project.delete", json!({"items": [bin.0]})).unwrap();
assert_eq!(s.active_sequence().unwrap(), &before);
assert!(s.project.resolve_media(outer).is_some(), "the surviving nested subclip must still resolve");
assert!(s.project.resolve_media(outer).unwrap().1.offline);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.root.parent_of(parent), Some(bin));
let _ = std::fs::remove_dir_all(root);
}
+108
View File
@@ -0,0 +1,108 @@
//! #1357: `edit.rippleDeleteHead` removes the portion of the selected clip from its in point
//! through the playhead and ripples the sequence closed.
use crate::Session;
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
fn f(s: &Session, n: i64) -> Tick {
s.sequence_rate().tick_of(n)
}
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
}
/// Four 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 4].
fn four_clips(s: &mut Session) -> [(ClipId, ClipId); 4] {
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Head", "video": 1, "audio": 1})).unwrap();
let rate = s.sequence_rate();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
for n in 0..4 {
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
}
let q = s.active_sequence().unwrap();
[0, 1, 2, 3].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
}
/// The head (in point .. playhead) goes and the rest of the sequence closes up: the clip keeps its
/// tail at the left of its old range, later clips shift left.
#[test]
fn ripple_delete_head_closes_the_head_and_shifts_later_clips() {
let mut s = Session::default();
let [(_, _), (pic2, snd2), (pic3, snd3), (pic4, snd4)] = four_clips(&mut s);
s.set_playhead(f(&s, 60));
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert_eq!(range(&s, pic2), (f(&s, 48), f(&s, 84)), "the head is gone and the tail slid left");
assert_eq!(range(&s, snd2), (f(&s, 48), f(&s, 84)), "the linked sound follows");
assert_eq!(range(&s, pic3), (f(&s, 84), f(&s, 132)), "the next picture closes up by the head length");
assert_eq!((range(&s, snd3), range(&s, pic4), range(&s, snd4)), ((f(&s, 84), f(&s, 132)), (f(&s, 132), f(&s, 180)), (f(&s, 132), f(&s, 180))));
assert_eq!(q.duration(), f(&s, 180), "the sequence is 12 frames shorter");
}
/// The trimmed clip's media in point advances by the head length (the head frames are dropped,
/// not hidden).
#[test]
fn ripple_delete_head_advances_the_source_in_point() {
let mut s = Session::default();
let [(pic1, _), ..] = four_clips(&mut s);
let in_before = s.active_sequence().unwrap().find_item(pic1).unwrap().1.source_in;
s.set_playhead(f(&s, 12));
s.state.selection = vec![pic1];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let item = s.active_sequence().unwrap().find_item(pic1).unwrap().1;
assert_eq!(item.source_in, in_before + f(&s, 12), "the head frames are dropped from the source");
assert_eq!((item.start, item.end()), (Tick::ZERO, f(&s, 36)), "the clip starts at zero, 36 frames long");
}
/// The playhead at or before the in point has no head to remove.
#[test]
fn ripple_delete_head_needs_the_playhead_past_the_in_point() {
let mut s = Session::default();
let [(pic1, _), ..] = four_clips(&mut s);
s.set_playhead(Tick::ZERO);
s.state.selection = vec![pic1];
s.execute("edit.rippleDeleteHead", json!({})).unwrap_err();
assert_eq!(range(&s, pic1), (Tick::ZERO, s.sequence_rate().tick_of(48)), "nothing moved");
}
/// The playhead past the clip's out point deletes the whole clip (the head covers all of it).
#[test]
fn ripple_delete_head_past_the_end_removes_the_whole_clip() {
let mut s = Session::default();
let [(_, _), (pic2, snd2), (pic3, snd3), ..] = four_clips(&mut s);
s.set_playhead(f(&s, 120)); // past clip 2's out point at 96
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert!(q.find_item(pic2).is_none() && q.find_item(snd2).is_none(), "the whole clip went");
assert_eq!(range(&s, pic3), (f(&s, 48), f(&s, 96)), "later clips closed the whole 48-frame gap");
assert_eq!(range(&s, snd3), (f(&s, 48), f(&s, 96)));
}
/// With Ripple Sequence Markers on, markers after the removed head move back with the clips, and
/// markers inside the head go with it.
#[test]
fn ripple_delete_head_ripples_sequence_markers() {
let mut s = Session::default();
let [(_, _), (pic2, _), (_, _), (_, _)] = four_clips(&mut s);
s.state.ripple_sequence_markers = true;
let at = |s: &Session, n: i64| s.sequence_rate().tick_of(n).0;
for (frame, name) in [(30, "before"), (52, "inside"), (120, "after")] {
s.execute("markers.add", json!({"time": at(&s, frame), "name": name})).unwrap();
}
s.set_playhead(f(&s, 60));
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
let markers: Vec<(String, Tick)> = q.markers.iter().map(|m| (m.name.clone(), m.start)).collect();
assert_eq!(markers, vec![("before".to_string(), f(&s, 30)), ("after".to_string(), f(&s, 108))], "the 12-frame head closes");
}
@@ -276,3 +276,40 @@ fn flash_cue_markers_round_trip() {
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_sequence().unwrap().markers.iter().all(|m| m.kind != MarkerKind::FlashCue));
}
/// Apply Video Transition (Cmd+D) at both ends of an adjustment layer with Lumetri Color fades the
/// grade in and out, as on a clip: halfway through each dissolve the picture is about halfway
/// between the original and the graded frame, not the original with a hard cut after it.
#[test]
fn cmd_d_on_an_adjustment_layer_fades_its_effects_in_and_out() {
let mut s = demo();
let item = s.execute("file.newAdjustmentLayer", json!({"seconds": 4})).unwrap()["item"].as_u64().unwrap();
let layer = s.execute("timeline.place", json!({"item": item, "track": "V3", "seconds": 1.5})).unwrap()["clips"][0].as_u64().unwrap();
s.execute("effects.apply", json!({"clips": [layer], "effect": "lumetri"})).unwrap();
s.execute("effects.setParam", json!({"clip": layer, "effect": "lumetri", "param": "exposure", "value": 2.0})).unwrap();
s.execute("timeline.select", json!({"clips": [layer]})).unwrap();
for edge in ["in", "out"] {
s.execute("sequence.applyVideoTransition", json!({"clip": layer, "edge": edge, "frames": 24})).unwrap();
}
let transitions: Vec<_> = s.active_sequence().unwrap().video_tracks[2].transitions.clone();
assert_eq!(transitions.len(), 2, "a dissolve at each end");
let mean = |s: &Session, t: Tick| {
let img = s.render_program_at(0.25, t).unwrap();
img.px.chunks(4).map(|c| f64::from(c[0] + c[1] + c[2])).sum::<f64>() / (img.w * img.h) as f64
};
for tr in &transitions {
let mid = tr.start + Tick(tr.duration.0 / 2);
let mf = mean(&s, mid);
// the original frame: the layer switched off; the graded frame: without the dissolves
s.execute("clip.enable", json!({"clips": [layer]})).unwrap();
let m0 = mean(&s, mid);
s.execute("edit.undo", json!({})).unwrap();
let ids: Vec<u64> = transitions.iter().map(|t| t.id.0).collect();
s.execute("sequence.removeTransition", json!({"transitions": ids})).unwrap();
let m1 = mean(&s, mid);
s.execute("edit.undo", json!({})).unwrap();
assert!(m1 > m0 * 1.2, "the grade brightens the frame: {m0} → {m1}");
let k = (mf - m0) / (m1 - m0);
assert!((0.25..0.75).contains(&k), "halfway through the dissolve the grade is about half on: {k:.3}");
}
}
-59
View File
@@ -774,65 +774,6 @@ pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
}
}
/// `timeline.move` names `"new"` as the `track` of a move.
pub(crate) fn move_wants_new_track(p: &Value) -> bool {
p.get("moves").and_then(Value::as_array).is_some_and(|m| m.iter().any(is_new_move))
}
/// One move of `timeline.move` with `"track":"new"`.
fn is_new_move(m: &Value) -> bool {
m.get("track").and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"))
}
/// `timeline.move` with `"track":"new"` on some moves (#1104: a clip dragged into the empty space
/// above the top video track or below the last audio track): one track is added after the last one
/// of each kind the `"new"` clips come from, they all land on it, and the track and the move are
/// one undo step. When the move fails, the new tracks go away again.
pub(crate) fn move_to_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move";
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (mut new_v, mut new_a) = (false, false);
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten().filter(|m| is_new_move(m)) {
let clip = crate::commands::clip_p(m, "clip").ok_or_else(|| bad(cmd, "need `clip` in each move"))?;
let (track, _) = q.find_item(clip).ok_or_else(|| bad(cmd, "no such clip"))?;
if q.video_tracks.iter().any(|t| t.id == track) {
new_v = true;
} else {
new_a = true;
}
}
let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?;
let mark = s.history.undo.len();
let (video, audio) = (added.get("video").and_then(|v| v.get(0)).cloned(), added.get("audio").and_then(|v| v.get(0)).cloned());
let mut moves = p.get("moves").and_then(Value::as_array).cloned().unwrap_or_default();
for m in moves.iter_mut().filter(|m| is_new_move(m)) {
let on_video = crate::commands::clip_p(m, "clip")
.and_then(|c| s.active_sequence().and_then(|q| q.find_item(c).map(|(t, _)| q.video_tracks.iter().any(|v| v.id == t))))
.unwrap_or(true);
m["track"] = if on_video { video.clone() } else { audio.clone() }.unwrap_or(Value::Null);
}
let mut q = p.clone();
q["moves"] = Value::Array(moves);
let run = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run;
match run(s, &q) {
Ok(v) => {
// the new tracks and the move are one undo step
if mark >= 1
&& s.history.undo.len() == mark + 1
&& let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop())
{
s.history.undo.push((label, before));
}
Ok(v)
}
Err(e) => {
s.undo();
s.history.redo.pop();
Err(e)
}
}
}
fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
+3 -1
View File
@@ -34,7 +34,7 @@ pub const PREFS_VERSION: u32 = 2;
#[serde(rename_all = "camelCase", default)]
pub struct GeneralPrefs {
/// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`,
/// `zh-cn`, `de`, `ru`, `fr`, `id`, or `system` (the default: the operating system's preferred language when the
/// `zh-cn`, `de`, `ru`, `fr`, `id`, `it`, or `system` (the default: the operating system's preferred language when the
/// interface has it, otherwise English).
pub interface_language: String,
/// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`.
@@ -1007,6 +1007,8 @@ static CATEGORIES: &[Category] = &[
("ru", "Русский"),
("fr", "Français"),
("id", "Bahasa Indonesia"),
("it", "Italiano"),
("ko", "한국어"),
]),
true,
),
+6
View File
@@ -44,6 +44,12 @@ fn interface_language_persists_and_rejects_hostile_values() {
}
set(&mut s, "general.interfaceLanguage", json!("id"));
assert_eq!(Preferences::load(&path).general.interface_language, "id");
// Italian is stored as `it`, not a locale tag
for value in [json!("it-IT"), json!("IT")] {
assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err());
}
set(&mut s, "general.interfaceLanguage", json!("it"));
assert_eq!(Preferences::load(&path).general.interface_language, "it");
// back to following the operating system (#218)
set(&mut s, "general.interfaceLanguage", json!("system"));
assert_eq!(Preferences::load(&path).general.interface_language, "system");
+20 -2
View File
@@ -737,7 +737,8 @@ impl Shortcuts {
let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) };
let before = self.snapshot();
if !add {
self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context()));
let copies = if nb.panel.is_none() { self.app_chords(command, None) } else { Vec::new() };
self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && (b.context() == nb.context() || is_copy(b, &copies))));
}
let mut reassigned = Vec::new();
if !keep_conflicts {
@@ -769,6 +770,7 @@ impl Shortcuts {
let before = self.snapshot();
let n0 = self.bindings.len();
let twins = shortcut_twins(command);
let copies = if panel == Some(None) { self.app_chords(command, chord.as_ref()) } else { Vec::new() };
self.bindings.retain(|b| {
let cmd = same_shortcut_command(&b.command, command);
let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c));
@@ -778,7 +780,7 @@ impl Shortcuts {
let panel_ok = match &panel {
None => true,
Some(p) if p.is_none() && twins => true,
Some(p) => &b.panel == p,
Some(p) => &b.panel == p || is_copy(b, &copies),
};
!(cmd && key_ok && panel_ok)
});
@@ -916,6 +918,16 @@ impl Shortcuts {
Ok(name)
}
/// The application-wide keys of `command` (only `key`, when given).
fn app_chords(&self, command: &str, key: Option<&Chord>) -> Vec<Chord> {
self.bindings
.iter()
.filter(|b| same_shortcut_command(&b.command, command) && b.panel.is_none())
.filter_map(Binding::chord)
.filter(|c| key.is_none_or(|k| k == c))
.collect()
}
// ---------------------------------------------------------------- queries
pub fn for_command(&self, id: &str) -> Vec<&Binding> {
@@ -1017,6 +1029,12 @@ fn same_shortcut_command(bound: &str, target: &str) -> bool {
bound == target || (shortcut_twins(bound) && shortcut_twins(target))
}
/// A panel binding on one of the command's application keys (New Bin's Cmd+B in the Project
/// panel) is a copy of that shortcut, so editing the application shortcut edits it too (#1333).
fn is_copy(b: &Binding, app_chords: &[Chord]) -> bool {
b.panel.is_some() && b.chord().is_some_and(|c| app_chords.contains(&c))
}
fn check_panel(panel: Option<&str>) -> std::result::Result<Option<&'static str>, String> {
match panel {
None => Ok(None),
+36
View File
@@ -133,6 +133,42 @@ fn clear_close_sequence_also_clears_file_close() {
assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit");
}
#[test]
fn clear_new_bin_frees_cmd_b_in_the_project_panel_too() {
// #1333: New Bin has Cmd+B application-wide and again in the Project panel. The editor's Clear
// sends panel Application, so only the first went; the Project panel kept Cmd+B.
let dir = temp_dir("new-bin");
let mut s = Session::default();
s.shortcuts.set_dir(&dir);
let b = Chord::parse("Cmd+B").unwrap();
let p = Platform::current();
assert_eq!(s.shortcuts.resolve(&b, Some("Project"), p).unwrap().command, "file.newBin");
s.execute("shortcuts.clear", json!({"command": "file.newBin", "panel": "Application"})).unwrap();
assert!(s.shortcuts.for_command("file.newBin").is_empty(), "{:?}", s.shortcuts.for_command("file.newBin"));
s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+B"})).unwrap();
let mut t = Shortcuts::new();
t.set_dir(&dir);
for sc in [&s.shortcuts, &t] {
assert_eq!(sc.resolve(&b, None, p).unwrap().command, "sequence.addEdit");
assert_eq!(sc.resolve(&b, Some("Project"), p).unwrap().command, "sequence.addEdit");
assert!(sc.for_command("file.newBin").is_empty());
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reassigning_new_bin_moves_its_project_panel_key_too() {
// #1333: assigning New Bin another key replaced Cmd+B application-wide only.
let mut s = Session::default();
s.execute("shortcuts.set", json!({"command": "file.newBin", "keys": "Cmd+Alt+B"})).unwrap();
let keys: Vec<(&str, Option<&str>)> = s.shortcuts.for_command("file.newBin").iter().map(|b| (b.keys.as_str(), b.panel.as_deref())).collect();
assert_eq!(keys, [("Cmd+Alt+B", None)]);
assert!(s.shortcuts.resolve(&Chord::parse("Cmd+B").unwrap(), Some("Project"), Platform::Mac).is_none());
// a panel key of its own (History ▸ Left for Undo) is not a copy of the application one: it stays
s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Alt+Z"})).unwrap();
assert!(s.shortcuts.for_command("edit.undo").iter().any(|b| b.panel.as_deref() == Some("History") && b.keys == "Left"));
}
#[test]
fn panel_shortcuts_override_application_ones_with_focus() {
let s = Session::default();
+113
View File
@@ -299,3 +299,116 @@ fn copy_leaves_the_originals_and_links_the_copies() {
assert!(r.is_err());
assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), 2);
}
#[test]
fn new_track_moves_and_copies_are_one_atomic_edit() {
for copy in [false, true] {
let mut s = demo();
let (vt, v, at, a) = last_pair(&s);
let before = s.project.clone();
let count = s.history.undo.len();
let q = s.active_sequence().unwrap();
let counts = (q.video_tracks.len(), q.audio_tracks.len());
let r = s
.execute(
"timeline.move",
json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0},{"clip":a.id.0,"track":"new","time":a.start.0}],"linked":false,"copy":copy}),
)
.unwrap();
let q = s.active_sequence().unwrap();
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (counts.0 + 1, counts.1 + 1));
let new_v = q.video_tracks.last().unwrap();
let new_a = q.audio_tracks.last().unwrap();
assert_eq!(new_a.channels, before.sequence(s.state.active_sequence.unwrap()).unwrap().track(at).unwrap().channels);
assert_eq!((new_v.items[0].item, new_a.items[0].item), (v.item, a.item));
assert_eq!((new_v.items[0].start, new_a.items[0].start), (v.start, a.start));
if copy {
assert_eq!(state(&s, v.id), (vt, v.start));
assert_eq!(state(&s, a.id), (at, a.start));
assert_ne!(new_v.items[0].id, v.id);
assert!(new_v.items[0].link.is_some() && new_v.items[0].link == new_a.items[0].link);
assert_ne!(new_v.items[0].link, v.link);
assert_eq!(s.state.selection.len(), 2);
assert_eq!(r["copied"].as_array().unwrap().len(), 2);
} else {
assert_eq!(state(&s, v.id), (new_v.id, v.start));
assert_eq!(state(&s, a.id), (new_a.id, a.start));
}
q.check().unwrap();
assert_eq!(s.history.undo.len(), count + 1);
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
}
#[test]
fn new_track_group_keeps_clips_from_the_same_source_together() {
let mut s = demo();
let q = s.active_sequence().unwrap();
let clips = q.video_tracks[0].items[..2].to_vec();
let tracks = q.video_tracks.len();
let before = s.project.clone();
let moves: Vec<_> = clips.iter().map(|c| json!({"clip":c.id.0,"track":"new","time":c.start.0})).collect();
s.execute("timeline.move", json!({"moves":moves,"linked":false})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(q.video_tracks.len(), tracks + 1);
let t = q.video_tracks.last().unwrap();
assert_eq!(t.items, clips);
q.check().unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
}
#[test]
fn refused_new_track_moves_preserve_document_and_both_history_stacks() {
let mut s = demo();
let (_, v, _, _) = last_pair(&s);
s.execute("timeline.setTrack", json!({"track":"V1","locked":true})).unwrap();
for moves in [
json!([{"clip":v.id.0,"track":"new","time":v.start.0}]),
json!([{"clip":u64::MAX,"track":"new","time":0}]),
json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]),
json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]),
] {
let before = s.project.clone();
let history = s.execute("history.list", json!({})).unwrap();
assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
}
s.execute("edit.undo", json!({})).unwrap();
let before = s.project.clone();
let history = s.execute("history.list", json!({})).unwrap();
assert!(!history["redo"].as_array().unwrap().is_empty());
for moves in [
json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]),
json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]),
json!([{"clip":v.id.0,"track":"new"}]),
json!([{"clip":v.id.0,"track":"new","time":"invalid"}]),
] {
assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
}
}
#[test]
fn new_track_moves_respect_the_track_limit() {
let mut s = demo();
let (_, v, _, _) = last_pair(&s);
s.edit_sequence("Limit fixture", |q, ctx, _| {
while q.video_tracks.len() < crate::sequence_tools::MAX_TRACKS {
q.video_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), filmcraft_project::TrackKind::Video, "Limit".into()));
}
Ok(())
})
.unwrap();
let before = s.project.clone();
let history = s.execute("history.list", json!({})).unwrap();
assert!(s.execute("timeline.move", json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0}],"linked":false})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
}
+12 -14
View File
@@ -363,18 +363,9 @@ impl Exporter {
}
}
None => {
let bps = if self.settings.audio.bits >= 24 { 3 } else { 2 };
let mut pcm = Vec::with_capacity(n * buf.len() * bps);
for i in 0..n {
for c in &buf {
let s = c[i].clamp(-1.0, 1.0);
if bps == 3 {
pcm.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
} else {
pcm.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes());
}
}
}
// little-endian, like the track's `lpcm` / `sowt` description says
let mut pcm = Vec::new();
crate::pcm::encode(crate::pcm::PcmContainer::Wav, &crate::audio_out::interleave(&buf), self.settings.audio.sample_format(), &mut pcm);
mux.write_sample(at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true })
.map_err(|e| ExportError::Io(e.to_string()))?;
}
@@ -428,8 +419,15 @@ impl Exporter {
Some(mux.add_track(c).map_err(|e| ExportError::Io(e.to_string()))?)
}
(Some(a), None) => {
let bits = if self.settings.audio.bits >= 24 { 24 } else { 16 };
let pcm = PcmConfig { bits, float: false, big_endian: false, signed: true, channels: a.channels as _, sample_rate: a.sr as f64 };
let f = self.settings.audio.sample_format();
let pcm = PcmConfig {
bits: f.bits() as _,
float: f.is_float(),
big_endian: false,
signed: true,
channels: a.channels as _,
sample_rate: a.sr as f64,
};
Some(mux.add_track(TrackConfig::new(SampleEntry::pcm(pcm), a.sr)).map_err(|e| ExportError::Io(e.to_string()))?)
}
};
+19 -11
View File
@@ -625,6 +625,14 @@ impl ExportSettings {
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
}
if self.audio.float && self.audio.bits != 32 {
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
}
let sample_format = self.audio.sample_format();
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
}
if self.field_order != FieldOrder::Progressive && self.has_video() {
return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label())));
}
@@ -1108,19 +1116,19 @@ impl AudioEncoder for AacEncoder {
/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit.
pub(crate) struct FlacAudio {
enc: filmcraft_flac::Encoder,
bits: u16,
format: SampleFormat,
/// Samples of the frame `flush` returns (shorter than a full block).
tail: u32,
}
impl FlacAudio {
pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result<Self> {
let bits = if s.audio.bits >= 24 { 24 } else { 16 };
let format = s.audio.sample_format();
let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?;
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8);
let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, format.bits() as u8);
cfg.level = s.audio.flac_level.min(8);
let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?;
Ok(FlacAudio { enc, bits, tail: 0 })
Ok(FlacAudio { enc, format, tail: 0 })
}
}
@@ -1129,7 +1137,7 @@ impl AudioEncoder for FlacAudio {
SampleEntry::flac(&self.enc.streaminfo())
}
fn encode(&mut self, planar: &[Vec<f32>]) -> Result<Vec<Vec<u8>>> {
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| self.format.quantise(s)).collect()).collect();
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))
}
@@ -1851,12 +1859,12 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
Format::Wav | Format::Aiff | Format::Flac => {
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
let (ch, sr, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format());
let frames = a.remaining();
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
let mut writer = match settings.format {
Format::Flac => {
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 });
let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, fmt.bits() as u8);
cfg.level = settings.audio.flac_level.min(8);
AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?))
}
@@ -1864,7 +1872,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
_ => AudioWriter::Pcm(pcm::PcmContainer::Aiff),
};
let head = match &writer {
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, fmt, frames).map_err(ExportError::Unsupported)?,
AudioWriter::Flac(enc) => enc.header(),
};
// one second per chunk: memory stays flat however long the range is
@@ -1885,9 +1893,9 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
buf.clear();
match &mut writer {
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf),
AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), fmt, &mut buf),
AudioWriter::Flac(enc) => {
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect();
let ints: Vec<Vec<i32>> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect();
let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect();
buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?;
}
@@ -1898,7 +1906,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
}
match &mut writer {
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?,
AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?,
AudioWriter::Flac(enc) => {
out.write_all(&enc.finish()).map_err(io)?;
// the frame sizes and MD5 are known now: fill them into STREAMINFO
+2 -1
View File
@@ -108,7 +108,8 @@ impl MxfMux {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let seed = format!("{}|{now}", settings.path);
let ids = PackageIds::from_seed(&seed, &s.name);
let bits = if settings.audio.bits >= 24 { 24 } else { 16 };
// integers only: `ExportSettings::validate` refuses float PCM in MXF
let bits = u32::from(settings.audio.sample_format().bits());
let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone());
cfg.picture = Some(picture(settings, s.width, s.height));
cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64);
+190 -59
View File
@@ -1,10 +1,13 @@
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
//! 16 or 24 bit, plus image-sequence file naming.
//! 16, 24 or 32-bit integer or 32-bit float ([`SampleFormat`]), plus image-sequence file naming.
//! Float WAV is `WAVE_FORMAT_IEEE_FLOAT` with a `fact` chunk; float AIFF is AIFF-C (`fl32`).
//!
//! The header is written first from the known length ([`header`]), then the samples in chunks
//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the
//! file in memory.
use crate::SampleFormat;
/// The container of an audio-only export.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PcmContainer {
@@ -12,47 +15,55 @@ pub enum PcmContainer {
Aiff,
}
/// Quantise one sample to a signed integer of `bits` (16 or 24).
pub(crate) fn quantise(s: f32, bits: u16) -> i32 {
let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 };
(s.clamp(-1.0, 1.0) * max).round() as i32
}
/// The sample size written for a requested `bits`: 24, else 16.
fn sample_bits(bits: u16) -> u16 {
if bits >= 24 { 24 } else { 16 }
}
/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits.
fn data_len(frames: u64, channels: u16, bits: u16) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8))
fn data_len(frames: u64, channels: u16, format: SampleFormat) -> Option<u64> {
frames.checked_mul(u64::from(channels))?.checked_mul(format.bytes() as u64)
}
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] {
if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
pub fn pad(frames: u64, channels: u16, format: SampleFormat) -> &'static [u8] {
if data_len(frames, channels, format).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
}
/// `KSDATAFORMAT_SUBTYPE_PCM` / `_IEEE_FLOAT` after their first two bytes (the format tag).
const SUBTYPE_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
/// AIFF-C format version 1 (`FVER`).
const AIFC_VERSION_1: u32 = 0xA280_5140;
/// The AIFF-C compression name of `fl32`, as a Pascal string of even length.
const FL32_NAME: &[u8] = b"\x1532-bit floating point";
/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not
/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech
/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is
/// an error naming WAV instead.
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format: SampleFormat, frames: u64) -> Result<Vec<u8>, String> {
let too_long = || "the audio is too long to write".to_string();
let bits = sample_bits(bits);
let bits = format.bits();
let bps = bits / 8;
let data = data_len(frames, channels, bits).ok_or_else(too_long)?;
let float = format.is_float();
let data = data_len(frames, channels, format).ok_or_else(too_long)?;
let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?;
match container {
PcmContainer::Wav => {
let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?;
let ext = channels > 2;
let fmt_len: u32 = if ext { 40 } else { 16 };
// plain PCM has no cbSize; IEEE float has an empty one
let fmt_len: u32 = if ext {
40
} else if float {
18
} else {
16
};
// every format but integer PCM has a `fact` chunk (the frame count; RF64: in `ds64`)
let fact_len: u64 = if float { 12 } else { 0 };
let padded = data.checked_add(data & 1).ok_or_else(too_long)?;
// "WAVE" + fmt chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
// "WAVE" + fmt chunk + fact chunk + data chunk header + samples
let riff = (4 + 8 + u64::from(fmt_len) + fact_len + 8).checked_add(padded).ok_or_else(too_long)?;
let rf64 = riff > u64::from(u32::MAX);
let mut v = Vec::with_capacity(80);
let mut v = Vec::with_capacity(100);
if rf64 {
const DS64: u64 = 8 + 28;
v.extend_from_slice(b"RF64");
@@ -68,21 +79,30 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
v.extend_from_slice(&(riff as u32).to_le_bytes());
v.extend_from_slice(b"WAVE");
}
let tag: u16 = if float { 3 } else { 1 };
v.extend_from_slice(b"fmt ");
v.extend_from_slice(&fmt_len.to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
v.extend_from_slice(&(if ext { 0xFFFE } else { tag }).to_le_bytes());
v.extend_from_slice(&channels.to_le_bytes());
v.extend_from_slice(&sample_rate.to_le_bytes());
v.extend_from_slice(&byte_rate.to_le_bytes());
v.extend_from_slice(&block_align.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
if ext {
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM / _IEEE_FLOAT
v.extend_from_slice(&22u16.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
v.extend_from_slice(&mask.to_le_bytes());
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
v.extend_from_slice(&tag.to_le_bytes());
v.extend_from_slice(&SUBTYPE_TAIL);
} else if float {
v.extend_from_slice(&0u16.to_le_bytes());
}
if float {
v.extend_from_slice(b"fact");
v.extend_from_slice(&4u32.to_le_bytes());
v.extend_from_slice(&(if rf64 { u32::MAX } else { u32::try_from(frames).unwrap_or(u32::MAX) }).to_le_bytes());
}
v.extend_from_slice(b"data");
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
@@ -93,16 +113,31 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?;
// SSND: offset + block size + samples
let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?;
let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
let mut v = Vec::with_capacity(54);
// floats need AIFF-C: a version chunk, and the compression type and name in COMM
let comm: u32 = if float { 18 + 4 + FL32_NAME.len() as u32 } else { 18 };
let fver: u32 = if float { 8 + 4 } else { 0 };
let form =
(4u32 + fver + (8 + comm)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
let mut v = Vec::with_capacity(90);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&form.to_be_bytes());
v.extend_from_slice(b"AIFFCOMM");
v.extend_from_slice(&18u32.to_be_bytes());
if float {
v.extend_from_slice(b"AIFCFVER");
v.extend_from_slice(&4u32.to_be_bytes());
v.extend_from_slice(&AIFC_VERSION_1.to_be_bytes());
} else {
v.extend_from_slice(b"AIFF");
}
v.extend_from_slice(b"COMM");
v.extend_from_slice(&comm.to_be_bytes());
v.extend_from_slice(&channels.to_be_bytes());
v.extend_from_slice(&frames32.to_be_bytes());
v.extend_from_slice(&bits.to_be_bytes());
v.extend_from_slice(&extended(sample_rate));
if float {
v.extend_from_slice(b"fl32");
v.extend_from_slice(FL32_NAME);
}
v.extend_from_slice(b"SSND");
v.extend_from_slice(&ssnd.to_be_bytes());
v.extend_from_slice(&0u32.to_be_bytes()); // offset
@@ -112,13 +147,12 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1
}
}
/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF).
pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec<u8>) {
let bits = sample_bits(bits);
let bps = usize::from(bits / 8);
/// Append interleaved samples in `format` (little-endian for WAV, big-endian for AIFF).
pub fn encode(container: PcmContainer, interleaved: &[f32], format: SampleFormat, out: &mut Vec<u8>) {
let bps = format.bytes();
out.reserve(interleaved.len().saturating_mul(bps));
for &s in interleaved {
let q = quantise(s, bits);
let q = if format.is_float() { SampleFormat::float(s).to_bits() as i32 } else { format.quantise(s) };
match container {
PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()),
PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()),
@@ -127,19 +161,19 @@ pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut
}
/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]).
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
let mut v = header(container, channels, sample_rate, bits, frames)?;
encode(container, interleaved, bits, &mut v);
v.extend_from_slice(pad(frames, channels, bits));
let mut v = header(container, channels, sample_rate, format, frames)?;
encode(container, interleaved, format, &mut v);
v.extend_from_slice(pad(frames, channels, format));
Ok(v)
}
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
/// = `0x3F`).
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits)
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, format)
}
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
@@ -156,9 +190,9 @@ fn extended(rate: u32) -> [u8; 10] {
out
}
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples.
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits)
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`; AIFF-C for floats) of interleaved samples.
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result<Vec<u8>, String> {
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, format)
}
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
@@ -181,6 +215,11 @@ pub fn image_sequence_path(path: &str, index: u64, count: u64) -> String {
mod tests {
use super::*;
const I16: SampleFormat = SampleFormat::Int16;
const I24: SampleFormat = SampleFormat::Int24;
const I32: SampleFormat = SampleFormat::Int32;
const F32: SampleFormat = SampleFormat::Float32;
#[test]
fn extended_sample_rates() {
assert_eq!(extended(44_100), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]);
@@ -198,10 +237,10 @@ mod tests {
#[test]
fn wav_and_aiff_layout() {
let s = [0.5f32, -0.5, 1.0, -1.0];
let w = write_wav(&s, 2, 48_000, 24).unwrap();
let w = write_wav(&s, 2, 48_000, I24).unwrap();
assert_eq!(w.len(), 44 + 12);
assert_eq!(&w[34..36], &24u16.to_le_bytes());
let a = write_aiff(&s, 2, 48_000, 16).unwrap();
let a = write_aiff(&s, 2, 48_000, I16).unwrap();
assert_eq!(&a[..4], b"FORM");
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes());
@@ -220,7 +259,7 @@ mod tests {
#[test]
fn wav_past_4_gib_is_rf64() {
let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
let h = header(PcmContainer::Wav, 6, 96_000, I24, frames).unwrap();
let data = frames * 6 * 3;
assert_eq!(&h[..4], b"RF64");
assert_eq!(le32(&h, 4), u32::MAX);
@@ -238,37 +277,129 @@ mod tests {
fn wav_up_to_4_gib_stays_riff() {
// the longest stereo 16-bit RIFF: 36 bytes of header after the size field
let max = (u64::from(u32::MAX) - 36) / 4;
let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap();
let h = header(PcmContainer::Wav, 2, 48_000, I16, max).unwrap();
assert_eq!(&h[..4], b"RIFF");
assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4);
assert_eq!(u64::from(le32(&h, 40)), max * 4);
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, I16, max + 1).unwrap()[..4], b"RF64");
}
#[test]
fn aiff_past_4_gib_is_refused_not_wrapped() {
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
let e = header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60 * 8).unwrap_err();
assert!(e.contains("export WAV"), "{e}");
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok());
assert!(header(PcmContainer::Aiff, 2, 48_000, I24, 48_000 * 60 * 60).is_ok());
}
#[test]
fn hostile_sizes_are_errors() {
for c in [PcmContainer::Wav, PcmContainer::Aiff] {
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
assert!(header(c, 2, 48_000, I24, u64::MAX).is_err());
assert!(header(c, u16::MAX, 48_000, I24, 1).is_err());
}
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err());
assert!(header(PcmContainer::Wav, 6, u32::MAX, I24, 1).is_err());
}
#[test]
fn odd_data_chunks_are_padded() {
// one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size
let w = write_wav(&[0.25], 1, 48_000, 24).unwrap();
let w = write_wav(&[0.25], 1, 48_000, I24).unwrap();
assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3));
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
let a = write_aiff(&[0.25], 1, 48_000, I24).unwrap();
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
assert!(pad(2, 1, 24).is_empty());
assert!(pad(2, 1, I24).is_empty());
}
fn be32(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
}
#[test]
fn integer_samples_clip_at_full_scale() {
assert_eq!([1.0, -1.0, 2.0, -2.0, f32::NAN, 0.5].map(|s| I32.quantise(s)), [i32::MAX, -i32::MAX, i32::MAX, -i32::MAX, 0, 1 << 30]);
assert_eq!([1.0, -1.0, 4.0].map(|s| I24.quantise(s)), [8_388_607, -8_388_607, 8_388_607]);
assert_eq!([1.0, -1.0, f32::INFINITY].map(|s| I16.quantise(s)), [32_767, -32_767, 32_767]);
assert_eq!(F32.quantise(0.5), 0);
assert_eq!([2.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY].map(SampleFormat::float), [2.5, 0.0, f32::MAX, f32::MIN]);
}
#[test]
fn wav_and_aiff_32_bit_integer() {
let w = write_wav(&[1.0, -1.0], 1, 48_000, I32).unwrap();
assert_eq!(w.len(), 44 + 8);
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 1, "integer PCM");
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]])), (4, 32));
assert_eq!(&w[44..], [i32::MAX.to_le_bytes(), (-i32::MAX).to_le_bytes()].concat());
let a = write_aiff(&[1.0, -1.0], 1, 48_000, I32).unwrap();
assert_eq!(&a[8..12], b"AIFF");
assert_eq!(u16::from_be_bytes([a[26], a[27]]), 32);
assert_eq!(&a[a.len() - 8..], [i32::MAX.to_be_bytes(), (-i32::MAX).to_be_bytes()].concat());
}
/// Float keeps the mix as it is: peaks above full scale survive.
#[test]
fn wav_32_bit_float() {
let s = [0.25f32, -2.0, 1.5, f32::NAN];
let w = write_wav(&s, 2, 48_000, F32).unwrap();
assert_eq!(&w[12..16], b"fmt ");
assert_eq!(le32(&w, 16), 18);
assert_eq!(u16::from_le_bytes([w[20], w[21]]), 3, "WAVE_FORMAT_IEEE_FLOAT");
assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]]), u16::from_le_bytes([w[36], w[37]])), (8, 32, 0));
assert_eq!((&w[38..42], le32(&w, 42), le32(&w, 46)), (&b"fact"[..], 4, 2));
assert_eq!((&w[50..54], le32(&w, 54)), (&b"data"[..], 16));
assert_eq!(le32(&w, 4) as usize, w.len() - 8);
let back: Vec<f32> = w[58..].chunks(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect();
assert_eq!(back, [0.25, -2.0, 1.5, 0.0]);
// 5.1: WAVE_FORMAT_EXTENSIBLE with the IEEE float sub-format, still with `fact`
let w = write_wav(&[0.5; 12], 6, 48_000, F32).unwrap();
assert_eq!((le32(&w, 16), u16::from_le_bytes([w[20], w[21]])), (40, 0xFFFE));
assert_eq!(&w[44..60], [&3u16.to_le_bytes()[..], &SUBTYPE_TAIL].concat());
assert_eq!((&w[60..64], le32(&w, 68)), (&b"fact"[..], 2));
// RF64: the frame count is in `ds64`
let h = header(PcmContainer::Wav, 2, 48_000, F32, 48_000 * 60 * 60 * 4).unwrap();
assert_eq!(&h[..4], b"RF64");
let fact = h.windows(4).position(|c| c == b"fact").unwrap();
assert_eq!(le32(&h, fact + 8), u32::MAX);
}
#[test]
fn aiff_32_bit_float_is_aifc() {
let a = write_aiff(&[0.25, -3.0], 1, 44_100, F32).unwrap();
assert_eq!((&a[..4], be32(&a, 4) as usize, &a[8..12]), (&b"FORM"[..], a.len() - 8, &b"AIFC"[..]));
assert_eq!((&a[12..16], be32(&a, 16), be32(&a, 20)), (&b"FVER"[..], 4, AIFC_VERSION_1));
assert_eq!((&a[24..28], be32(&a, 28)), (&b"COMM"[..], 44));
assert_eq!((u16::from_be_bytes([a[32], a[33]]), be32(&a, 34), u16::from_be_bytes([a[38], a[39]])), (1, 2, 32));
assert_eq!(&a[50..54], b"fl32");
assert_eq!(&a[54..76], FL32_NAME);
assert_eq!((&a[76..80], be32(&a, 80)), (&b"SSND"[..], 8 + 8));
assert_eq!(&a[92..], [0.25f32.to_be_bytes(), (-3.0f32).to_be_bytes()].concat());
}
/// ffprobe (an external oracle; skipped where it is not installed) reads every new format.
#[test]
fn ffprobe_reads_32_bit_formats() {
let dir = std::env::temp_dir().join(format!("filmcraft-pcm32-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let s: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.01).sin() * 1.5).collect();
let cases = [
("i32.wav", write_wav(&s, 2, 48_000, I32).unwrap(), "pcm_s32le"),
("f32.wav", write_wav(&s, 2, 48_000, F32).unwrap(), "pcm_f32le"),
("f32-6.wav", write_wav(&s, 6, 48_000, F32).unwrap(), "pcm_f32le"),
("i32.aiff", write_aiff(&s, 2, 48_000, I32).unwrap(), "pcm_s32be"),
("f32.aifc", write_aiff(&s, 2, 48_000, F32).unwrap(), "pcm_f32be"),
];
for (name, bytes, codec) in cases {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
let out = std::process::Command::new("ffprobe")
.args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,bits_per_sample", "-of", "default=nw=1"])
.arg(&path)
.output();
let Ok(out) = out else { break };
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains(&format!("codec_name={codec}")) && text.contains("bits_per_sample=32"), "{name}: {text}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
@@ -278,8 +409,8 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.wav");
let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f);
let mut f = header(PcmContainer::Wav, 2, 48_000, I24, frames).unwrap();
encode(PcmContainer::Wav, &[0.0; 9_600], I24, &mut f);
std::fs::write(&path, &f).unwrap();
let out = std::process::Command::new("ffprobe")
.args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"])
+101 -10
View File
@@ -223,15 +223,96 @@ pub struct AudioSettings {
pub channels: u32,
/// AAC bitrate.
pub bitrate_kbps: u32,
/// PCM and FLAC sample size: 16 or 24.
/// PCM and FLAC sample size: 16, 24 or 32 (other values round down to one of them, 16 at least).
pub bits: u16,
/// 32-bit floating-point PCM instead of integers (needs `bits` 32; WAV, AIFF and QuickTime).
pub float: bool,
/// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless.
pub flac_level: u8,
}
impl Default for AudioSettings {
fn default() -> Self {
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 }
AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, float: false, flac_level: 5 }
}
}
impl AudioSettings {
/// The sample format [`AudioSettings::bits`] and [`AudioSettings::float`] ask for.
pub fn sample_format(&self) -> SampleFormat {
match self.bits {
32.. if self.float => SampleFormat::Float32,
32.. => SampleFormat::Int32,
24.. => SampleFormat::Int24,
_ => SampleFormat::Int16,
}
}
/// Ask for `f` ([`AudioSettings::bits`] and [`AudioSettings::float`]).
pub fn set_sample_format(&mut self, f: SampleFormat) {
self.bits = f.bits();
self.float = f.is_float();
}
}
/// How PCM and FLAC audio stores its samples. The mix is 32-bit float: the integer formats
/// clip it at full scale, 32-bit float keeps it as it is (peaks above 0 dBFS included).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleFormat {
Int16,
Int24,
Int32,
Float32,
}
impl SampleFormat {
pub const ALL: [SampleFormat; 4] = [SampleFormat::Int16, SampleFormat::Int24, SampleFormat::Int32, SampleFormat::Float32];
pub fn bits(self) -> u16 {
match self {
SampleFormat::Int16 => 16,
SampleFormat::Int24 => 24,
SampleFormat::Int32 | SampleFormat::Float32 => 32,
}
}
/// Bytes per sample.
pub fn bytes(self) -> usize {
usize::from(self.bits() / 8)
}
pub fn is_float(self) -> bool {
self == SampleFormat::Float32
}
/// The name the Export settings show.
pub fn label(self) -> &'static str {
match self {
SampleFormat::Int16 => "16 bit",
SampleFormat::Int24 => "24 bit",
SampleFormat::Int32 => "32 bit",
SampleFormat::Float32 => "32 bit float",
}
}
/// One mix sample as a signed integer of this size, clipped at full scale (0 for
/// [`SampleFormat::Float32`], which is never quantised). Not-a-number becomes silence.
pub fn quantise(self, s: f32) -> i32 {
// f32 products for 16 and 24 bit (the bytes every earlier export wrote); f64 for 32 bit,
// whose full scale 2^31 - 1 f32 cannot hold. NaN casts to 0, the casts saturate.
let s = s.clamp(-1.0, 1.0);
match self {
SampleFormat::Int16 => (s * 32_767.0).round() as i32,
SampleFormat::Int24 => (s * 8_388_607.0).round() as i32,
SampleFormat::Int32 => (f64::from(s) * 2_147_483_647.0).round() as i32,
SampleFormat::Float32 => 0,
}
}
/// One mix sample as stored by [`SampleFormat::Float32`]: as it is, NaN as silence and
/// infinities as the largest finite values.
pub fn float(s: f32) -> f32 {
if s.is_nan() { 0.0 } else { s.clamp(f32::MIN, f32::MAX) }
}
}
@@ -473,6 +554,16 @@ impl ExportSettings {
Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint }
}
/// Whether the audio codec and container of this export store samples as `f`: PCM in WAV,
/// AIFF and QuickTime takes all four, MXF integers only; FLAC integers only; AAC none.
pub fn supports_sample_format(&self, f: SampleFormat) -> bool {
match self.audio_codec() {
AudioCodec::Aac => false,
AudioCodec::Flac => !f.is_float(),
AudioCodec::Pcm | AudioCodec::Auto => !(f.is_float() && self.format.is_mxf()),
}
}
/// The audio codec actually used.
pub fn audio_codec(&self) -> AudioCodec {
match (self.format, self.audio.codec) {
@@ -548,7 +639,7 @@ impl ExportSettings {
Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
let audio_bps = if self.has_audio() || self.format.is_audio_only() {
let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 };
let pcm = r.sample_rate as f64 * r.channels as f64 * f64::from(self.audio.sample_format().bits());
match self.audio_codec() {
AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0,
// typical of music and dialogue; noise compresses less, silence far more
@@ -649,13 +740,13 @@ impl ExportSettings {
};
match self.audio_codec() {
AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate),
AudioCodec::Flac => format!(
"FLAC (lossless) {}-bit, level {}, {} Hz, {ch}",
if self.audio.bits >= 24 { 24 } else { 16 },
self.audio.flac_level.min(8),
r.sample_rate
),
_ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate),
AudioCodec::Flac => {
format!("FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", self.audio.sample_format().bits(), self.audio.flac_level.min(8), r.sample_rate)
}
_ => {
let f = self.audio.sample_format();
format!("Uncompressed {}-bit{} PCM, {} Hz, {ch}", f.bits(), if f.is_float() { " float" } else { "" }, r.sample_rate)
}
}
} else {
"No audio".to_string()
+199
View File
@@ -1135,3 +1135,202 @@ fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets()
assert_eq!(chunks.get(), 3);
assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed)));
}
/// 32-bit integer and float audio in every container that takes them, read back by ffprobe (an
/// external oracle) and, where FilmCraft imports the format, by FilmCraft's own readers.
#[test]
fn thirty_two_bit_audio_in_every_container() {
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
let dir = Scratch::new("pcm32");
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 2)));
let cases: [(Format, SampleFormat, &str, &str, &str); 8] = [
(Format::Wav, SampleFormat::Float32, "f.wav", "pcm_f32le", "32"),
(Format::Wav, SampleFormat::Int32, "i.wav", "pcm_s32le", "32"),
(Format::Aiff, SampleFormat::Float32, "f.aif", "pcm_f32be", "32"),
(Format::Aiff, SampleFormat::Int32, "i.aif", "pcm_s32be", "32"),
(Format::ProRes, SampleFormat::Float32, "f.mov", "pcm_f32le", "32"),
(Format::ProRes, SampleFormat::Int32, "i.mov", "pcm_s32le", "32"),
(Format::MxfOp1a, SampleFormat::Int32, "i.mxf", "pcm_s32le", "32"),
(Format::Flac, SampleFormat::Int32, "i.flac", "flac", "32"),
];
for (format, sf, name, codec, bits) in cases {
let path = dir.path(name);
let mut s = ExportSettings { format, path: path.clone(), range, ..Default::default() };
s.audio.set_sample_format(sf);
assert!(s.supports_sample_format(sf), "{name}");
export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}"));
let bytes = std::fs::read(&path).unwrap();
// FilmCraft's own importer: the -6 dB tone at a peak of 0.5
if !matches!(format, Format::MxfOp1a) {
let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap_or_else(|e| panic!("{name}: {e}"));
let a = src.audio(0, 24_000, 48_000).unwrap();
let peak = a.channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()));
assert!((peak - 0.5).abs() < 0.01, "{name}: peak {peak}");
}
if let Some(j) = ffprobe_json(&["-show_streams"], &path) {
let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone();
assert_eq!(st["codec_name"].as_str(), Some(codec), "{name}");
let depth = if codec == "flac" { &st["bits_per_raw_sample"] } else { &st["bits_per_sample"] };
assert_eq!(depth.as_str().map(str::to_string).or_else(|| depth.as_u64().map(|v| v.to_string())).as_deref(), Some(bits), "{name}");
}
}
// every 32-bit file holds the same samples (float exactly, integers within one step of 2^-31)
if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("32-bit audio") {
let raw = |name: &str| {
let out = std::process::Command::new(&ffmpeg)
.args(["-v", "error", "-i", &dir.path(name), "-map", "0:a", "-f", "f32le", "-c:a", "pcm_f32le", "-"])
.output()
.unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::<Vec<f32>>()
};
let reference = raw("f.wav");
assert_eq!(reference.len(), 24_000 * 2);
for name in ["f.aif", "f.mov"] {
assert!(raw(name) == reference, "{name}: float samples differ from the WAV's");
}
for name in ["i.wav", "i.aif", "i.mov", "i.mxf", "i.flac"] {
let got = raw(name);
assert_eq!(got.len(), reference.len(), "{name}");
let worst = got.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-6, "{name}: differs by {worst}");
}
}
}
/// The mix is 32-bit float: 32-bit float export keeps a +6 dB tone's peaks of 2.0, integers clip
/// them at full scale.
#[test]
fn float_export_keeps_peaks_above_full_scale() {
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(6.0));
let dir = Scratch::new("pcm-hot");
let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4)));
let peak = |sf: SampleFormat, name: &str| {
let path = dir.path(name);
let mut s = ExportSettings { format: Format::Wav, path: path.clone(), range, ..Default::default() };
s.audio.set_sample_format(sf);
export(&p, seq, &s, &m, &Progress::default()).unwrap();
let src = filmcraft_codecs::open_bytes(name, std::fs::read(&path).unwrap().into()).unwrap();
src.audio(0, 12_000, 48_000).unwrap().channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs()))
};
let float = peak(SampleFormat::Float32, "f.wav");
assert!((float - 2.0).abs() < 0.02, "float keeps +6 dBFS: {float}");
for (sf, name) in [(SampleFormat::Int32, "i32.wav"), (SampleFormat::Int24, "i24.wav"), (SampleFormat::Int16, "i16.wav")] {
let p = peak(sf, name);
assert!(p <= 1.0 && p > 0.99, "{name} clips at full scale: {p}");
}
}
#[test]
fn sample_formats_each_codec_and_container_stores() {
use SampleFormat::*;
let supported = |format: Format, codec: AudioCodec| {
let s = ExportSettings { format, audio: AudioSettings { codec, ..Default::default() }, ..Default::default() };
SampleFormat::ALL.iter().filter(|&&f| s.supports_sample_format(f)).copied().collect::<Vec<_>>()
};
assert_eq!(supported(Format::Wav, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::Aiff, AudioCodec::Auto), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::ProRes, AudioCodec::Pcm), [Int16, Int24, Int32, Float32]);
assert_eq!(supported(Format::MxfOp1a, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::MxfOpAtom, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::Flac, AudioCodec::Auto), [Int16, Int24, Int32]);
assert_eq!(supported(Format::ProRes, AudioCodec::Flac), [Int16, Int24, Int32]);
assert!(supported(Format::H264, AudioCodec::Auto).is_empty(), "MP4 is AAC");
// `bits` rounds down to a size; `float` only with 32
let fmt = |bits, float| AudioSettings { bits, float, ..Default::default() }.sample_format();
assert_eq!(
[fmt(0, false), fmt(20, false), fmt(24, false), fmt(31, false), fmt(32, false), fmt(64, false), fmt(32, true)],
[Int16, Int16, Int24, Int24, Int32, Int32, Float32]
);
}
/// Hostile or contradictory settings (CLI, control channel, MCP, old presets) are errors, never
/// a panic or a silently different file.
#[test]
fn unsupported_sample_formats_are_refused() {
let refused = |format: Format, codec: AudioCodec, bits: u16, float: bool| {
let s = ExportSettings { format, path: "x".into(), audio: AudioSettings { codec, bits, float, ..Default::default() }, ..Default::default() };
s.validate().err().map(|e| e.to_string())
};
assert!(refused(Format::Wav, AudioCodec::Auto, 24, true).is_some_and(|e| e.contains("32")));
assert!(refused(Format::Flac, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("FLAC")));
assert!(refused(Format::ProRes, AudioCodec::Flac, 32, true).is_some_and(|e| e.contains("FLAC")));
assert!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("MXF")));
for (format, codec) in [(Format::Wav, AudioCodec::Auto), (Format::Aiff, AudioCodec::Auto), (Format::ProRes, AudioCodec::Pcm)] {
assert_eq!(refused(format, codec, 32, true), None, "{format:?}");
}
assert_eq!(refused(Format::Flac, AudioCodec::Auto, 32, false), None);
assert_eq!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, false), None);
// AAC has no sample size: the PCM / FLAC choice is ignored, as before
assert_eq!(refused(Format::H264, AudioCodec::Auto, 32, true), None);
// no audio, nothing to refuse
let s = ExportSettings {
format: Format::MxfOp1a,
include_audio: false,
audio: AudioSettings { bits: 32, float: true, ..Default::default() },
..Default::default()
};
assert!(s.validate().is_ok());
}
#[test]
fn sample_format_settings_round_trip_summary_and_estimate() {
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "wav", "audio": {"bits": 32, "float": true}})).unwrap();
assert_eq!(s.audio.sample_format(), SampleFormat::Float32);
let back: ExportSettings = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
assert_eq!(back.audio.sample_format(), SampleFormat::Float32);
// settings saved before 32-bit audio read integers
let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap();
assert_eq!((old.float, old.sample_format()), (false, SampleFormat::Int24));
let sum = |sf: SampleFormat, format: Format| {
let mut s = ExportSettings { format, ..Default::default() };
s.audio.set_sample_format(sf);
(
s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio,
s.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)),
)
};
let (f, f_bytes) = sum(SampleFormat::Float32, Format::Wav);
assert!(f.starts_with("Uncompressed 32-bit float PCM, 48000 Hz"), "{f}");
let (i, _) = sum(SampleFormat::Int32, Format::Wav);
assert!(i.starts_with("Uncompressed 32-bit PCM"), "{i}");
let (_, i16_bytes) = sum(SampleFormat::Int16, Format::Wav);
assert!(f_bytes > i16_bytes * 19 / 10 && f_bytes < i16_bytes * 21 / 10, "{f_bytes} vs {i16_bytes}");
let (flac, _) = sum(SampleFormat::Int32, Format::Flac);
assert!(flac.starts_with("FLAC (lossless) 32-bit"), "{flac}");
}
/// FilmCraft imports its own 32-bit FLAC (symphonia rejected the frame header's 32-bit size code
/// as reserved: "unsupported media"), at full scale and with noise, sample for sample.
#[test]
fn own_importer_reads_32_bit_flac_at_full_scale() {
let n = 20_000usize;
let mut seed = 7u64;
let noise: Vec<i32> = (0..n)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 32) as u32 as i32
})
.collect();
let extremes: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN + 1 }).collect();
for level in [0u8, 5, 8] {
let mut enc = filmcraft_flac::Encoder::new(filmcraft_flac::EncoderConfig { level, ..filmcraft_flac::EncoderConfig::new(48_000, 2, 32) }).unwrap();
let mut file = enc.header();
file.extend(enc.encode(&[&extremes, &noise]).unwrap());
file.extend(enc.finish());
let at = filmcraft_flac::STREAMINFO_OFFSET as usize;
file[at..at + 34].copy_from_slice(&enc.streaminfo());
let src = filmcraft_codecs::open_bytes("x.flac", file.into()).unwrap_or_else(|e| panic!("level {level}: {e}"));
let a = src.audio(0, n, 48_000).unwrap();
for (c, want) in [&extremes, &noise].iter().enumerate() {
let got = &a.channels[c];
assert_eq!(got.len(), n, "level {level} channel {c}");
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
let w = (f64::from(w) / 2_147_483_648.0) as f32;
assert!((g - w).abs() <= 1e-6, "level {level} channel {c} sample {i}: {g} vs {w}");
}
}
}
}
+5 -4
View File
@@ -13,7 +13,7 @@ symphonia bootstrap decoder in `filmcraft-codecs`.
```rust
use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET};
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24)
let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–32)
cfg.level = 8; // 0 fastest … 8 smallest; 5 by default
let mut enc = Encoder::new(cfg)?;
let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor)
@@ -37,10 +37,11 @@ let last_len = enc.pending_samples(); // duration
| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest |
| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback |
| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe |
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest |
| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest (32-bit: independent only, a side channel would need 33 bits) |
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples,
seek tables.
Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, seek tables.
32-bit samples (frame headers point at STREAMINFO for the size) decode only in recent decoders (libFLAC 1.4+,
ffmpeg 6+).
| Level | LPC order | Rice partition order | Stereo decorrelation |
|---|---|---|---|
+2
View File
@@ -53,6 +53,8 @@ fn sample_size_code(bps: u8) -> u8 {
16 => 4,
20 => 5,
24 => 6,
// 32 bits has code 7 since RFC 9639, which earlier decoders (symphonia 0.5, FilmCraft's own
// FLAC import) reject as reserved; 0 ("as STREAMINFO says") decodes everywhere
_ => 0,
}
}
+10 -7
View File
@@ -1,6 +1,6 @@
//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec).
//!
//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the
//! - [`Encoder`]: planar integer samples (8–32 bit, 1–8 channels) → a `.flac` stream: the
//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of
//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed
//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes;
@@ -61,7 +61,8 @@ pub struct EncoderConfig {
pub sample_rate: u32,
/// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1).
pub channels: u8,
/// 8–24.
/// 8–32. At 32 bits stereo channels are coded independently: the side channel of
/// left/side, side/right and mid/side coding would need 33 bits.
pub bits_per_sample: u8,
/// Samples per channel per frame, 16–65535 (4096 by default).
pub block_size: u16,
@@ -92,8 +93,8 @@ impl Encoder {
if !(1..=8).contains(&cfg.channels) {
return Err(Error::InvalidConfig("1 to 8 channels"));
}
if !(8..=24).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 24 bits per sample"));
if !(8..=32).contains(&cfg.bits_per_sample) {
return Err(Error::InvalidConfig("8 to 32 bits per sample"));
}
if !(1..=1_048_575).contains(&cfg.sample_rate) {
return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz"));
@@ -170,8 +171,9 @@ impl Encoder {
if planar.iter().any(|c| c.len() != n) {
return Err(Error::UnequalChannels);
}
let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1;
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) {
// i64: at 32 bits the range is all of i32 (1 << 31 overflows i32)
let max = (1i64 << (self.cfg.bits_per_sample - 1)) - 1;
if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| i64::from(s) > max || i64::from(s) < -max - 1) {
return Err(Error::SampleRange(bad));
}
for (p, c) in self.pending.iter_mut().zip(planar) {
@@ -217,7 +219,8 @@ impl Encoder {
let effort = self.effort;
let code = |x: &[i32], b: u8| predict::choose(x, b, effort);
let data = match block {
[l, r] if effort.stereo => {
// the side channel takes one bit more than the samples: at most 32 (i32)
[l, r] if effort.stereo && bps < 32 => {
let side: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| a - b).collect();
let mid: Vec<i32> = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect();
let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps));
+29 -7
View File
@@ -24,7 +24,7 @@ impl Rng {
/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise,
/// a full-scale square, silence and a lone click.
fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
let max = f64::from((1i32 << (bps - 1)) - 1);
let max = ((1i64 << (bps - 1)) - 1) as f64;
let mut r = Rng(seed | 1);
(0..n)
.map(|i| {
@@ -43,7 +43,7 @@ fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec<i32> {
"click" => f64::from(u8::from(i == n / 2)),
_ => 0.0,
};
((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32)
((v * max).round() as i64).clamp(-(max as i64) - 1, max as i64) as i32
})
.collect()
}
@@ -93,13 +93,14 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
}
// STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16
// and 24 bit compare
// hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16,
// 24 and 32 bit compare
let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34];
let pcm = match bps {
8 => Some("s8"),
16 => Some("s16le"),
24 => Some("s24le"),
32 => Some("s32le"),
_ => None,
};
if let Some(pcm) = pcm {
@@ -118,7 +119,7 @@ fn round_trip(name: &str, planar: &[Vec<i32>], cfg: EncoderConfig, chunk: usize)
#[test]
fn every_signal_round_trips_exactly() {
for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] {
for bps in [16u8, 24] {
for bps in [16u8, 24, 32] {
let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)];
if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() {
return;
@@ -129,11 +130,13 @@ fn every_signal_round_trips_exactly() {
#[test]
fn channel_layouts_rates_and_sizes() {
let cases: [(&str, u8, u8, u32, usize); 6] = [
let cases: [(&str, u8, u8, u32, usize); 8] = [
("mono-8bit", 1, 8, 8_000, 3_001),
("mono-24-44k1", 1, 24, 44_100, 9_999),
("stereo-20bit", 2, 20, 96_000, 10_000),
("5.1-24", 6, 24, 48_000, 12_345),
("mono-32", 1, 32, 48_000, 7_777),
("5.1-32", 6, 32, 96_000, 12_345),
("odd-rate", 2, 16, 37_800, 5_000),
("tiny", 2, 16, 48_000, 1),
];
@@ -167,7 +170,8 @@ fn levels_and_block_sizes_trade_time_for_size() {
fn bad_input_is_an_error_not_a_panic() {
assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 33)).is_err());
assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err());
assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err());
assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err());
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap();
@@ -179,3 +183,21 @@ fn bad_input_is_an_error_not_a_panic() {
assert!(enc.finish().is_empty());
assert_eq!(&enc.header()[..4], b"fLaC");
}
/// 32-bit stereo at the extremes of i32: left minus right would need 33 bits, so the encoder must
/// not code a side channel (it overflowed `i32` and wrote garbage); every level decodes exactly.
#[test]
fn full_scale_32_bit_stereo_round_trips() {
let n = 9_000;
let left: Vec<i32> = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect();
let right: Vec<i32> = left.iter().map(|&v| if v == i32::MAX { i32::MIN } else { i32::MAX }).collect();
let planar = vec![left, right];
for level in [0u8, 5, 8] {
let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 32) };
if round_trip(&format!("extremes-32-level{level}"), &planar, cfg, 4096).is_none() {
return;
}
}
let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 32)).unwrap();
assert!(enc.encode(&[&[i32::MIN, i32::MAX], &[0, -1]]).is_ok());
}
+365
View File
@@ -0,0 +1,365 @@
//! Which adapter to draw with when one GPU is listed through two versions of one driver.
//!
//! On Windows the Vulkan loader takes one driver from each display adapter's driver package. A PC
//! whose GPUs were installed from different packages of one vendor (a laptop with an integrated
//! and a discrete AMD GPU, the first on the manufacturer's old package and the second on a current
//! one) loads two versions of that driver, and the newer GPU is listed once through each. wgpu
//! takes the first entry of the device type it prefers, which is whichever version the loader
//! listed first; a window drawn through the stale one kills the process inside the driver
//! (`amdvlk64.dll`, 0xc0000005), where no Rust error handling runs.
//!
//! [`choose`] is wgpu's own rule (`Instance::request_adapter`: the adapters that can draw to the
//! surface, by device type for the power preference, then as enumerated) with one addition: when
//! the adapter that rule picks is listed again by the same driver, the entry exposing the most
//! features is used, i.e. the newer version. Without such a duplicate the choice is wgpu's own.
//! Entries of one GPU from different drivers (RADV and AMDVLK, a vendor's driver and Dozen) are
//! left alone: those are installed side by side on purpose, and wgpu's order between them stays.
use std::cmp::Reverse;
use std::fmt;
use wgpu::{Adapter, Backend, DeviceType, PowerPreference, Surface};
/// One enumerated adapter as plain data, so that [`choose`] is a pure function tests run without
/// a GPU.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Candidate {
pub name: String,
pub backend: Backend,
pub device_type: DeviceType,
/// PCI vendor and device ids; both zero when the backend reports none (OpenGL).
pub vendor: u32,
pub device: u32,
/// PCI bus id such as `0000:03:00.0`; empty when the driver reports none.
pub pci_bus_id: String,
/// The driver's name (Vulkan) or version (DirectX 12).
pub driver: String,
/// More about the driver, for the log.
pub driver_info: String,
/// How many wgpu features the driver exposes; a newer version of one driver exposes more.
pub features: u32,
/// Whether the surface can be drawn through this adapter (true without a surface).
pub usable: bool,
}
impl Candidate {
pub fn of(adapter: &Adapter, surface: Option<&Surface<'_>>) -> Self {
let info = adapter.get_info();
Candidate {
name: info.name,
backend: info.backend,
device_type: info.device_type,
vendor: info.vendor,
device: info.device,
pci_bus_id: info.device_pci_bus_id,
driver: info.driver,
driver_info: info.driver_info,
features: adapter.features().bits().0.iter().map(|word| word.count_ones()).sum(),
usable: surface.is_none_or(|s| adapter.is_surface_supported(s)),
}
}
}
impl fmt::Display for Candidate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match self.device_type {
DeviceType::DiscreteGpu => "discrete GPU",
DeviceType::IntegratedGpu => "integrated GPU",
DeviceType::VirtualGpu => "virtual GPU",
DeviceType::Cpu => "software",
DeviceType::Other => "unknown type",
};
let driver = format!("{} {}", self.driver, self.driver_info);
write!(f, "\"{}\" ({:?}, {kind}, driver {}, {} features", self.name, self.backend, driver.trim(), self.features)?;
if !self.pci_bus_id.is_empty() {
write!(f, ", bus {}", self.pci_bus_id)?;
}
if !self.usable {
write!(f, ", cannot draw to the window")?;
}
write!(f, ")")
}
}
/// Whether `a` and `b` are one GPU listed twice by one driver, i.e. through two installed
/// versions of it: the same backend, device type, PCI vendor and device and driver name, and the
/// same bus id when both report one. Adapters without PCI ids (OpenGL reports zeros) are never
/// the same: two of them cannot be told apart.
fn same_gpu_and_driver(a: &Candidate, b: &Candidate) -> bool {
let identified = a.vendor != 0 || a.device != 0;
let same_bus = a.pci_bus_id.is_empty() || b.pci_bus_id.is_empty() || a.pci_bus_id == b.pci_bus_id;
identified && a.backend == b.backend && a.device_type == b.device_type && a.vendor == b.vendor && a.device == b.device && a.driver == b.driver && same_bus
}
/// wgpu's device type order for `power` (wgpu-core's `Instance::request_adapter`): lower first.
/// Without a preference the adapters stay as enumerated.
fn type_order(device_type: DeviceType, power: PowerPreference) -> u8 {
let prefer_integrated = match power {
PowerPreference::LowPower => true,
PowerPreference::HighPerformance => false,
PowerPreference::None => return 0,
};
match device_type {
DeviceType::DiscreteGpu if prefer_integrated => 2,
DeviceType::IntegratedGpu if prefer_integrated => 1,
DeviceType::DiscreteGpu => 1,
DeviceType::IntegratedGpu => 2,
DeviceType::Other => 3,
DeviceType::VirtualGpu => 4,
DeviceType::Cpu => 5,
}
}
/// The index of the adapter wgpu's `request_adapter` picks: the first usable one of the best
/// device type for `power`. `None` without a usable adapter.
fn wgpu_choice(candidates: &[Candidate], power: PowerPreference) -> Option<usize> {
let usable = candidates.iter().enumerate().filter(|(_, c)| c.usable);
usable.min_by_key(|(index, c)| (type_order(c.device_type, power), *index)).map(|(index, _)| index)
}
/// The index of the adapter to draw with: [`wgpu_choice`], or the entry exposing the most
/// features among those that are the same GPU listed by the same driver (the first of them when
/// several expose as many). `None` without a usable adapter.
pub fn choose(candidates: &[Candidate], power: PowerPreference) -> Option<usize> {
let first = wgpu_choice(candidates, power)?;
let Some(preferred) = candidates.get(first) else {
return Some(first);
};
let versions = candidates.iter().enumerate().filter(|(_, c)| c.usable && same_gpu_and_driver(preferred, c));
versions.max_by_key(|(index, c)| (c.features, Reverse(*index))).map(|(index, _)| index).or(Some(first))
}
/// [`choose`] over enumerated adapters, as egui-wgpu's `native_adapter_selector` for the window
/// (`surface`). The adapters and the choice go to the log.
pub fn pick(adapters: &[Adapter], surface: Option<&Surface<'_>>, power: PowerPreference) -> Result<Adapter, String> {
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::of(a, surface)).collect();
for candidate in &candidates {
log::info!("graphics adapter available: {candidate}");
}
let listed = || candidates.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
let index = choose(&candidates, power).ok_or_else(|| format!("no usable graphics adapter among {}: {}", candidates.len(), listed()))?;
let (Some(chosen), Some(adapter)) = (candidates.get(index), adapters.get(index)) else {
return Err(format!("graphics adapter {index} of {} is missing", adapters.len()));
};
let others = candidates.iter().enumerate().filter(|(i, c)| *i != index && c.usable && same_gpu_and_driver(chosen, c)).count();
if others > 0 {
log::warn!(
"{:?} lists \"{}\" {} times: several versions of its driver are installed, and a window drawn through a stale one can crash. Using the one with the most features; installing one driver package for every GPU removes the others.",
chosen.backend,
chosen.name,
others.saturating_add(1)
);
}
log::info!("graphics adapter chosen: {chosen}");
Ok(adapter.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use wgpu::Backend::{Dx12, Gl, Vulkan};
use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other};
use wgpu::PowerPreference::{HighPerformance, LowPower};
const AMD: u32 = 0x1002;
const RX_6800M: u32 = 0x73df;
const RADEON_IGPU: u32 = 0x1638;
const POWERS: [PowerPreference; 3] = [PowerPreference::None, LowPower, HighPerformance];
#[expect(clippy::too_many_arguments, reason = "one line per adapter in the listings below")]
fn gpu(
name: &str,
backend: Backend,
device_type: DeviceType,
vendor: u32,
device: u32,
bus: &str,
driver: &str,
driver_info: &str,
features: u32,
) -> Candidate {
Candidate {
name: name.into(),
backend,
device_type,
vendor,
device,
pci_bus_id: bus.into(),
driver: driver.into(),
driver_info: driver_info.into(),
features,
usable: true,
}
}
/// An ASUS G513QY as wgpu 30 lists it: the integrated Radeon is on the manufacturer's 2021
/// driver package and the RX 6800M on a 2026 one, so the Vulkan loader has both versions of
/// AMD's driver, and the 2021 one lists the RX 6800M first. A window drawn through that entry
/// died inside `amdvlk64.dll` (0xc0000005) on every launch.
fn g513qy() -> Vec<Candidate> {
let amd = "AMD proprietary driver";
vec![
gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2", 60),
gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", amd, "26.9.2 (AMD proprietary shader compiler)", 68),
gpu("AMD Radeon(TM) Graphics", Vulkan, IntegratedGpu, AMD, RADEON_IGPU, "", amd, "21.30.02.13", 56),
gpu("AMD Radeon RX 6800M", Dx12, DiscreteGpu, AMD, RX_6800M, "0000:03:00.0", "32.0.21045.11001", "", 36),
gpu("AMD Radeon(TM) Graphics", Dx12, IntegratedGpu, AMD, RADEON_IGPU, "0000:08:00.0", "30.0.13002.13003", "", 36),
gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.9549", "", 36),
]
}
#[test]
fn a_gpu_listed_through_two_versions_of_its_driver_draws_with_the_newer_one() {
let adapters = g513qy();
// the window asks for high performance; `WGPU_POWER_PREF=none` for no preference
for power in [HighPerformance, PowerPreference::None] {
assert_eq!(wgpu_choice(&adapters, power), Some(0), "wgpu takes the stale entry, which crashed");
assert_eq!(choose(&adapters, power), Some(1));
}
// whichever version the loader lists first
let mut newer_first = adapters.clone();
newer_first.swap(0, 1);
assert_eq!(choose(&newer_first, HighPerformance), Some(0));
// two entries exposing as much: wgpu's, the first
let mut equal = adapters.clone();
if let Some(c) = equal.get_mut(1) {
c.features = 60;
}
assert_eq!(choose(&equal, HighPerformance), Some(0));
// three versions: the one exposing the most
let mut three = adapters.clone();
three.insert(1, gpu("AMD Radeon RX 6800M", Vulkan, DiscreteGpu, AMD, RX_6800M, "", "AMD proprietary driver", "24.1.1", 64));
assert_eq!(choose(&three, HighPerformance), Some(2));
}
#[test]
fn a_newer_version_never_overtakes_another_gpu_or_backend() {
// low power: the integrated GPU, as wgpu picks, whatever the discrete one is listed as
assert_eq!(choose(&g513qy(), LowPower), Some(2));
// DirectX 12 enumerated first: its entry of the GPU stays the choice
let mut dx12_first = g513qy();
dx12_first.rotate_left(3);
for power in [HighPerformance, PowerPreference::None] {
assert_eq!(choose(&dx12_first, power), Some(0));
}
assert_eq!(choose(&dx12_first, LowPower), wgpu_choice(&dx12_first, LowPower));
}
#[test]
fn without_two_versions_of_one_driver_the_choice_is_wgpus() {
let nvidia = |backend, bus, driver, features| gpu("NVIDIA GeForce RTX 4070", backend, DiscreteGpu, 0x10de, 0x2786, bus, driver, "", features);
let intel = |backend, bus, driver, features| gpu("Intel(R) UHD Graphics 770", backend, IntegratedGpu, 0x8086, 0x4680, bus, driver, "", features);
let radeon = |bus, driver, features| gpu("AMD Radeon RX 7900 XT", Vulkan, DiscreteGpu, AMD, 0x744c, bus, driver, "", features);
let gl = |name: &str, features| gpu(name, Gl, Other, 0, 0, "", "", "", features);
let without = |skip: usize| g513qy().into_iter().enumerate().filter(|(i, _)| *i != skip).map(|(_, c)| c).collect::<Vec<_>>();
let listings: Vec<Vec<Candidate>> = vec![
vec![],
vec![nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)],
// a laptop with two GPUs, through Vulkan and DirectX 12
vec![
intel(Vulkan, "0000:00:02.0", "Intel Corporation", 60),
nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70),
nvidia(Dx12, "0000:01:00.0", "32.0.15.6094", 36),
intel(Dx12, "0000:00:02.0", "31.0.101.5186", 36),
gpu("Microsoft Basic Render Driver", Dx12, Cpu, 0x1414, 0x8c, "", "10.0.26100.1", "", 36),
],
// one GPU through two different drivers, the second exposing more: Linux with AMDVLK and RADV
vec![radeon("0000:03:00.0", "AMD open-source driver", 58), radeon("0000:03:00.0", "radv", 71)],
// a vendor's driver and Dozen (Vulkan on DirectX 12)
vec![nvidia(Vulkan, "", "Dozen", 75), nvidia(Vulkan, "0000:01:00.0", "NVIDIA", 70)],
// two cards of one model, on their own buses
vec![radeon("0000:03:00.0", "AMD proprietary driver", 60), radeon("0000:04:00.0", "AMD proprietary driver", 68)],
// OpenGL reports no ids: two adapters cannot be told apart
vec![gl("ANGLE", 20), gl("ANGLE", 30)],
vec![gpu("llvmpipe (LLVM 19.1.0, 256 bits)", Vulkan, Cpu, 0x10005, 0, "", "llvmpipe", "", 50)],
// the laptop above with either version of the driver alone
without(0),
without(1),
];
for adapters in &listings {
for power in POWERS {
assert_eq!(choose(adapters, power), wgpu_choice(adapters, power), "{power:?} over {adapters:#?}");
}
}
assert_eq!(choose(&[], HighPerformance), None);
}
#[test]
fn two_entries_are_one_gpu_only_with_the_same_ids_type_driver_and_bus() {
let stale = g513qy().swap_remove(0);
let newer = g513qy().swap_remove(1);
assert!(same_gpu_and_driver(&stale, &newer) && same_gpu_and_driver(&stale, &stale));
// a bus id only one version reports does not tell them apart; two different ones do
let on_bus = |bus: &str| Candidate { pci_bus_id: bus.into(), ..newer.clone() };
assert!(same_gpu_and_driver(&stale, &on_bus("0000:03:00.0")));
assert!(same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:03:00.0")));
assert!(!same_gpu_and_driver(&on_bus("0000:03:00.0"), &on_bus("0000:04:00.0")));
for other in [
Candidate { backend: Dx12, ..newer.clone() },
Candidate { device_type: IntegratedGpu, ..newer.clone() },
Candidate { vendor: 0x10de, ..newer.clone() },
Candidate { device: RADEON_IGPU, ..newer.clone() },
Candidate { driver: "radv".into(), ..newer.clone() },
] {
assert!(!same_gpu_and_driver(&stale, &other), "{other}");
}
let unidentified = Candidate { vendor: 0, device: 0, ..newer };
assert!(!same_gpu_and_driver(&unidentified, &unidentified));
}
#[test]
fn adapters_that_cannot_draw_to_the_window_are_left_out() {
let unusable = |which: &dyn Fn(usize, &Candidate) -> bool| {
let mut adapters = g513qy();
for (i, c) in adapters.iter_mut().enumerate() {
c.usable = !which(i, c);
}
adapters
};
// the newer version cannot: the other entry is all there is of that GPU through Vulkan
assert_eq!(choose(&unusable(&|i, _| i == 1), HighPerformance), Some(0));
assert_eq!(choose(&unusable(&|i, _| i == 0), HighPerformance), Some(1));
// Vulkan cannot at all: DirectX 12
assert_eq!(choose(&unusable(&|_, c| c.backend == Vulkan), HighPerformance), Some(3));
assert_eq!(choose(&unusable(&|_, _| true), HighPerformance), None);
}
#[test]
fn candidates_describe_themselves_for_the_log() {
let adapters = g513qy();
let text = adapters.get(1).map(ToString::to_string).unwrap_or_default();
assert_eq!(text, "\"AMD Radeon RX 6800M\" (Vulkan, discrete GPU, driver AMD proprietary driver 26.9.2 (AMD proprietary shader compiler), 68 features)");
let offscreen = adapters.get(4).map(|c| Candidate { usable: false, ..c.clone() }.to_string()).unwrap_or_default();
assert_eq!(
offscreen,
"\"AMD Radeon(TM) Graphics\" (Dx12, integrated GPU, driver 30.0.13002.13003, 36 features, bus 0000:08:00.0, cannot draw to the window)"
);
}
/// Against the adapters of the machine running the tests: [`wgpu_choice`] is what wgpu's
/// `request_adapter` returns, and [`pick`] returns that adapter too, or a version of it
/// exposing more features.
#[test]
fn on_this_host_the_choice_is_wgpus_unless_its_driver_is_listed_twice() {
if wgpu::Instance::enabled_backend_features().is_empty() {
return;
}
// no OpenGL: the app leaves it out on Windows, where creating it can crash a driver
let backends = wgpu::Backends::PRIMARY;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::of(a, None)).collect();
for power in POWERS {
let options = wgpu::RequestAdapterOptions { power_preference: power, ..Default::default() };
let wgpu_own = pollster::block_on(instance.request_adapter(&options)).ok().map(|a| Candidate::of(&a, None));
assert_eq!(wgpu_choice(&candidates, power).and_then(|i| candidates.get(i)), wgpu_own.as_ref(), "{power:?} over {candidates:#?}");
let picked = pick(&adapters, None, power).ok().map(|a| Candidate::of(&a, None));
match (&wgpu_own, &picked) {
(Some(wgpu_own), Some(picked)) if wgpu_own != picked => {
assert!(same_gpu_and_driver(wgpu_own, picked) && picked.features > wgpu_own.features, "{power:?}: {picked} instead of {wgpu_own}");
}
_ => assert_eq!(picked, wgpu_own, "{power:?}"),
}
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ const EXACT: f32 = 1e-4;
const FLIPS: f64 = 0.01;
fn cx(t: Tick, px_scale: f32) -> FxCtx<'static> {
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working: filmcraft_color::WorkingSpace::Rec709 }
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709 }
}
fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance {
+1
View File
@@ -38,6 +38,7 @@ use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use filmcraft_render::Blend;
use filmcraft_render::plan::{FramePlan, PlanLayer, PlanStep};
pub mod adapter;
pub mod export_renderer;
pub mod fx;
pub mod lut;
+23 -9
View File
@@ -569,8 +569,9 @@ impl Pps {
let sps = spss.get(sps_id as usize).and_then(|s| s.as_ref()).ok_or_else(|| crate::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
let entropy_coding_mode = r.read_flag()?;
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
let num_slice_groups = r.read_ue()? + 1;
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
let num_slice_groups_minus1 = r.read_ue()?;
ensure!(num_slice_groups_minus1 < 8, "num_slice_groups out of range");
let num_slice_groups = num_slice_groups_minus1 + 1;
let mut slice_group_map_type = 0;
if num_slice_groups > 1 {
// Parsed for completeness; FMO decoding is unsupported.
@@ -592,7 +593,10 @@ impl Pps {
r.read_ue()?;
}
6 => {
let n = r.read_ue()? + 1;
let n_minus1 = r.read_ue()?;
// pic_size_in_map_units_minus1: one slice_group_id per map unit of the picture.
ensure!(n_minus1 < sps.pic_width_in_mbs.saturating_mul(sps.pic_height_in_map_units), "pic_size_in_map_units out of range");
let n = n_minus1 + 1;
let bits = 32 - (num_slice_groups - 1).leading_zeros();
for _ in 0..n {
r.read_bits(bits)?;
@@ -601,13 +605,23 @@ impl Pps {
_ => {}
}
}
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
let num_ref_idx_l0_minus1 = r.read_ue()?;
let num_ref_idx_l1_minus1 = r.read_ue()?;
ensure!(num_ref_idx_l0_minus1 < 32 && num_ref_idx_l1_minus1 < 32, "num_ref_idx_default_active out of range");
let num_ref_idx_l0_default_active = num_ref_idx_l0_minus1 + 1;
let num_ref_idx_l1_default_active = num_ref_idx_l1_minus1 + 1;
let weighted_pred = r.read_flag()?;
let weighted_bipred_idc = r.read_bits(2)?;
let pic_init_qp = 26 + r.read_se()?;
let pic_init_qs = 26 + r.read_se()?;
// 7.4.2.2.1 fall-back: a value of 3 shall be considered equal to 2.
let weighted_bipred_idc = (r.read_bits(2)?).min(2);
// 7.4.2.2: pic_init_qp_minus26 in -(26 + QpBdOffsetY)..=25, pic_init_qs_minus26 in -26..=25.
let qp_bd_offset_y = 6 * (sps.bit_depth_luma.saturating_sub(8)) as i32;
let qp_min = -(26 + qp_bd_offset_y);
let pic_init_qp_minus26 = r.read_se()?;
ensure!((qp_min..=25).contains(&pic_init_qp_minus26), "pic_init_qp_minus26 out of range");
let pic_init_qs_minus26 = r.read_se()?;
ensure!((-26..=25).contains(&pic_init_qs_minus26), "pic_init_qs_minus26 out of range");
let pic_init_qp = 26 + pic_init_qp_minus26;
let pic_init_qs = 26 + pic_init_qs_minus26;
let chroma_qp_index_offset = r.read_se()?;
ensure!((-12..=12).contains(&chroma_qp_index_offset), "chroma_qp_index_offset out of range");
let deblocking_filter_control_present = r.read_flag()?;
+142
View File
@@ -0,0 +1,142 @@
//! #1441: malformed PPS fields must return `Error::Invalid`, not panic.
use filmcraft_h264::Decoder;
/// Valid, synthetic Baseline SPS (176x144, 8-bit).
const SPS: [u8; 12] = [0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1e, 0xda, 0x0b, 0x13, 0x90];
fn decode_pps(pps: &[u8]) -> Result<(), filmcraft_h264::Error> {
let mut decoder = Decoder::with_threads(1);
decoder.decode(&SPS, 0).expect("sps decodes");
decoder.decode(pps, 0).map(|_| ())
}
/// The reporter's literal: PPS with `num_ref_idx_l0_default_active_minus1 = u32::MAX`.
#[test]
fn hostile_pps_returns_an_error_instead_of_panicking() {
let pps = [0x00, 0x00, 0x00, 0x01, 0x68, 0xc8, 0x00, 0x00, 0x03, 0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0x02, 0x3c, 0x80];
let outcome = std::panic::catch_unwind(|| {
let mut decoder = Decoder::with_threads(1);
decoder.decode(&SPS, 0).unwrap();
decoder.decode(&pps, 0)
});
assert!(outcome.is_ok_and(|result| result.is_err()));
}
/// Write ue(v) as bits (helper for building synthetic PPS payloads).
fn push_ue(bits: &mut Vec<bool>, v: u32) {
let x = v as u64 + 1;
let len = 64 - x.leading_zeros();
for _ in 1..len {
bits.push(false);
}
for i in (0..len).rev() {
bits.push(x & (1 << i) != 0);
}
}
fn push_bits(bits: &mut Vec<bool>, v: u32, n: u32) {
for i in (0..n).rev() {
bits.push(v & (1 << i) != 0);
}
}
fn to_bytes(bits: &[bool]) -> Vec<u8> {
let mut out = Vec::new();
for chunk in bits.chunks(8) {
let mut b = 0u8;
for (i, v) in chunk.iter().enumerate() {
b |= (*v as u8) << (7 - i);
}
out.push(b);
}
out
}
/// Build a PPS NAL (header byte 0x68 + payload) with `num_slice_groups_minus1` = u32::MAX.
#[test]
fn hostile_num_slice_groups_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, u32::MAX); // num_slice_groups_minus1
bits.extend(std::iter::repeat_n(false, 64));
let payload = to_bytes(&bits);
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&payload);
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `num_ref_idx_l1_default_active_minus1 = u32::MAX` (l0 valid) must error, not panic.
#[test]
fn hostile_num_ref_idx_l1_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0); // num_slice_groups_minus1
push_ue(&mut bits, 1); // num_ref_idx_l0_minus1
push_ue(&mut bits, u32::MAX); // num_ref_idx_l1_minus1
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `pic_init_qp_minus26 = i32::MAX` must error, not overflow.
#[test]
fn hostile_pic_init_qp_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0); // num_slice_groups_minus1
push_ue(&mut bits, 0); // num_ref_idx_l0_minus1
push_ue(&mut bits, 0); // num_ref_idx_l1_minus1
bits.push(false); // weighted_pred
push_bits(&mut bits, 0, 2); // weighted_bipred_idc
// se(i32::MAX): codeNum = 2*MAX-1 = u32::MAX-2... encode via ue of huge codeNum
push_ue(&mut bits, u32::MAX - 2); // codeNum for +2^31-1... any out-of-range codeNum works
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `weighted_bipred_idc = 3` is concealed to 2 per 7.4.2.2.1 (not rejected, not a panic).
#[test]
fn reserved_weighted_bipred_idc_is_concealed() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
bits.push(false); // weighted_pred
push_bits(&mut bits, 3, 2); // weighted_bipred_idc = 3 (reserved)
push_ue(&mut bits, 0); // pic_init_qp_minus26 = 0
push_ue(&mut bits, 0); // pic_init_qs_minus26 = 0
push_ue(&mut bits, 0); // chroma_qp_index_offset = 0
bits.push(false); // deblocking_filter_control_present
bits.push(false); // constrained_intra_pred
bits.push(false); // redundant_pic_cnt_present
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
// parses fine; the decoder treats the field as 2
decode_pps(&nal).expect("weighted_bipred_idc=3 concealed to 2");
}
/// A well-formed minimal PPS still parses (no regression).
#[test]
fn minimal_valid_pps_parses() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
bits.push(false); // weighted_pred
push_bits(&mut bits, 0, 2); // weighted_bipred_idc
push_ue(&mut bits, 0); // pic_init_qp_minus26
push_ue(&mut bits, 0); // pic_init_qs_minus26
push_ue(&mut bits, 0); // chroma_qp_index_offset
bits.push(true); // deblocking_filter_control_present
bits.push(false); // constrained_intra_pred
bits.push(false); // redundant_pic_cnt_present
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
if let Err(e) = decode_pps(&nal) {
panic!("minimal PPS rejected: {e}");
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ while let Some(p) = d.next_packet()? { // file order, all tracks
| `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate |
| `A_FLAC` | `Flac` | `fLaC` + metadata blocks |
| `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio |
| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2` | `Ac3`, `Eac3`, `Mp3`, `Mp2` | |
| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | |
| `S_TEXT/UTF8` | `SubRip` | |
| `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` |
| `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header |
+4
View File
@@ -45,6 +45,8 @@ pub enum Codec {
Mp3,
/// `A_MPEG/L2`.
Mp2,
/// `A_MPEG/L1`.
Mp1,
/// `S_TEXT/UTF8` (SubRip-style plain text).
SubRip,
/// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`.
@@ -86,6 +88,7 @@ impl Codec {
Codec::Eac3 => "eac3".into(),
Codec::Mp3 => "mp3".into(),
Codec::Mp2 => "mp2".into(),
Codec::Mp1 => "mp1".into(),
Codec::SubRip => "subrip".into(),
Codec::WebVtt => "webvtt".into(),
Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(),
@@ -173,6 +176,7 @@ pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInf
"A_EAC3" => Codec::Eac3,
"A_MPEG/L3" => Codec::Mp3,
"A_MPEG/L2" => Codec::Mp2,
"A_MPEG/L1" => Codec::Mp1,
"S_TEXT/UTF8" => Codec::SubRip,
"S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt,
"S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false },
+4 -1
View File
@@ -172,7 +172,7 @@ pub trait MediaSource: Send + Sync {
pub type SharedSource = Arc<dyn MediaSource>;
/// File extensions we recognise at import (the media browser filters on these).
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "dng"];
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "hdr", "dng"];
pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"];
pub const VIDEO_EXTENSIONS: &[&str] = &[
"mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv",
@@ -197,6 +197,9 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result<Shar
if still::exr::sniff(&bytes) {
return Ok(Arc::new(still::exr::still(name, &bytes)?));
}
if still::hdr::sniff(&bytes) {
return Ok(Arc::new(still::hdr::still(name, &bytes)?));
}
if wav::sniff(&bytes) {
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
}
+5 -1
View File
@@ -221,7 +221,7 @@ impl MediaSource for ImageSequenceSource {
}
}
/// Decode one frame file: OpenEXR and DNG camera raw as linear floats (DNG with its metadata, for
/// Decode one frame file: OpenEXR, Radiance HDR and DNG camera raw as linear floats (DNG with its metadata, for
/// the sequence's timing), every other format through the still decoders as RGBA8. Returns the
/// frame, whether it has alpha, and its pixel format for the media info.
fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, Option<filmcraft_dng::Info>)> {
@@ -229,6 +229,10 @@ fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, O
let d = crate::still::exr::decode(name, bytes)?;
return Ok((d.frame, d.has_alpha, d.pixel_format, None));
}
if crate::still::hdr::sniff(bytes) {
let frame = crate::still::hdr::decode(name, bytes)?;
return Ok((frame, false, "RGB 32-bit float (RGBE)".to_string(), None));
}
if crate::still::dng::sniff(bytes) {
let d = crate::still::dng::decode(name, bytes)?;
return Ok((d.frame, false, d.pixel_format, Some(d.info)));
+10 -1
View File
@@ -1,4 +1,4 @@
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, DNG camera raw) as media
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, Radiance HDR, DNG camera raw) as media
//! sources. Animated PNG and GIF files open as movies instead ([`crate::animated`]); here they would
//! show their first frame.
@@ -11,6 +11,8 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result,
pub mod dng;
pub mod exr;
pub mod hdr;
mod ycbcr_tiff;
/// Default still duration (Premiere's preference default is 5 seconds).
pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
@@ -18,6 +20,7 @@ pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
/// Whether `b` (the contents of the file `name`) is a still image we decode.
pub fn sniff(name: &str, b: &[u8]) -> bool {
crate::dpx::sniff(b)
|| hdr::sniff(b)
|| crate::tga::sniff(name, b)
|| b.starts_with(b"\x89PNG")
|| b.starts_with(&[0xFF, 0xD8, 0xFF])
@@ -46,6 +49,9 @@ pub fn decode_raster(name: &str, bytes: &[u8]) -> Result<Raster> {
if crate::tga::sniff(name, bytes) {
return crate::tga::decode(name, bytes);
}
if ycbcr_tiff::sniff(bytes) {
return ycbcr_tiff::decode(name, bytes);
}
let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?;
let has_alpha = img.color().has_alpha();
let rgba = img.to_rgba8();
@@ -64,6 +70,9 @@ impl StillSource {
if dng::sniff(bytes) {
return dng::still(name, bytes);
}
if hdr::sniff(bytes) {
return hdr::still(name, bytes);
}
let r = decode_raster(name, bytes)?;
Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64))
}
+52 -2
View File
@@ -1,5 +1,6 @@
//! OpenEXR stills (and the frames of EXR image sequences), read from the public OpenEXR file layout:
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk) or ZIP (16 lines) compression,
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk), ZIP or PXR24 (16 lines)
//! compression,
//! half, float or uint channels. R, G, B and A become the frame's channels (missing colour channels
//! are 0, missing alpha is opaque); a luminance-only file (Y) is shown grey. EXR values are
//! scene-linear with premultiplied alpha, which is exactly the compositor's working format, so the
@@ -7,7 +8,7 @@
//!
//! The display window is the frame; pixels of the data window outside it are cut, and the parts of
//! it the data window does not cover are transparent. Tiled, deep and multi-part files, chroma
//! subsampled channels and the PIZ, PXR24, B44 and DWA compressions are refused with a clear error.
//! subsampled channels and the PIZ, B44 and DWA compressions are refused with a clear error.
mod inflate;
@@ -134,12 +135,15 @@ enum Compression {
None,
Rle,
Zip,
/// Lossy 24-bit floats: deflated byte planes of per-line differences.
Pxr24,
}
impl Compression {
fn lines_per_chunk(self, code: u8) -> usize {
match self {
Compression::Zip if code == 3 => 16,
Compression::Pxr24 => 16,
_ => 1,
}
}
@@ -227,6 +231,7 @@ fn header(name: &str, b: &[u8]) -> Result<Header> {
0 => Compression::None,
1 => Compression::Rle,
2 | 3 => Compression::Zip,
5 => Compression::Pxr24,
_ => {
let what = ["PIZ", "PXR24", "B44", "B44A", "DWAA", "DWAB"].get(usize::from(code).wrapping_sub(4)).copied().unwrap_or("unknown");
return Err(MediaError::Unsupported(format!("{name}: {what} compressed OpenEXR is not supported yet")));
@@ -329,6 +334,45 @@ fn unrle(src: &[u8], expected: usize) -> Option<Vec<u8>> {
(out.len() == expected).then_some(out)
}
/// Bytes of one sample in PXR24's byte planes (a float keeps its top 24 bits only).
fn plane_count(ty: PixelType) -> usize {
match ty {
PixelType::Half => 2,
PixelType::Float => 3,
PixelType::Uint => 4,
}
}
/// Undo PXR24: per scan line and channel, the samples' differences (to the previous sample of the
/// line, modulo 2^16 / 2^24 / 2^32) are stored as byte planes, most significant first. Returns the
/// lines in the plain layout (little-endian samples).
fn unpxr24(planes: &[u8], channels: &[Channel], width: usize, lines: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(planes.len().checked_mul(2)?);
let mut at = 0usize;
for _ in 0..lines {
for c in channels {
let n = plane_count(c.ty);
let len = n.checked_mul(width)?;
let plane = planes.get(at..at.checked_add(len)?)?;
at += len;
let mut acc = 0u32;
for x in 0..width {
let mut d = 0u32;
for k in 0..n {
d = d << 8 | u32::from(*plane.get(k * width + x)?);
}
acc = acc.wrapping_add(d);
match c.ty {
PixelType::Half => out.extend_from_slice(&(acc as u16).to_le_bytes()),
PixelType::Float => out.extend_from_slice(&((acc & 0xFF_FFFF) << 8).to_le_bytes()),
PixelType::Uint => out.extend_from_slice(&acc.to_le_bytes()),
}
}
}
}
(at == planes.len()).then_some(out)
}
/// Decode an EXR file into premultiplied linear RGBA floats.
pub fn decode(name: &str, b: &[u8]) -> Result<Decoded> {
let h = header(name, b)?;
@@ -403,6 +447,12 @@ pub fn decode(name: &str, b: &[u8]) -> Result<Decoded> {
Compression::None => return Err(bad(format!("chunk {i} has the wrong size"))),
Compression::Rle => unpredict(unrle(data, expected).ok_or_else(|| bad(format!("chunk {i}: bad RLE data")))?).into(),
Compression::Zip => unpredict(inflate::zlib(data, expected).map_err(|e| bad(format!("chunk {i}: {e}")))?).into(),
Compression::Pxr24 => {
let per_line = h.channels.iter().try_fold(0usize, |t, c| t.checked_add(plane_count(c.ty).checked_mul(dw)?));
let planes = per_line.and_then(|l| l.checked_mul(n)).ok_or_else(|| bad("chunk size".into()))?;
let inflated = inflate::zlib(data, planes).map_err(|e| bad(format!("chunk {i}: {e}")))?;
unpxr24(&inflated, &h.channels, dw, n).ok_or_else(|| bad(format!("chunk {i}: bad PXR24 data")))?.into()
}
}
};
for (l, out) in rows.chunks_exact_mut(row).enumerate() {
+74 -2
View File
@@ -25,6 +25,35 @@ fn predict(raw: &[u8]) -> Vec<u8> {
t
}
/// The test-side PXR24 packing of the `lines` of `raw` (plain layout): per line and channel, the
/// differences to the previous sample as byte planes, floats cut to their top 24 bits.
fn pxr24(raw: &[u8], chans: &[Chan], width: usize, lines: usize) -> Vec<u8> {
let mut at = 0;
let mut out = Vec::new();
for _ in 0..lines {
for (_, t) in chans {
let size = if *t == 1 { 2 } else { 4 };
let mut prev = 0u32;
let mut planes = vec![Vec::new(); size];
for x in 0..width {
let b = &raw[at + x * size..at + (x + 1) * size];
let v = match *t {
1 => u32::from(u16::from_le_bytes([b[0], b[1]])),
2 => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) >> 8,
_ => u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
};
let d = v.wrapping_sub(prev);
prev = v;
let n = if *t == 2 { 3 } else { size };
planes[0..n].iter_mut().enumerate().for_each(|(k, p)| p.push((d >> (8 * (n - 1 - k))) as u8));
}
at += width * size;
out.extend(planes.concat());
}
}
out
}
/// OpenEXR RLE: runs of 3+ equal bytes as runs, the rest as literals.
fn rle(t: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
@@ -105,7 +134,7 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value:
attr(&mut f, "lineOrder", "lineOrder", &[0]);
attr(&mut f, "pixelAspectRatio", "float", &1f32.to_le_bytes());
f.push(0);
let lpc = if comp == 3 { 16 } else { 1 };
let lpc = if comp == 3 || comp == 5 { 16 } else { 1 };
let ys: Vec<i32> = (data[1]..=data[3]).step_by(lpc).collect();
let table = f.len();
f.resize(table + ys.len() * 8, 0);
@@ -121,6 +150,10 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value:
let packed = match comp {
0 => raw,
1 => rle(&predict(&raw)),
5 => {
let lines = (data[3] - y0 + 1).min(16) as usize;
zlib_stored(&pxr24(&raw, chans, (data[2] - data[0] + 1) as usize, lines))
}
_ => zlib_stored(&predict(&raw)),
};
let off = f.len() as u64;
@@ -178,7 +211,7 @@ fn rgba_half_every_compression() {
// pixel (3, 2): R G B A
let p = &px[(2 * 7 + 3) * 4..][..4];
assert_eq!(p, &[pattern(3, 3, 2), pattern(2, 3, 2), pattern(1, 3, 2), pattern(0, 3, 2)]);
for comp in [1, 2, 3] {
for comp in [1, 2, 3, 5] {
let f = exr_file(RGBA_HALF, [0, 0, w - 1, h - 1], [0, 0, w - 1, h - 1], comp, pattern);
assert_ne!(f, want, "compression {comp} packs differently");
let d = decode("a.exr", &f).unwrap_or_else(|e| panic!("compression {comp}: {e}"));
@@ -186,6 +219,43 @@ fn rgba_half_every_compression() {
}
}
#[test]
fn pxr24_keeps_half_and_uint_exactly_and_floats_to_24_bits() {
// 40 wide, 35 tall: three chunks, the last of 3 lines; one channel of every type
let chans: &[Chan] = &[("B", 2), ("G", 1), ("R", 2), ("Z", 0)];
let value = |c: usize, x: i32, y: i32| match c {
0 => 1.0 / 3.0 + x as f32 * 0.37 - y as f32 * 1.5, // not exact in 24 bits
1 => x as f32 * 0.0625 + y as f32,
2 => 6000.7 - (x * y) as f32 * 1.25,
_ => (x * 1000 + y) as f32,
};
let f = exr_file(chans, [0, 0, 39, 34], [0, 0, 39, 34], 5, value);
let d = decode("p.exr", &f).unwrap();
assert_eq!((d.frame.width, d.frame.height), (40, 35));
assert_eq!(d.pixel_format, "RGB 32-bit float");
let px = floats(&d);
for (x, y) in [(0, 0), (1, 0), (39, 0), (0, 16), (7, 17), (39, 34), (13, 33)] {
let p = &px[(y * 40 + x) * 4..][..4];
let (x, y) = (x as i32, y as i32);
let cut = |v: f32| f32::from_bits(v.to_bits() & 0xFFFF_FF00);
assert_eq!(p, &[cut(value(2, x, y)), half(f16(value(1, x, y))), cut(value(0, x, y)), 1.0], "pixel ({x}, {y})");
}
}
#[test]
fn damaged_pxr24_is_an_error() {
let mut f = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern);
assert!(decode("p.exr", &f).is_ok());
// cut the file inside the last chunk
f.truncate(f.len() - 20);
assert!(decode("p.exr", &f).is_err());
// a chunk whose deflate data is garbage
let mut g = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern);
let at = g.windows(2).rposition(|w| w == [0x78, 0x01]).unwrap();
g[at] = 0;
assert!(decode("p.exr", &g).is_err());
}
#[test]
fn luminance_only_is_grey_and_opaque() {
let f = exr_file(&[("Y", 2)], [0, 0, 3, 1], [0, 0, 3, 1], 2, |_, x, y| x as f32 * 0.25 + y as f32 * 4.0);
@@ -242,6 +312,8 @@ fn unsupported_files_say_why() {
assert!(matches!(decode("t.exr", &tiled), Err(MediaError::Unsupported(m)) if m.contains("tiled")));
let piz = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 4, pattern);
assert!(matches!(decode("p.exr", &piz), Err(MediaError::Unsupported(m)) if m.contains("PIZ")));
let b44 = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 6, pattern);
assert!(matches!(decode("b.exr", &b44), Err(MediaError::Unsupported(m)) if m.contains("B44")));
let dwab = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 9, pattern);
assert!(matches!(decode("d.exr", &dwab), Err(MediaError::Unsupported(m)) if m.contains("DWAB")));
let ry = exr_file(&[("BY", 1), ("RY", 1), ("Y", 1)], [0, 0, 1, 1], [0, 0, 1, 1], 0, pattern);
+223
View File
@@ -0,0 +1,223 @@
//! Radiance HDR (`.hdr`, `.pic`) stills, read from Greg Ward's public RGBE format description
//! ("Real Pixels", Graphics Gems II): a text header (`#?RADIANCE` or `#?RGBE`, `FORMAT=32-bit_rle_rgbe`),
//! a blank line, a resolution line (`-Y h +X w` and the seven other axis orders), then one RGBE
//! pixel (shared exponent) per sample, flat, in the old run-length form or in the "new" per-channel
//! run-length form (scan lines of 8 to 32767 pixels). RGBE is scene-linear light, so the frame keeps
//! 32-bit floats (values above 1.0 survive into effects and colour work). The `EXPOSURE` and
//! `PRIMARIES` header lines are not applied, which is what other readers do too; the XYZE colour
//! format is refused.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Transfer};
use filmcraft_frame::VideoFrame;
use filmcraft_time::FrameRate;
use super::{DEFAULT_STILL_DURATION, StillSource};
use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo};
/// Largest picture we decode (each pixel takes 16 bytes as RGBA floats).
const MAX_PIXELS: u64 = 1 << 26;
/// Longest header we read (real ones are a few hundred bytes).
const MAX_HEADER: usize = 64 << 10;
/// The colour of HDR frames: scene-linear light, Rec.709 primaries.
const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL };
/// Whether `b` starts like a Radiance picture.
pub fn sniff(b: &[u8]) -> bool {
b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE")
}
fn err(name: &str, what: &str) -> MediaError {
MediaError::Decode(format!("{name}: Radiance HDR: {what}"))
}
/// Open a Radiance picture as a still.
pub fn still(name: &str, bytes: &[u8]) -> Result<StillSource> {
let frame = decode(name, bytes)?;
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Still,
duration: DEFAULT_STILL_DURATION,
video: Some(VideoStreamInfo {
width: frame.width,
height: frame.height,
frame_rate: FrameRate::FPS_29_97,
par: (1, 1),
codec: "HDR".into(),
pixel_format: "RGB 32-bit float (RGBE)".into(),
color: LINEAR_709,
has_alpha: false,
bitrate: None,
hdr: None,
}),
audio_streams: Vec::new(),
container: "Image".into(),
start_timecode: None,
file_size: Some(bytes.len() as u64),
};
Ok(StillSource { info, frame: Arc::new(frame) })
}
/// One resolution-line axis: `(is_x, positive)`.
fn axis(tok: &str) -> Option<(bool, bool)> {
match tok {
"+X" => Some((true, true)),
"-X" => Some((true, false)),
"+Y" => Some((false, true)),
"-Y" => Some((false, false)),
_ => None,
}
}
/// RGBE to linear floats: `mantissa * 2^(e - 136)`, zero for exponent 0. (Radiance's own reader adds
/// half a mantissa step; ffmpeg and stb_image do not, which also makes 1.0 = (128, 128, 128, 129)
/// read back as exactly 1.0.)
fn rgbe(p: [u8; 4]) -> [f32; 3] {
if p[3] == 0 {
return [0.0; 3];
}
let scale = 2f32.powi(i32::from(p[3]) - 136);
[f32::from(p[0]) * scale, f32::from(p[1]) * scale, f32::from(p[2]) * scale]
}
/// Read the pixels of one scan line of `n` pixels into `line`.
fn scanline(name: &str, b: &[u8], pos: &mut usize, line: &mut [[u8; 4]]) -> Result<()> {
let n = line.len();
let take = |pos: &mut usize, k: usize| -> Result<&[u8]> {
let s = b.get(*pos..pos.saturating_add(k)).ok_or_else(|| err(name, "truncated pixel data"))?;
*pos += k;
Ok(s)
};
let head = b.get(*pos..*pos + 4);
if (8..=32767).contains(&n)
&& let Some(&[2, 2, hi, lo]) = head
&& hi & 0x80 == 0
{
if usize::from(hi) << 8 | usize::from(lo) != n {
return Err(err(name, "scan line length does not match the picture"));
}
*pos += 4;
for c in 0..4 {
let mut x = 0;
while x < n {
let code = take(pos, 1)?[0];
if code > 128 {
let run = usize::from(code - 128);
let v = take(pos, 1)?[0];
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "run past the end of the scan line"))?;
for p in dst {
p[c] = v;
}
x += run;
} else {
let run = usize::from(code);
if run == 0 {
return Err(err(name, "empty literal run"));
}
let src = take(pos, run)?;
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "literal past the end of the scan line"))?;
for (p, &v) in dst.iter_mut().zip(src) {
p[c] = v;
}
x += run;
}
}
}
return Ok(());
}
// flat pixels, with the old run-length form: (1, 1, 1, k) repeats the previous pixel k << shift times
let (mut x, mut shift) = (0usize, 0u32);
while x < n {
let s = take(pos, 4)?;
let p = [s[0], s[1], s[2], s[3]];
if p[..3] == [1, 1, 1] {
let count = usize::from(p[3]).checked_shl(shift).unwrap_or(usize::MAX);
let prev = if x > 0 { line.get(x - 1).copied() } else { None }.ok_or_else(|| err(name, "run with no previous pixel"))?;
let dst = line.get_mut(x..x.saturating_add(count).min(n)).ok_or_else(|| err(name, "run past the end of the scan line"))?;
dst.fill(prev);
x += dst.len();
shift = shift.saturating_add(8).min(24);
} else {
if let Some(d) = line.get_mut(x) {
*d = p;
}
x += 1;
shift = 0;
}
}
Ok(())
}
/// Decode a Radiance picture to an opaque linear float frame.
pub fn decode(name: &str, b: &[u8]) -> Result<VideoFrame> {
if !sniff(b) {
return Err(err(name, "not a Radiance picture"));
}
// header: lines up to the first empty one, then the resolution line
let mut pos = 0;
let line = |pos: &mut usize| -> Result<String> {
let rest = b.get(*pos..).ok_or_else(|| err(name, "truncated header"))?;
let end = rest.iter().take(MAX_HEADER).position(|&c| c == b'\n').ok_or_else(|| err(name, "truncated header"))?;
let s = String::from_utf8_lossy(rest.get(..end).unwrap_or_default()).trim_end_matches('\r').to_string();
*pos += end + 1;
Ok(s)
};
loop {
let l = line(&mut pos)?;
if l.is_empty() {
break;
}
if let Some(f) = l.strip_prefix("FORMAT=") {
match f.trim() {
"32-bit_rle_rgbe" => {}
"32-bit_rle_xyze" => return Err(err(name, "the XYZE colour format is not supported")),
other => return Err(err(name, &format!("unknown format {other}"))),
}
}
if pos > MAX_HEADER {
return Err(err(name, "header too long"));
}
}
let res = line(&mut pos)?;
let t: Vec<&str> = res.split_whitespace().collect();
let &[a1, n1, a2, n2] = t.as_slice().first_chunk::<4>().ok_or_else(|| err(name, "bad resolution line"))?;
let ((x1, p1), (x2, p2)) = (axis(a1).ok_or_else(|| err(name, "bad resolution line"))?, axis(a2).ok_or_else(|| err(name, "bad resolution line"))?);
if x1 == x2 {
return Err(err(name, "resolution line names one axis twice"));
}
let n1: usize = n1.parse().map_err(|_| err(name, "bad resolution line"))?;
let n2: usize = n2.parse().map_err(|_| err(name, "bad resolution line"))?;
let (w, h) = if x1 { (n1, n2) } else { (n2, n1) };
if w == 0 || h == 0 || (w as u64).saturating_mul(h as u64) > MAX_PIXELS {
return Err(err(name, "picture size out of range"));
}
// a scan line takes at least a few bits of file, so a header cannot ask for more than the data has
if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) {
return Err(err(name, "truncated pixel data"));
}
let mut px = vec![1f32; w * h * 4];
let mut scan = vec![[0u8; 4]; n2];
for i in 0..n1 {
scanline(name, b, &mut pos, &mut scan)?;
for (j, &p) in scan.iter().enumerate() {
// the first axis counts scan lines, the second runs along one
let (x, y) = if x1 {
(if p1 { i } else { w - 1 - i }, if p2 { h - 1 - j } else { j })
} else {
(if p2 { j } else { w - 1 - j }, if p1 { h - 1 - i } else { i })
};
let [r, g, bl] = rgbe(p);
if let Some(o) = px.get_mut((y * w + x) * 4..(y * w + x) * 4 + 3) {
o.copy_from_slice(&[r, g, bl]);
}
}
}
let mut frame = VideoFrame::rgba_f32(w as u32, h as u32, px);
frame.color = LINEAR_709;
Ok(frame)
}
#[cfg(test)]
mod tests;
+134
View File
@@ -0,0 +1,134 @@
use super::*;
fn file(res: &str, body: &[u8]) -> Vec<u8> {
let mut v = format!("#?RADIANCE\n# test\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n{res}\n").into_bytes();
v.extend_from_slice(body);
v
}
fn px(f: &VideoFrame) -> Vec<f32> {
match &f.data {
filmcraft_frame::PixelData::RgbaF32(p) => p.to_vec(),
_ => panic!("not float"),
}
}
/// New-style RLE of one scan line, as Radiance's own writer does: per channel, runs of 3+ equal bytes.
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
let n = line.len();
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
for c in 0..4 {
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
let mut i = 0;
while i < n {
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
if run >= 3 {
out.extend([128 + run as u8, ch[i]]);
i += run;
} else {
let mut j = i + 1;
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
j += 1;
}
out.push((j - i) as u8);
out.extend_from_slice(&ch[i..j]);
i = j;
}
}
}
out
}
#[test]
fn rgbe_values() {
assert_eq!(rgbe([0, 0, 0, 0]), [0.0; 3]);
assert_eq!(rgbe([128, 128, 128, 129]), [1.0; 3]);
assert_eq!(rgbe([128, 64, 0, 136]), [128.0, 64.0, 0.0]);
assert_eq!(rgbe([128, 128, 128, 128])[0], 0.5);
assert!(rgbe([255, 255, 255, 255])[0].is_finite());
}
#[test]
fn flat_top_to_bottom() {
let body = [[10u8, 20, 30, 136], [1, 2, 3, 136], [4, 5, 6, 137], [7, 8, 9, 135]].concat();
let f = decode("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
assert_eq!((f.width, f.height), (2, 2));
let p = px(&f);
assert_eq!(&p[0..4], &[10.0, 20.0, 30.0, 1.0]);
assert_eq!(&p[4..8], &[1.0, 2.0, 3.0, 1.0]);
assert_eq!(&p[8..12], &[8.0, 10.0, 12.0, 1.0]); // exponent 137: scale 2
assert_eq!(&p[12..16], &[3.5, 4.0, 4.5, 1.0]); // exponent 135: scale 1/2
}
#[test]
fn orientations() {
// 3 wide, 2 high, pixel value = its index in the file
let body: Vec<u8> = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect();
let red = |res: &str| -> Vec<f32> {
let f = decode("a.hdr", &file(res, &body)).unwrap();
px(&f).chunks(4).map(|p| p[0]).collect()
};
assert_eq!(red("-Y 2 +X 3"), [0., 1., 2., 3., 4., 5.]);
assert_eq!(red("+Y 2 +X 3"), [3., 4., 5., 0., 1., 2.]);
assert_eq!(red("-Y 2 -X 3"), [2., 1., 0., 5., 4., 3.]);
// scan lines are columns: file index = x * 2 + (row, top first for -Y)
assert_eq!(red("+X 3 -Y 2"), [0., 2., 4., 1., 3., 5.]);
assert_eq!(red("-X 3 +Y 2"), [5., 3., 1., 4., 2., 0.]);
}
#[test]
fn run_length_lines() {
let w = 40usize;
let rows: Vec<Vec<[u8; 4]>> = (0..3).map(|y| (0..w).map(|x| if x < 20 { [100, 50, 25, 130] } else { [(x * 5 + y) as u8, 7, 9, 131] }).collect()).collect();
let body: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
let f = decode("a.hdr", &file(&format!("-Y 3 +X {w}"), &body)).unwrap();
let p = px(&f);
for (y, r) in rows.iter().enumerate() {
for (x, q) in r.iter().enumerate() {
let want = rgbe(*q);
assert_eq!(&p[(y * w + x) * 4..(y * w + x) * 4 + 3], &want, "pixel {x},{y}");
}
}
}
#[test]
fn old_style_runs() {
// one pixel, then (1,1,1,3) repeats it 3 times, then a (1,1,1,1) pair repeats 1<<0 then 1<<8 -> clipped to the line
let body = [[5u8, 6, 7, 136], [1, 1, 1, 3], [9, 9, 9, 136]].concat();
let f = decode("a.hdr", &file("-Y 1 +X 5", &body)).unwrap();
let p = px(&f);
assert_eq!(p[0], 5.0);
assert_eq!(p[12], 5.0); // fourth pixel is still a repeat
assert_eq!(p[16], 9.0);
}
#[test]
fn hostile_input_is_an_error_not_a_panic() {
let ok = file("-Y 2 +X 2", &[0; 16]);
assert!(decode("a.hdr", &ok).is_ok());
for cut in 0..ok.len() - 1 {
// a cut anywhere is an error (the header or the pixel data is incomplete), never a panic
assert!(decode("a.hdr", &ok[..cut]).is_err(), "{cut}");
}
assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err());
assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err());
assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err());
assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err());
assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err());
assert!(decode("a.hdr", b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\0\0\0\0").is_err());
// an RLE line announcing the wrong width, a run past the end, a run at the start of a flat line
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 11, 0])).is_err());
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 10, 200, 1])).is_err());
assert!(decode("a.hdr", &file("-Y 1 +X 3", &[1, 1, 1, 9, 0, 0, 0, 0])).is_err());
assert!(decode("a.hdr", b"#?RADIANCE").is_err());
}
#[test]
fn opens_as_a_float_still() {
let body = [[128u8, 128, 128, 136]; 4].concat();
let s = still("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
let info = crate::MediaSource::info(&s);
assert_eq!(info.video.as_ref().map(|v| (v.width, v.height, v.codec.as_str())), Some((2, 2, "HDR")));
let src = crate::open_bytes("a.hdr", file("-Y 2 +X 2", &body).into(), &[]).unwrap();
assert_eq!(src.info().kind, MediaKind::Still);
}
+174
View File
@@ -0,0 +1,174 @@
//! Uncompressed and PackBits YCbCr TIFFs (TIFF 6.0 §21), as ffmpeg writes from YUV sources. The
//! image crate reads YCbCr only inside JPEG compression, so these are decoded here: 8-bit chunky
//! data units of any subsampling (1, 2 or 4 each way), converted to RGB with the file's
//! YCbCrCoefficients and ReferenceBlackWhite (§20), chroma repeated across each data unit.
//!
//! Every number comes from the file: sizes, strip offsets and counts are checked before use, the
//! picture is capped at [`MAX_PIXELS`], and PackBits output stops at the bytes the image needs.
use filmcraft_dng::tiff::{Tiff, tags};
use super::Raster;
use crate::{MediaError, Result};
/// Largest picture decoded (1 GiB of RGBA), as for animated images.
const MAX_PIXELS: u64 = 1 << 28;
const PHOTOMETRIC_YCBCR: u64 = 6;
const COMPRESSION_NONE: u64 = 1;
const COMPRESSION_PACKBITS: u64 = 32773;
const YCBCR_COEFFICIENTS: u16 = 529;
const YCBCR_SUBSAMPLING: u16 = 530;
const REFERENCE_BLACK_WHITE: u16 = 532;
/// Whether `bytes` is a TIFF whose first image is uncompressed or PackBits YCbCr (the ones this
/// module decodes instead of the image crate).
pub fn sniff(bytes: &[u8]) -> bool {
let Ok((t, first)) = Tiff::new(bytes) else { return false };
let Ok((ifd, _)) = t.read_ifd(first, 0) else { return false };
let num = |tag| ifd.get(tag).and_then(|e| t.uint(e).ok());
num(tags::PHOTOMETRIC) == Some(PHOTOMETRIC_YCBCR) && matches!(num(tags::COMPRESSION).unwrap_or(COMPRESSION_NONE), COMPRESSION_NONE | COMPRESSION_PACKBITS)
}
/// Decode the first image of a YCbCr TIFF (see [`sniff`]) to RGBA.
pub fn decode(name: &str, bytes: &[u8]) -> Result<Raster> {
let bad = |what: &str| MediaError::Decode(format!("{name}: YCbCr TIFF: {what}"));
let unsupported = |what: &str| MediaError::Unsupported(format!("{name}: YCbCr TIFF with {what}"));
let (t, first) = Tiff::new(bytes).map_err(|_| bad("not a TIFF"))?;
let (ifd, _) = t.read_ifd(first, 0).map_err(|_| bad("unreadable image directory"))?;
let uints = |tag: u16| -> Result<Option<Vec<u64>>> { ifd.get(tag).map(|e| t.uints(e).map_err(|_| bad("unreadable field"))).transpose() };
let one = |tag: u16| -> Result<Option<u64>> { Ok(uints(tag)?.and_then(|v| v.first().copied())) };
let width = one(tags::IMAGE_WIDTH)?.ok_or_else(|| bad("no ImageWidth"))?;
let height = one(tags::IMAGE_LENGTH)?.ok_or_else(|| bad("no ImageLength"))?;
if width == 0 || height == 0 {
return Err(bad("empty image"));
}
if width.saturating_mul(height) > MAX_PIXELS {
return Err(unsupported("more than 268 million pixels"));
}
if one(tags::SAMPLES_PER_PIXEL)?.unwrap_or(1) != 3 {
return Err(unsupported("other than three samples per pixel"));
}
if uints(tags::BITS_PER_SAMPLE)?.unwrap_or_else(|| vec![1]).iter().any(|&b| b != 8) {
return Err(unsupported("other than 8 bits per sample"));
}
if one(tags::PLANAR_CONFIGURATION)?.unwrap_or(1) != 1 {
return Err(unsupported("separate planes"));
}
if ifd.get(tags::TILE_OFFSETS).is_some() {
return Err(unsupported("tiles"));
}
let compression = one(tags::COMPRESSION)?.unwrap_or(COMPRESSION_NONE);
let sub = uints(YCBCR_SUBSAMPLING)?.unwrap_or_else(|| vec![2, 2]);
let (sh, sv) = match sub.as_slice() {
[h, v, ..] if [1, 2, 4].contains(h) && [1, 2, 4].contains(v) => (*h, *v),
_ => return Err(bad("invalid YCbCrSubSampling")),
};
// §21: the data units cover the image rounded up to whole units.
let (units_x, units_y) = (width.div_ceil(sh), height.div_ceil(sv));
let unit_len = sh * sv + 2;
let need = usize::try_from(units_x.saturating_mul(units_y).saturating_mul(unit_len)).map_err(|_| bad("too large"))?;
let offsets = uints(tags::STRIP_OFFSETS)?.ok_or_else(|| bad("no StripOffsets"))?;
let counts = uints(tags::STRIP_BYTE_COUNTS)?.ok_or_else(|| bad("no StripByteCounts"))?;
let mut units: Vec<u8> = Vec::with_capacity(need);
for (&off, &len) in offsets.iter().zip(&counts) {
if units.len() >= need {
break;
}
let strip = usize::try_from(off)
.ok()
.zip(usize::try_from(len).ok())
.and_then(|(o, l)| bytes.get(o..o.checked_add(l)?))
.ok_or_else(|| bad("strip outside the file"))?;
match compression {
COMPRESSION_NONE => units.extend_from_slice(strip.get(..need - units.len()).unwrap_or(strip)),
_ => unpackbits(strip, &mut units, need),
}
}
if units.len() < need {
return Err(bad("image data shorter than the image"));
}
let coeffs = match ifd.get(YCBCR_COEFFICIENTS) {
Some(e) => t.floats(e).map_err(|_| bad("unreadable YCbCrCoefficients"))?,
None => vec![0.299, 0.587, 0.114],
};
let [lr, lg, lb] = coeffs.get(..3).and_then(|c| <[f64; 3]>::try_from(c).ok()).ok_or_else(|| bad("invalid YCbCrCoefficients"))?;
if lg.abs() < 1e-6 {
return Err(bad("invalid YCbCrCoefficients"));
}
let rbw = match ifd.get(REFERENCE_BLACK_WHITE) {
Some(e) => t.floats(e).map_err(|_| bad("unreadable ReferenceBlackWhite"))?,
None => vec![0.0, 255.0, 128.0, 255.0, 128.0, 255.0],
};
let [yb, yw, cbb, cbw, crb, crw] = rbw.get(..6).and_then(|c| <[f64; 6]>::try_from(c).ok()).ok_or_else(|| bad("invalid ReferenceBlackWhite"))?;
if (yw - yb).abs() < 1e-6 || (cbw - cbb).abs() < 1e-6 || (crw - crb).abs() < 1e-6 {
return Err(bad("invalid ReferenceBlackWhite"));
}
// §20: luma to 0–255, chroma to ±127 about its reference black; §21: back to R'G'B'.
let to_rgb = |y: u8, cb: u8, cr: u8| -> [u8; 3] {
let y = (f64::from(y) - yb) * 255.0 / (yw - yb);
let cb = (f64::from(cb) - cbb) * 127.0 / (cbw - cbb);
let cr = (f64::from(cr) - crb) * 127.0 / (crw - crb);
let r = cr * (2.0 - 2.0 * lr) + y;
let b = cb * (2.0 - 2.0 * lb) + y;
let g = (y - lb * b - lr * r) / lg;
[r, g, b].map(|v| v.round().clamp(0.0, 255.0) as u8)
};
let (w, h) = (width as usize, height as usize);
let (sh, sv, units_x) = (sh as usize, sv as usize, units_x as usize);
let mut rgba = vec![255u8; w * h * 4];
for (i, unit) in units.chunks_exact(sh * sv + 2).take(need / (sh * sv + 2)).enumerate() {
let (ux, uy) = (i % units_x, i / units_x);
let (cb, cr) = (unit[sh * sv], unit[sh * sv + 1]);
for (k, &y) in unit[..sh * sv].iter().enumerate() {
let (x, row) = (ux * sh + k % sh, uy * sv + k / sh);
if x < w && row < h {
let at = (row * w + x) * 4;
rgba[at..at + 3].copy_from_slice(&to_rgb(y, cb, cr));
}
}
}
Ok(Raster { width: width as u32, height: height as u32, rgba, has_alpha: false })
}
/// PackBits (TIFF 6.0 §9) from `src` appended to `out`, stopping at `limit` bytes of output or at
/// a run that the data ends inside.
fn unpackbits(src: &[u8], out: &mut Vec<u8>, limit: usize) {
let mut p = 0;
while out.len() < limit {
let Some(&n) = src.get(p) else { return };
let n = n as i8;
p += 1;
if n >= 0 {
let len = n as usize + 1;
let Some(lit) = src.get(p..p + len) else { return };
out.extend_from_slice(&lit[..len.min(limit - out.len())]);
p += len;
} else if n != -128 {
let Some(&b) = src.get(p) else { return };
let len = (1 - n as isize) as usize;
out.extend(std::iter::repeat_n(b, len.min(limit - out.len())));
p += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packbits_runs_literals_and_noops() {
let mut out = Vec::new();
// literal 2, a no-op, a run of 3, then a literal cut short by the end of the data
unpackbits(&[1, 10, 11, 0x80, 0xFE, 7, 4, 1], &mut out, 100);
assert_eq!(out, [10, 11, 7, 7, 7]);
let mut capped = Vec::new();
unpackbits(&[0xFE, 7, 0xFE, 8], &mut capped, 4);
assert_eq!(capped, [7, 7, 7, 8]);
}
}
+48
View File
@@ -141,3 +141,51 @@ fn exr_sequence_plays_linear_float_frames() {
assert!(max_diff(&ours, &want) == 0.0, "frame {i}");
}
}
/// PXR24 (lossy 24-bit floats; ffmpeg's encoder cannot write it): OpenImageIO writes the files and
/// re-writes them uncompressed (its OpenEXR reader undoes the compression), and our decode of the
/// PXR24 file must give exactly the floats of that uncompressed copy.
#[test]
fn exr_pxr24_matches_openexr() {
let tool = |args: &[&str]| std::process::Command::new("oiiotool").args(args).status().is_ok_and(|s| s.success());
if !tool(&["--version"]) {
eprintln!("SKIPPED: oiiotool is not installed");
return;
}
for (name, depth, channels, size) in [("f4", "float", 4, "95x63"), ("h4", "half", 4, "40x35"), ("f3", "float", 3, "17x16"), ("h3", "half", 3, "16x17")] {
let packed = dir().join(format!("pxr24_{name}.exr"));
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let ok = |p: &Path| {
filmcraft_testkit::fixtures::generate(p, |tmp| {
let t = tmp.to_string_lossy().to_string();
if p == packed.as_path() {
tool(&[
"--pattern",
"fill:topleft=1,0,0,0.5:topright=0,1,0,0.5:bottomleft=0,0,1,1:bottomright=1,1,1,1",
size,
&channels.to_string(),
"--noise:type=gaussian:mean=0:stddev=0.3",
"-d",
depth,
"--compression",
"pxr24",
"-o",
&t,
])
} else {
tool(&[packed.to_string_lossy().as_ref(), "-d", depth, "--compression", "none", "-o", &t])
}
})
};
let (Some(packed), Some(_)) = (ok(&packed), ok(&plain)) else {
eprintln!("SKIPPED: oiiotool cannot write {name}");
continue;
};
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let decode = |p: &Path| {
floats(&filmcraft_media::open_bytes("still.exr", std::fs::read(p).unwrap().into(), &[]).unwrap().video_frame(FrameRequest::full(Tick(0))).unwrap())
};
let (ours, want) = (decode(&packed), decode(&plain));
assert_eq!(max_diff(&ours, &want), 0.0, "{name}");
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Radiance HDR stills against ffmpeg (an external test oracle): we write RGBE files in the flat and
//! new run-length forms, and our decode must give the floats ffmpeg's decoder gives.
use std::path::Path;
use filmcraft_frame::PixelData;
use filmcraft_media::FrameRequest;
const W: usize = 70; // 8..=32767, so scan lines may use the new run-length form
const H: usize = 9;
/// A deterministic picture with long runs (so RLE has runs) and noisy stretches (literals).
fn picture() -> Vec<[u8; 4]> {
let mut s = 0x1234_5678u32;
(0..W * H)
.map(|i| {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let (x, y) = (i % W, i / W);
if x < 30 {
[200, 100, 50, 120 + y as u8]
} else if i % 3 == 0 {
[(s >> 24) as u8, 0, 255, 0] // exponent 0: black
} else {
[(s >> 24) as u8, (s >> 16) as u8, (s >> 8) as u8, 100 + (s & 31) as u8]
}
})
.collect()
}
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
let n = line.len();
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
for c in 0..4 {
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
let mut i = 0;
while i < n {
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
if run >= 3 {
out.extend([128 + run as u8, ch[i]]);
i += run;
} else {
let mut j = i + 1;
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
j += 1;
}
out.push((j - i) as u8);
out.extend_from_slice(&ch[i..j]);
i = j;
}
}
}
out
}
fn write(path: &Path, res: &str, body: &[u8]) {
let mut v = format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n{res}\n").into_bytes();
v.extend_from_slice(body);
std::fs::write(path, v).unwrap();
}
fn check(ff: &Path, name: &str, res: &str, body: &[u8]) {
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/hdr");
let path = dir.join(name);
write(&path, res, body);
let out = std::process::Command::new(ff).args(["-v", "error", "-i"]).arg(&path).args(["-f", "rawvideo", "-pix_fmt", "gbrpf32le", "-"]).output().unwrap();
assert!(out.status.success(), "ffmpeg decode of {name}: {}", String::from_utf8_lossy(&out.stderr));
let v: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
let n = W * H;
assert_eq!(v.len(), 3 * n, "{name}: ffmpeg frame size");
let src = filmcraft_media::open_bytes(name, std::fs::read(&path).unwrap().into(), &[]).unwrap();
let f = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap();
assert_eq!((f.width as usize, f.height as usize), (W, H));
let PixelData::RgbaF32(p) = &f.data else { panic!("HDR frames are float") };
let mut worst = 0f32;
for i in 0..n {
// gbrp planes: 0 = G, 1 = B, 2 = R
for (c, plane) in [2, 0, 1].into_iter().enumerate() {
let (a, b) = (p[i * 4 + c], v[plane * n + i]);
worst = worst.max((a - b).abs() / b.abs().max(1e-6));
}
assert_eq!(p[i * 4 + 3], 1.0);
}
// ffmpeg's decode and ours differ only by float rounding of the same formula
assert!(worst == 0.0, "{name}: worst relative difference {worst}");
}
#[test]
fn matches_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let pic = picture();
let rows: Vec<&[[u8; 4]]> = pic.chunks(W).collect();
let flat: Vec<u8> = pic.iter().flatten().copied().collect();
check(&ff, "flat.hdr", &format!("-Y {H} +X {W}"), &flat);
let rle: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
check(&ff, "rle.hdr", &format!("-Y {H} +X {W}"), &rle);
// Other axis orders are covered by unit tests: ffmpeg reads every file as top-down, which the
// format description does not (+Y starts at the bottom row).
}
+156
View File
@@ -0,0 +1,156 @@
//! YCbCr TIFF stills (#1293): uncompressed and PackBits files with PhotometricInterpretation 6, as
//! ffmpeg writes from YUV sources. The files are built here from TIFF 6.0 (§21 YCbCr images, §9
//! PackBits), little-endian, one strip.
use filmcraft_media::still::decode_raster;
/// A YCbCr TIFF: `width` × `height`, subsampling `sub` (horizontal, vertical), `data` the strip
/// as stored (data units, already compressed when `compression` is 32773), plus `extra` entries
/// (tag, type, values).
fn ycbcr_tiff(width: u32, height: u32, sub: (u16, u16), compression: u16, data: &[u8], extra: &[(u16, u16, Vec<u32>)]) -> Vec<u8> {
let mut entries: Vec<(u16, u16, Vec<u32>)> = vec![
(256, 4, vec![width]),
(257, 4, vec![height]),
(258, 3, vec![8, 8, 8]),
(259, 3, vec![u32::from(compression)]),
(262, 3, vec![6]),
(273, 4, vec![0]), // patched below
(277, 3, vec![3]),
(278, 4, vec![height]),
(279, 4, vec![data.len() as u32]),
(530, 3, vec![u32::from(sub.0), u32::from(sub.1)]),
];
entries.extend(extra.iter().cloned());
entries.sort_by_key(|e| e.0);
let ifd_at = 8u32;
let ifd_len = 2 + 12 * entries.len() as u32 + 4;
let mut extra_data: Vec<u8> = Vec::new();
let mut ifd: Vec<u8> = (entries.len() as u16).to_le_bytes().to_vec();
let data_at = ifd_at + ifd_len;
let mut values_at = data_at + data.len() as u32;
for (tag, ty, vals) in &entries {
let vals = if *tag == 273 { vec![data_at] } else { vals.clone() };
let mut bytes: Vec<u8> = Vec::new();
for v in &vals {
match ty {
3 => bytes.extend((*v as u16).to_le_bytes()),
5 => bytes.extend(v.to_le_bytes()), // rationals are passed as num, den pairs
_ => bytes.extend(v.to_le_bytes()),
}
}
let count = if *ty == 5 { vals.len() / 2 } else { vals.len() };
ifd.extend(tag.to_le_bytes());
ifd.extend(ty.to_le_bytes());
ifd.extend((count as u32).to_le_bytes());
if bytes.len() <= 4 {
bytes.resize(4, 0);
ifd.extend(&bytes);
} else {
ifd.extend(values_at.to_le_bytes());
values_at += bytes.len() as u32;
extra_data.extend(bytes);
}
}
ifd.extend(0u32.to_le_bytes());
let mut out = b"II*\0".to_vec();
out.extend(ifd_at.to_le_bytes());
out.extend(ifd);
out.extend(data);
out.extend(extra_data);
out
}
fn px(r: &filmcraft_media::still::Raster, x: u32, y: u32) -> [u8; 4] {
let i = ((y * r.width + x) * 4) as usize;
[r.rgba[i], r.rgba[i + 1], r.rgba[i + 2], r.rgba[i + 3]]
}
fn near(a: [u8; 4], b: [u8; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 2)
}
#[test]
fn subsampled_ycbcr_places_each_data_unit() {
// 4 × 2 at 2 × 2: two data units (four Y, then Cb, Cr). Left grey, right red (BT.601, the
// default YCbCrCoefficients, full range: the default ReferenceBlackWhite).
let data = [128, 128, 128, 128, 128, 128, 76, 76, 76, 76, 85, 255];
let r = decode_raster("frame.tiff", &ycbcr_tiff(4, 2, (2, 2), 1, &data, &[])).unwrap();
assert_eq!((r.width, r.height), (4, 2));
for y in 0..2 {
assert!(near(px(&r, 0, y), [128, 128, 128, 255]) && near(px(&r, 1, y), [128, 128, 128, 255]), "{:?}", px(&r, 0, y));
assert!(near(px(&r, 2, y), [254, 0, 0, 255]) && near(px(&r, 3, y), [254, 0, 0, 255]), "{:?}", px(&r, 2, y));
}
}
#[test]
fn packbits_ycbcr_with_video_range_reference_levels() {
// 2 × 1 at 1 × 1 (4:4:4): white then black in video range (16–235, chroma 128 ± 112), the
// ReferenceBlackWhite ffmpeg writes. PackBits: a literal run of 6 bytes.
let units = [235, 128, 128, 16, 128, 128];
let mut data = vec![5u8];
data.extend(units);
let rbw = vec![16, 1, 235, 1, 128, 1, 240, 1, 128, 1, 240, 1];
let r = decode_raster("frame.tif", &ycbcr_tiff(2, 1, (1, 1), 32773, &data, &[(532, 5, rbw)])).unwrap();
assert!(near(px(&r, 0, 0), [255, 255, 255, 255]), "{:?}", px(&r, 0, 0));
assert!(near(px(&r, 1, 0), [0, 0, 0, 255]), "{:?}", px(&r, 1, 0));
}
#[test]
fn sizes_that_are_not_a_whole_number_of_data_units_are_padded() {
// 3 × 3 at 2 × 2: the data units cover 4 × 4 (TIFF 6.0 §21), the extra column and row dropped.
let unit = |y: u8| [y, y, y, y, 128, 128];
let data: Vec<u8> = [unit(50), unit(100), unit(150), unit(200)].concat();
let r = decode_raster("frame.tiff", &ycbcr_tiff(3, 3, (2, 2), 1, &data, &[])).unwrap();
assert_eq!((r.width, r.height, r.rgba.len()), (3, 3, 36));
assert!(near(px(&r, 0, 0), [50, 50, 50, 255]) && near(px(&r, 2, 0), [100, 100, 100, 255]));
assert!(near(px(&r, 0, 2), [150, 150, 150, 255]) && near(px(&r, 2, 2), [200, 200, 200, 255]));
}
#[test]
fn damaged_ycbcr_tiffs_fail_without_panicking() {
let data: Vec<u8> = (0..4 * 4 / 4 * 6).map(|i| (i * 37 % 256) as u8).collect();
let good = ycbcr_tiff(4, 4, (2, 2), 1, &data, &[]);
let mut cases: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
for i in 0..good.len() {
for bit in [0, 3, 7] {
let mut b = good.clone();
b[i] ^= 1 << bit;
cases.push(b);
}
}
// A huge size, a zero subsampling factor, and a PackBits run past the strip.
cases.push(ycbcr_tiff(65_535, 65_535, (2, 2), 1, &data, &[]));
cases.push(ycbcr_tiff(4, 4, (0, 2), 1, &data, &[]));
cases.push(ycbcr_tiff(4, 4, (2, 2), 32773, &[0x81, 7], &[]));
for (i, b) in cases.into_iter().enumerate() {
assert!(std::panic::catch_unwind(|| decode_raster("damaged.tiff", &b).map(|r| r.rgba.len())).is_ok(), "case {i} panicked");
}
}
/// ffmpeg (an external oracle) writes YCbCr TIFFs from YUV frames; our decode must match ffmpeg's
/// own RGB decode of the same file. A smooth gradient, so the chroma upsampling (we repeat chroma
/// across a data unit) does not dominate; 95 × 63 so the data units do not line up.
#[test]
fn ffmpeg_ycbcr_tiffs_match_ffmpeg_decode() {
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("YCbCr TIFF oracle") else { return };
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/tiff_ycbcr");
for pix_fmt in ["yuv420p", "yuv422p", "yuv444p"] {
let tiff = dir.join(format!("gradient_{pix_fmt}.tiff"));
let made = filmcraft_testkit::fixtures::generate(&tiff, |tmp| {
std::process::Command::new(&ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "gradients=size=95x63:seed=7", "-frames:v", "1", "-pix_fmt", pix_fmt])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
});
let Some(tiff) = made else { continue };
let out = std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&tiff).args(["-f", "rawvideo", "-pix_fmt", "rgb24", "-"]).output().unwrap();
let ours = decode_raster("gradient.tiff", &std::fs::read(&tiff).unwrap()).unwrap();
assert_eq!((ours.width, ours.height), (95, 63), "{pix_fmt}");
let theirs = out.stdout;
assert_eq!(theirs.len(), 95 * 63 * 3, "{pix_fmt}");
let worst =
ours.rgba.as_chunks::<4>().0.iter().zip(theirs.as_chunks::<3>().0).flat_map(|(a, b)| (0..3).map(move |c| a[c].abs_diff(b[c]))).max().unwrap();
assert!(worst <= 6, "{pix_fmt}: worst channel difference {worst}");
}
}
+91 -6
View File
@@ -91,6 +91,9 @@ pub(crate) struct Splitter {
open: Option<Open>,
pub units: Vec<Unit>,
pub skipped: u64,
/// Units beyond [`MAX_UNITS`] were dropped.
pub truncated: bool,
max_units: usize,
// MPEG video state
header_start: Option<u64>,
seq_in_unit: bool,
@@ -174,7 +177,6 @@ pub struct FrameInfo {
const ADTS_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350];
/// Parse the frame header at the start of `b` (MPEG audio, ADTS, AC-3, E-AC-3).
/// Bytes of the audio frame whose header starts `b` (MPEG audio, ADTS, LATM, AC-3, E-AC-3), or
/// `None` without a valid header there. For containers that store frames in chunks that need not
/// follow them (AVI): the frames are found again with it.
@@ -220,7 +222,8 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option<FrameInfo> {
// E-AC-3: fscod(2) numblkscod(2) acmod(3) lfeon(1)
let fscod = b[4] >> 6;
let (rate, blocks) = if fscod == 3 {
([24_000, 22_050, 16_000, 0][((b[4] >> 4) & 3) as usize], 6)
// fscod2 3 is reserved (no sample rate)
(*[24_000, 22_050, 16_000].get(((b[4] >> 4) & 3) as usize)?, 6)
} else {
([48_000, 44_100, 32_000][fscod as usize], [1, 2, 3, 6][((b[4] >> 4) & 3) as usize])
};
@@ -230,7 +233,8 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option<FrameInfo> {
let fscod = b[4] >> 6;
let rate = *[48_000u32, 44_100, 32_000].get(fscod as usize)?;
// bsi: bsid(5) bsmod(3) acmod(3) [cmixlev(2)] [surmixlev(2)] [dsurmod(2)] lfeon(1)
let acmod = b[6] >> 5;
let b6 = *b.get(6)?;
let acmod = b6 >> 5;
let mut bit = 3; // bits of byte 6 consumed
if acmod & 1 != 0 && acmod != 1 {
bit += 2;
@@ -241,7 +245,7 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option<FrameInfo> {
if acmod == 2 {
bit += 2;
}
let word = u16::from_be_bytes([b[6], *b.get(7)?]);
let word = u16::from_be_bytes([b6, *b.get(7)?]);
let lfe = (word >> (15 - bit)) & 1;
Some(FrameInfo { sample_rate: rate, channels: AC3_CHANNELS[acmod as usize] + lfe as u32, samples: 1536, variant: bsid })
}
@@ -258,6 +262,18 @@ fn eac3_joins_unit(b: &[u8]) -> bool {
}
}
/// Shortest E-AC-3 syncframe accepted. A real syncframe holds the 6 header bytes read here plus
/// bsi and audio blocks (the smallest real ones are tens of bytes), so shorter "frames"
/// (`0B 77 00 01` is 4 bytes) are noise; they would otherwise turn every 4 input bytes into a
/// 64-byte `Unit`.
const EAC3_MIN_FRAME: usize = 8;
/// Most units one stream may index. A `Unit` is 64 bytes, so this bounds a stream's table to
/// 512 MiB. It is 47 hours of 48 kHz AAC (the densest audio, 46.9 units/s), 9 hours of 240 fps
/// video: far beyond any real file, while a hostile file (a unit per 7 input bytes) cannot grow
/// the table without limit.
pub(crate) const MAX_UNITS: usize = 1 << 23;
/// Full-bandwidth channels per AC-3 audio coding mode (A/52 Table 5.8).
const AC3_CHANNELS: [u32; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
@@ -289,7 +305,8 @@ fn frame_len(sync: AudioSync, b: &[u8]) -> Option<usize> {
ac3_frame_bytes(b[4] >> 6, b[4] & 0x3F)
} else if bsid <= 16 {
// E-AC-3: frmsiz
Some(((((b[2] & 7) as usize) << 8) | b[3] as usize) * 2 + 2)
let len = ((((b[2] & 7) as usize) << 8) | b[3] as usize) * 2 + 2;
(len >= EAC3_MIN_FRAME).then_some(len)
} else {
None
}
@@ -309,6 +326,8 @@ impl Splitter {
open: None,
units: Vec::new(),
skipped: 0,
truncated: false,
max_units: MAX_UNITS,
header_start: None,
seq_in_unit: false,
gop: None,
@@ -373,6 +392,10 @@ impl Splitter {
if end <= o.start || o.pos == u64::MAX {
return;
}
if self.units.len() >= self.max_units {
self.truncated = true;
return;
}
let size = (end - o.start).min(u32::MAX as u64) as u32;
self.units.push(Unit { pts: o.pts, dts: o.dts, key: o.key, disposable: o.disposable, size, pos: o.pos, offset: o.offset, picture: o.picture });
}
@@ -382,7 +405,12 @@ impl Splitter {
if data.is_empty() {
return;
}
self.chunks.push_back((pos, self.es_pos));
// PES-delimited modes resolve a unit's position from `pos` right here and never look a
// chunk up again, so remembering chunks there would only grow without bound (NAL streams
// keep theirs: an access unit can start inside a PES; `keep_from` prunes them)
if matches!(self.mode, Mode::MpegVideo | Mode::Audio(_) | Mode::Nal { .. }) {
self.chunks.push_back((pos, self.es_pos));
}
if let Some(o) = self.open.as_mut()
&& o.pos == u64::MAX
&& o.start == self.es_pos
@@ -700,4 +728,61 @@ mod tests {
// AC-3 frames are never joined
assert!(!eac3_joins_unit(&[0x0B, 0x77, 0x40, 0, 0, 8 << 3]));
}
#[test]
fn ac3_header_prefix_of_six_bytes_is_not_indexed_out_of_bounds() {
// exactly the six bytes frame_len() guarantees: byte 6 (acmod) is missing
assert_eq!(frame_info(&Codec::Ac3, &[0x0B, 0x77, 0, 0, 0x1E, 0x40]), None);
assert_eq!(frame_info(&Codec::Ac3, &[0x0B, 0x77, 0, 0, 0x1E, 0x40, 0]), None);
}
#[test]
fn eac3_reserved_sample_rate_is_refused() {
// fscod 3 and fscod2 3: no sample rate
let mut f = vec![0x0B, 0x77, 0, 7, 0xF0, 16 << 3];
f.resize(16, 0);
assert_eq!(frame_info(&Codec::Eac3, &f), None);
f[4] = 0xE0; // fscod2 2: 16 kHz
assert_eq!(frame_info(&Codec::Eac3, &f).map(|i| i.sample_rate), Some(16_000));
}
#[test]
fn four_byte_eac3_frames_make_no_units() {
let es: Vec<u8> = [0x0B, 0x77, 0x00, 0x01].repeat(10_000);
let mut s = Splitter::new(&Codec::Eac3);
s.feed(0, &es);
s.finish();
assert!(s.units.is_empty(), "{} units from 40000 noise bytes", s.units.len());
}
#[test]
fn pes_delimited_modes_do_not_retain_chunks() {
for codec in [Codec::LpcmBluray, Codec::Unknown(0x99)] {
let mut s = Splitter::new(&codec);
for k in 0..5000u64 {
s.pes_start(Some(k as i64), None);
s.feed(k * 188, &[0x11; 184]);
}
s.finish();
assert!(s.chunks.len() <= 2, "{codec:?}: {} chunks retained", s.chunks.len());
assert_eq!(s.units.len(), 5000, "{codec:?}");
assert!(s.units.iter().enumerate().all(|(k, u)| u.pos == k as u64 * 188 && u.offset == 0), "{codec:?}");
}
}
#[test]
fn unit_table_is_capped() {
let frame = |k: u8| {
let mut v = vec![0x0B, 0x77, 0, 7, (3 << 4) | (2 << 1), 16 << 3];
v.resize(16, k);
v
};
let mut s = Splitter::new(&Codec::Eac3);
s.max_units = 10;
let es: Vec<u8> = (0..100).flat_map(frame).collect();
s.feed(0, &es);
s.finish();
assert_eq!(s.units.len(), 10);
assert!(s.truncated);
}
}
+15 -2
View File
@@ -218,7 +218,7 @@ pub struct Unit {
/// No other unit depends on it (B pictures of MPEG-1/2 video, non-reference H.264/HEVC).
pub disposable: bool,
pub size: u32,
/// TS: index of the packet holding the first byte; PS: byte offset of its PES packet.
/// TS: byte offset of the packet holding the first byte; PS: byte offset of its PES packet.
pub(crate) pos: u64,
/// Offset of the first byte within that packet's elementary-stream payload.
pub(crate) offset: u32,
@@ -284,6 +284,13 @@ pub struct File {
pub warnings: Vec<String>,
}
/// Largest unit `read_unit` returns. The biggest real units are intra pictures (a few MB at
/// 4K/8K broadcast rates); a stream without PES timestamps can make one unit of a whole PID.
pub const MAX_UNIT_BYTES: u32 = 128 << 20;
/// Bytes reserved up front by `read_unit`; the buffer grows beyond this only as data is read.
const UNIT_PREALLOC: usize = 1 << 20;
impl File {
/// The first stream of `kind` whose codec satisfies `f`.
pub fn find(&self, kind: Kind, f: impl Fn(&Codec) -> bool) -> Option<usize> {
@@ -294,7 +301,11 @@ impl File {
pub fn read_unit<S: ByteSource + ?Sized>(&self, src: &S, s: usize, i: usize) -> Result<Vec<u8>> {
let st = self.streams.get(s).ok_or_else(|| Error::Invalid("no such stream".into()))?;
let u = st.units.get(i).ok_or_else(|| Error::Invalid("no such unit".into()))?;
let mut out = Vec::with_capacity(u.size as usize);
if u.size > MAX_UNIT_BYTES {
return Err(Error::Invalid(format!("unit {i} is {} bytes (limit {MAX_UNIT_BYTES})", u.size)));
}
// grow as bytes arrive: a damaged table must not reserve more than the file can supply
let mut out = Vec::with_capacity((u.size as usize).min(UNIT_PREALLOC));
match self.format {
Format::Ts { packet_size, sync_offset } => ts::read(src, packet_size, sync_offset, st, u, &mut out)?,
Format::Ps { .. } => ps::read(src, st, u, &mut out)?,
@@ -345,3 +356,5 @@ impl Unwrap {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod work_tests;
+5 -10
View File
@@ -3,9 +3,6 @@
/// A parsed PES header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PesHeader {
pub stream_id: u8,
/// PES_packet_length (bytes after the length field; 0 = unbounded, video in TS).
pub packet_length: usize,
/// Bytes from the packet start code to the first payload byte.
pub header_len: usize,
/// Raw 33-bit timestamps.
@@ -28,10 +25,8 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
if b.len() < 6 || b[0] != 0 || b[1] != 0 || b[2] != 1 {
return None;
}
let stream_id = b[3];
let packet_length = u16::from_be_bytes([b[4], b[5]]) as usize;
if plain_stream(stream_id) {
return Some(PesHeader { stream_id, packet_length, header_len: 6, pts: None, dts: None });
if plain_stream(b[3]) {
return Some(PesHeader { header_len: 6, pts: None, dts: None });
}
if b.len() >= 9 && b[6] & 0xC0 == 0x80 {
// ISO/IEC 13818-1 syntax
@@ -47,7 +42,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
dts = Some(timestamp(&b[14..19]));
}
}
return Some(PesHeader { stream_id, packet_length, header_len, pts, dts });
return Some(PesHeader { header_len, pts, dts });
}
// ISO/IEC 11172-1 syntax: stuffing, STD buffer, timestamps
let mut p = 6;
@@ -78,7 +73,7 @@ pub(crate) fn parse(b: &[u8]) -> Option<PesHeader> {
0 if c == 0x0F => p += 1,
_ => return None,
}
Some(PesHeader { stream_id, packet_length, header_len: p, pts, dts })
Some(PesHeader { header_len: p, pts, dts })
}
#[cfg(test)]
@@ -98,7 +93,7 @@ mod tests {
b.extend(ts_bytes(1, dts));
b.push(0xAA);
let h = parse(&b).unwrap();
assert_eq!((h.stream_id, h.header_len, h.pts, h.dts), (0xE0, 19, Some(pts), Some(dts)));
assert_eq!((h.header_len, h.pts, h.dts), (19, Some(pts), Some(dts)));
// MPEG-1: stuffing, STD buffer, PTS only
let mut b = vec![0, 0, 1, 0xC0, 0, 20, 0xFF, 0xFF, 0x40, 0x20];
b.extend(ts_bytes(2, 90_000));
+114 -12
View File
@@ -12,17 +12,33 @@ struct Win<'a, S: ByteSource + ?Sized> {
src: &'a S,
buf: Vec<u8>,
start: u64,
/// Bytes zero-filled by window refills (tests count them).
#[cfg(test)]
zero_filled: usize,
}
impl<'a, S: ByteSource + ?Sized> Win<'a, S> {
fn new(src: &'a S) -> Self {
Self { src, buf: Vec::new(), start: 0 }
Self {
src,
buf: Vec::new(),
start: 0,
#[cfg(test)]
zero_filled: 0,
}
}
/// The bytes [pos, pos + n) (fewer at the end of the file).
fn get(&mut self, pos: u64, n: usize) -> Result<&[u8]> {
let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64;
if !have {
let want = n.max(1 << 20);
// never more than the file still holds past `pos`: `read_some` would not fill it, and
// zeroing a megabyte for every packet near the end of the file is wasted work
let remaining = self.src.len().saturating_sub(pos);
let want = (n.max(1 << 20) as u64).min(remaining) as usize;
#[cfg(test)]
{
self.zero_filled += want.saturating_sub(self.buf.len());
}
self.buf.resize(want, 0);
let got = read_some(self.src, pos, &mut self.buf)?;
self.buf.truncate(got);
@@ -94,6 +110,9 @@ fn packet_len(b: &[u8]) -> Option<usize> {
let stuff = (*b.get(13)? & 7) as usize;
Some(14 + stuff)
} else if b[4] >> 4 == 2 {
// MPEG-1 pack header: start code, 5-byte SCR, 3-byte mux rate. A file cut inside
// it is not a pack header yet (`scan` reads the SCR from the first 9 bytes).
b.get(..12)?;
Some(12)
} else {
None
@@ -116,13 +135,13 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
let mut scr: Option<(i64, i64)> = None;
let mut resyncs = 0u64;
while pos + 4 <= len {
let head = win.get(pos, 32)?.to_vec();
let Some(plen) = packet_len(&head) else {
let head = win.get(pos, 32)?;
let Some(plen) = packet_len(head) else {
// resynchronise on the next pack header or PES start code
resyncs += 1;
let mut p = pos + 1;
let found = loop {
let w = win.get(p, 1 << 16)?.to_vec();
let w = win.get(p, 1 << 16)?;
if w.len() < 4 {
break None;
}
@@ -159,7 +178,7 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
scr = Some(scr.map_or((v, v), |(a, b): (i64, i64)| (a.min(v), b.max(v))));
} else if id == 0xBC {
// program stream map: elementary stream id → stream_type
let body = win.get(pos, plen)?.to_vec();
let body = win.get(pos, plen)?;
if body.len() >= 12 {
let info = u16::from_be_bytes([body[8], body[9]]) as usize;
let mut i = 10 + info;
@@ -174,11 +193,11 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, mpeg1: bool) -> Result<File>
}
}
} else if id == 0xBD || (0xC0..=0xEF).contains(&id) || id == 0xFD {
let pkt = win.get(pos, plen)?.to_vec();
let pkt = win.get(pos, plen)?;
if pkt.len() < plen {
break; // truncated
}
if let Some(h) = pes::parse(&pkt) {
if let Some(h) = pes::parse(pkt) {
let payload = &pkt[h.header_len.min(plen)..];
let (sub, skip) = if id == 0xBD {
match payload.first() {
@@ -239,14 +258,14 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, st: &Stream, u: &Unit, out:
let len = src.len();
let mut win = Win::new(src);
while out.len() < need && pos + 4 <= len {
let head = win.get(pos, 32)?.to_vec();
let Some(plen) = packet_len(&head) else {
let head = win.get(pos, 32)?;
let Some(plen) = packet_len(head) else {
// corrupt: stop at what we have
return Ok(());
};
if head[3] == stream_id {
let pkt = win.get(pos, plen)?.to_vec();
if let Some(h) = pes::parse(&pkt) {
let pkt = win.get(pos, plen)?;
if let Some(h) = pes::parse(pkt) {
let payload = &pkt[h.header_len.min(pkt.len())..];
let matches = stream_id != 0xBD || payload.first().copied() == sub_id;
if matches {
@@ -266,3 +285,86 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, st: &Stream, u: &Unit, out:
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The window refill before the zero-fill cap: always `max(n, 1 MiB)` bytes, counted when the
/// buffer has to grow.
struct OldWin<'a> {
src: &'a [u8],
buf: Vec<u8>,
start: u64,
zero_filled: usize,
}
impl OldWin<'_> {
fn get(&mut self, pos: u64, n: usize) -> Vec<u8> {
let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64;
if !have {
let want = n.max(1 << 20);
self.zero_filled += want.saturating_sub(self.buf.len());
self.buf.resize(want, 0);
let got = read_some(self.src, pos, &mut self.buf).unwrap();
self.buf.truncate(got);
self.start = pos;
}
let i = (pos - self.start) as usize;
let end = (i + n).min(self.buf.len());
self.buf[i.min(end)..end].to_vec()
}
}
fn data(len: usize) -> Vec<u8> {
(0..len).map(|i| (i * 31 + i / 7) as u8).collect()
}
#[test]
fn capped_window_returns_what_the_uncapped_window_returned() {
for len in [0usize, 1, 31, 32, 33, 1000, (1 << 20) - 1, 1 << 20, (1 << 20) + 5, (3 << 20) + 17] {
let d = data(len);
let mut new = Win::new(d.as_slice());
let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 };
let len64 = len as u64;
let mut positions = vec![
0,
1,
31,
32,
1 << 19,
1 << 20,
(1 << 20) - 1,
(1 << 20) + 1,
2 << 20,
len64.saturating_sub(40),
len64.saturating_sub(1),
len64,
len64 + 1,
len64 + 100,
];
positions.extend((0..40).map(|k| len64.saturating_sub(100) + k * 3));
for &pos in &positions {
for n in [0usize, 1, 4, 32, 33, 1000, 65_541, 1 << 20, (1 << 20) + 1, 2 << 20] {
assert_eq!(new.get(pos, n).unwrap(), old.get(pos, n).as_slice(), "len {len} pos {pos} n {n}");
}
}
}
}
#[test]
fn misses_near_the_end_zero_at_most_the_bytes_that_are_left() {
let len = (2 << 20) + 1000;
let d = data(len);
let mut new = Win::new(d.as_slice());
let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 };
// a PES packet whose length runs past the end of the file, asked for once per packet
let first = (2u64 << 20) as usize;
for k in 0..500 {
let pos = (first + k) as u64;
assert_eq!(new.get(pos, 2000).unwrap(), old.get(pos, 2000).as_slice());
}
assert!(new.zero_filled <= 1000, "zeroed {} bytes, only 1000 are left in the file", new.zero_filled);
assert!(old.zero_filled > 499 * ((1 << 20) - 1000), "the reference zeroes about a megabyte per miss: {}", old.zero_filled);
}
}
+212
View File
@@ -276,6 +276,50 @@ fn truncation_and_mutation_never_panic() {
}
}
/// Opens `data` and reads every unit; errors are fine, panics are not.
fn open_and_read_all(data: &[u8]) {
let data = data.to_vec();
if let Ok(f) = open(&data) {
for (s, st) in f.streams.iter().enumerate() {
for i in 0..st.units.len() {
let _ = f.read_unit(&data, s, i);
}
}
}
}
#[test]
fn program_stream_cut_inside_a_pack_header_never_panics() {
// MPEG-1 pack header cut after the 4-byte start code + 3 bytes (once panicked in `scan`).
open_and_read_all(&[0, 0, 1, 0xBA, 0x21, 0, 0]);
for n in 4..=12 {
let mpeg1 = [0u8, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1];
open_and_read_all(&mpeg1[..n]);
}
}
#[test]
fn program_stream_truncation_at_every_length_never_panics() {
let frames: Vec<Vec<u8>> = (0..3).map(|k| mpeg2_frame(if k == 0 { 1 } else { 2 }, k as u16, false, 300)).collect();
let es: Vec<u8> = frames.concat();
for mpeg1 in [false, true] {
let mut out = Vec::new();
for (k, chunk) in es.chunks(500).enumerate() {
if mpeg1 {
out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]);
} else {
out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]);
}
out.extend(pes(0xE0, Some(3600 * k as i64), None, chunk, true));
}
out.extend([0, 0, 1, 0xB9]);
assert!(open(&out).is_ok(), "the untruncated stream opens (mpeg1: {mpeg1})");
for cut in 0..=out.len() {
open_and_read_all(&out[..cut]);
}
}
}
#[test]
fn timestamps_unwrap_across_the_33_bit_wrap() {
let mut u = Unwrap::default();
@@ -286,3 +330,171 @@ fn timestamps_unwrap_across_the_33_bit_wrap() {
assert_eq!(u.unwrap(near + 1000), near + 1000);
assert_eq!(u.unwrap(4200), (1 << 33) + 4200);
}
fn adts_frame(k: u8) -> Vec<u8> {
let mut v = vec![0xFF, 0xF1, 0x50, 0x80, 0x0C, 0x9F, 0xFC];
v.extend([k; 93]);
v
}
#[test]
fn non_pes_payload_is_dropped_until_the_next_unit_start() {
// a unit start whose payload is not a PES header, then continuation packets that happen to
// hold ADTS frames: nothing of it may become a unit
let mut mux = TsMux::new(false);
mux.psi(&[(0x0F, 0x1100)]);
mux.packet(0x1100, true, &[0x55; 184]);
for k in 0..10 {
mux.packet(0x1100, false, &adts_frame(k));
}
let f = open(&mux.out).unwrap();
assert_eq!(f.streams.first().map(|s| s.units.len()), Some(0));
// a real PES packet afterwards is indexed again
let mut mux2 = TsMux::new(false);
mux2.psi(&[(0x0F, 0x1100)]);
mux2.packet(0x1100, true, &[0x55; 184]);
mux2.packet(0x1100, false, &adts_frame(1));
mux2.pes(0x1100, &pes(0xC0, Some(9000), None, &adts_frame(2), true));
let f = open(&mux2.out).unwrap();
let u = &f.streams[0].units;
assert_eq!(u.len(), 1);
assert_eq!(u[0].pts, Some(9000));
}
#[test]
fn adaptation_field_only_unit_start_keeps_the_pes_in_progress() {
let mut mux = TsMux::new(false);
mux.psi(&[(0x03, 0x1100)]);
let data = pes(0xC0, Some(9000), None, &[mp2_frame(0), mp2_frame(1)].concat(), true);
mux.packet(0x1100, true, &data[..184]);
// PUSI set, adaptation field only (no payload)
let cc = mux.cc.get(&0x1100).copied().unwrap_or(0);
let mut af = vec![0x47, 0x40 | 0x11, 0x00, 0x20 | cc, 183, 0x00];
af.resize(188, 0xFF);
mux.out.extend(af);
for chunk in data[184..].chunks(184) {
mux.packet(0x1100, false, chunk);
}
let f = open(&mux.out).unwrap();
let s = &f.streams[0];
assert_eq!(s.units.len(), 2);
assert_eq!(s.units[0].pts, Some(9000));
assert_eq!(f.read_unit(&mux.out, 0, 1).unwrap(), mp2_frame(1));
}
#[test]
fn oversized_unit_is_refused_without_reserving_it() {
let f = File {
format: Format::Ts { packet_size: 188, sync_offset: 0 },
streams: vec![Stream {
id: StreamId::Pid(0x100),
stream_type: 0x1B,
codec: Codec::H264,
language: None,
units: vec![Unit { pts: None, dts: None, key: true, disposable: false, size: u32::MAX, pos: 0, offset: 0, picture: None }],
lpcm: None,
pes_packets: 0,
skipped_bytes: 0,
}],
program: None,
pcr_range: None,
warnings: Vec::new(),
};
assert!(matches!(f.read_unit(&Vec::<u8>::new(), 0, 0), Err(Error::Invalid(_))));
}
/// A `ByteSource` that counts the bytes requested through `read_at`.
struct Counting<'a> {
data: &'a [u8],
read: std::cell::Cell<u64>,
}
impl ByteSource for Counting<'_> {
fn len(&self) -> u64 {
self.data.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
self.read.set(self.read.get() + buf.len() as u64);
self.data.read_at(offset, buf)
}
}
#[test]
fn bad_adaptation_fields_do_not_amplify_reads() {
// aligned packets whose adaptation_field_length (255) overruns the packet: the packet grid is
// intact, so this must cost one pass over the file, not one block read per packet
let mut data = Vec::new();
for _ in 0..3000 {
data.extend([0x47, 0x11, 0x00, 0x30]);
data.extend(std::iter::repeat_n(0xFF, 184));
}
let src = Counting { data: &data, read: Default::default() };
let f = open(&src).unwrap();
assert!(f.warnings.iter().any(|w| w.contains("adaptation field")), "{:?}", f.warnings);
assert!(!f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings);
let read = src.read.get();
assert!(read <= 4 * data.len() as u64, "read {read} bytes of a {}-byte file", data.len());
}
#[test]
fn garbage_without_sync_does_not_amplify_reads() {
// a sync byte every 189 bytes: five-in-a-row never lines up, every 0x47 is a false candidate
let mut data = Vec::new();
for _ in 0..3000 {
data.extend([0x47]);
data.extend(std::iter::repeat_n(0x11, 188));
}
let head: Vec<u8> = (0..188 * 8).map(|i| if i % 188 == 0 { 0x47 } else { 0 }).collect();
let mut all = head;
all.extend(data);
let src = Counting { data: &all, read: Default::default() };
let f = open(&src).unwrap();
assert!(f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings);
let read = src.read.get();
assert!(read <= 4 * all.len() as u64, "read {read} bytes of a {}-byte file", all.len());
}
#[test]
fn transport_stream_resyncs_after_garbage() {
const BLOCK_PACKETS: usize = 4096;
for m2ts in [false, true] {
let ps = if m2ts { 192 } else { 188 };
// garbage in front of the stream (shorter than 8 packets) and, at a few places around the
// first block boundary, between two frames; lengths that are not a multiple of the packet
for (delta, glen) in [(0usize, 100usize), (1, 37), (2, 188), (3, 301), (5, 5), (8, 1000)] {
let edge = (BLOCK_PACKETS - delta) * ps;
let mut mux = TsMux::new(m2ts);
mux.out.extend([0x11, 0x47, 0x22]); // garbage prefix with a lone 0x47
mux.psi(&[(0x02, 0x1011)]);
let mut expect = Vec::new();
let mut placed = false;
let mut k = 0usize;
while k < 3000 || !placed {
if !placed && mux.out.len() >= edge {
// garbage between two PES packets, with lone sync bytes inside
let mut g: Vec<u8> = (0..glen).map(|i| 0x10 + (i % 7) as u8).collect();
if let Some(b) = g.get_mut(3) {
*b = 0x47;
}
mux.out.extend(g);
placed = true;
}
let f = mpeg2_frame(if k == 0 { 1 } else { 2 }, (k % 1000) as u16, false, 40 + k % 300);
mux.pes(0x1011, &pes(0xE0, Some(90_000 + 3600 * k as i64), None, &f, false));
expect.push(f);
k += 1;
}
assert!(mux.out.len() > (BLOCK_PACKETS + 4) * ps, "stream must cross a block boundary");
let data = mux.out;
let f = open(&data).unwrap();
assert!(f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings);
let v = f.find(Kind::Video, |_| true).unwrap();
let s = &f.streams[v];
assert_eq!(s.units.len(), expect.len(), "m2ts {m2ts} delta {delta} glen {glen}");
for (i, fr) in expect.iter().enumerate() {
assert_eq!(&f.read_unit(&data, v, i).unwrap(), fr, "unit {i} (m2ts {m2ts} delta {delta} glen {glen})");
assert_eq!(s.units[i].pts, Some(90_000 + 3600 * i as i64));
}
}
}
}
+117 -29
View File
@@ -6,14 +6,29 @@ use crate::es::Splitter;
use crate::pes::{self, PesHeader};
use crate::{ByteSource, Codec, Error, File, Format, Result, Stream, StreamId, Unit, Unwrap, read_some};
/// Byte-wise table for the MSB-first CRC-32 (polynomial 0x04C11DB7): entry `i` is the CRC register
/// after shifting the byte `i` through eight bit steps.
const CRC_TABLE: [u32; 256] = {
let mut t = [0u32; 256];
let mut i = 0;
while i < 256 {
let mut crc = (i as u32) << 24;
let mut bit = 0;
while bit < 8 {
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
bit += 1;
}
t[i] = crc;
i += 1;
}
t
};
/// CRC-32 of PSI sections (MSB-first, polynomial 0x04C11DB7, initial value all ones; Annex A).
pub(crate) fn crc32_mpeg(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= (b as u32) << 24;
for _ in 0..8 {
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
}
crc = (crc << 8) ^ CRC_TABLE[((crc >> 24) as u8 ^ b) as usize];
}
crc
}
@@ -29,9 +44,19 @@ struct Packet<'a> {
payload: &'a [u8],
}
fn packet(p: &[u8]) -> Option<Packet<'_>> {
/// Why a packet could not be parsed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Bad {
/// No sync byte (or too short): the packet grid is lost.
Sync,
/// Sync byte present but the adaptation field overruns the packet: the grid is intact, the
/// packet carries nothing usable.
Adaptation,
}
fn packet(p: &[u8]) -> std::result::Result<Packet<'_>, Bad> {
if p.len() < 188 || p[0] != 0x47 {
return None;
return Err(Bad::Sync);
}
let pusi = p[1] & 0x40 != 0;
let pid = (((p[1] & 0x1F) as u16) << 8) | p[2] as u16;
@@ -44,7 +69,7 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
let al = p[4] as usize;
start = 5 + al;
if start > 188 {
return None;
return Err(Bad::Adaptation);
}
if al > 0 {
let flags = p[5];
@@ -57,7 +82,7 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
}
}
let has_payload = afc & 1 != 0;
Some(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } })
Ok(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } })
}
/// Reassembles a PES header that may span packets; yields each packet's elementary-stream bytes.
@@ -65,14 +90,25 @@ fn packet(p: &[u8]) -> Option<Packet<'_>> {
pub(crate) struct PesAsm {
hdr: Vec<u8>,
waiting: bool,
/// The packet that started the current unit was not a PES packet: its continuation packets
/// are dropped until the next unit start.
dropping: bool,
}
impl PesAsm {
/// The PES header completed by this packet (if any) and the packet's ES payload bytes.
pub fn packet<'a>(&mut self, pusi: bool, payload: &'a [u8]) -> (Option<PesHeader>, &'a [u8]) {
if pusi {
if payload.is_empty() {
// an adaptation-field-only packet carries no PES data: it must not cut short
// the PES packet in progress
return (None, &[]);
}
self.hdr.clear();
self.waiting = true;
self.dropping = false;
} else if self.dropping {
return (None, &[]);
} else if !self.waiting {
return (None, payload);
}
@@ -88,6 +124,7 @@ impl PesAsm {
if self.hdr.len() > 1024 || (self.hdr.len() >= 6 && !(self.hdr[0] == 0 && self.hdr[1] == 0 && self.hdr[2] == 1)) {
// not a PES packet after all: drop until the next unit start
self.waiting = false;
self.dropping = true;
self.hdr.clear();
return (None, &[]);
}
@@ -216,6 +253,39 @@ fn first_sync(head: &[u8], packet_size: usize, sync_offset: usize) -> Option<usi
(0..packet_size.min(head.len())).find(|&s| (0..5).all(|k| head.get(s + sync_offset + k * packet_size) == Some(&0x47)))
}
/// Find where packets line up again, searching `block` from byte `from`: the first packet start
/// `i` whose sync byte and the next four packets' sync bytes are all 0x47. Returns `(i, false)`
/// when found. Returns `(i, true)` when the block ran out first: `i` is where to refill from (a
/// candidate whose five-packet window does not fit yet, or the last place a whole packet fits).
/// At the end of the file (`at_end`) a candidate is accepted with as many sync bytes as exist.
fn resync(block: &[u8], from: usize, packet_size: usize, sync_offset: usize, at_end: bool) -> (usize, bool) {
let mut i = from;
while i + packet_size <= block.len() {
if block.get(i + sync_offset) == Some(&0x47) {
let mut aligned = true;
let mut complete = true;
for k in 1..5 {
match block.get(i + sync_offset + k * packet_size) {
Some(&0x47) => {}
Some(_) => {
aligned = false;
break;
}
None => {
complete = false;
break;
}
}
}
if aligned {
return (i, !complete && !at_end);
}
}
i += 1;
}
(i, true)
}
pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_offset: usize) -> Result<File> {
let len = src.len();
let mut head = vec![0u8; packet_size * 8];
@@ -236,30 +306,39 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
let (mut lost_sync, mut cc_errors, mut crc_errors) = (0u64, 0u64, 0u64);
const BLOCK: usize = 4096;
let mut block = vec![0u8; BLOCK * packet_size];
let mut bad_adaptation = 0u64;
while off + packet_size as u64 <= len {
let got = read_some(src, off, &mut block)?;
let whole = got / packet_size;
if whole == 0 {
if got < packet_size {
break;
}
let mut k = 0;
while k < whole {
let pos = off + (k * packet_size) as u64;
let raw = &block[k * packet_size..(k + 1) * packet_size];
let Some(pk) = packet(&raw[sync_offset..]) else {
// lost sync: find the next offset where packets line up again
lost_sync += 1;
let rest = &block[k * packet_size + 1..got];
match first_sync(rest, packet_size, sync_offset).or_else(|| rest.iter().position(|&b| b == 0x47).map(|p| p.saturating_sub(sync_offset))) {
Some(s) => {
off = pos + 1 + s as u64;
let at_end = off + got as u64 >= len;
// `p`: byte index of the next packet in `block`. Packets are parsed in place; a lost
// sync is repaired inside the loaded block, and the block is refilled from the file
// (at `off + p`) only when it is used up, so the bytes read stay close to the file size.
let mut p = 0usize;
while p + packet_size <= got {
let pos = off + p as u64;
let raw = &block[p..p + packet_size];
let pk = match packet(&raw[sync_offset..]) {
Ok(pk) => pk,
Err(Bad::Adaptation) => {
// the packet grid is intact: this packet just has no usable payload
bad_adaptation += 1;
p += packet_size;
continue;
}
Err(Bad::Sync) => {
lost_sync += 1;
let (next, exhausted) = resync(&block[..got], p + 1, packet_size, sync_offset, at_end);
p = next;
if exhausted {
break;
}
None => off = pos + got as u64,
continue;
}
k = usize::MAX;
break;
};
k += 1;
p += packet_size;
if let Some(pcr) = pk.pcr {
// 33-bit base × 300 wraps at 2^33·300
let wrap = (1i64 << 33) * 300;
@@ -362,13 +441,15 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
}
e.split.feed(pos, bytes);
}
if k != usize::MAX {
off += (whole * packet_size) as u64;
}
// `p >= 1` here (the loop ran at least once or `resync` moved past the failed packet)
off += p.max(1) as u64;
}
if lost_sync > 0 {
warnings.push(format!("lost packet sync {lost_sync} time(s)"));
}
if bad_adaptation > 0 {
warnings.push(format!("{bad_adaptation} packet(s) with an invalid adaptation field"));
}
if cc_errors > 0 {
warnings.push(format!("{cc_errors} continuity counter error(s)"));
}
@@ -384,6 +465,9 @@ pub(crate) fn scan<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
e.split.finish();
e.stream.units = std::mem::take(&mut e.split.units);
e.stream.skipped_bytes = e.split.skipped;
if e.split.truncated {
warnings.push(format!("PID {pid}: more than {} access units, the rest are not indexed", crate::es::MAX_UNITS));
}
streams.push(e.stream);
}
Ok(File { format: Format::Ts { packet_size, sync_offset }, streams, program, pcr_range, warnings })
@@ -406,7 +490,11 @@ pub(crate) fn read<S: ByteSource + ?Sized>(src: &S, packet_size: usize, sync_off
}
for k in 0..whole {
let raw = &block[k * packet_size..(k + 1) * packet_size];
let Some(pk) = packet(&raw[sync_offset..]) else { return Ok(()) };
let pk = match packet(&raw[sync_offset..]) {
Ok(pk) => pk,
Err(Bad::Sync) => return Ok(()),
Err(Bad::Adaptation) => continue,
};
if pk.pid != pid {
continue;
}
+113
View File
@@ -0,0 +1,113 @@
//! Equality and counting tests for the redundant-work removals: the old implementation is kept
//! here as the reference, the new one must give the same results.
use super::*;
/// The bitwise CRC-32 that `ts::crc32_mpeg` replaced.
fn crc32_bitwise(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= (b as u32) << 24;
for _ in 0..8 {
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
}
}
crc
}
/// Deterministic xorshift bytes.
fn noise(seed: u32, n: usize) -> Vec<u8> {
let mut x = seed | 1;
(0..n)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
(x >> 8) as u8
})
.collect()
}
#[test]
fn table_crc_equals_the_bitwise_crc() {
// the standard check value of CRC-32/MPEG-2
assert_eq!(ts::crc32_mpeg(b"123456789"), 0x0376_E6E7);
assert_eq!(ts::crc32_mpeg(&[]), 0xFFFF_FFFF);
for b in 0..=255u8 {
assert_eq!(ts::crc32_mpeg(&[b]), crc32_bitwise(&[b]));
}
for n in [0usize, 1, 2, 3, 4, 5, 15, 16, 17, 183, 184, 1023, 1024, 4096] {
for seed in 1..6u32 {
let d = noise(seed * 7919 + n as u32, n);
assert_eq!(ts::crc32_mpeg(&d), crc32_bitwise(&d), "len {n} seed {seed}");
}
}
// a section followed by its own CRC checks to zero
let mut sec = noise(5, 40);
let crc = ts::crc32_mpeg(&sec);
sec.extend(crc.to_be_bytes());
assert_eq!(ts::crc32_mpeg(&sec), 0);
}
/// MPEG-1 layer II frame, 48 kHz 192 kb/s (576 bytes), distinct per `k` and `salt`.
fn mp2_frame(k: usize, salt: u8) -> Vec<u8> {
let mut v = vec![0xFF, 0xFD, 0xA4, 0x00];
v.extend((0..572).map(|i| (i as u8) ^ (k as u8) ^ salt));
v
}
fn pes_packet(stream_id: u8, payload: &[u8]) -> Vec<u8> {
let len = 3 + payload.len();
let mut v = vec![0, 0, 1, stream_id, (len >> 8) as u8, len as u8, 0x80, 0, 0];
v.extend_from_slice(payload);
v
}
/// Two audio streams interleaved in maximal PES packets over more than three window refills,
/// with a pack header per PES packet; MPEG-2 or MPEG-1 pack headers.
fn big_program_stream(mpeg1: bool, frames: usize) -> (Vec<u8>, Vec<Vec<u8>>, Vec<Vec<u8>>) {
let a: Vec<Vec<u8>> = (0..frames).map(|k| mp2_frame(k, 0)).collect();
let b: Vec<Vec<u8>> = (0..frames).map(|k| mp2_frame(k, 0x55)).collect();
let (ea, eb) = (a.concat(), b.concat());
let mut out = Vec::new();
for (ca, cb) in ea.chunks(60_000).zip(eb.chunks(60_000)) {
for (id, chunk) in [(0xC0, ca), (0xC1, cb)] {
if mpeg1 {
out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]);
} else {
out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]);
}
out.extend(pes_packet(id, chunk));
}
}
out.extend([0, 0, 1, 0xB9]);
(out, a, b)
}
#[test]
fn program_stream_with_large_pes_packets_gives_back_every_frame() {
for mpeg1 in [false, true] {
let (data, a, b) = big_program_stream(mpeg1, 2000); // about 1.2 MB per stream
assert!(data.len() > 2 << 20, "more than the 1 MiB window twice");
let f = open(&data).unwrap();
assert_eq!(f.format, Format::Ps { mpeg1 });
assert_eq!(f.streams.len(), 2);
for (s, frames) in f.streams.iter().zip([&a, &b]) {
assert_eq!(s.units.len(), frames.len());
for (i, fr) in frames.iter().enumerate() {
assert_eq!(&f.read_unit(&data, f.streams.iter().position(|x| std::ptr::eq(x, s)).unwrap(), i).unwrap(), fr, "mpeg1 {mpeg1} unit {i}");
}
}
// cut inside the last packets: never panics, and the units before the cut are intact
for cut in [data.len() - 1, data.len() - 4, data.len() - 30_000, data.len() - 61_000, 100_000, 1 << 20] {
let d = &data[..cut];
let Ok(f) = open(d) else { continue };
for (si, frames) in [&a, &b].into_iter().enumerate() {
let Some(s) = f.streams.get(si) else { continue };
for i in 0..s.units.len().saturating_sub(1) {
assert_eq!(f.read_unit(d, si, i).unwrap(), frames[i], "cut {cut} stream {si} unit {i}");
}
}
}
}
}
+3 -2
View File
@@ -97,9 +97,10 @@ fn changed_in_band_parameter_sets_switch_to_software() {
let out = decode_all(&mut d, &same);
assert!(d.is_hardware());
assert_same("repeated SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &same));
// a different SPS (same values, trailing zero byte) at the second sync sample: software
// a different SPS (same values, another nal_ref_idc: a trailing zero byte is not a change) at
// the second sync sample: software
let mut changed_sps = sps.clone();
changed_sps.push(0);
changed_sps[0] ^= 0x40;
let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap();
let changed: Vec<_> = s.samples.iter().enumerate().map(|(i, (x, p))| (if i == k { with_sps(&changed_sps, x) } else { x.clone() }, *p)).collect();
let mut d = hybrid(&s, usize::MAX);
+138
View File
@@ -156,6 +156,26 @@ fn motion_and_masks_restrict_the_region() {
assert!(near(img.get(280, 90), [1.0, 0.0, 0.0], 1e-4));
}
#[test]
fn a_transform_replaces_the_picture_below() {
// Premiere: Transform on an adjustment layer moves the composite below; it is not a copy
// drawn over the untouched original (#1524).
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "transform");
let tf = clip(&mut s, adj).effects.iter_mut().find(|e| e.effect == "transform").unwrap();
tf.params.get_mut("scale_height").unwrap().value = ParamValue::Float(50.0);
let img = render(&s);
assert!(near(img.get(160, 90), [1.0, 0.0, 0.0], 1e-4), "{:?}", img.get(160, 90));
assert_eq!(img.get(10, 10), [0.0; 4], "the original is gone outside the scaled picture");
// at 50 % opacity: half the original, half the (empty) transformed picture
clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0);
let c = render(&s).get(10, 10);
assert!((c[0] - 0.5).abs() < 1e-3 && (c[3] - 0.5).abs() < 1e-3, "{c:?}");
}
#[test]
fn nested_adjustment_layers_stay_in_their_sequence() {
let mut s = scene(2);
@@ -184,3 +204,121 @@ fn nested_adjustment_layers_stay_in_their_sequence() {
};
assert_eq!(img.get(5, 5), plain.get(5, 5));
}
#[test]
fn transform_shutter_on_an_adjustment_layer_spans_one_sequence_frame() {
// A 60 fps sequence: at 360° the shutter is one 60 fps frame, so the frames just outside
// the move stay sharp (they were blurred with a 24 fps shutter, #1088).
use filmcraft_project::{Interpolation, Keyframe};
let r60 = FrameRate::FPS_60;
let mut s = scene(2);
s.p.sequence_mut(s.seq).unwrap().settings.frame_rate = r60;
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
let below = place(&mut s.p, s.seq, 0, red);
clip(&mut s, below).effect_mut("motion").unwrap().params.get_mut("scale").unwrap().value = ParamValue::Float(25.0);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "transform");
let key = |sec: f64, x: f64| {
let mut k = Keyframe::new(Tick::from_seconds_f64(sec), ParamValue::Vec2(filmcraft_geom::Vec2::new(x, 90.0)));
k.interp = Interpolation::Linear;
k
};
let e = clip(&mut s, adj).effect_mut("transform").unwrap();
e.params.get_mut("shutter_angle").unwrap().value = ParamValue::Float(360.0);
e.params.get_mut("position").unwrap().keyframes = vec![key(0.5, 160.0), key(1.0, 260.0)];
let mut diff = |frame: i64| {
let mut at = |shutter: bool| {
clip(&mut s, adj).effect_mut("transform").unwrap().params.get_mut("shutter_override").unwrap().value = ParamValue::Bool(shutter);
render_sequence(&s.p, s.seq, r60.tick_of(frame), RenderOptions::default(), &s.map)
};
let (sharp, blurred) = (at(false), at(true));
sharp.px.iter().zip(&blurred.px).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max)
};
assert!(diff(29) < 1e-4, "frame before the move is blurred");
assert!(diff(61) < 1e-4, "frame after the move is blurred");
assert!(diff(45) > 1e-2, "moving frame is sharp");
}
fn dissolve(s: &mut Scene, track: usize, from: Option<ClipId>, to: Option<ClipId>, start: i64) {
let tr = filmcraft_project::Transition {
id: filmcraft_project::TransitionId(900 + start as u64),
effect: find_effect("cross_dissolve").unwrap().instance(),
start: R.tick_of(start),
duration: R.tick_of(12),
from,
to,
align: Default::default(),
reverse: false,
};
s.p.sequence_mut(s.seq).unwrap().video_tracks[track].transitions.push(tr);
}
fn at(s: &Scene, frame: i64) -> [f32; 4] {
render_sequence(&s.p, s.seq, R.tick_of(frame), RenderOptions::default(), &s.map).get(160, 90)
}
/// A dissolve at the start of an adjustment layer (Cmd+D) fades its effects in, and one at its
/// end fades them out, instead of leaving them off for the transition and cutting in after it.
#[test]
fn a_dissolve_fades_the_adjustment_layers_effects_in_and_out() {
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "invert");
dissolve(&mut s, 1, None, Some(adj), 0);
dissolve(&mut s, 1, Some(adj), None, 36);
// fade in over frames 0..12: red → halfway → cyan
assert!(near(at(&s, 0), [1.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 0));
assert!(near(at(&s, 3), [0.75, 0.25, 0.25], 1e-3), "a quarter in: {:?}", at(&s, 3));
assert!(near(at(&s, 6), [0.5, 0.5, 0.5], 1e-3), "halfway in: {:?}", at(&s, 6));
assert!(near(at(&s, 24), [0.0, 1.0, 1.0], 1e-3), "{:?}", at(&s, 24));
// fade out over frames 36..48
assert!(near(at(&s, 42), [0.5, 0.5, 0.5], 1e-3), "halfway out: {:?}", at(&s, 42));
assert!(near(at(&s, 45), [0.75, 0.25, 0.25], 1e-3), "three quarters out: {:?}", at(&s, 45));
}
/// The layer's own opacity and blend mode still count during the fade.
#[test]
fn a_dissolve_on_an_adjustment_layer_keeps_its_opacity_and_blend_mode() {
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "invert");
clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0);
dissolve(&mut s, 1, None, Some(adj), 0);
// halfway through the fade, at 50 % opacity: a quarter of the way to cyan
assert!(near(at(&s, 6), [0.75, 0.25, 0.25], 1e-3), "{:?}", at(&s, 6));
// Multiply (red × cyan = black) fades from red to black
let op = clip(&mut s, adj).effect_mut("opacity").unwrap();
op.params.get_mut("opacity").unwrap().value = ParamValue::Float(100.0);
op.params.get_mut("blend").unwrap().value =
ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == "Multiply").unwrap() as u32);
assert!(near(at(&s, 6), [0.5, 0.0, 0.0], 1e-3), "{:?}", at(&s, 6));
assert!(near(at(&s, 24), [0.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 24));
}
/// A frame where a transition touches an adjustment layer is composited on the CPU, which knows
/// how to fade one; the layered (GPU) plan does not.
#[test]
fn a_transition_with_an_adjustment_layer_is_not_planned_as_layers() {
let mut s = scene(2);
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
let blue = matte(&mut s.p, &mut s.map, [0.0, 0.0, 1.0, 1.0]);
place(&mut s.p, s.seq, 0, red);
// V2: a blue clip from 0 to 24, then an adjustment layer from 24, a dissolve centred on the cut
let b = place(&mut s.p, s.seq, 1, blue);
clip(&mut s, b).duration = R.tick_of(24);
let a = s.p.add_item("Adjustment Layer", Label::Lavender, ItemKind::AdjustmentLayer { width: 320, height: 180, rate: R, duration: R.tick_of(48) }, None);
let mut ti = s.p.make_track_item(a, TrackKind::Video, R.tick_of(24), TimeRange::new(Tick::ZERO, R.tick_of(24)), R).unwrap();
ti.effects.insert(0, find_effect("invert").unwrap().instance());
let ac = ti.id;
s.p.sequence_mut(s.seq).unwrap().video_tracks[1].items.push(ti);
dissolve(&mut s, 1, Some(b), Some(ac), 18);
let q = s.p.sequence(s.seq).unwrap();
// frame 20 is still on the blue clip, but the dissolve into the adjustment layer shows
assert!(!crate::plan::layered_at(&s.p, q, R.tick_of(20)));
// halfway (frame 24): half blue, half the inverted red (cyan)
assert!(near(at(&s, 24), [0.0, 0.5, 1.0], 1e-3), "{:?}", at(&s, 24));
}
+26 -5
View File
@@ -109,9 +109,11 @@ pub(crate) fn track_input_at(
if !tr.range().overlaps(&range) {
continue;
}
// Re-mix: recompute the covered samples with crossfade gains.
let from = tr.from.and_then(|id| track.item(id));
let to = tr.to.and_then(|id| track.item(id));
// Re-mix: recompute the covered samples with crossfade gains. Both sides play over the
// whole transition, into their handles as the picture does, so a transition centred on a
// cut overlaps the two clips instead of only fading each on its own side of the cut.
let from = tr.from.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
let to = tr.to.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end()));
let s0 = tr.start.to_units_floor(sr as i64).max(start);
let s1 = tr.end().to_units_floor(sr as i64).min(start + frames as i64);
if s1 <= s0 {
@@ -124,10 +126,10 @@ pub(crate) fn track_input_at(
}
let mut a = AudioBuffer::silence(sr, w, n);
let mut b = AudioBuffer::silence(sr, w, n);
if let Some(f) = from {
if let Some(f) = &from {
mix_item(project, f, s0, n, sr, sources, &mut a, 1.0, &live);
}
if let Some(tt) = to {
if let Some(tt) = &to {
mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0, &live);
}
let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32;
@@ -143,6 +145,25 @@ pub(crate) fn track_input_at(
tbuf
}
/// `item` lengthened to cover `[t0, t1)` with its media timing unchanged: the part before its in
/// point and after its out point plays the source's handles (silent where the source has none).
/// Remixed clips have no linear media to extend and stay as they are.
fn with_handles(item: &TrackItem, t0: Tick, t1: Tick) -> TrackItem {
let mut ext = item.clone();
if crate::remix::is_remixed(item) {
return ext;
}
let head = (item.start - t0).max(Tick::ZERO);
let tail = (t1 - item.end()).max(Tick::ZERO);
ext.start = item.start - head;
ext.duration = item.duration + head + tail;
// forward media time is source_in + (t - start)·speed; reversed it counts down from the out
// point, so the extra head (forward) or tail (reverse) moves source_in back
let shift = if item.reverse { tail } else { head };
ext.source_in = item.source_in - Tick((shift.0 as f64 * item.speed.abs()).round() as i64);
ext
}
/// Held Clip Mixer controls of one track.
#[derive(Default)]
struct ClipLive {
+2
View File
@@ -192,6 +192,7 @@ fn graded_stats(img: &Image, base: &EffectInstance, x: &[f32; 10], skin_protect:
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, x), &cx);
@@ -361,6 +362,7 @@ mod tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, &x), &cx);
+5
View File
@@ -29,6 +29,8 @@ pub struct FxCtx<'a> {
/// The clip's surroundings (other frames, other tracks, sequence geometry) for temporal,
/// track-reading and reframing effects; `None` in isolated tests and adjustment layers.
pub env: Option<&'a dyn crate::vfx::FxEnv>,
/// Sequence frame rate (shutter-angle motion blur); also known where `env` is `None`.
pub frame_rate: f64,
/// The sequence's working colour space: Lumetri grades HDR (PQ / HLG) working spaces in their
/// own signal, normalised to HDR White ([`filmcraft_color::GradeSpace`]).
pub working: filmcraft_color::WorkingSpace,
@@ -934,6 +936,7 @@ mod tests {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@@ -1529,6 +1532,7 @@ mod lumetri_cpu_parity_tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
@@ -1568,6 +1572,7 @@ mod lumetri_cpu_parity_tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
let render = |roundness: f64| {
+4 -1
View File
@@ -355,7 +355,9 @@ impl FxOp {
}
}
"sharpen" => {
let r = cx.px_scale.max(0.35);
// below a sigma of ~0.58 the three boxes all round to radius 0 and the mask is a
// no-op, so Sharpen vanished at 1/2 playback resolution and below (#1351)
let r = cx.px_scale.max(0.6);
let (rx, ry) = gaussian_boxes(w, h, r, r);
FxOp::Unsharp { rx, ry, amount: f(e, "amount", cx) / 100.0, threshold: 0.0 }
}
@@ -1113,6 +1115,7 @@ mod tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
// Both sides of each cutoff, including samples inside the former artificial fade.
+76 -40
View File
@@ -189,14 +189,25 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
if let Some(tr) = track.transitions.iter().find(|tr| tr.range().contains(t)) {
let a = tr.from.and_then(|id| track.item(id)).filter(|i| i.enabled);
let b = tr.to.and_then(|id| track.item(id)).filter(|i| i.enabled);
let la = a
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
.map(|(img, op, _)| with_opacity(img, op))
.unwrap_or_else(|| Image::new(w, h));
let lb = b
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
.map(|(img, op, _)| with_opacity(img, op))
.unwrap_or_else(|| Image::new(w, h));
// An adjustment layer has no picture of its own: in a transition it is the frame below
// with its effects, so a dissolve fades the effects in or out.
let layer = |i: &TrackItem, below: &Image| -> Option<(Image, Blend)> {
if is_adjustment(project, i) {
return opts.effects.then(|| adjustment_layer(project, seq, i, t, opts, &tc, below));
}
item_layer(project, seq, i, t, opts, sources, &tc).map(|(img, op, bl)| (with_opacity(img, op), bl))
};
let below = canvas.snapshot();
let la = a.and_then(|i| layer(i, &below));
let lb = b.and_then(|i| layer(i, &below));
// a fade from or to nothing keeps the one layer's blend mode (an adjustment layer in
// Multiply ends the fade as it carries on); between two layers they mix normally
let bl = match (&la, &lb) {
(Some((_, x)), None) | (None, Some((_, x))) => *x,
_ => Blend::Normal,
};
let la = la.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
let lb = lb.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
let p = tr.progress(t) as f32;
// Reverse plays the transition backwards (e.g. an iris closing on the outgoing clip)
// while still going from A to B.
@@ -205,48 +216,27 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
} else {
transitions::apply_scaled(&tr.effect, &la, &lb, p, opts.scale)
};
blend::composite(canvas.get(), &mixed, 1.0, Blend::Normal);
blend::composite(canvas.get(), &mixed, 1.0, bl);
continue;
}
let Some(item) = track.item_at(t) else { continue };
if !item.enabled {
continue;
}
let pi = project.item(item.item);
if let Some(pi) = pi
&& matches!(pi.kind, ItemKind::AdjustmentLayer { .. })
{
if is_adjustment(project, item) {
if !opts.effects {
continue;
}
let mt = item.effect_time_at(t);
let mut adjusted = canvas.snapshot();
let cx = effects::FxCtx {
t: mt,
px_scale: opts.scale,
seconds: (t - item.start).seconds(),
timecode: &tc,
clip_name: &item.name,
project: Some(project),
env: None,
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
mask::apply_effect(&mut adjusted, e, &cx);
let below = canvas.snapshot();
let (adj, bl) = adjustment_layer(project, seq, item, t, opts, &tc, &below);
if bl == Blend::Normal {
// The adjusted picture already replaces the original (see `adjustment_layer`).
canvas.set(adj);
} else {
let mut out = below;
blend::composite(&mut out, &adj, 1.0, bl);
canvas.set(out);
}
let (op, bl) = opacity_blend(item, mt);
// Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original.
let mut out = canvas.snapshot();
let mut adj = adjusted;
adj.scale_alpha(op);
mask::apply_opacity_masks(&mut adj, item, mt, opts.scale);
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
mask::scale_by(&mut adj, &cov);
}
blend::composite(&mut out, &adj, 1.0, bl);
canvas.set(out);
continue;
}
match item_layer_ex(project, seq, item, t, opts, sources, &tc, true) {
@@ -279,6 +269,51 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
canvas
}
fn is_adjustment(project: &Project, item: &TrackItem) -> bool {
project.item(item.item).is_some_and(|pi| matches!(pi.kind, ItemKind::AdjustmentLayer { .. }))
}
/// An adjustment layer as a layer over `below` (the composite of the tracks under it): `below`
/// with the layer's effects, faded by its opacity and opacity masks and limited to its
/// (Motion-transformed) frame, and the blend mode it mixes with.
fn adjustment_layer(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, opts: RenderOptions, tc: &str, below: &Image) -> (Image, Blend) {
let (w, h) = (below.w, below.h);
let mt = item.effect_time_at(t);
let mut adj = below.clone();
let cx = effects::FxCtx {
t: mt,
px_scale: opts.scale,
seconds: (t - item.start).seconds(),
timecode: tc,
clip_name: &item.name,
project: Some(project),
env: None,
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
mask::apply_effect(&mut adj, e, &cx);
}
let (op, bl) = opacity_blend(item, mt);
// Adjustment layer opacity (and opacity masks, in sequence pixels) weigh adjusted against original.
let mut weight = Image::filled(w, h, [op; 4]);
mask::apply_opacity_masks(&mut weight, item, mt, opts.scale);
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
mask::scale_by(&mut weight, &cov);
}
let cov: Vec<f32> = weight.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
if bl == Blend::Normal {
// The adjusted picture replaces the original instead of being drawn over it: what
// Transform or Basic 3D leave empty shows nothing of the untransformed picture (#1524).
mask::mix(&mut adj, below, &cov);
} else {
mask::scale_by(&mut adj, &cov);
}
(adj, bl)
}
/// Coverage of an adjustment layer's (Motion-transformed) frame in output pixels, or `None` when
/// it covers the whole output.
fn adjustment_region(seq: &Sequence, item: &TrackItem, project: &Project, mt: Tick, scale: f32, w: usize, h: usize) -> Option<Image> {
@@ -483,6 +518,7 @@ pub(crate) fn item_layer_ex(
clip_name: &item.name,
project: Some(project),
env: Some(&env),
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::effects::{FxCtx, apply};
const RW: f32 = REFERENCE_WHITE_NITS as f32;
fn cx(working: WorkingSpace) -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working }
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working }
}
fn lumetri(params: &[(&str, ParamValue)]) -> EffectInstance {
+1
View File
@@ -228,6 +228,7 @@ pub fn thumbnail(preset: &LumetriPreset, w: usize, h: usize) -> Image {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &preset.instance(), &cx);
+44
View File
@@ -7,6 +7,7 @@ use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, Medi
use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, send_lane};
use filmcraft_project::{
AudioChannels, AutomationMode, ItemId, ItemKind, Keyframe, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, TrackSend,
Transition, TransitionId,
};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
@@ -406,6 +407,49 @@ fn audio_transition_curves() {
}
}
/// A transition centred on a cut mixes both clips across its whole length: the outgoing clip plays
/// on past its out point and the incoming one starts before its in point (their handles), as the
/// picture does, so the two overlap instead of each just dipping at the cut. The incoming source is
/// a ramp, so its handle must come from the media just before its in point.
#[test]
fn audio_transition_on_a_cut_crossfades_through_the_handles() {
fn ramp(i: i64, _: usize) -> f32 {
i as f32 * 1e-6
}
let mut r = Rig::new(&[dc1, ramp]);
let (a_src, b_src) = (r.track(0).items[0].item, r.track(1).items[0].item);
let sec = |s: f64| Tick::from_seconds_f64(s);
let a = r.p.make_track_item(a_src, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, sec(1.0)), FrameRate::FPS_24).unwrap();
let b = r.p.make_track_item(b_src, TrackKind::Audio, sec(1.0), TimeRange::new(sec(2.0), sec(1.0)), FrameRate::FPS_24).unwrap();
let effect = filmcraft_project::vtransition::find_transition("constant_gain", filmcraft_project::EffectKind::AudioTransition).unwrap().instance();
let tr = Transition {
id: TransitionId(1),
effect,
start: sec(0.75),
duration: sec(0.5),
from: Some(a.id),
to: Some(b.id),
align: Default::default(),
reverse: false,
};
r.track(1).items.clear();
let t0 = r.track(0);
t0.items = vec![a, b];
t0.transitions.push(tr);
// the incoming clip plays media sample 96 000 at the cut (sample 48 000)
let b_at = |s: i64| ramp(s + 48_000, 0);
// constant gain at p = 0.25, 0.5 (the cut), 0.75: (1 - p)·A + p·B
for (s, p) in [(42_000, 0.25), (48_000, 0.5), (54_000, 0.75)] {
let want = (1.0 - p) * 0.1 + p * b_at(s);
let [l, rr] = r.mix(s, 1);
assert!(close(l[0], want, 1e-5) && close(rr[0], want, 1e-5), "sample {s}: {} (want {want})", l[0]);
}
// outside the transition each clip plays alone, at full level
for (s, want) in [(35_000, 0.1), (61_000, b_at(61_000))] {
assert!(close(r.mix(s, 1)[0][0], want, 1e-6), "sample {s}");
}
}
// ------------------------------------------------------------------------------------- 5.1
/// Distinct DC level per source channel: 0.1, 0.2, … (L, R, C, LFE, Ls, Rs for 6-channel sources).
+13 -8
View File
@@ -228,20 +228,24 @@ fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool {
}
/// Whether the frame of `seq` at `t` can be planned as layers: no adjustment layer and no
/// transition the compositor cannot mix itself is showing.
fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
/// transition the compositor cannot mix itself is showing (a transition into or out of an
/// adjustment layer counts, also on the side of the cut where the other clip is).
pub(crate) fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
let adjustment = |it: &TrackItem| project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }));
for tr in &seq.video_tracks {
if !tr.enabled {
continue;
}
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t))
&& !simple_transition(&trn.effect.effect)
&& trn.effect.effect != "push"
{
return false;
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) {
if !simple_transition(&trn.effect.effect) && trn.effect.effect != "push" {
return false;
}
if [trn.from, trn.to].into_iter().flatten().filter_map(|c| tr.item(c)).any(adjustment) {
return false;
}
}
if let Some(it) = tr.item_at(t)
&& project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }))
&& adjustment(it)
{
return false;
}
@@ -434,6 +438,7 @@ fn push_item(
clip_name: &item.name,
project: Some(project),
env: None,
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
let ops: Option<Vec<FxOp>> = chain.iter().map(|e| FxOp::eval(e, &cx, lw, lh).filter(FxOp::gpu_ok)).collect();
+28 -10
View File
@@ -39,7 +39,7 @@
//! size and rate match the sequence. Green (a valid preview exists) is decided by the preview
//! store, which knows which hashes have files.
use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, Transition};
use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, TrackKind, Transition};
use filmcraft_time::{FrameRate, Tick};
use serde::Serialize;
@@ -166,7 +166,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec<Segment> {
match l {
Layer::Item(ti, it) => {
h.json(&("item", ti));
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Video, 0);
absolute |= prints_time(it);
clips.push(it.id);
}
@@ -180,7 +180,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec<Segment> {
for side in [a, b] {
match side {
Some(it) => {
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Video, 0);
absolute |= prints_time(it);
clips.push(it.id);
}
@@ -277,7 +277,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec<AudioSegment> {
for side in [x.from, x.to] {
match side.and_then(|id| tr.item(id)).filter(|i| i.enabled) {
Some(it) => {
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Audio, 0);
heard = true;
}
None => h.json(&"none"),
@@ -285,7 +285,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec<AudioSegment> {
}
} else if let Some(it) = tr.item_at(t).filter(|i| i.enabled) {
h.json(&("item", ti));
hash_item(&mut h, project, it, t, 0);
hash_item(&mut h, project, it, t, TrackKind::Audio, 0);
heard = true;
}
}
@@ -300,7 +300,9 @@ fn prints_time(it: &TrackItem) -> bool {
it.effects.iter().any(|e| e.enabled && e.effect == "timecode")
}
fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, depth: u32) {
/// Hash a clip and its source as heard (`kind` Audio) or seen (`kind` Video): a nested sequence
/// contributes only the tracks of that kind, so editing its picture keeps its sound's previews.
fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, kind: TrackKind, depth: u32) {
let mut c = it.clone();
c.id = Default::default();
c.start -= origin;
@@ -312,10 +314,10 @@ fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, de
c.name.clear();
}
h.json(&c);
hash_source(h, project, it.item, depth);
hash_source(h, project, it.item, kind, depth);
}
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, kind: TrackKind, depth: u32) {
let Some(pi) = project.item(id) else {
h.json(&"missing");
return;
@@ -338,18 +340,34 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) {
}
ItemKind::Subclip { parent, range, .. } => {
h.json(&("subclip", range));
hash_source(h, project, *parent, depth + 1);
hash_source(h, project, *parent, kind, depth + 1);
}
ItemKind::AdjustmentLayer { .. } => h.json(&("adjustment", &pi.kind)),
ItemKind::Graphic { .. } => h.json(&("graphic", &pi.kind)),
ItemKind::Sequence(s) => {
h.json(&("sequence", &s.settings));
if kind == TrackKind::Audio {
// the nest's sound is the nested sequence's whole mix (see `audio_segments`)
h.json(&("mix", s.master_volume_db, &s.master_effects, &s.master_mixer, &s.submix_tracks, &s.multicam));
for (ti, tr) in s.audio_tracks.iter().enumerate() {
h.json(&("track", ti, tr.muted, tr.solo, tr.volume_db, tr.pan, tr.channels, &tr.effects, &tr.mixer, tr.items.len()));
for it in &tr.items {
hash_item(h, project, it, Tick::ZERO, kind, depth + 1);
}
for x in &tr.transitions {
let mut xc = x.clone();
xc.id = Default::default();
h.json(&xc);
}
}
return;
}
// a nested sequence's captions are drawn into its picture
h.json(&("captions", &s.caption_tracks));
for (ti, tr) in s.video_tracks.iter().enumerate() {
h.json(&("track", ti, tr.enabled, tr.items.len()));
for it in &tr.items {
hash_item(h, project, it, Tick::ZERO, depth + 1);
hash_item(h, project, it, Tick::ZERO, kind, depth + 1);
}
for x in &tr.transitions {
let mut xc = x.clone();
+53 -1
View File
@@ -5,7 +5,7 @@ use filmcraft_media::{DemoScene, Generator, MediaSource};
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, find_effect};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
use crate::preview::{Need, Segment, effect_cost_ms, segment_at, video_segments};
use crate::preview::{Need, Segment, audio_segments, effect_cost_ms, segment_at, video_segments};
fn add_gen(p: &mut Project, g: GeneratorSource) -> ItemId {
let info = g.info().clone();
@@ -234,6 +234,7 @@ fn effect_costs() {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &e, &cx);
@@ -248,3 +249,54 @@ fn effect_costs() {
println!("{ms:8.2} ms {id:24} tier {:.0}", effect_cost_ms(id));
}
}
/// Put `item` on track 0 of `kind` in `seq` from frame `start_f` for `dur_f` frames.
fn place_kind(p: &mut Project, seq: ItemId, kind: TrackKind, item: ItemId, start_f: i64, dur_f: i64) -> ClipId {
let r = FrameRate::FPS_24;
let ti = p.make_track_item(item, kind, r.tick_of(start_f), TimeRange::new(Tick::ZERO, r.tick_of(dur_f)), r).unwrap();
let id = ti.id;
let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(kind)[0];
t.items.push(ti);
t.sort();
id
}
fn audio_hashes(p: &Project, seq: ItemId) -> Vec<String> {
audio_segments(p, seq).into_iter().map(|g| g.hash).collect()
}
#[test]
fn a_nests_audio_previews_follow_the_sound_of_its_sequence() {
// Inner has a picture clip and a sound clip; Outer nests Inner (picture and sound), and Top
// nests Outer's sound
let (mut p, matte, ocean, inner) = setup();
let pic = place_kind(&mut p, inner, TrackKind::Video, matte, 0, 48);
let snd = place_kind(&mut p, inner, TrackKind::Audio, ocean, 0, 48);
let settings = p.sequence(inner).unwrap().settings.clone();
let outer = p.new_sequence("outer", settings.clone(), 1, 1, None);
place_kind(&mut p, outer, TrackKind::Video, inner, 24, 48);
place_kind(&mut p, outer, TrackKind::Audio, inner, 24, 48);
let top = p.new_sequence("top", settings, 1, 1, None);
place_kind(&mut p, top, TrackKind::Audio, outer, 12, 72);
let (audio, picture, deep) = (audio_hashes(&p, outer), video_segments(&p, outer), audio_hashes(&p, top));
assert_eq!(audio.len(), 1);
// the sound inside the nest changes: the nest's audio preview is stale, its picture is not
p.sequence_mut(inner).unwrap().find_item_mut(snd).unwrap().1.gain_db = -12.0;
assert_ne!(audio_hashes(&p, outer), audio, "a clip's gain inside the nest");
assert_ne!(audio_hashes(&p, top), deep, "a clip's gain two nests down");
assert_eq!(bounds(&video_segments(&p, outer)), bounds(&picture));
assert_eq!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30), "the picture did not change");
let (audio, deep) = (audio_hashes(&p, outer), audio_hashes(&p, top));
// the nested sequence's track mix counts too
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = true;
assert_ne!(audio_hashes(&p, outer), audio, "a muted track inside the nest");
p.sequence_mut(inner).unwrap().audio_tracks[0].muted = false;
assert_eq!(audio_hashes(&p, outer), audio);
// the picture inside the nest changes: its audio preview stays valid
add_fx(&mut p, inner, pic, "tint");
assert_ne!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30));
assert_eq!(audio_hashes(&p, outer), audio, "a video effect inside the nest");
assert_eq!(audio_hashes(&p, top), deep);
}
+2 -1
View File
@@ -233,7 +233,7 @@ pub fn rotate_3d(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
/// Transform: affine place, with optional shutter-angle motion blur when the override is on.
pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let fps = cx.env.map_or(24.0, |env| env.frame_rate()).max(1.0);
let fps = cx.frame_rate.max(1.0);
let angle = fv(e, "shutter_angle", cx);
let n = if bv(e, "shutter_override") && angle > 0.5 { 7 } else { 1 };
let span = (angle as f64 / 360.0) / fps;
@@ -247,6 +247,7 @@ pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
clip_name: cx.clip_name,
project: cx.project,
env: cx.env,
frame_rate: cx.frame_rate,
working: cx.working,
};
let anchor = crate::effects::point(e, "anchor", &cx2, im);
+1
View File
@@ -103,6 +103,7 @@ impl FxEnv for ItemEnv<'_> {
clip_name: &self.item.name,
project: Some(self.project),
env: None,
frame_rate: self.seq.settings.frame_rate.as_f64(),
working: self.seq.settings.color.working,
};
for e in &self.item.effects {
+29
View File
@@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> {
clip_name: "clip",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@@ -1154,3 +1155,31 @@ mod pipeline {
assert!(e.px.iter().all(|v| v.is_finite()));
}
}
#[test]
fn transform_shutter_uses_the_sequence_frame_rate_without_env() {
// An adjustment layer has no env: the shutter must still span one 60 fps frame at 360°, so
// the frames just before the motion starts and just after it ends stay sharp (#1088).
let mut e = inst("transform");
set(&mut e, "shutter_override", ParamValue::Bool(true));
setf(&mut e, "shutter_angle", 360.0);
let mut a = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)));
a.interp = Interpolation::Linear;
let mut b = Keyframe::new(Tick::from_seconds_f64(2.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0)));
b.interp = Interpolation::Linear;
e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] });
let img = picture(32, 32);
let mut off = e.clone();
set(&mut off, "shutter_override", ParamValue::Bool(false));
let delta = |frame: i32| {
let s = f64::from(frame) / 60.0;
let cx = FxCtx { t: Tick::from_seconds_f64(s), seconds: s, frame_rate: 60.0, ..cx() };
max_diff(&run("transform", &off, &img, &cx), &run("transform", &e, &img, &cx))
};
for frame in [59, 121] {
assert!(delta(frame) < 1e-4, "frame {frame} is blurred outside the motion ({})", delta(frame));
}
for frame in [60, 90, 120] {
assert!(delta(frame) > 1e-3, "frame {frame} moves but is sharp");
}
}
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more