448 Commits

Author SHA1 Message Date
aiongg fa550ce2d0 Build: use craft-media for bitstream, isobmff and matroska (#2435)
* Build: use craft-media for bitstream, isobmff and matroska

filmcraft-bitstream, filmcraft-isobmff and filmcraft-matroska re-export
craft_media::{bitstream, isobmff, matroska} from craft-libs. The packages
and their public paths stay, so dependent crates build unchanged. The
integration tests in crates/isobmff/tests and crates/matroska/tests run
against the re-exports.

* Build: pin craft-media at 9db732e

craft-media spells the Matroska colour types in US English: Colour is
Color and VideoInfo::colour is VideoInfo::color. The spec names
element_ids::COLOUR, COLOUR_SPACE and VpcConfig::colour_primaries stay.

* matroska: the oracle skips the duration of a track's last frame

Ported from craft-media: the last audio or video frame of a track cut by
-t is shorter than a codec frame. ffmpeg 8 writes a shortened
BlockDuration and reports the codec frame duration; a remux by our writer
stores DefaultDuration and ffprobe reports the shortened one. Subtitle
cue durations are still compared. This fixes oracle::mjpeg_ac3 with
ffmpeg 8. The HDR test is named hevc_hdr_color, as in craft-media.

* matroska: laced_remux laces every audio track and checks timestamps

craft-media laces only tracks with a DefaultDuration. The remux gives
each audio track without one its mean packet spacing, so Vorbis, FLAC
and PCM lace too, and compares every packet's timestamp with the
source's. Ported from craft-media's oracle_matroska.rs.

* codecs: the Matroska audio timing test laces its Xiph case

craft-media laces only tracks with a DefaultDuration. The test's
Matroska helper sets the AAC access unit's duration (1024 samples,
rounded to the nanosecond), and the test checks that the Xiph-laced file
has laced blocks and the unlaced one has none.

* Build: repin craft-media to f97c84d27 (NAL length-size checks)

craft-libs#245 is on storytold/craft-libs main, so the dependency points there.

* Build: repin craft-media to f0ad6c1e1 (QuickTime v2 PCM entry limits)
2026-10-23 17:51:38 -04:00
Dylan M. Taylor e45ff58346 ci(sonar): don't cancel superseded runs on main, so coverage is published (#3026) 2026-10-23 17:00:18 -04:00
aiongg 93a845ce41 i18n: missing translations never fail; xtask ci prints the missing list (#3012) 2026-10-23 15:39:16 -04:00
Dylan M. Taylor 20f02b9aa1 release: the git-sources gate moves into craft-common-actions (storytold/craft-common-actions#78) (#3014)
The check is shared at release.yml@main now; keep only the reusable call.
2026-10-23 14:41:15 -04:00
aiongg f46a1a8436 CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#2937)
ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD and Windows ARM64 run nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 14:21:15 -04:00
aiongg c3b4c4deb0 Packaging: pin the signing client and appimagetool, and check their hashes (#2610)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Packaging: an architecture without a pinned appimagetool hash fails early

* Packaging: pin the AppImage runtime (type2-runtime 20251108) and pass it with --runtime-file

* Packaging: assign the empty hashes with '' (shellcheck SC1007)
2026-10-23 14:00:44 -04:00
aiongg fbeccbe9bc Fonts: pin craft-fonts bfcbf71; CRAFT_FONTS_REQUIRED fails without CRAFT_FONTS_DIR (#2646)
* Fonts: pin craft-fonts bfcbf71; CRAFT_FONTS_REQUIRED fails without CRAFT_FONTS_DIR

craft-fonts main adds Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai and
Noto Sans Georgian. FilmCraft registers every CRAFT_FONTS entry, so releases
pick them up with the pin bump. ATTRIBUTION.md and NOTICE list them.

With CRAFT_FONTS_REQUIRED set, crates/text/build.rs errors when
CRAFT_FONTS_DIR is unset or empty, not only when it is not a checkout.

Refs storytold/craft-libs#197

* Fonts: only CRAFT_FONTS_REQUIRED=1 enables required mode

* text: the CRAFT_FONTS_REQUIRED message says unset or empty
2026-10-23 13:38:11 -04:00
aiongg 4743cfb939 fix(engine): resolve stored media paths with craft-docpath's resolve_absolute (#2609)
* fix(engine): use resolve_absolute for stored media paths

* fix(engine): give the reason when a scratch disk folder is refused

* CI: check that git dependencies come from storytold

* CI: release only with git dependencies from storytold

* doc_path: a foreign drive path without a document is refused, not document-relative

* Workflows: one Git-source check: lockfile present and current, no source replacement, storytold hosts only

* Workflows: the Git-source check reads the legacy .cargo/config too

* Workflows: the Git-source check parses cargo config with tomllib
2026-10-23 13:37:55 -04:00
aiongg 0a881266c8 CI: xtask ci in the dev profile, one test binary per crate, nextest (#2860)
* Tests: one integration-test binary per crate

Each crate with several tests/*.rs files keeps them as modules of tests/it/,
so cargo links one test executable per crate instead of one per file. Files
that change process-wide state or time a workload stay binaries of their own.

* Profiles: line tables for workspace crates, no debug info for dependencies

* Windows ARM64: nightly and on release pushes instead of every push to main

* xtask ci: clippy and tests in the dev profile

The release profile's thin LTO and single codegen unit made linking the test
binaries take most of the gate. The codec and audio crates stay at opt-level 3
in the dev profile, so their tests run at release speed.

* xtask ci: nextest, doctests, no examples, one wasm check

Tests run with cargo nextest when it is installed (cargo test otherwise) and
build with --lib --bins --tests, so examples aren't linked; doctests run
separately. The wasm check keeps going past the first failing crate. VP8
fixture generation locks across processes, since nextest runs each test in
its own.

* VP9 synthetic fixtures: write aside and rename

A test in another process could read a fixture ffmpeg was still writing.

* Fuzz tests: a third of each sweep by default, all of it with FILMCRAFT_FUZZ_ITERS

Every seed, mutation kind and thread count still runs. Header bytes each get
one overwrite value and two bit flips in rotation, so every value and bit
index still occurs across the region. FILMCRAFT_FUZZ_ITERS=N runs the whole
sweep plus N random rounds per seed, as before.

* Platform fallback tests: one failure point per hybrid path

The failure-anywhere test injected a failure at four points around every
restart point; the hybrid's paths differ only in the first GOP, around the
second and last restart points, at the end and never. The replay test needs
the first two GOPs.

* Export tests: shorter ranges with the same checks

The bitrate test measures 0.5–1.5 s, where each preset reaches its own
target (on 0–3 s the simple demo left two presets at the same rate). The
every-preset test exports three frames per preset.

* Audio tests: effects in parallel, a table-driven FFT for the Opus quality metric

The extreme-settings test keeps both settings and the 40 s tail for every
effect, one thread each. The Opus quality metric's FFT builds its twiddles
once and allocates nothing per transform (same algorithm and summation order).

* CI: dev-profile gate with nextest

xtask ci no longer builds with --release, so the release-profile overrides
go; nextest is installed for the test step.

* CI: apt packages through the cached install-apt-packages action

* Review fixes: fuzz tests with a panic hook in their own binaries, loud fixture lock

The fuzz tests of av1, h264, hevc, mpeg2v, vp8 and vp9 count panics through
a process-wide hook, so a panic in another test of the same process would
fail them; they are binaries of their own again. The VP8 fixture lock fails
the test instead of skipping it silently, and the full-sweep doc comments say
how many truncation points the full run samples.

* Fuzz tests: rotate the default subset per run (FILMCRAFT_FUZZ_SEED replays one)

* platform tests: decode-switch tests in their own binaries, one NVENC lock
2026-10-23 13:34:16 -04:00
Dylan M. Taylor 20e159dd68 tools: quit the super-linear regex scans in the spec table extractors (SonarCloud S8786) (#3007)
* packaging: use [[ for bash conditional tests (SonarCloud S7688)

SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests.

* tools: quit the super-linear regex scans in the spec table extractors (SonarCloud S8786)

SonarCloud flags five regular expressions in the AV1/HEVC extract_tables.py tools as super-linear: a lazy (.*) under re.S that rescans the whole remaining spec text at every position, a \s*([^|]+?)\s* sandwich, and \s+(.*) tails that can absorb the same whitespace repeatedly.

- the fence block reader now walks the ~~~~ c / tilde-run pairs with two linear searches instead of the lazy .*? findall; block-pair and 7+ tilde-run semantics are unchanged
- the symbol-row capture restricts its first and last characters away from [|] so the lazy group's backtracking jumps are gone (identical for every non-blank cell; a cell of only spaces is no longer captured, which the table rows never contain)
- the empty-comma cell splitter no longer lets \s* swallow the newline it anchors on; consecutive line breaks inside a cell no longer split (spec cells are single-line rows)
- initValue / ScalingList value captures start on a non-space so \\s+ and .* no longer share a split point (captured text is unchanged for all real values)

Verified old vs new run on synthetic spec markdown: byte-identical spé tables output and stderr.
2026-10-23 13:08:41 -04:00
Dylan M. Taylor 4d089668a8 packaging: use [[ for bash conditional tests (SonarCloud S7688) (#3003)
SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests.
2026-10-23 12:53:57 -04:00
Dylan M. Taylor c5c1693989 Use chrono instead of hand-rolled UTC conversion (#2662)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies (app log
timestamp, Media Browser date column, auto-save file-name stamp, MXF header
timestamp, golden-image attribution date) with chrono as a workspace
dependency. The existing chrono entry on apps/filmcraft (clock feature) was
lifted to the workspace. Output formats are unchanged and pinned by tests.
AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked. logging::timestamp and golden::today now read the duration since the
epoch and go through the fallible DateTime::from_timestamp, reading an
out-of-range or pre-epoch clock as the epoch as the old saturating code did.
timestamp, today and media_browser::format_date write the year with {:04}
rather than %Y, which prefixes a '+' past year 9999.

Tests cover the far-future and year-10000 cases for timestamp and format_date.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve out-of-range years instead of the epoch (FredApps review)

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output:
inputs the old hand-rolled civil-from-days converted (e.g. `10_000_000_000_000`
s → 318857-05-20 17:46:40) collapsed to `1970-01-01 00:00:00`. Restore the old
conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian
cycles (146_097 days), convert the in-range remainder with chrono, and carry the
year back as i128 (saturating to the output type). Only the year leaves chrono's
range; month, day and time of day are always exact.

Sites (all UTC):
- apps/filmcraft logging.rs `timestamp` (SystemTime, u64 secs): pre-1970 still
  clamps to the epoch; a far-future clock keeps its year. u64::MAX/2 s →
  "292277026596-12-04T15:30:07.000Z", 10e12 s → "318857-05-20T17:46:40.000Z"
  (was "1970-01-01..."). SystemTime cases built with a checked add (Windows-safe).
- crates/engine media_browser.rs `format_date` (u64): 10e12 s → "318857-05-20
  17:46", u64::MAX → "584554051223-11-09 07:00" (was "1970-01-01 00:00").
- crates/format autosave.rs `civil` (i64 + i32 offset): keeps years, e.g.
  i64::MAX → 292277026596-12-04 15:30:07, i64::MIN → -292277022657-01-27. The
  separate `unix + offset_secs` overflow guard (checked_add) is kept: only an
  overflowing shift (old code wrapped it to garbage) reads as the epoch.
- crates/mxf from_unix (i64): keeps month/day/time, clamps only `year` to the
  u16 field as before. 10e12 s → year 65535, May 20 17:46:40; -10e12 s → year 0,
  Aug 13 06:13:20.
- crates/testkit golden.rs `today` (u64): same helper; pinned via `utc_parts`.

chrono stays the only calendar logic (NaiveDate::from_num_days_from_ce_opt on the
in-cycle remainder). No new chrono features; still default-features = false, std.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:18:37 -04:00
Earl Co 6f87c0e381 security(exr): validate chunks before frame allocation (#2913)
* security(exr): validate chunks before frame allocation

* exr: raise the decoded-buffer cap to 2 GiB so 8K float plates open

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:54:49 -04:00
Earl Co 920ad641a8 security(prores): bound decoded plane storage (#2931)
* security(prores): bound decoded plane storage

* prores: raise the decoded-plane cap to 1 GiB so 8K 4444 with alpha decodes

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:54:45 -04:00
Earl Co 99ec34de30 security(vp9): bound frame allocation (#2930)
* security(vp9): bound frame allocation

* vp9: raise the frame-size cap to 2^26 luma samples so level 6.x 8K frames decode

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

* vp9: keep the frame-size cap below the imports

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:54:40 -04:00
Earl Co 34848b3964 security(vp8): bound frame allocation (#2929)
* security(vp8): bound frame allocation

* vp8: raise the frame-size cap to 2^26 luma samples so 8K frames still decode

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:54:36 -04:00
Earl Co b28db6c4db fix(bitstream): reject invalid length-prefixed NAL widths (#2953)
* fix(bitstream): validate NAL prefix size

* bitstream: accept every NAL length width the avcC/hvcC field can declare (1 to 4)

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:54:32 -04:00
Earl Co 10c2e0255c security(bitstream): cache RBSP stop-bit lookup (#2880) 2026-10-23 10:54:27 -04:00
Earl Co b1effb9ab1 security(export): confine metadata-derived names (#2903) 2026-10-23 10:54:22 -04:00
Earl Co 9a0096a979 security(automation): isolate bridge screenshots (#2919) 2026-10-23 10:54:17 -04:00
Earl Co e1d5597477 security(av1): require unique complete tile coverage (#2917) 2026-10-23 10:54:12 -04:00
Earl Co f5a9a6f5e7 security(codecs): deduplicate hardware parameter-set checks (#2890) 2026-10-23 10:54:07 -04:00
Earl Co f554fdcb4f fix(interchange): confine embedded audio output names (#2956) 2026-10-23 10:54:02 -04:00
Earl Co 16d93a8c3d security(videotoolbox): bound reorder buffering (#2908) 2026-10-23 10:53:57 -04:00
Earl Co 7e980f31c9 security(project): validate graphic templates in linear time (#2886) 2026-10-23 10:53:52 -04:00
Earl Co 21b56ac090 security(apv): validate tile payload before allocation (#2916) 2026-10-23 10:53:47 -04:00
Earl Co 8119b36c21 security(mpeg2v): index picture extensions linearly (#2904) 2026-10-23 10:53:42 -04:00
Earl Co 70d0260b6e security(dnx): validate fields before frame allocation (#2914) 2026-10-23 10:53:38 -04:00
dependabot[bot] b12336cf73 ci(deps): bump Swatinem/rust-cache (#2971)
Bumps the actions group with 1 update: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache).


Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 10:53:33 -04:00
Earl Co 7f8c9921a1 security(engine): confine stored media filesystem access (#2869)
* security(engine): confine stored media filesystem access

* relink: index symlinked files; skip only symlinked folders

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:53:29 -04:00
Earl Co 875eec90e9 security(render): bound expanded frame plans (#2942)
* security(render): bound expanded frame plans

* render: count plan layers as they are pushed instead of re-summing every step

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:53:24 -04:00
Earl Co 1c40c48289 security(mxf): share resolved descriptor metadata (#2949)
* security(mxf): share resolved descriptor metadata

* mxf: keep every source-package track in the no-material-package fallback

Two packages may reuse a track id and number; comparing only those dropped a real track.

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:53:19 -04:00
Marco Arnulfo 0b99bafcc4 Project panel: a clip dropped on New Item makes a sequence from it (fixes #2855) (#2940) 2026-10-23 10:53:14 -04:00
Earl Co 66da0e5270 security(avi): bound OpenDML index work (#2925) 2026-10-23 10:53:09 -04:00
Earl Co a6ed3ecd14 security(text): bound transformed glyph masks (#2900) 2026-10-23 10:53:05 -04:00
Earl Co 71d5f52b4d security(opus): validate direct multistream headers (#2905) 2026-10-23 10:53:00 -04:00
Gabriel Luís da Silva 980c388325 codecs: walk open-GOP leading pictures back in a loop, not by recursion (#2920)
* codecs: walk open-GOP leading pictures back in a loop, not by recursion

MxfVideo::sync_before and MpegVideo::sync_before recursed one GOP back
per leading picture, so a crafted index whose every GOP leads its key
frame recursed once per GOP: tens of thousands of frames for a small
file, a plausible stack overflow in dev builds.

Both now use gop::open_gop_sync, a loop over the same steps. It stops
when the index's sync sample lies past the picture instead of looping,
and reads pts and keys with get().

Fixes #2803

* gop: keep decode_start's doc comment on decode_start

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:52:55 -04:00
Earl Co 68cfb0b52d security(text): keep font scans within configured folders (#2876)
* security(text): keep font scans within configured folders

* fonts: still find symlinked font files; skip only symlinked folders

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

* fonts: rustfmt

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:52:49 -04:00
Earl Co d3ec344691 security(ui): bound export folder elision work (#2902)
* security(ui): bound export folder elision work

* export mode: keep location_link's doc comment on the function

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:52:45 -04:00
Earl Co 441e80b7a6 security(tts): make malformed-token scanning linear (#2871)
* security(tts): make malformed-token scanning linear

* tts-text: find a pause marker's ] by byte, so a multi-byte character at byte 64 doesn't hide it

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:52:40 -04:00
Earl Co 599516dc1c fix(control): bound synthetic pointer events (#2868)
* fix(control): bound synthetic pointer events

* control: check ui.click's count before queuing any input events

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:52:35 -04:00
Earl Co 9722d77aa5 security(engine): bound graphic template thumbnails (#2887)
* security(engine): bound graphic template thumbnails

* graphic templates: scale a capped thumbnail to fit both sides of the canvas

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:52:30 -04:00
Earl Co ebe15de141 security(bench): isolate temporary export outputs (#2928)
* security(bench): isolate temporary export outputs

* chore(bench): record tempfile dependency
2026-10-23 10:52:26 -04:00
Earl Co 73265622b2 security(h264enc): cache second-pass plan sums (#2906) 2026-10-23 10:52:21 -04:00
Earl Co cbeb748ecc security(matroska): compose stripped private data once (#2911) 2026-10-23 10:52:17 -04:00
Earl Co 370019bdfc security(isobmff): bound metadata expansion (#2933) 2026-10-23 10:52:13 -04:00
Earl Co ccb80b2e9d security(platform): bound hybrid replay output (#2938) 2026-10-23 10:52:08 -04:00
Earl Co 8fa72cf18d security(codecs): avoid quadratic MPEG timestamp lookup (#2883) 2026-10-23 10:52:03 -04:00
Earl Co 19ff23cba3 security(hevc): validate slice payload before allocation (#2939) 2026-10-23 10:51:59 -04:00
Earl Co 04a4a7bc72 security(proxies): reserve outputs atomically (#2922)
* security(proxies): reserve outputs atomically

* proxies: ReservedOutput::file returns an Option instead of expect()

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

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:51:54 -04:00
Marco Arnulfo 9f16d97750 Raise a loaded project's next_id past the ids already in it (#2864)
* Raise a loaded project's next_id past the ids already in it (fixes #2809)

* Demo project: start new ids past its fixed transition and marker ids (#2809)

* Count transcript, source graphic and narration keys as ids in use on load (#2809)

* Format tests: a transcript's key raises next_id on load (#2809)

* Count ids that are only referenced (clip items, subclip parents, transition ends, sends, outputs, cameras, merged clips) on load (#2809)
2026-10-23 10:51:49 -04:00
Earl Co f6e053b352 security(text): bound text and phonemizer work (#2870)
* security(text): bound layout and font-name work

* security(tts): bound phonemizer input expansion
2026-10-23 10:51:44 -04:00
Earl Co 199b70c1d1 security(interchange): bound imported track indices (#2910) 2026-10-23 10:51:39 -04:00
HEOJUNFO 700bdd9190 Scroll the timeline clip menu when it is taller than the screen (#2852) (#2862)
* Scroll the timeline clip menu when it is taller than the screen (#2852)

The clip menu has about 35 items and ran off the bottom of short
screens with no way to reach the rest. It now scrolls inside the window,
as the panel menu already does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Let the clip menu move up before it scrolls

The scroll area only took the height left below the pointer, so the menu
stayed where it was opened and scrolled even when the whole menu fit on
the screen, and the clip menu tests in clip_dialogs_ui could no longer
click the items past that height. Asking for the full height lets the
popup move up as before; it only scrolls when the screen is too short.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 10:51:34 -04:00
skylar taylor-barrick b366ff3205 Regression tests for #130, #703, #1084 and #1309 (#2861)
* #703: test that timeline track lines are visible and the playhead head is short

The lines between timeline tracks were drawn in the timeline background colour, so they could not be seen, nothing marked the top of the highest video track, and the playhead head covered the whole ruler. The tests check that separator-coloured lines run under every track and above the top video track, and that the head stays within the bottom 14 points of the ruler. Without the fix no such lines are drawn and the head is 38 points tall.

* #130: test that timecodes and menus use the Premiere-style type

The big timecodes were 15 pt semibold and the menu bar's menus used egui's default text size. The tests check that the timeline, program and reference timecodes are painted in 16 pt regular in the timecode colour, and that File menu items are drawn at 13 pt. Without the fix the timecodes are 15 pt semibold and the menu items are 12 pt.

* #1084: test that the Import captions file button label is legible in light mode

The label was always white, so it disappeared against the light grey button in light mode. The test shows the empty Captions tab in the dark and light themes and requires at least 3:1 contrast between the label and the button fill. Without the fix the light theme fails at 1.04:1.

* #1309: test that the sort arrow does not cover a sorted column's name

In a narrow List-view column the sort arrow was painted over the end of the clipped column name. The test narrows the sorted Name column and requires the name's visible clip rect to end left of the arrow. Without the fix the name stays visible up to x=67 while the arrow starts at x=60.

* #1084: hold the Import captions label to 4.5:1 contrast

The label is normal-size text (12 pt semibold), so WCAG AA asks for 4.5:1, not the 3:1 the test allowed. Both themes clear 4.5:1 today; with the fix reverted the label is at 1.04:1.

* #130: menu bar titles use the 13 pt menu text, like their menus

#130 styled the menus' items with theme::menu_style through MenuConfig, which egui applies to submenus only. The bar's own titles kept egui's default 12 pt, dimmer button text. MenuBar::style takes the same style, so the titles and their menus now share one size and colour, as in a native menu bar. The #130 test now checks the File title too; without this change it fails at 12 pt.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-23 10:51:23 -04:00
Dylan M. Taylor 3fadb8af6a Merge pull request #2983 from dylanmtaylor/sonar/security-reliability
fix: harden saves, CLI file I/O and AV1 tooling (SonarCloud security/reliability)
2026-10-23 10:18:25 -04:00
Dylan M. Taylor 689e37782a fix: harden CLI file I/O and AV1 tooling (SonarCloud)
- Read and write CLI inputs/outputs on a blocking pool instead of stalling
  the async runtime (rust:S7493).
- Document the FLAC STREAMINFO MD5 as the RFC 9639 integrity signature it
  is, and validate the AV1 generator's spec path before reading it.
- Normalize the DCT basis literals that read as numeric suffixes.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-23 09:53:17 -04:00
skylar taylor-barrick abf44fda90 Reject non-hex characters in colour hex parsing (#2600)
Accept only ASCII hexadecimal digits after a single optional hash in color strings.
2026-10-22 22:56:01 -06:00
skylar taylor-barrick 229b3a0fec #2679: effects.setParam refuses values outside the parameter's range (#2709)
Reject float effect parameter values outside the declared inclusive range without changing project state.
2026-10-22 22:52:46 -06:00
skylar taylor-barrick 85c431d6f6 Reject signed fields in SCC timecode parser (#2630)
Reject signed SCC timecode fields while preserving valid and drop-frame parsing.
2026-10-22 22:52:36 -06:00
skylar taylor-barrick edb501a3fe Fix false overlaps for empty time ranges (#2601)
Treat zero- and negative-duration time ranges as empty in overlap checks.
2026-10-22 22:51:02 -06:00
skylar taylor-barrick f1ba4e9596 Escape tab/newline/CR in XML attribute values (#2586)
Encode attribute whitespace using numeric character references so it survives XML parsing.
2026-10-22 22:49:04 -06:00
skylar taylor-barrick b31a5d94da Keep WebVTT cues whose identifier starts with NOTE (#2629)
Keep valid WebVTT cues with NOTE-prefixed identifiers instead of dropping them as comments.
2026-10-22 22:48:54 -06:00
skylar taylor-barrick 0f88c67928 Reject signed fields inside multi-field timecodes (#2636)
Reject signed components in separated timecodes while preserving relative prefixes and empty fields.
2026-10-22 22:48:28 -06:00
skylar taylor-barrick e29bc50fe0 Merge branch 'main' into fix/2679-setparam-range 2026-10-22 22:30:53 -06:00
skylar taylor-barrick 8e294e7d2a Merge branch 'main' into fix/h846-xml-writer-does-not-escape-tab-n 2026-10-22 22:30:50 -06:00
skylar taylor-barrick 87f7594407 Merge branch 'main' into fix/h938-color-parse-hex-accepts-signed-d 2026-10-22 22:30:48 -06:00
skylar taylor-barrick 0667ce3907 Merge branch 'main' into fix/empty-time-range-overlaps 2026-10-22 22:30:44 -06:00
skylar taylor-barrick 1b1ed5cc70 Merge branch 'main' into fix/h1115-webvtt-cue-whose-identifier-star 2026-10-22 22:30:41 -06:00
skylar taylor-barrick c3142b63c6 Merge branch 'main' into fix/h1135-scc-timecode-parser-accepts-sign 2026-10-22 22:30:39 -06:00
skylar taylor-barrick ce1b1cd718 Merge branch 'main' into fix/h1166-parse-timecode-accepts-signed-fi 2026-10-22 22:30:35 -06:00
coderabbitai[bot] d3801e2df8 control: Require session-token authentication before dispatching requests (#2764)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-22 22:17:48 -04:00
skylar taylor-barrick 22ab5833d0 M8: Fujifilm F-Log, F-Log2 and F-Log2 C colour spaces (F-Gamut / F-Gamut C) (#2819)
Add Fujifilm log curves, F-Gamut C primaries, and color-space presets.
2026-10-22 19:40:42 -06:00
skylar taylor-barrick 3131b1462c Merge branch 'main' into feat/fujifilm-flog 2026-10-22 19:24:50 -06:00
skylar taylor-barrick f47a1f6480 CI: run cargo xtask ci on pull requests and pushes to main (#2563)
CI: run cargo xtask ci on pull requests and pushes to main
2026-10-22 18:47:06 -06:00
skylar taylor-barrick 07f2d3a213 i18n test: leave installed right-to-left faces out of the Japanese check (#2576)
i18n test: leave installed right-to-left faces out of the Japanese check
2026-10-22 18:14:46 -06:00
Brandon Thomas 274c4cf85b Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:06:07 -04:00
skylar taylor-barrick f83764e634 dev_icon: write the MimeType line the packaged .desktop file has (#2572)
dev_icon: write the MimeType line the packaged .desktop file has
2026-10-22 17:55:56 -06:00
skylar taylor-barrick 2860e4bdf8 Read the a of a.m. and attached am as the letter name A (#2832)
Read the a of a.m. and attached am as the letter name A
2026-10-22 17:44:05 -06:00
skylar taylor-barrick defb9ca947 Keep file URL hosts that only start with "localhost" (#2757)
Keep file URL hosts that only start with "localhost"
2026-10-22 17:43:57 -06:00
skylar taylor-barrick a9c6803489 Speak the minus sign of negative currency amounts in TTS normalizer (#2756)
Speak the minus sign of negative currency amounts in TTS normalizer
2026-10-22 17:43:48 -06:00
skylar taylor-barrick 13a9ed1f6d Reject TTML offset times that have no integer digits (#2753)
Reject TTML offset times that have no integer digits
2026-10-22 17:43:40 -06:00
Brandon Thomas 74242b51d6 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:37:00 -04:00
NetterSilver cda58a2b1d Read the a of a.m. and attached am as the letter name A
Found while testing crates/tts-text/src/normalize.rs:416 (parse_time am/pm suffix) and :947 (parse_wordish a.m. branch); consumed by crates/tts-text/src/phonemize.rs:398 (is_single_letter)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:39:43 +02:00
melchiaaronzedek 6cfd42c511 M8: Fujifilm F-Log, F-Log2 and F-Log2 C colour spaces (F-Gamut / F-Gamut C)
Interpret Footage ▸ Color Management now offers "Fujifilm F-Log / F-Gamut", "Fujifilm F-Log2 /
F-Gamut" and "Fujifilm F-Log2 C / F-Gamut C" (X-S20, X-H2S, X-T5, GFX…), implemented from
Fujifilm's GFX ETERNA Technical Guide white paper v1.01 (the F-Log / F-Log2 data sheet
formulas): 10-bit code-value domain, F-Gamut = BT.2020 primaries, F-Gamut C primaries
(0.7347, 0.2653) (0.0263, 0.9737) (0.1173, -0.0224). Round trip and monotonicity tests cover
the new curves with the others; reference values from the white paper's table: 0 % → 95,
18 % → 470 / 400, 90 % → 705 / 570 (F-Log / F-Log2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:32:05 +05:30
BFlat dd525c93d4 Merge pull request #2763 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:48 +07:00
bflatastic d30fbaf4e6 README: the app icon above the title 2026-10-22 17:57:51 +07:00
NetterSilver 47e5f63ded Keep file URL hosts that only start with "localhost"
Found while testing crates/interchange/src/common.rs:68

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:14:56 +00:00
NetterSilver 033caf30ff Speak the minus sign of negative currency amounts in TTS normalizer
Found while testing crates/tts-text/src/normalize.rs:78-97 (normalize, '-' / currency arms)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:08:27 +00:00
NetterSilver b692d0262e Reject TTML offset times that have no integer digits
Found while testing crates/captions/src/ttml.rs:358 (parse_time, offset-time branch) and :310 (decimal_ticks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 07:47:55 +00:00
Dylan M. Taylor 45901fccd0 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#2720)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 19:36:08 -04:00
Dylan M. Taylor 7576186878 security: validate CLI paths/args in dev tools (SonarCloud S2083/S8707/S8705) (#2723)
* security: validate CLI paths/args in dev tools (SonarCloud S2083/S8707/S8705/S7044)

Resolve and validate command-line path arguments before use, enforce HTTPS at
download sinks, and confirm path containment. Hardens local codegen/fetch tools
against path-traversal and command-argument-injection findings.

* ci: exclude dev tool scripts from SonarCloud coverage gate

The extract_tables.py codegen tools aren't part of the unit-tested surface;
exclude tools/, docs/, examples/ from coverage so the new-code coverage gate
doesn't block hardening-only changes.
2026-10-21 19:35:47 -04:00
Dylan M. Taylor 3325caab6a fix: use async file I/O in async MCP screenshot handler (SonarCloud rust:S7493) (#2724)
The async screenshot() handler read and deleted the screenshot with blocking
std::fs calls, stalling the Tokio executor. Switch to tokio::fs::read /
tokio::fs::remove_file (await) and enable the tokio "fs" feature.
2026-10-21 19:35:42 -04:00
skylar taylor-barrick 410f44e341 Merge pull request #1795 from g1obalworlds/fix/hostile-input-video-codecs
Hostile-input hardening for video decoders (AV1 panic + oversize allocation, MPEG-2 allocation) and fuzz tests
2026-10-21 17:09:37 -06:00
skylar taylor-barrick 14eb1d6157 Merge pull request #2555 from justacalico/fix/audio-output-recovery
Re-open a failed audio output while playing instead of going silent for good
2026-10-21 17:09:30 -06:00
skylar taylor-barrick ed7d11dbca Merge pull request #1987 from nuclearspike/codex/security-mp4-audio-duration
codecs: avoid duration-sized silence allocation for damaged MP4 audio
2026-10-21 17:09:22 -06:00
skylar taylor-barrick c6581d83a7 Merge pull request #2023 from nuclearspike/codex/security-aaf-extraction-collision
engine: preserve existing files when AAF/OMF imports embedded audio
2026-10-21 17:09:16 -06:00
skylar taylor-barrick 157166bf15 Merge pull request #2388 from Felix-Helleckes/fix/2342-cli-run-waits-for-export
#2342: CLI file.exportMedia waits for the export before the process exits
2026-10-21 17:09:10 -06:00
skylar taylor-barrick 093e2de1d0 Merge pull request #1955 from Silvertree2010/fix/h786-auto-save-rotation-deletes-the-n
Order auto-saves by real time so rotation keeps the newest
2026-10-21 17:09:04 -06:00
skylar taylor-barrick 7b3bd08acb Merge pull request #2006 from ssandweiss/fix/1943-macos-cmd-q-save-prompt
fix(macos): ask to save on Cmd-Q instead of quitting outright (#1943)
2026-10-21 17:08:58 -06:00
Dylan M. Taylor e8253fff50 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:38 -04:00
Dylan M. Taylor 78ceb6e3c7 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:58 -04:00
irgendwasmitfelix 2bc99e0d7a #2679: effects.setParam refuses a value outside the parameter's range
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:33:20 +02:00
Dylan M. Taylor d643a88665 Merge pull request #2157 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 02:38:55 -04:00
NetterSilver 58b58440eb Reject signed fields inside multi-field timecodes
Found while testing crates/time/src/lib.rs:483 (parse_timecode, multi-field branch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 03:43:17 +02:00
NetterSilver 643371f6dd Reject signed fields in SCC timecode parser
Found while testing crates/captions/src/scc.rs:37 (parse_timecode)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:21:18 +02:00
NetterSilver 2736a1831e Keep WebVTT cues whose identifier starts with NOTE
Found while testing crates/captions/src/vtt.rs:75 (parse, NOTE block check)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:11:19 +02:00
Lucenx9 50152171e6 M3: Ignore empty time ranges in overlap checks
Require both ranges to be nonempty before comparing endpoints. This prevents zero and negative durations inside a clip from reporting a false overlap, matching is_empty and intersect.

Validation: 22 time tests and 69 edit tests pass; crate Clippy, formatting, layers and assets pass. Full CI is blocked by three existing UI nonminimal_bool diagnostics; contributor approved publishing with this blocker documented.

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-10-20 19:25:05 +02:00
NetterSilver 1bda219da0 Reject non-hex characters in colour hex parsing
Found while testing crates/color/src/lib.rs:298-302 (parse_hex); used by crates/engine/src/commands.rs:3615 (json_to_param, Color params)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:17:23 +00:00
Brandon Thomas 70f0fc7a75 Skip CodeRabbit reviews on Dependabot PRs (#2594)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:38:41 -04:00
Dylan M. Taylor 6687835a5d Merge pull request #2368 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:32:22 -04:00
NetterSilver ae313a8801 Escape tab/newline/CR in XML attribute values
Found while testing crates/interchange/src/xml.rs:7 (escape, used by start_tag for attributes)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:58:22 +02:00
FredApps e2da60722e i18n test: leave installed right-to-left faces out of the Japanese check
#395 (cfe0c751) appends installed right-to-left faces (system-arab,
system-hebr) after every other fallback, by design. On a machine with an
Arabic system font and craft-fonts without Arabic, they follow the
craft-fonts faces, so japanese_needs_an_installed_font, which expects a
craft-fonts face last, has failed since then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:01:43 +02:00
FredApps 79f5036c3f dev_icon: write the MimeType line the packaged .desktop file has
69a6c4b6 added MimeType= to packaging/linux/ai.storyteller.filmcraft.desktop
but not to the entry a dev build writes, so
dev_icon::tests::desktop_file_matches_the_packaged_fields has failed on
main since then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:37:10 +02:00
FredApps 28fcadddd2 CI: build the release test binaries without LTO, allow 120 minutes
xtask ci runs clippy and the tests with --release. With the shipped
release profile (thin LTO, codegen-units = 1) linking the test binaries
alone ran past 70 minutes on a standard runner. CI keeps opt-level but
turns LTO off and uses 16 codegen units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:17:27 +02:00
FredApps b787c150e7 CI: run cargo xtask ci on pull requests and pushes to main
Only path-filtered workflows run on pull requests today, so most PRs land
without a test run. Port of the wordcraft#800 workflow: Linux, standard
GitHub-hosted runner, read-only token, no secrets, craft-fonts pinned to
the release.yml commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:57:34 +02:00
justacalico a25d707876 修复播放中音频输出断流后无法自动恢复的问题 2026-10-19 23:01:59 -07:00
Logan Jennings 18cad0b0ce Fuzz seeds that test what they claim: VP8 -slices 8 (8 partitions), VP9 two tile columns at 512 px, HEVC 2x2 tiles via kvazaar; hang test ignores the timeout override 2026-10-19 20:13:43 -07:00
Logan Jennings c1e431ad2d AV1 limits are the level 6.x ones (16384 wide, 8704 high, 35651584 samples); hang guard instead of per-run time asserts; encoder failure is not a skip
- decoder.rs: MaxHSize 16384, MaxVSize 8704 and MaxPicSize 35651584 per AV1 Annex A (the earlier
  16384 x 8704 product is four times the area limit); boundary unit tests on frame_size_allowed;
  README states the three limits.
- testkit: with_deadline/set_progress (whole-sweep hang guard naming the last input;
  FILMCRAFT_HOSTILE_TIMEOUT_SECS overrides) and ffmpeg_has_encoder.
- The nine fuzz_hostile tests drop their per-run elapsed assertions (which could not catch a
  hang and could fail a slow runner) and run under the guard.
- A seed whose encoder is absent from ffmpeg still skips (and fails under
  FILMCRAFT_REQUIRE_ORACLES=1); an encoder that is present but fails now fails the test.
- mpeg2v failure labels carry the seed name.
2026-10-19 19:20:37 -07:00
Brandon Thomas cdf43fd366 Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:18:41 -04:00
Logan Jennings 0fd76d55e8 Review follow-up: drop the 1.5 GB end-to-end max-size test; report every skipped seed
The exact 16384 x 8704 boundary stays covered by the frame_size_allowed unit tests;
decoding such a frame needs ~1.5 GB, too much for every cargo test. Empty seeds and the
mpeg2v seed skip now say so, like the other skips.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 17:36:52 -07:00
Logan Jennings bd3b2097bb Address review: AV1 area cap is exactly level 6.3 (16384 x 8704); document new limits; report skipped seeds
The cap was 1 << 27, below the 16384 x 8704 it quotes, so a conforming maximum-size
frame was refused. Extract frame_size_allowed() and test the exact boundary, plus a
stream at 16384 x 8704 that must pass the size check. Note the AV1 and MPEG-2 limits
in the crate READMEs. A fuzz seed that ffmpeg cannot encode (encoder missing) now
reports SKIPPED, and fails under FILMCRAFT_REQUIRE_ORACLES=1, instead of passing silently.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 17:19:41 -07:00
Dylan M. Taylor fb4cbda47a Merge pull request #2323 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-19 19:23:20 -04:00
Sam Sandweiss c840eeba59 fix(macos): the native Quit item still quits in control-only mode
CodeRabbit on #2006: --control-input-only drops every command-inbox
command, and Quit now arrives that way instead of as terminate:, so it
left the window open. app.quit is let through; other menu commands are
still ignored, as docs/control-protocol.md says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:23:25 -07:00
Sam Sandweiss d98280f27b docs(macos): say when the Quit item picks up a remapped shortcut
Review on #2006.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:53:23 -07:00
Sam Sandweiss 8eb2d43649 fix(macos): ask to save on Cmd-Q instead of quitting outright (#1943)
The native app menu's Quit item was muda's predefined quit, which calls
AppKit's terminate: and owns the Cmd-Q key equivalent, so the window never
got the close request the unsaved-changes prompt waits for. Quit is now a
regular menu item that sends the app.quit command, which closes the window
through the prompt, on the command's shortcut (Cmd-Q unless remapped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:53:01 -07:00
Brandon Thomas 105c09bd80 Merge pull request #2452 from Felix-Helleckes/feat/1487-text-panel-graphics-tab
#1487: the Text panel's Graphics tab lists and edits the sequence's graphic text
2026-10-19 12:54:19 -04:00
Brandon Thomas 520e32d893 Merge main into #2452 (resolve append conflicts)
# Conflicts:
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/zh-cn.tsv
#	crates/ui-egui/tests/graphics_ui.rs
2026-10-19 12:54:10 -04:00
Brandon Thomas cf3bbde67b Merge pull request #2451 from Felix-Helleckes/feat/1186-track-color-labels
Timeline: colour labels for tracks (#1186)
2026-10-19 12:54:07 -04:00
Brandon Thomas 837835f81f Merge main into #2451 (resolve append conflicts)
# Conflicts:
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/zh-cn.tsv
2026-10-19 12:53:57 -04:00
Brandon Thomas 6927ebebba Merge pull request #2459 from Felix-Helleckes/feat/2358-animate-graphic-properties
#2358: stopwatches in the Essential Graphics panel animate layer properties
2026-10-19 12:53:54 -04:00
Brandon Thomas 4922e5328c Merge main into #2459 (resolve append conflicts)
# Conflicts:
#	crates/ui-egui/tests/graphics_ui.rs
2026-10-19 12:53:46 -04:00
Brandon Thomas 302f51835a Merge pull request #2472 from Silvertree2010/fix/h568-audio-samples-time-display-shows
Fix AudioSamples time display for negative times
2026-10-19 12:53:42 -04:00
Brandon Thomas d90538d094 Merge main into #2472 (resolve append conflicts)
# Conflicts:
#	crates/time/src/lib.rs
2026-10-19 12:53:33 -04:00
Brandon Thomas 94eb3df3e5 Merge pull request #2482 from Felix-Helleckes/fix/1985-floating-panel-reopens-floating
#1985: a panel closed while floating opens again floating where it was
2026-10-19 12:52:18 -04:00
Brandon Thomas 48e4ab8456 Merge pull request #2462 from Felix-Helleckes/i18n/german-complete
i18n: German (de) covers the whole interface
2026-10-19 12:52:14 -04:00
Brandon Thomas 0fd5c33303 Merge pull request #2440 from Felix-Helleckes/fix/1682-cap-thumbnail-textures
#1682: thumbnail textures are capped, least recently used freed first
2026-10-19 12:52:10 -04:00
Brandon Thomas 7e3060d185 Merge pull request #2450 from Felix-Helleckes/fix/2329-indent-effects
#2329: the Effects panel indents what a folder holds under it
2026-10-19 12:52:06 -04:00
Brandon Thomas d61f23c7c9 Merge pull request #2457 from Felix-Helleckes/feat/2325-ui-animations-toggle
#2325: Settings > Appearance can turn the interface animations off
2026-10-19 12:52:02 -04:00
Brandon Thomas bb5fa3a740 Merge pull request #2478 from Felix-Helleckes/fix/1362-multicam-from-timeline
#1362: Create Multi-Camera Source Sequence and Merge Clips work on Timeline selections
2026-10-19 12:51:58 -04:00
Brandon Thomas 666c8d1bdc Merge pull request #2480 from MichaelMuClaude/fix/doc-inspect-sequence-race-1477
fix(mcp): pin doc_inspect's sequence to the reported active id (#1477)
2026-10-19 12:51:53 -04:00
Brandon Thomas fec702eca4 Merge pull request #2481 from Felix-Helleckes/fix/2253-reversible-ui-zoom
#2253: Ctrl+Plus no longer scales the interface one way only; View > Reset Interface Zoom
2026-10-19 12:51:48 -04:00
Brandon Thomas 045e694204 Merge pull request #2499 from jackcanon/jackcanon/font-size-controls
Properties: selected-character drags undo as one gesture
2026-10-19 12:51:44 -04:00
Brandon Thomas f4b7486b3e Merge pull request #2436 from Felix-Helleckes/fix/2185-stream-preview-files
#2185: preview files are read in place, not loaded whole
2026-10-19 12:51:40 -04:00
Brandon Thomas 70b6539aa9 Merge pull request #2477 from Felix-Helleckes/fix/2430-click-moves-caret
#2430: a click in the text being edited on the monitor moves the caret
2026-10-19 12:51:35 -04:00
Brandon Thomas 6811d4141a Merge pull request #2458 from Felix-Helleckes/feat/2328-effect-controls-divider
#2328: Effect Controls draws a line between the fixed and the added effects
2026-10-19 12:51:30 -04:00
Brandon Thomas 21e8636bd4 Merge pull request #2467 from Silvertree2010/fix/h546-cube-with-both-a-1d-and-a-3d-tab
Preserve per-channel shaper domain in .cube round trip
2026-10-19 12:51:26 -04:00
Brandon Thomas eee1e403c2 Merge pull request #2479 from Silvertree2010/fix/h617-seconds-time-display-shows-0-000
Fix "-0.000" in Seconds time display for tiny negative times
2026-10-19 12:51:22 -04:00
irgendwasmitfelix 9ade822e64 #1985: a refused refloat keeps the panel's floating place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:18:58 +02:00
jackcanon 204505bcf5 FONT-UNDO-001: merge selected-character property drags
Use the existing begin/merge history contract for character styles, isolating clip, layer and selected ranges. Cover actual pointer gestures, undo/redo, fresh gestures and invalid targets.
2026-10-19 08:23:19 -07:00
irgendwasmitfelix 18f0cfe404 #1985: a Timeline closed while floating floats again too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:11:14 +02:00
irgendwasmitfelix af3263cd9b #2253: Reset Interface Zoom replies with the zoom it set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:04:46 +02:00
irgendwasmitfelix 37ec175ced #1362: test Merge Clips from a Timeline selection it can merge, and its explanation for one it can't
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:57:33 +02:00
irgendwasmitfelix 5510a52a06 #1985: a panel closed while floating opens again floating where it was
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:51:33 +02:00
Michael ce8c699fe8 fix(mcp): read activeSequence as u64, not i64 (review)
ItemId wraps a u64 (item_p parses the `item` param with u64_p), so
reading activeSequence with as_i64 would silently return None for
any id above i64::MAX and make document()'s sequence go null instead
of inspecting the valid id project.inspect reported. Switches to
as_u64 and adds a regression test for that boundary (proven to fail
against as_i64 first).

Addresses a CodeRabbit review comment on #2480.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 20:49:45 +08:00
irgendwasmitfelix 6a2516f4fa #2253: Ctrl+Plus goes where Ctrl+= goes, and View > Reset Interface Zoom undoes a session zoom
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:44:21 +02:00
Michael 27b327ab65 fix(mcp): pin doc_inspect's sequence to the reported active id (#1477)
document() composed project.inspect and sequence.inspect as two
separate round trips to the app (two lock scopes in headless mode,
two TCP round trips in bridge mode). sequence.inspect with no `item`
answers for whichever sequence is active *now*, not the one
project.inspect just reported, so if the active sequence changes in
that window (another client, or the app's own UI, switching tabs)
the pair comes back inconsistent: project.activeSequence names one
sequence while sequence describes another.

Reproduced against a running bridge-mode app: a real MCP client
hammering doc_inspect while an independent connection flipped
sequence.open between two sequences mismatched on ~300/300 calls, so
this is a wide window, not a rare edge case.

Fix: read project.inspect's activeSequence id first, then pass it
explicitly as sequence.inspect's `item`, so the second call always
describes the sequence the first one named, regardless of what
becomes active afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 20:39:03 +08:00
NetterSilver 405619b417 Fix "-0.000" in Seconds time display for tiny negative times
Found while testing crates/time/src/lib.rs:423 (format_time, TimeDisplay::Seconds)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 12:18:13 +00:00
irgendwasmitfelix 838a919663 i18n: German needs only the core menus, like French and Korean; later strings may stay English
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:16:49 +02:00
irgendwasmitfelix 52e3c5b9c4 #1362: Create Multi-Camera Source Sequence and Merge Clips work on clips selected in the Timeline
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:59:29 +02:00
irgendwasmitfelix 63a5a6bb38 #2430: a click in the text being edited on the monitor moves the caret
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:48:47 +02:00
NetterSilver 9a93d175a4 Fix AudioSamples time display for negative times
Found while testing crates/time/src/lib.rs:418 (format_time, TimeDisplay::AudioSamples)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:27:22 +00:00
irgendwasmitfelix 9e778e3693 #2358: the Tracking test checks where the keyframes land
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:24:12 +02:00
NetterSilver e9f4a9ff6b Preserve per-channel shaper domain in .cube round trip
Found while testing crates/color/src/lut.rs:290 (Lut::to_cube, the `both` branch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:42:03 +00:00
irgendwasmitfelix 1d7e2238b0 i18n: German has every Spanish entry; scope the coverage claims
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:32:36 +02:00
irgendwasmitfelix aa918e739f #2358: "mask": null counts as no mask when creating a missing parameter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:27:28 +02:00
irgendwasmitfelix 3d16f9323f #1487: a Graphics tab edit writes keyframed text where it is shown
With the playhead outside a clip the tab shows the clip's text at its
start, but the edit went to graphics.set without a time, which writes at
the playhead clamped into the clip (its last frame). For keyframed text
the shown value stayed and another keyframe changed. Each row now keeps
the time it read its text at and the edit is written there; a new test
covers keyframed text with the playhead past the clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:15:40 +02:00
irgendwasmitfelix c59917baf9 #2358: Toggle Animation creates a parameter an older instance lacks
The new stopwatches show for every animatable parameter of the layer's
definition, but effects.toggleAnimation only found parameters stored in
the instance, so a layer saved before a parameter existed answered "no
such param". The parameter is now created from its default first (an
existing one and its keyframes are left alone). Engine test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:06:53 +02:00
irgendwasmitfelix 142428048a #2329: the Effects panel indents what a folder holds under it
An effect in a sub-folder was drawn left of that sub-folder's label (Gaussian Blur at 47 points,
Blur & Sharpen at 55), so folders and effects read as one level. Rows now follow one rule,
`tree_x(depth)`: an effect sits one level deeper than its folder (sub-folder or, for loose
effects, the top folder), with its icon and label where a folder at that depth has them; user and
built-in presets and Lumetri presets the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn
2026-10-19 09:48:33 +02:00
irgendwasmitfelix ca7c56238d #2328: Effect Controls draws a line between the fixed and the added effects
The first effect added from the library after a clip's fixed effects (Motion, Opacity, Time
Remapping; Volume, Channel Volume, Panner on audio) gets an accent-coloured line on its top edge.
It is drawn on the row's edge, so no row moves; with only fixed effects there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn
2026-10-19 09:41:57 +02:00
Claude f4b1bee71f i18n: German (de) covers the whole interface
#630 shipped German for the menus. This adds the other 3,213 strings, so
de.tsv has every entry of the Spanish catalog (3,590): panels, dialogs,
effects and their parameters, transitions, presets, Settings, shortcut
commands, export presets, tooltips and status messages. Same style as the
menus (du, infinitives, placeholders, ellipses and escapes kept); the
glossary in the header is extended. The 377 menu entries are unchanged.

german_entries_cover_the_original_menu_catalog required every entry to be
a menu label. It becomes german_covers_the_menus_and_has_no_dead_entries,
like the French and Italian tests: every entry needs a Spanish counterpart,
the core menus and the native macOS app menu are required, later strings
may stay English. german_renders_with_bundled_fonts checks the umlauts,
ß and German quotation marks against the bundled fonts.

Docs: ROADMAP.md, docs/localization-parity.md, docs/gaps.md, ui-egui README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJhvSJ5odpWzFw9HhPzAix
2026-10-19 09:41:57 +02:00
irgendwasmitfelix a0a75e728e #2358: stopwatches in the Essential Graphics panel animate layer properties
Letter spacing (Tracking) does animate in the engine — keyframes on the text
layer interpolate and the rendered width follows them — but the Essential
Graphics panel had no way to start that: its number and point rows had no
stopwatch, so a change there always set the value for the whole clip.

Every animatable number/point row in the panel (Tracking, Leading, Font
Size, Baseline Shift, Position, Anchor, Scale, Rotation, Opacity…) now has
the same stopwatch as Effect Controls. It calls `effects.toggleAnimation` on
the layer; once a property animates, a change in the panel adds or updates a
keyframe at the playhead (graphics.set was already keyframe-aware). A
Type-tool character selection edits character styles, which have no
keyframes, so those rows show no stopwatch while it is active.

Automation id: `graphics.prop.<id>.stopwatch`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn
2026-10-19 09:41:57 +02:00
irgendwasmitfelix 7ac0269d2c Timeline: colour labels for tracks (#1186)
A track can carry a colour label, as many NLEs offer. The strip at the
left edge of a track header opens a menu with None and the 16 labels
(with the user's label names and colours, as in Edit > Label). The label
tints the header and the lane.

- `Track.label: Option<Label>`, serde default and skipped when None, so
  older projects load and plain tracks save as before
- `timeline.setTrack {label}` (a label name; "" / "None" / null clears),
  an undoable Track Settings step; unknown names are refused
- `sequence.inspect` reports each track's `label`
- automation ids `timeline.track.<V1|A1…>.color` and `.color.<Label>`

Renaming, adding and deleting tracks from the header are covered by the
open PRs for #654 and #1630, so this only adds the colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn
2026-10-19 09:41:56 +02:00
irgendwasmitfelix 35076281b3 #2325: Settings > Appearance can turn the interface animations off
"Animate zoom, scrolling and menus" (appearance.animations, on by default). Off, the Timeline
zooms and scrolls in one step instead of easing, and egui's fades and slides (menus, collapsing
sections, tooltips) are instant. The style is checked every frame, because a theme or font change
installs it again. Translated in the catalogs that cover the Settings rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn
2026-10-19 09:41:56 +02:00
irgendwasmitfelix dcbb001948 #1487: the Text panel's Graphics tab lists and edits the sequence's graphic text
The Graphics tab of the Text panel was a placeholder ("Graphics text search
arrives with M10.1–M10.2"), so text in graphics never showed up there.

It now lists the text of every text layer of every graphic clip in the
active sequence, as Premiere's does: in timeline order (then lower tracks
first, then paint order), each entry with its clip's start timecode and
track (and the layer's name when it has one). A search filters by text or
name. The timecode moves the playhead to the clip and selects the clip and
the layer (so Essential Graphics and the Program monitor's handles follow);
the text edits in place and is committed with `graphics.set` when the field
loses focus — one undo step, per-character styles kept as for any text edit.
Text that is keyframed shows its value at the playhead when the playhead is
in the clip.

Automation ids: `text.graphics.search`, `text.graphics.<clip>.<layer>.row`,
`.goto`, `.text`. The three new messages are translated in the catalogs the
coverage test requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn
2026-10-19 09:41:56 +02:00
irgendwasmitfelix eff5f024e5 #2185: the not-read-whole assertion applies where previews stream
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:22:03 +02:00
irgendwasmitfelix 258699e2ab #2342: only an explicit "wait": false skips waiting for the export
A `"wait": null` (or any non-boolean) was kept and then read as false,
so the process could exit before the export was written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:18:30 +02:00
irgendwasmitfelix 85960b9592 #2342: --help says run waits for each export before the next line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:27:29 +02:00
irgendwasmitfelix 1968616c3f #1682: thumbnail textures are capped, least recently used freed first
Every frame shown as a timeline tile or a Project panel hover scrub got a
texture of its own (thumb-<item>-<frame>-<width>) that was kept until the
project was replaced, so scrolling, zooming and scrubbing grew GPU (on
integrated GPUs, system) memory without bound. Beyond 512 thumbnail
textures the least recently used are freed, never one drawn in the
current pass.

Refs #1682

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:18:36 +02:00
nuclearspike b23675bd17 fix(import): preserve occupied AAF/OMF embedded-audio paths 2026-10-18 17:53:11 -06:00
nuclearspike 2f4b072222 engine: preserve existing files when AAF/OMF imports embedded audio
Publish extracted WAVs through each host's no-clobber operation, retry occupied names, and update imported references before project merge. Distinguish blocked directories from file collisions, route AAF/OMF/OMFI extensions consistently in both import modes, and keep the open project on replace-mode parse or extraction failure. Cover native, virtual, and staged-write failures.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-18 15:49:18 -06:00
irgendwasmitfelix 49b300f2f2 #2185: preview files are read in place, not loaded whole
Rendered previews are full-resolution ProRes (gigabytes a minute), and
PreviewStore::open read each file whole into memory, keeping up to six
open; the render also opened every finished segment right away. Rendering
previews and playing them back filled memory until the OS killed the
app. Native builds now open preview files through a file reader (the
container's index now, frames on demand), refusing any whole-file read,
and a finished segment is opened when it is first played.

Fixes #2185

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:21:56 +02:00
Dylan M. Taylor 8f7d48da49 docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:39 -04:00
Dylan M. Taylor 6c3948a36c ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:31:32 -06:00
irgendwasmitfelix 163fffbefa #2342: CLI file.exportMedia waits for the export before the process exits
Local CLI commands (`run`, `exec`) exit right after the last command, which
killed a background export before anything was written. Long commands now
default to `wait: true` there; an explicit `wait` is kept, bridge mode is
unchanged (the app keeps running).

Fixes #2342

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:02:41 +02:00
Dylan M. Taylor 6dc6a10db6 docs: fix attribution sidecar for color panel and describe panels rather than naming them 2026-10-18 12:51:18 -04:00
Dylan M. Taylor e15066b939 docs: add SonarCloud and CodeRabbit badges and standardize to flat-square 2026-10-18 11:05:44 -04:00
Dylan M. Taylor d501ae1666 docs: replace Lumetri, Premiere, and Final Cut references with generic terms 2026-10-18 10:46:48 -04:00
Dylan M. Taylor 9455ed41a1 docs: drop references to competing commercial software 2026-10-18 10:45:01 -04:00
Brandon Thomas 48088e3a8e Merge pull request #1927 from marcoarnulfo/fix/clip-mixer-strips-fit
#1922: Audio Clip Mixer strips narrow to fit the panel, and scroll when they cannot
2026-10-18 03:21:32 -04:00
Brandon Thomas 4dc802170d Merge pull request #1655 from notdezzi/fix/1542-caption-margin
captions: pin the setStyle margin contract with a regression test; clearer track error (#1542)
2026-10-18 03:21:28 -04:00
Brandon Thomas 9bc4b6b266 Merge pull request #2125 from 00xJS/fix/1544-export-job-poll
#1544: an export started without wait says where to follow it (jobs.list, jobs.cancel)
2026-10-18 03:21:23 -04:00
Brandon Thomas 8a7127d687 Merge pull request #2016 from wstock/pixel-format-from-sps
codecs: the pixel-format label comes from the SPS before the avcC/hvcC fields
2026-10-18 03:21:19 -04:00
Brandon Thomas f9a774c5de Merge pull request #1849 from marcoarnulfo/fix/caption-properties
Properties panel edits the selected caption's text and in/out times
2026-10-18 03:21:14 -04:00
Brandon Thomas 33459a26ca Merge pull request #2313 from TheGalitube/codex/fix-issue-2263-resize-cursors
Fix resize cursor directions for graphics handles (#2263)
2026-10-18 03:21:08 -04:00
Brandon Thomas c2efc23c9a Merge pull request #2214 from Silvertree2010/fix/h2233-tts-normalizer-reads-a-price-wit
Fix TTS reading of prices with more than two decimal digits
2026-10-18 03:21:03 -04:00
Brandon Thomas 310f11d9b0 Merge pull request #1925 from marcoarnulfo/fix/monitor-zoom-3d-perspective
Basic 3D: Distance to Image keeps its perspective at every render scale
2026-10-18 03:20:56 -04:00
Brandon Thomas 55d5a6e96a Merge pull request #1817 from dotCooCoo/fix/faces-without-outlines
Fonts: faces with no outline tables are no longer listed
2026-10-18 03:20:50 -04:00
Brandon Thomas 19b5526b6f Merge pull request #2290 from Wbunker/fix/cfb-writer-quadratic-duplicate-check
fix(cfb): adding a child is O(1), not a scan of every sibling (AAF export of 20 000 markers took a minute)
2026-10-18 03:20:45 -04:00
Brandon Thomas 24d191c5e7 Merge pull request #2090 from ssandweiss/fix/menu-enabled-states
fix(engine): enable Multi-Camera Enable/Flatten only for clips they work on
2026-10-18 03:20:39 -04:00
Brandon Thomas a06de0534a Merge pull request #1937 from Ni-zav/fix/media-header-overflow-s20261014t1610z
Saturate crop and field-height math on hostile Matroska/MXF/AAF headers
2026-10-18 03:20:34 -04:00
Brandon Thomas 26591777a0 Merge pull request #1907 from Guilhermeb-Ferrarezi/perf/lens-blur-disc-kernel-1871
#1871: Lens Blur works out its disc offsets once per frame, not per pixel
2026-10-18 03:20:22 -04:00
Brandon Thomas 6e1157c7a6 Merge pull request #1797 from marcoarnulfo/fix/source-waveform-zoom-detail
monitor: the Source waveform follows the time ruler's zoom
2026-10-18 03:20:17 -04:00
Brandon Thomas 2ec9c4c419 Merge pull request #2261 from Silvertree2010/fix/h2452-number-field-calculator-merges-n
Refuse numbers separated by spaces or a unit in number fields
2026-10-18 03:20:12 -04:00
Brandon Thomas 02a209cae7 Merge pull request #2141 from Silvertree2010/fix/h1878-cli-render-silently-renders-fram
Reject non-numeric --seconds and --scale in CLI render
2026-10-18 03:20:07 -04:00
Brandon Thomas fe050dbc8e Merge pull request #2026 from marcoarnulfo/fix/graph-keyframe-drag
fix(effect-controls): a dropped graph keyframe stays where it was dropped
2026-10-18 03:20:02 -04:00
Brandon Thomas 0014fb2808 Merge pull request #1935 from Ni-zav/fix/matroska-metadata-depth-s20261014t1610z
matroska: bound recursive chapter and tag parsing
2026-10-18 03:19:57 -04:00
Brandon Thomas 1f16aaa061 Merge pull request #1761 from webdevtodayjason/ttml-default-tickrate
Captions: default TTML tick rate follows the frame rate multiplier
2026-10-18 03:19:44 -04:00
Brandon Thomas 0e74a96e98 Merge pull request #2144 from ssandweiss/fix/2142-sdr-colr-export
fix(export): tag SDR exports with a colr box (#2142)
2026-10-18 03:19:39 -04:00
Brandon Thomas 07d4077bed Merge pull request #2082 from Silvertree2010/fix/h1407-color-a-3dl-lut-with-a-huge-inpu
Reject .3dl LUTs whose input mesh is larger than 256 entries
2026-10-18 03:19:34 -04:00
Brandon Thomas 2df01a7436 Merge pull request #1936 from Ni-zav/fix/cli-dotted-key-depth-s20261014t1610z
cli: reject deeply nested dotted parameters before parsing
2026-10-18 03:19:29 -04:00
Brandon Thomas 64f6dc34c6 Merge pull request #1881 from marcoarnulfo/fix/outer-stroke-fill-off
Graphics: an outer stroke with Fill off draws only the ring outside the edge
2026-10-18 03:19:24 -04:00
Brandon Thomas 6403ecbf86 Merge pull request #1777 from alexng353/fix/dialog-button-cursor
[agent] Dialog buttons show the pointing hand on hover
2026-10-18 03:19:18 -04:00
Brandon Thomas ba29721357 Merge pull request #1315 from vbasky/fix/1250-gpu-float-skip
Skip GPU goldens when Rgba32Float is not renderable
2026-10-18 03:19:13 -04:00
Brandon Thomas a46d197c1e Format the pixel-aspect test (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:59:46 -04:00
Brandon Thomas 4a69a038d3 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:43:10 -04:00
Max Mittelstädt 896525e9df Fix issue #2263 resize cursor directions 2026-10-18 01:21:20 +02:00
skylar taylor-barrick 38ac719f61 Merge pull request #2304 from skylartaylor/open-original
Follow stored media paths only on this computer or the project's share
2026-10-17 12:13:50 -06:00
skylar taylor-barrick 4f8464332f Build: take craft-docpath from the merged craft-libs rev
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
2026-10-17 12:03:05 -06:00
Wbunker a711c61840 fix(cfb): adding a child is O(1), not a scan of every sibling
Writer::add rejected a duplicate name by comparing the new name with every
sibling through compare_names, which allocates two Vec<u16> per call, so n
children in one storage cost O(n^2) allocating comparisons. An AAF export
puts every marker and component in one storage: 20 000 markers took 58 s,
20 000 clips 56 s, 20 000 connected clips 87 s and 100 000 markers never
finished (an FCPXML of 1 MB is enough). Doubling the input quadrupled the
time; every other exporter stayed linear.

Each storage keeps a set of its children's names as sibling order compares
them (UTF-16 code units, upper-cased), so the duplicate check is a hash
lookup. Same result: case-insensitive, also beyond ASCII, per storage.

20 000 markers now export to AAF in 0.38 s, 100 000 in 2.0 s, 20 000 clips in
0.23 s and 20 000 connected clips in 0.45 s.

Tests: adding 40 000 children must take under 3 s (0.02 s now, 21 s before)
and the duplicate rules are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:05:53 -04:00
NetterSilver fb5b2dc40a Refuse numbers separated by spaces or a unit in number fields
Found while testing crates/ui-egui/src/calc.rs:34-41 (eval)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:36:49 +02:00
aiongg ff66e124a4 Build: use craft-docpath from craft-libs 2026-10-17 02:20:44 +00:00
NetterSilver d0750c1dc4 Fix TTS reading of prices with more than two decimal digits
Found while testing crates/tts-text/src/normalize.rs:352 (parse_amount, used by parse_money at :266)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 20:11:34 +02:00
aiongg f565f7b38d fix(engine): follow stored media paths only on this computer or the project's share
Media and proxy paths read from a project file, a project template or an
interchange file are opened only when they name a local file or a file on
the network share the document itself is on (craft-docpath); a relative
path is taken from the document's folder. Other paths count as missing:
the item renders the offline slate with the reason, the open says how many
were not opened, and Link Media lists them. This covers the open-time
scan, rendering and proxies, the growing-files look and interchange
import. Files imported or linked in this session are opened as given.

The Scratch Disks folders use the same check, and Captured Audio and Video
(voice-over takes, pasted images, the default proxy folder) and Audio
Previews follow the rule Auto Save and Video Previews already follow.
2026-10-16 17:27:39 +00:00
aiongg 07374d1f2a fix(engine): use project scratch folders only inside the project folder or when chosen
Project Auto Save and Video Previews folders named in a project file are used
only when they are inside the project's own folder or were chosen in Project
Settings > Scratch Disks on this computer (remembered in the preference
autoSave.scratchFolders). Other folders fall back to the default location,
with a log line and a message when the project opens. Network-share and
device paths are refused for these folders.
2026-10-16 16:37:56 +00:00
aiongg 3984ef90ff fix(engine): Edit Original opens only regular files with a media extension
A folder or bundle, a link to another kind of file, or a file whose extension
the importer doesn't take is refused with a message instead of being handed to
the system's default app.
2026-10-16 16:22:07 +00:00
Dylan M. Taylor 09aaf9bf15 ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action (#2158) 2026-10-16 10:08:51 -06:00
Dylan M. Taylor 756cfdd89d ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:57:12 -04:00
skylar taylor-barrick c2f65871e9 Merge pull request #2124 from dylanmtaylor/ci/shared-freebsd-workflow
ci: delegate FreeBSD workflow to craft-common-actions and add nix check
2026-10-15 21:26:41 -06:00
Sam Sandweiss 678fe73221 fix(export): tag SDR exports with a colr box (#2142)
ColorSignal::apply_to wrote the sample entry's colr box only for HDR, so
SDR ProRes, DNxHR and APV MOVs carried no colour that Apple's media stack
reads (it takes their colour only from colr, not from the codecs' frame
headers): QuickTime Player and Final Cut saw no primaries, transfer or
matrix. SDR exports now get colr with the export's colour too (nclc in
MOV, nclx in MP4); HDR metadata stays PQ-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:53:32 -07:00
NetterSilver d4a69262e1 Reject non-numeric --seconds and --scale in CLI render
Found while testing apps/filmcraft-cli/src/main.rs:468

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 01:29:53 +00:00
Dylan M. Taylor df97b85f59 ci: delegate FreeBSD workflow to craft-common-actions and add nix check 2026-10-15 20:45:39 -04:00
00xJ0 a358c3518e #1544: an export started without wait says where to follow it (jobs.list, jobs.cancel) 2026-10-15 16:37:59 -07:00
Sam Sandweiss be283bc54c fix(engine): enable Multi-Camera Enable/Flatten only for clips they work on
Clip > Multi-Camera > Enable and Flatten were enabled for any selection
and then failed when chosen with plain clips selected ("select nested
sequence or multi-camera clips"). Their enabled checks now ask what the
commands need: a nested sequence clip for Enable, an enabled multi-camera
clip for Flatten (linked partners included), so the menu greys them out
otherwise. Calls naming their clips still work without a selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:29:27 -07:00
NetterSilver c08f3bff15 Reject .3dl LUTs whose input mesh is larger than 256 entries
Found while testing crates/color/src/lut.rs:371

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:23:12 +00:00
Vikram Bhaskaran c63d9766e9 Scope the Rgba32Float skip to the effect-chain parity test.
Main now falls back to CPU effects when an adapter cannot render to
Rgba32Float, so the scene, blend and transition parity tests still run
on such adapters. Only gpu_effect_chains_match_cpu_render needs a float
render target, and it is the one test that skips without it. Also revert
the import reorder, which failed cargo fmt --check on edition 2024.
2026-10-15 22:53:52 +11:00
Marco Arnulfo 16b62adc25 fix(effect-controls): a dropped graph keyframe stays where it was dropped (fixes #1918) 2026-10-15 11:36:01 +02:00
wstock 29dbd61782 codecs: the pixel-format label comes from the SPS before the avcC/hvcC fields
An avcC High-profile extension or an hvcC record can carry a chroma
format of 0 for a 4:2:0 stream, which labelled such files "YUV 4:0:0"
in Media File Properties (two files of the 92 in #1959). The SPS is what
the decoder decodes, so it is read first; the record's fields are only
the fallback when no SPS can be parsed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
2026-10-15 06:55:44 +00:00
Earl Co 8309f21a6c fix(export): prevent nested renderer-pool deadlocks
Bound outer export batches by actual renderer-pool capacity before nested Rayon work can steal a sibling frame. Cover image sequences, GIF, stepped export and overlapping audio with the original one-renderer regression.

Addresses #1956.

Full original GPU contribution preserved at `preserve/861-gpu-export-original` (674e8cd68d91184be4fad56d6ecb6e90e83d1f1b).

Co-authored-by: Codex <noreply@openai.com>
2026-10-14 23:05:50 -06:00
nuclearspike 56d3a70a1e codecs: avoid duration-sized silence allocation for damaged MP4 audio
Cache decoder failures without allocating silence from the file's sample duration. Zero only the failed packet's overlap with the requested source-rate buffer, including samples from an earlier valid packet when their spans overlap.

Co-authored-by: Codex <noreply@openai.com>
2026-10-14 19:39:31 -06:00
Paul 9ed9650cad fix(media): preserve Unicode prefixes in image-sequence filenames
Compute the final ASCII frame-number boundary without slicing inside a UTF-8 character. Preserve Unicode prefixes and add parser and public image-sequence import regressions, including missing frames and timeline placement.

Fixes #1565

Co-authored-by: Codex <noreply@openai.com>
2026-10-14 17:43:05 -06:00
Paul 0b432e3834 fix(interchange): avoid UTF-8 slice panics in file URLs
Use a checked localhost prefix probe so Unicode file URLs retain their media paths without slicing inside a UTF-8 character. Add public converter, resolver and EDL import regressions.

Fixes #1591

Co-authored-by: Codex <noreply@openai.com>
2026-10-14 16:24:29 -06:00
Ikki Nishihara 39a8b65e33 fix(timeline): preserve individual linked clip selections
Respect per-action linked selection, preserve exact current selections for Clear and Ripple Delete, and keep Option-drag group copying intact. Add regression coverage and align the Spanish and synthetic TIFF test fixtures.

Restores the individual-selection handling originally submitted by @vbasky in #685, with corrected click/drag behavior.

Fixes #1163
Fixes #680

Assisted-by: OpenAI Codex

Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
2026-10-14 15:40:21 -06:00
NetterSilver 208465eeff Order auto-saves by real time so rotation keeps the newest
Found while testing crates/format/src/autosave.rs:74 (list_auto_saves / rotate, called from write_auto_save at :102)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 22:45:02 +02:00
Marco Arnulfo dde4bb7d31 #1922: Audio Clip Mixer strips narrow to fit the panel, and scroll when they cannot (fixes #1922) 2026-10-14 20:34:31 +02:00
Marco Arnulfo ca35e1bb9d Basic 3D: Distance to Image keeps its perspective at every render scale (fixes #1920) 2026-10-14 20:20:58 +02:00
Guilherme Barbosa Ferrarezi 98f257f56e #1871: Lens Blur works out its disc offsets once per frame, not per pixel
The Lens Blur transition blurred both pictures with a disc of golden-angle
samples and computed every sample's offset (a square root, a sine and a cosine)
again for every pixel, though the offsets depend only on the radius and the
sample count. They are now worked out once per frame (`disc_offsets`) and the
pixels come out bit for bit the same. A 1080p picture at the default radius
blurs in 50 ms instead of 95 ms (1.9x), and a Lens Blur frame blurs two.
2026-10-14 14:25:19 -03:00
Nigel Ihza Firdausy 6be4da05cc aaf: saturate field-based source height 2026-10-14 23:15:51 +07:00
Nigel Ihza Firdausy 9fcd14ec11 mxf: saturate interlaced picture height 2026-10-14 23:15:42 +07:00
Nigel Ihza Firdausy 1cf868ba4b matroska: saturate input-derived pixel crop sums 2026-10-14 23:15:30 +07:00
Nigel Ihza Firdausy 9b9ffefbe9 cli: reject deeply nested dotted parameters before parsing 2026-10-14 23:15:07 +07:00
Nigel Ihza Firdausy 056a2c5edb matroska: bound recursive chapter and tag parsing 2026-10-14 23:14:48 +07:00
Marco Arnulfo 16b458c24c Graphics: an outer stroke with Fill off draws only the ring outside the edge (fixes #1876) 2026-10-14 16:21:27 +02:00
Brandon Thomas f03e21556d Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:15:06 -04:00
Marco Arnulfo 0253ba22c4 Properties panel edits the selected caption's text and in/out times (fixes #1846) 2026-10-14 09:33:55 +02:00
Marco Arnulfo dcc4c89de7 monitor: the Source waveform follows the time ruler's zoom (fixes #1782) 2026-10-14 04:57:04 +02:00
Alexander Ng 450af7b1cf fix(ui): dialog buttons show the pointing hand on hover
The crash-recovery prompt, the Revert confirmation and Settings draw their
buttons through local `button` helpers that never set a hover cursor, so
Discard / Not Now / Recover, Yes / No and Help / Reset… / OK / Cancel kept
the arrow. Both helpers now ask for `CursorIcon::PointingHand`, and
tests/dialog_buttons_ui.rs hovers each button headless and checks the cursor
egui requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:39:06 -07:00
webdevtodayjason e671aae25a Captions: default TTML tick rate follows the frame rate multiplier 2026-10-13 18:15:20 -05:00
Logan Jennings cb2ac66c3b Harden AV1 and MPEG-2 decoders against hostile input; add mutation-fuzz tests for the video codecs
- av1: a 4:2:2 stream (profile bit flipped) could reach blocks with no chroma size (BLOCK_INVALID) and
  index MAX_TX_SIZE_RECT out of bounds; reject them with an error in decode_block.
- av1: frame size accepted up to 65536x65536 (gigabytes of buffers from a few header bytes); cap at 16384 per
  side and 2^27 samples.
- mpeg2v: 16383x16383 4:4:4 sequences (gigabytes per picture) are refused; area capped at 2^25.
- fuzz_hostile tests (truncation, header byte/bit sweeps, random mutation) for h264, hevc, vp8, vp9, av1,
  mpeg2v, apv, prores and dnx; ffmpeg-based ones skip without ffmpeg.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-13 13:40:36 -07:00
dotCooCoo eafc6208d9 Fonts: faces with no outline tables are no longer listed
read_face (crates/text/src/sfnt.rs) also listed faces with none of the
tables skrifa reads glyph outlines from (glyf, CFF, CFF2, VARC), such
as faces whose glyphs are bitmaps only or whose outlines are in Apple's
hvgl table. The renderer draws glyphs only from those outlines, so text
set in such a face is blank. On macOS 27.0.1 the system scan listed
"GB18030 Bitmap" (NISC18030.ttf, bdat bitmaps), so it appeared in the
Font menus.

read_face now returns no face when the table directory has none of
these tables, before it reads the name table. In a collection, the
faces with outlines keep their index. VectorCraft's font scan has the
same check (storytold/vectorcraft#781).
2026-10-13 07:51:29 -07:00
notdezzi 1306339749 #1542: pin the captions.setStyle margin contract with a regression test; name a bad track in the error 2026-10-13 14:49:05 +02:00
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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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 Thomas 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
sanyamsingh001 13c89bf9df export: clip caption sidecars to the exported range
Co-authored-by: Codex <codex@openai.com>
2026-10-13 13:32:11 +10:00
heojunfo 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 Yao 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 Yao 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
sanyamsingh001 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
CokeRoach 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
Narmjep 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
CokeRoach 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-co 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
sanyamsingh001 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 Lussi 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
sanyamsingh001 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
txiki739 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
vangel76 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
jahvari 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 Boonstra 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 31539f1b84 Skip GPU golden tests when the adapter cannot render to Rgba32Float. 2026-10-13 00:40:09 +11: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
lloyd 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
lloyd 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
lloyd 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
lloyd 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
NDelic 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
NDelic 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
NDelic d74ed39c24 Contributors: add n-delic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:18:07 +02:00
NDelic 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
liangforstudy 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
liangforstudy 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 Yao 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
591 changed files with 29814 additions and 11527 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+14
View File
@@ -0,0 +1,14 @@
# `cargo nextest run` (used by `cargo xtask ci`) runs every test in a process of its own, in parallel.
[test-groups]
# NVENC sessions are a limited resource on consumer GPUs.
nvenc = { max-threads = 1 }
[[profile.default.overrides]]
filter = "package(filmcraft-platform) and (binary(/nvenc/) or test(/nvenc/))"
test-group = "nvenc"
# A throughput gate: it measures one core's speed, so nothing else runs beside it.
[[profile.default.overrides]]
filter = "package(filmcraft-render) and binary(mixer_perf)"
threads-required = "num-test-threads"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+30
View File
@@ -0,0 +1,30 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
timeout-minutes: 120
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libasound2-dev
+13 -34
View File
@@ -5,17 +5,19 @@
# golden-image tests need tools and a GPU the VM doesn't have.)
name: FreeBSD
# The VM build takes an hour or more, so it runs nightly on main rather than on every push or
# dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
schedule:
- cron: "37 2 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "37 2 * * *"
workflow_dispatch:
concurrency:
@@ -27,32 +29,9 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + package)
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
# The same packages as release.yml's freebsd job: winit/wgpu (X11, Wayland, Vulkan,
# Mesa), the file dialogs (GTK 3), fonts, and audio (cpal over ALSA: alsa-lib).
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "*"
rustc --version
export CARGO_TARGET_DIR=/var/tmp/filmcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$'
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
skip-tests: true
run-package-check: true
+61
View File
@@ -0,0 +1,61 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: filmcraft
app-title: FilmCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+6 -7
View File
@@ -4,19 +4,18 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# The package build takes about 15 minutes, so it runs nightly on main and on pushes to `release`
# rather than on every push to main: a dependency or runner-image change still shows up within a
# day. PRs that change the Windows packaging itself still run it.
on:
schedule:
- cron: "41 4 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
+1
View File
@@ -5,3 +5,4 @@ plan/
/log
.DS_Store
.claude/worktrees/
__pycache__/
+2
View File
@@ -127,6 +127,8 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
+7 -1
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 |
@@ -26,7 +28,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `docs/images/filmcraft-keyframes.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-export.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frame from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-assembly.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Carnival of Souls (1962), Night of the Living Dead (1968), thumbnails of all demo media | MIT OR Apache-2.0; film frames public domain |
| `docs/images/filmcraft-lumetri.png` | FilmCraft contributors | Original work: FilmCraft UI crop (Lumetri Color panel) | MIT OR Apache-2.0 |
| `docs/images/filmcraft-color-panel.png` | FilmCraft contributors | Original work: FilmCraft UI crop (color grading panel) | MIT OR Apache-2.0 |
| `docs/images/filmcraft-scopes.png` | FilmCraft contributors | Original work: FilmCraft UI crop (scopes of a Charade (1963) frame, public domain) | MIT OR Apache-2.0 |
| `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain |
| `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain |
@@ -128,6 +130,10 @@ carry each font's licence as `OFL-<family>.txt`.
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 |
| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
| Noto Sans CJK SC Regular (Google, Adobe) | Chinese UI text and media names; Chinese fallback for titles (native builds) | OFL-1.1 |
| Noto Sans Arabic Regular (Google) | Arabic UI text; Arabic fallback for titles (native builds) | OFL-1.1 |
| Noto Sans Thai Regular (Google) | Thai fallback for titles (native builds) | OFL-1.1 |
| Noto Sans Georgian Regular (Google) | Georgian fallback for titles (native builds) | OFL-1.1 |
## First-party brand marks
+1
View File
@@ -16,6 +16,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
- **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id.
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde so the control channel can read/set it.
- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm` (checks L0–L4 crates). `cargo xtask ci` runs them all.
Generated
+82 -57
View File
@@ -1011,6 +1011,19 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1674,7 +1687,7 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"arboard",
"chrono",
@@ -1699,6 +1712,7 @@ dependencies = [
"pollster 0.4.0",
"rfd",
"serde_json",
"subtle",
"sys-locale",
"winit",
"winresource",
@@ -1707,7 +1721,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1717,7 +1731,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1725,7 +1739,7 @@ dependencies = [
[[package]]
name = "filmcraft-apv"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1735,14 +1749,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1752,13 +1766,14 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"tokio",
]
[[package]]
name = "filmcraft-av1"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1769,21 +1784,21 @@ dependencies = [
[[package]]
name = "filmcraft-avi"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-bitstream"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"proptest",
"craft-media",
]
[[package]]
name = "filmcraft-captions"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1796,11 +1811,11 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.7.2"
version = "0.12.0"
[[package]]
name = "filmcraft-cli"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@@ -1822,7 +1837,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@@ -1860,7 +1875,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -1868,7 +1883,7 @@ dependencies = [
[[package]]
name = "filmcraft-dng"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-dng",
"rayon",
@@ -1877,7 +1892,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1887,7 +1902,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-geom",
"filmcraft-media",
@@ -1900,8 +1915,10 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"chrono",
"craft-docpath",
"filmcraft-audio-dsp",
"filmcraft-captions",
"filmcraft-codecs",
@@ -1934,7 +1951,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
@@ -1965,7 +1982,7 @@ dependencies = [
[[package]]
name = "filmcraft-flac"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1974,20 +1991,22 @@ dependencies = [
[[package]]
name = "filmcraft-format"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"chrono",
"filmcraft-media",
"filmcraft-project",
"filmcraft-time",
"log",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-frame"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -1997,7 +2016,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -2005,7 +2024,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -2022,7 +2041,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-export",
@@ -2041,7 +2060,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2051,7 +2070,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2061,7 +2080,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2071,7 +2090,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2088,26 +2107,26 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"craft-media",
"filmcraft-testkit",
"proptest",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-matroska"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"craft-media",
"filmcraft-testkit",
"serde_json",
]
[[package]]
name = "filmcraft-media"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-dng",
@@ -2123,7 +2142,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpa"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2132,7 +2151,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2141,7 +2160,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2149,28 +2168,29 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"chrono",
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-platform"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-codecs",
@@ -2196,7 +2216,7 @@ dependencies = [
[[package]]
name = "filmcraft-project"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2210,7 +2230,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2220,7 +2240,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2240,7 +2260,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"serde",
@@ -2249,7 +2269,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2267,18 +2287,20 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"chrono",
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"harfrust",
"skrifa",
"tempfile",
"unicode-bidi",
"unicode-linebreak",
"unicode-segmentation",
@@ -2286,7 +2308,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -2294,7 +2316,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2308,7 +2330,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts-text"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"serde",
"thiserror 2.0.21",
@@ -2316,7 +2338,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"cpu-time",
"craft-ui",
@@ -2348,6 +2370,7 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"tempfile",
"toml 1.1.6+spec-1.1.0",
"unicode-bidi",
"web-time",
@@ -2355,23 +2378,25 @@ dependencies = [
[[package]]
name = "filmcraft-vp8"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-vp9"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-web"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -7396,7 +7421,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.2"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
+9 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.7.2"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -67,9 +67,13 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
roxmltree = "0.21"
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", default-features = false }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
@@ -89,11 +93,15 @@ too_many_arguments = "allow"
needless_range_loop = "allow"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces at a fraction of full debug info's size;
# dependencies need none.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
# Codec hot loops: debug-build tests (RFC/oracle vectors) are minutes slower below opt-level 3.
[profile.dev.package.filmcraft-h264]
+1 -1
View File
@@ -17,5 +17,5 @@ Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fon
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
+42 -30
View File
@@ -8,22 +8,29 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" width="128" height="128" alt="FilmCraft icon: an engraved owl on a purple tile">
</p>
<h1 align="center">FilmCraft</h1>
<p align="center">
<b>Video editing, color and sound; an open-source, clean-room reimplementation of Adobe Premiere Pro, rebuilt in pure Rust.</b>
<b>Video editing, color and sound; an open-source, professional non-linear video editor, built in pure Rust.</b>
</p>
<p align="center">
An open-source, clean-room take on the Adobe Premiere Pro workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
An open-source, modern non-linear video editing workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
By the ArtCraft team.
</p>
<p align="center">
<a href="#license-and-credits"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6"></a>
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-6a3fd6?logo=rust&logoColor=white">
<img alt="Runs on macOS, Windows and Linux" src="https://img.shields.io/badge/runs%20on-macOS%20%7C%20Windows%20%7C%20Linux-8b5cf6">
<a href="#status"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-6a3fd6"></a>
<a href="#license-and-credits"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6?style=flat-square"></a>
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-6a3fd6?style=flat-square&logo=rust&logoColor=white">
<img alt="Runs on macOS, Windows and Linux" src="https://img.shields.io/badge/runs%20on-macOS%20%7C%20Windows%20%7C%20Linux-8b5cf6?style=flat-square">
<a href="#status"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-6a3fd6?style=flat-square"></a>
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_filmcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/filmcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -39,7 +46,7 @@
<br>
<p align="center">
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with Lumetri and Scale keyframes on the shot, the Lumetri Color panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with color grading and Scale keyframes on the shot, the color grading panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
</p>
<p align="center"><sub><i>Apollo 11 - Tranquility</i>: a three-minute documentary edit of NASA's 1969 launch, landing and moonwalk film, with subtitles from the mission transcript. Every frame in these screenshots comes from public-domain footage, decoded, composited and graded by FilmCraft's own code.</sub></p>
@@ -66,7 +73,7 @@
<br>
FilmCraft is a non-linear editor for people who know Premiere: the same panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
FilmCraft is a professional non-linear editor: familiar panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
<br>
@@ -84,7 +91,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft and standard NLE key layouts.
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
<p align="center">
@@ -103,7 +110,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
<p align="center"><sub>The Color workspace on <i>Charade</i> (1963), with live scopes beside the Program monitor.</sub></p>
**A complete Lumetri-style grading panel**, in the order a colourist works:
**A complete color grading panel**, in the order a colourist works:
- **Basic Correction:** temperature and tint, exposure, contrast, highlights, shadows, whites, blacks, saturation.
- **Creative:** eight built-in looks with an intensity control, plus faded film, sharpen and vibrance. The looks are colour transforms written in code, not LUT files.
@@ -115,11 +122,11 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
<table>
<tr>
<td width="34%" valign="top" align="center">
<img src="docs/images/filmcraft-lumetri.png" alt="The Lumetri Color panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
<sub><b>The Lumetri Color panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
<img src="docs/images/filmcraft-color-panel.png" alt="The color grading panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
<sub><b>The color grading panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
</td>
<td width="66%" valign="top" align="center">
<img src="docs/images/filmcraft-scopes.png" alt="Lumetri Scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
<img src="docs/images/filmcraft-scopes.png" alt="The video scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
<sub><b>Scopes that tell the truth.</b> The waveform and vectorscope are computed from the graded frame. All grading happens in linear light, in 32-bit float.</sub>
</td>
</tr>
@@ -130,14 +137,14 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
## Effects and motion
<p align="center">
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and Lumetri Color, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and a colour grade, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
</p>
<p align="center"><sub>NASA's <i>Earth Views from the ISS</i> with a look applied, and the Effects browser open on the dissolves.</sub></p>
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
- **A comprehensive Video Effects bin (over 90 effects across 16 categories), legacy effects, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
- **Advanced keyframing:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
<p align="center">
@@ -179,7 +186,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
| **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 |
| **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 |
| **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska |
| **Avid DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
| **DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
| **MJPEG, PCM** | ✓ | ✓ | |
| **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own |
| **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks |
@@ -200,7 +207,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on.
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and standard OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
- **Background jobs** with progress and cancel, so you keep editing while it renders.
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
@@ -210,11 +217,11 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
Move timelines between FilmCraft and every other editor:
- **Final Cut Pro 7 XML (xmeml):** the format Premiere and DaVinci Resolve exchange. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
- **FCP 7 XML (xmeml):** industry-standard timeline interchange format. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
- **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips.
- **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`.
- **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track.
- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
- **AAF (Edit Protocol):** standard timeline and audio interchange format. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
- **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside.
Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk.
@@ -223,7 +230,7 @@ Import merges the document's bins, media and sequences into your project as one
## Built for agents
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with more commands continuously added. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
```jsonc
// over the control channel (JSON lines on TCP) or as MCP tool calls
@@ -277,22 +284,22 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log |
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus |
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment, the ranked list of shortfalls, milestones and current focus |
| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension |
## Status
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2):
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10):
- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
- **Feature breadth: ~86%.** Professional NLE menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to handling full production work on real projects.
The biggest gaps today:
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but color grading, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Native `.prproj` project import.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
@@ -347,6 +354,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 |
@@ -448,15 +462,13 @@ with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
FilmCraft is an independent implementation. It contains no proprietary code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. FilmCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<br>
<p align="center">
+7 -3
View File
@@ -5,7 +5,7 @@
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (language table: German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
@@ -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% |
| German | partial (whole interface; menus native-reviewed, the rest welcome) | ~100% |
| Vietnamese | none | 0% |
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
@@ -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,8 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Language table: German covers the whole interface (~100%) |
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
| 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 |
+17
View File
@@ -2,6 +2,9 @@
use serde_json::{Map, Value};
// Keep nested CLI params safely within the depth also accepted by JSON serialization.
const MAX_PARAM_NESTING: usize = 64;
/// Options that take a value (`--project p`); every other `--x` is a flag.
const VALUED: &[&str] = &[
"--project",
@@ -84,6 +87,9 @@ impl Args {
let mut m = Map::new();
for kv in rest {
let (k, v) = kv.split_once('=').ok_or_else(|| format!("expected key=value, got `{kv}`"))?;
if k.bytes().filter(|&b| b == b'.').count() >= MAX_PARAM_NESTING {
return Err(format!("parameter key exceeds {MAX_PARAM_NESTING} nesting levels"));
}
insert_dotted(&mut m, k, parse_value(v));
}
Ok(Value::Object(m))
@@ -141,6 +147,17 @@ mod tests {
assert_eq!(args("exec x").params_from(2).unwrap(), json!({}));
}
#[test]
fn dotted_key_depth_is_bounded() {
let allowed = format!("{}leaf=1", "a.".repeat(MAX_PARAM_NESTING - 1));
let a = Args::parse(["exec".into(), "x".into(), allowed]);
assert!(a.params_from(2).is_ok());
let too_deep = format!("{}leaf=1", "a.".repeat(MAX_PARAM_NESTING));
let b = Args::parse(["exec".into(), "x".into(), too_deep]);
assert!(b.params_from(2).unwrap_err().contains("nesting levels"));
}
#[test]
fn json_params() {
let a = Args::parse(["exec".into(), "x".into(), r#"{"a": {"b": [1]}}"#.into()]);
+51 -8
View File
@@ -69,7 +69,9 @@ USAGE
SUBCOMMANDS
exec <id> [key=value ...] run one command; prints its result as JSON
exec <id> '<json params>' same, params as one JSON object
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok)
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok), in
order: file.exportMedia waits for its file before the next line
(and before exit) unless its params say \"wait\": false
commands [filter] [--json] list commands (id, label, shortcut, params)
describe <id> one command as JSON (menu, shortcut, params, enabled now)
inspect [project|sequence] project tree or active sequence as JSON (default: both)
@@ -135,6 +137,25 @@ fn usage(msg: impl std::fmt::Display) -> ! {
std::process::exit(2)
}
/// Read a file off the runtime thread: `cli` is async because MCP, bridge calls and control
/// commands await, so a blocking `std::fs` read would stall every other task on that thread.
async fn read_file(path: impl Into<std::path::PathBuf>) -> std::io::Result<Vec<u8>> {
let path = path.into();
tokio::task::spawn_blocking(move || std::fs::read(path)).await.map_err(std::io::Error::other)?
}
/// `read_file` for UTF-8 text.
async fn read_text(path: impl Into<std::path::PathBuf>) -> std::io::Result<String> {
let path = path.into();
tokio::task::spawn_blocking(move || std::fs::read_to_string(path)).await.map_err(std::io::Error::other)?
}
/// Write a file off the runtime thread; see `read_file`.
async fn write_file(path: impl Into<std::path::PathBuf>, data: Vec<u8>) -> std::io::Result<()> {
let path = path.into();
tokio::task::spawn_blocking(move || std::fs::write(path, data)).await.map_err(std::io::Error::other)?
}
fn fail(msg: impl std::fmt::Display) -> ! {
diag(format_args!("filmcraft-cli: {msg}"));
std::process::exit(1)
@@ -169,9 +190,21 @@ impl Backend {
Backend::Local(Box::new(s))
}
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
async fn exec(&mut self, id: &str, mut params: Value) -> Result<Value, String> {
match self {
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
// this process exits after the command, so a background export would never be written:
// long commands wait unless the caller says otherwise (#2342)
Backend::Local(s) => {
if filmcraft_automation::long_job::LONG_COMMANDS.contains(&id)
&& let Some(p) = params.as_object_mut()
{
// anything but an explicit `false` (missing, null, a typo'd value) waits
if p.get("wait").and_then(Value::as_bool) != Some(false) {
p.insert("wait".into(), json!(true));
}
}
s.execute(id, params).map_err(|e| e.to_string())
}
// a blocking export runs as an app job polled by the client (#91, #92)
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
}
@@ -293,7 +326,7 @@ async fn cli() {
"bench-decode" => {
let path = a.pos(1).unwrap_or_else(|| usage("bench-decode <media>"));
let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120);
let src = filmcraft_codecs::open_bytes(path, std::fs::read(path).unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e));
let src = filmcraft_codecs::open_bytes(path, read_file(path).await.unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e));
let rate = src.info().frame_rate();
let t0 = std::time::Instant::now();
for f in 0..n {
@@ -349,7 +382,7 @@ async fn cli() {
let text = if script == "-" {
std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e))
} else {
std::fs::read_to_string(script).unwrap_or_else(|e| fail(format!("{script}: {e}")))
read_text(script).await.unwrap_or_else(|e| fail(format!("{script}: {e}")))
};
let mut b = Backend::open(&a);
let mut failed = 0;
@@ -465,15 +498,15 @@ async fn cli() {
usage("render is headless; with --bridge use the MCP `render_frame` tool or `exec` ui commands");
}
let Backend::Local(mut s) = Backend::open(&a) else { usage("render is headless") };
let secs: f64 = a.opt("--seconds").and_then(|v| v.parse().ok()).unwrap_or(0.0);
let scale: f32 = a.opt("--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
let secs: f64 = finite_opt(&a, "--seconds").unwrap_or(0.0);
let scale: f32 = finite_opt(&a, "--scale").unwrap_or(1.0);
let out = a.opt("--out").unwrap_or("frame.png");
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
let t0 = std::time::Instant::now();
let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
let dt = t0.elapsed();
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
write_file(out, png).await.unwrap_or_else(|e| fail(format!("{out}: {e}")));
let offline: Vec<Value> = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect();
check_offline(&a, &offline, &format!("render: {out}"));
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
@@ -551,6 +584,16 @@ fn check_offline(a: &Args, offline: &[Value], what: &str) {
}
}
/// A numeric option, or a usage error when it is not a finite number (so a typo never silently
/// falls back to a default).
fn finite_opt<T: std::str::FromStr + Copy + Into<f64>>(a: &Args, name: &str) -> Option<T> {
let v = a.opt(name)?;
match v.parse::<T>() {
Ok(n) if n.into().is_finite() => Some(n),
_ => usage(format!("{name}: expected a finite number, got `{v}`")),
}
}
/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be
/// rendered (no sequence, bad scale).
fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec<filmcraft_engine::media_pool::OfflineMedia>), String> {
@@ -3,7 +3,7 @@
use std::io::Write;
use std::process::{Command, Output, Stdio};
use serde_json::Value;
use serde_json::{Value, json};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).output().expect("spawn filmcraft-cli")
@@ -150,6 +150,23 @@ fn export_wav_waits_for_job() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_waits_for_an_export_on_its_last_line() {
// #2342: `file.exportMedia` as the last line of a script is written before `run` exits
let dir = std::env::temp_dir().join(format!("fc-cli-run-x-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("last.wav");
// `"wait": null` waits too: only an explicit `false` doesn't
let script = json!({"id": "file.exportMedia", "params": {"path": out.to_str().unwrap(), "format": "wav", "wait": null}}).to_string();
let mut child =
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--demo", "run", "-"]).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().unwrap();
child.stdin.take().unwrap().write_all(format!("{script}\n").as_bytes()).unwrap();
let o = child.wait_with_output().unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout));
assert!(std::fs::metadata(&out).map(|m| m.len() > 44).unwrap_or(false), "{}", String::from_utf8_lossy(&o.stdout));
let _ = std::fs::remove_dir_all(&dir);
}
/// `export --preset`: a built-in preset by name (dash or en dash), a custom range, a user preset
/// from `--data-dir`, `--list-presets`, `--queue`, and a clean failure for an unknown preset.
#[test]
@@ -295,6 +312,27 @@ fn closed_stdout_still_runs_the_script_and_saves() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `render` with a non-numeric or non-finite `--seconds` / `--scale` is a usage error (exit 2) and
/// writes nothing, instead of silently rendering frame 0 at full scale.
#[test]
fn render_rejects_bad_numbers() {
let dir = std::env::temp_dir().join(format!("fc-cli-render-nan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let still = dir.join("s.png").to_str().unwrap().to_string();
for (opt, bad) in
[("--seconds", "1s"), ("--seconds", "1,5"), ("--seconds", "abc"), ("--seconds", "NaN"), ("--seconds", "inf"), ("--scale", "xyz"), ("--scale", "NaN")]
{
let (secs, scale) = if opt == "--seconds" { (bad, "0.1") } else { ("1", bad) };
let o = cli(&["--demo", "render", "--seconds", secs, "--scale", scale, "--out", &still]);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{opt} {bad}: {err}");
assert!(err.contains("filmcraft-cli:") && err.contains(opt), "{opt} {bad}: {err}");
assert!(!std::path::Path::new(&still).exists(), "{opt} {bad} wrote a file");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the
/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the
/// placeholder frames; then it still warns.
+2
View File
@@ -0,0 +1,2 @@
mod cli;
mod workflows;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
//! Spacing of browser downloads (pure, so it is tested natively in `tests/it/download_pacing.rs`; the
//! anchor clicks live in [`crate::fs::download`]).
//!
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
+47
View File
@@ -352,6 +352,24 @@ impl Services for WebServices {
Ok(())
}
fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> {
if path.starts_with("/opfs/") {
return Err(io::Error::new(io::ErrorKind::Unsupported, "exclusive OPFS writes are unavailable"));
}
let hook = FS.with(|fs| {
let mut fs = fs.borrow_mut();
if fs.entries.contains_key(path) {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs.entries.insert(path.to_owned(), Entry::Mem(Arc::from(data)));
Ok(fs.on_write.clone())
})?;
if let Some(h) = hook {
h(path, data);
}
Ok(())
}
fn file_size(&self, path: &str) -> io::Result<u64> {
Self::entry_reader(path).map(|r| r.len())
}
@@ -435,3 +453,32 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
});
Ok(())
}
#[cfg(test)]
mod exclusive_write_tests {
use super::*;
#[test]
fn virtual_file_creation_is_exclusive_and_opfs_fails_closed() {
let host = WebServices;
let existing = "/files/collision-test.wav";
let fresh = "/files/new-extraction-test.wav";
FS.with(|fs| {
let mut fs = fs.borrow_mut();
fs.on_write = None;
fs.entries.insert(existing.into(), Entry::Mem(Arc::from(&b"original"[..])));
fs.entries.remove(fresh);
});
assert_eq!(host.write_file_new(existing, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert_eq!(host.read_file(existing).unwrap(), b"original");
host.write_file_new(fresh, b"new").unwrap();
assert_eq!(host.read_file(fresh).unwrap(), b"new");
assert_eq!(host.write_file_new(fresh, b"again").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert_eq!(host.write_file_new("/opfs/media/new.wav", b"new").unwrap_err().kind(), io::ErrorKind::Unsupported);
FS.with(|fs| {
let mut fs = fs.borrow_mut();
fs.entries.remove(existing);
fs.entries.remove(fresh);
});
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
//! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in
//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
//! `tests/it/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
//!
//! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write
//! that fails is retried after the interval, without waiting for another edit (#197).
@@ -1,10 +1,10 @@
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
//! wasm32-only.
#[path = "../src/download_pacing.rs"]
mod download_pacing;
#[path = "../../src/download_pacing.rs"]
mod pacing;
use download_pacing::{GAP_MS, Pacer};
use pacing::{GAP_MS, Pacer};
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
#[test]
+2
View File
@@ -0,0 +1,2 @@
mod download_pacing;
mod recovery_policy;
@@ -1,10 +1,10 @@
//! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the
//! crate is wasm32-only.
#[path = "../src/recovery_policy.rs"]
mod recovery_policy;
#[path = "../../src/recovery_policy.rs"]
mod policy;
use recovery_policy::{Action, INTERVAL_S, Policy};
use policy::{Action, INTERVAL_S, Policy};
/// #197: a snapshot write that fails is retried after the interval without another edit.
#[test]
+3 -1
View File
@@ -29,12 +29,14 @@ filmcraft-platform = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
serde_json = { workspace = true }
# Constant-time comparison of control-channel credentials.
subtle = "2.6"
log = { workspace = true }
rfd = { workspace = true }
cpal = "0.16"
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
arboard = "3"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
chrono = { workspace = true, features = ["clock"] }
sys-locale = "0.3"
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
open = "5"
+2 -1
View File
@@ -9,7 +9,8 @@ Usage: filmcraft [options] [project.fcproj | media files...]
Options:
--control <port> start the localhost JSON-lines control server
(also: FILMCRAFT_CONTROL_PORT)
(also: FILMCRAFT_CONTROL_PORT; requires FILMCRAFT_CONTROL_TOKEN,
64 random hex characters; see docs/control-protocol.md)
--control-input-only accept widget input only from the control server
(requires --control or FILMCRAFT_CONTROL_PORT)
--demo open the demo project
+128 -15
View File
@@ -4,7 +4,9 @@
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
//! never reaches the UI thread. Every request must carry the session credential from
//! `FILMCRAFT_CONTROL_TOKEN`; without a configured credential the listener stays closed.
//! See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
@@ -15,6 +17,7 @@ use std::time::Duration;
use filmcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
use subtle::ConstantTimeEq;
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
@@ -23,7 +26,17 @@ pub const MAX_LINE: usize = 4 * 1024 * 1024;
pub const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok())
}
fn start_with_token(port: u16, ctx: egui::Context, token: Option<String>) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
// An inherited environment variable shares the credential with the owner's bridge process
// without exposing it in command-line arguments, logs, or a shared discovery file.
let Some(token) = token.filter(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())) else {
eprintln!("filmcraft: control server disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md)");
return rx;
};
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -32,11 +45,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
std::thread::spawn(move || accept_loop(listener, tx, ctx, token));
rx
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context, token: String) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
@@ -46,12 +59,13 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
}
let tx = tx.clone();
let ctx = ctx.clone();
let token = token.clone();
// The slot is given back when the guard drops: after `serve` returns, if it panics, or if
// the thread can't be started (the closure, and with it the guard, is dropped).
let slot = Slot(Arc::clone(&open));
let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx);
serve(stream, &tx, &ctx, &token);
});
if let Err(e) = spawned {
eprintln!("filmcraft: control server could not start a connection thread: {e}");
@@ -68,9 +82,9 @@ impl Drop for Slot {
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, token: &str) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
serve_lines(read, stream, token, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
@@ -90,7 +104,7 @@ enum Line {
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
fn parse_line(bytes: &[u8], token: &str) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
@@ -108,6 +122,12 @@ fn parse_line(bytes: &[u8]) -> Line {
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
// Authenticate every request before constructing or enqueueing a ControlRequest. A valid
// earlier request must not authorize a later one with a missing or incorrect credential.
let authenticated = obj.get("token").and_then(Value::as_str).is_some_and(|supplied| bool::from(supplied.as_bytes().ct_eq(token.as_bytes())));
if !authenticated {
return Line::Reject("control authentication failed; closing the connection".into());
}
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
}
@@ -138,14 +158,14 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, token: &str, mut handle: impl FnMut(String, Value) -> Option<Value>) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Ok(true) => match parse_line(&buf, token) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
@@ -169,10 +189,12 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
serve_lines(input, &mut out, |m, _p| {
serve_lines(input, &mut out, TOKEN, |m, _p| {
calls.push(m);
Some(json!({"ok": true, "result": null}))
});
@@ -182,7 +204,10 @@ mod tests {
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
let input = format!(
"{{\"token\":\"{TOKEN}\",\"id\":1,\"method\":\"a\"}}\n\n{{\"token\":\"{TOKEN}\",\"id\":2,\"method\":\"b\",\"params\":{{}}}}\r\n{{\"token\":\"{TOKEN}\",\"method\":\"c\"}}"
);
let (r, calls) = run(input.as_bytes());
assert_eq!(calls, ["a", "b", "c"]);
assert_eq!(r.len(), 3);
assert_eq!(r[0]["id"], 1);
@@ -220,7 +245,8 @@ mod tests {
#[test]
fn a_request_before_junk_still_runs() {
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
let input = format!("{{\"token\":\"{TOKEN}\",\"method\":\"a\"}}\nGET / HTTP/1.1\n{{\"token\":\"{TOKEN}\",\"method\":\"b\"}}\n");
let (r, calls) = run(input.as_bytes());
assert_eq!(calls, ["a"]);
assert_eq!(r.len(), 2);
assert_eq!(r[1]["ok"], false);
@@ -256,11 +282,11 @@ mod tests {
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
serve_lines(read, s, TOKEN, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
c.write_all(format!("{{\"token\":\"{TOKEN}\",\"id\":7,\"method\":\"x\"}}\nhello\n{{\"token\":\"{TOKEN}\",\"method\":\"y\"}}\n").as_bytes()).unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
@@ -275,7 +301,7 @@ mod tests {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), TOKEN.into()));
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
@@ -285,4 +311,91 @@ mod tests {
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
#[test]
fn missing_or_invalid_token_leaves_the_listener_closed() {
for token in [None, Some(String::new()), Some("a".repeat(63)), Some("a".repeat(65)), Some("z".repeat(64))] {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let rx = start_with_token(port, egui::Context::default(), token);
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
let _still_available = TcpListener::bind(("127.0.0.1", port)).unwrap();
}
}
#[test]
fn unauthenticated_save_as_closes_before_any_command_runs() {
for token in
[Value::Null, json!(false), json!(42), json!([]), json!({}), json!(""), json!("a".repeat(64)), json!(&TOKEN[..63]), json!(format!("{TOKEN}0"))]
{
let mut request = json!({"id": 1, "method": "engine.execute", "params": {"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}}});
if !token.is_null() {
request["token"] = token;
}
// A valid request after a rejected one must never execute, either.
let input = format!("{request}\n{}\n", json!({"token": TOKEN, "method": "ui.inspect"}));
let (replies, calls) = run(input.as_bytes());
assert!(calls.is_empty());
assert_eq!(replies, [json!({"ok": false, "error": "control authentication failed; closing the connection"})]);
}
}
#[test]
fn authentication_is_required_on_every_request() {
let input = format!(
"{}\n{}\n{}\n",
json!({"token": TOKEN, "method": "ui.inspect"}),
json!({"method": "app.quit"}),
json!({"token": TOKEN, "method": "app.quit"})
);
let (replies, calls) = run(input.as_bytes());
assert_eq!(calls, ["ui.inspect"]);
assert_eq!(replies.len(), 2);
assert_eq!(replies[1]["ok"], false);
}
#[test]
fn tcp_authentication_happens_before_enqueueing() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
serve(s, &tx, &egui::Context::default(), TOKEN);
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"method\":\"engine.execute\",\"params\":{\"command\":\"file.saveAs\",\"params\":{\"path\":\"/owner/project.fcproj\"}}}\n").unwrap();
c.shutdown(std::net::Shutdown::Write).unwrap();
let mut reply = String::new();
c.read_to_string(&mut reply).unwrap();
server.join().unwrap();
assert!(reply.contains("control authentication failed"));
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
}
#[test]
fn authenticated_tcp_request_reaches_the_ui_queue() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
serve(s, &tx, &egui::Context::default(), TOKEN);
});
let params = json!({"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(c, "{}", json!({"id": 7, "token": TOKEN, "method": "engine.execute", "params": params})).unwrap();
c.shutdown(std::net::Shutdown::Write).unwrap();
let request = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(request.method, "engine.execute");
assert_eq!(request.params, params);
request.reply.send(json!({"ok": true})).unwrap();
let mut reply = String::new();
c.read_to_string(&mut reply).unwrap();
server.join().unwrap();
assert_eq!(serde_json::from_str::<Value>(&reply).unwrap(), json!({"id": 7, "ok": true}));
}
}
+2
View File
@@ -215,6 +215,8 @@ fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String {
out.push_str("StartupNotify=true\n");
out.push_str(&format!("StartupWMClass={app_id}\n"));
out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n");
out.push_str("MimeType=application/x-filmcraft;video/mp4;video/x-m4v;video/quicktime;video/x-matroska;video/webm;");
out.push_str("video/vnd.avi;video/x-msvideo;application/mxf;video/mp2t;video/mpeg;video/3gpp;\n");
out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n");
out
}
+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]
+36 -19
View File
@@ -97,29 +97,35 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
/// instead of panicking.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
/// clock can't overflow).
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -390,6 +396,17 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
// collapsing to the epoch (reviewer FredApps). Windows' SystemTime can't hold such an
// instant, so it is built with a checked add and only asserted where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+55 -7
View File
@@ -8,6 +8,7 @@
//! (the demo project), Open Most Recent, or an empty project.
//!
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! `FILMCRAFT_CONTROL_TOKEN` is required (see `docs/control-protocol.md`).
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
//! either source.
//!
@@ -447,11 +448,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 +460,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 +490,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 +526,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:?}");
}
}
+37 -3
View File
@@ -19,6 +19,15 @@ fn accel(s: &str) -> Option<Accelerator> {
mapped.parse().ok()
}
/// The app menu's Quit item: the `app.quit` command (which asks to save unsaved changes) on its
/// shortcut, Cmd+Q unless remapped in the Keyboard Shortcuts editor. The key equivalent is read
/// once, when the menu bar is built: `app.quit` has no menu path, so it is not in the menu tree the
/// updater receives, and a remap shows on the Quit item after the next launch.
fn quit_item(app: &FilmcraftApp) -> (&'static str, Option<Accelerator>) {
let keys = app.session.shortcuts.primary("app.quit").unwrap_or_else(|| "Cmd+Q".into());
("app.quit", accel(&keys))
}
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
@@ -53,9 +62,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
let predefined = [
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
(quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))),
];
let [(_, hide), (_, hide_others), (_, quit)] = &predefined;
let [(_, hide), (_, hide_others)] = &predefined;
// Quit is ours, not the predefined item: that one calls `terminate:` and skips the unsaved
// changes prompt (#1943); the `app.quit` command closes the window through it
let (quit_id, quit_accel) = quit_item(app);
let quit = MenuItem::with_id(quit_id, app.ui.language.tr(quit_label), true, quit_accel);
let _ = app_menu.append_items(&[
&about,
&discord,
@@ -65,7 +77,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
hide,
hide_others,
&PredefinedMenuItem::separator(),
quit,
&quit,
]);
let _ = bar.append(&app_menu);
for top in MENUS {
@@ -131,6 +143,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
};
@@ -143,6 +159,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
for (label, item) in &predefined {
item.set_text(language.tr(label));
}
quit.set_text(language.tr(quit_label));
for (shown, mi) in &mut native {
let Some(it) = items.iter().find(|i| i.id == shown.id) else { continue };
if it.label != shown.label {
@@ -160,3 +177,20 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
});
(rx, update)
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_engine::Session;
/// #1943: Cmd+Q must reach the `app.quit` command, which closes the window through the unsaved
/// changes prompt, instead of the predefined Quit item's `terminate:`.
#[test]
fn quit_item_runs_the_app_quit_command_on_cmd_q() {
let app = FilmcraftApp::new(Session::default());
let (id, accelerator) = quit_item(&app);
assert_eq!(id, "app.quit");
assert_eq!(accelerator, accel("Cmd+Q"));
assert!(accelerator.is_some(), "Cmd+Q is a native key equivalent");
}
}
+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"
@@ -1,7 +1,7 @@
//! Decoder accuracy against ffmpeg's decoder on streams produced by ffmpeg's AAC encoder (oracle
//! only; skipped when ffmpeg is absent).
mod common;
use crate::common;
use common::*;
@@ -1,6 +1,6 @@
//! Encoder quality/bitrate tests using ffmpeg as an external decoding oracle (skipped if absent).
mod common;
use crate::common;
use common::*;
use filmcraft_aac::{BitrateMode, EncoderConfig, WindowShape};
+4
View File
@@ -0,0 +1,4 @@
mod common;
mod decoder_oracle;
mod encoder_oracle;
mod robustness;
@@ -1,7 +1,7 @@
//! Fuzz-style robustness: mutated access units, configs and ADTS streams must never panic, and the
//! encoder must accept hostile input (NaN, ±inf, > full scale, silence, odd chunk sizes).
mod common;
use crate::common;
use common::*;
use filmcraft_aac::{AudioSpecificConfig, Decoder, Encoder, EncoderConfig, split_adts};
+2 -2
View File
@@ -54,7 +54,7 @@ let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationReco
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs`
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/it/fuzz.rs`
across thousands of mutated and random inputs.
### Encoder
@@ -67,7 +67,7 @@ let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationReco
## Accuracy (vs FFmpeg as an External Oracle)
`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
`tests/it/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`.
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
+14 -1
View File
@@ -377,7 +377,20 @@ fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[
let blocks_x = blk_w / 8;
let blocks_y = blk_h / 8;
let mut comp_buf = vec![0u16; tw_c * th_c];
// Every coded 8x8 block needs at least one bit for its DC code. Validate
// that the component can describe every required block before allocating
// its complete reconstructed plane.
let block_count = (num_mb_cols as usize)
.checked_mul(num_mb_rows as usize)
.and_then(|n| n.checked_mul(blocks_x))
.and_then(|n| n.checked_mul(blocks_y))
.ok_or(Error::CorruptTile(tile_idx))?;
if comp_bytes.len() < block_count.div_ceil(8) {
return Err(Error::CorruptTile(tile_idx));
}
let samples = tw_c.checked_mul(th_c).ok_or(Error::CorruptTile(tile_idx))?;
let mut comp_buf = vec![0u16; samples];
let mut r = BitReader::new(comp_bytes);
let mut prev_dc: i32 = 0;
+108
View File
@@ -0,0 +1,108 @@
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
//! truncation at (nearly) every length must never panic or run away (errors are fine).
use std::panic::{AssertUnwindSafe, catch_unwind};
use filmcraft_apv::*;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
fn seeds() -> Vec<Vec<u8>> {
let mut v = Vec::new();
for (p, w, h) in [(Profile::P422_10, 64u32, 48u32), (Profile::P444_10, 48, 32), (Profile::P4444_10, 32, 32), (Profile::P400_10, 32, 32)] {
let chroma = p.default_chroma();
let depth = p.default_bit_depth();
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
for (i, s) in f.y.iter_mut().enumerate() {
*s = (64 + (i * 37) % 800) as u16;
}
let mut cfg = EncoderConfig::new(p, w, h);
cfg.qp = Some(16);
let Ok(mut enc) = Encoder::with_config(cfg) else { continue };
if let Ok(d) = enc.encode(&f) {
v.push(d);
}
if let Ok(d) = enc.encode_raw_au(&f) {
v.push(d);
}
}
v
}
fn run(d: &[u8], label: &str) {
filmcraft_testkit::set_progress(label);
let res = catch_unwind(AssertUnwindSafe(|| {
for depth in [None, Some(8), Some(16)] {
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
}
let _ = probe(d);
let _ = split_raw_bitstream(d);
}));
assert!(res.is_ok(), "panic: {label}");
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_frames_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let seeds = seeds();
assert!(!seeds.is_empty());
for (si, seed) in seeds.iter().enumerate() {
let stride = (seed.len() / 400).max(1);
for n in (0..seed.len()).step_by(stride) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
for p in 0..seed.len().min(256) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p}.{b}"));
}
}
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
for p in 0..seed.len().min(128) {
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(p..p + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
for i in 0..iters {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
run(&d, &format!("seed {si} random {i}"));
}
}
}
+3
View File
@@ -0,0 +1,3 @@
mod fuzz;
mod fuzz_hostile;
mod oracle;
@@ -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 {
+30 -24
View File
@@ -271,31 +271,37 @@ mod tests {
#[test]
fn extreme_settings_stay_finite_and_denormal_free() {
for e in effects() {
for setting in [0.0f32, 1.0] {
let mut fx = (e.create)(SR, 2);
for p in e.params {
fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max });
}
let mut l = noise(3, 24000, 4.0);
let mut r = noise(4, 24000, 4.0);
l[100] = 1e6;
r[200] = -1e6;
fx.process(&mut [&mut l, &mut r]);
assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id);
// Long silence afterwards: output decays without denormals.
fx.reset();
let mut l = noise(3, 4800, 0.5);
let mut r = noise(4, 4800, 0.5);
fx.process(&mut [&mut l, &mut r]);
for _ in 0..40 {
let mut l = vec![0.0f32; 48000];
let mut r = vec![0.0f32; 48000];
fx.process(&mut [&mut l, &mut r]);
assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id);
}
// One thread per effect. The tail stays 40 s long: delays, reverbs and graphic EQs still
// produce output in its last second, some of it within 3x of the smallest normal f32.
std::thread::scope(|scope| {
for e in effects() {
scope.spawn(move || {
for setting in [0.0f32, 1.0] {
let mut fx = (e.create)(SR, 2);
for p in e.params {
fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max });
}
let mut l = noise(3, 24000, 4.0);
let mut r = noise(4, 24000, 4.0);
l[100] = 1e6;
r[200] = -1e6;
fx.process(&mut [&mut l, &mut r]);
assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id);
// Long silence afterwards: output decays without denormals.
fx.reset();
let mut l = noise(3, 4800, 0.5);
let mut r = noise(4, 4800, 0.5);
fx.process(&mut [&mut l, &mut r]);
for _ in 0..40 {
let mut l = vec![0.0f32; 48000];
let mut r = vec![0.0f32; 48000];
fx.process(&mut [&mut l, &mut r]);
assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id);
}
}
});
}
}
});
}
#[test]
+2 -1
View File
@@ -16,7 +16,8 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time", "fs"] }
base64 = "0.22"
schemars = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
tempfile = "3"
+115 -21
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,24 +13,32 @@ 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>>,
/// Inherited from the same owner-controlled environment as the desktop app.
token: Option<String>,
/// 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,
}
impl BridgeClient {
/// `addr` such as `127.0.0.1:9876` (loopback only).
/// `addr` such as `127.0.0.1:9876` (loopback only). Reads `FILMCRAFT_CONTROL_TOKEN`
/// from the environment; the desktop app requires this credential on every request.
pub fn new(addr: impl Into<String>) -> Result<Self, AutomationError> {
let addr = addr.into();
let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr);
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, token: std::env::var("FILMCRAFT_CONTROL_TOKEN").ok(), idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
}
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
@@ -71,21 +80,15 @@ 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 line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let line = format!("{}\n", json!({"id": id, "token": self.token, "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()))?;
let mut buf = String::new();
@@ -98,21 +101,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 +209,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() {
@@ -206,4 +285,19 @@ mod tests {
assert_eq!(seen.len(), 2);
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
}
#[tokio::test]
async fn credential_is_sent_on_every_request() {
let (addr, seen) = fake_app(Value::Null).await;
let mut b = BridgeClient::new(addr).unwrap();
// Inject directly to avoid mutating the process environment in parallel tests.
let token = "0123456789abcdef".repeat(4);
b.token = Some(token.clone());
b.call("ui.inspect", json!({})).await.unwrap();
b.execute("file.saveAs", json!({"path": "/owner/project.fcproj"})).await.unwrap();
let seen = seen.lock().await;
assert_eq!(seen.len(), 2);
assert!(seen.iter().all(|request| request["token"] == token));
assert_eq!(seen[1]["params"]["params"]["path"], "/owner/project.fcproj");
}
}
+92 -4
View File
@@ -278,9 +278,22 @@ impl FilmcraftMcp {
}
/// The project tree plus the active sequence (null when there is none).
///
/// `project.inspect` and `sequence.inspect` are two separate calls (two lock scopes in
/// headless mode, two round trips to the app in bridge mode), so the active sequence can
/// change between them if another client, or the app's own UI, switches sequence tabs in
/// that window (#1477). Pinning `sequence.inspect` to the `activeSequence` id this
/// `project.inspect` just reported keeps the pair consistent: the returned `sequence`, when
/// not null, always describes the sequence the returned `project.activeSequence` names.
async fn document(&self) -> Result<Value, AutomationError> {
let project = self.run("project.inspect", json!({})).await?;
let sequence = self.run("sequence.inspect", json!({})).await.ok();
// `ItemId` is a u64 (`item_p` parses the `item` param with `u64_p`): read it as one too,
// not `as_i64`, so an id above `i64::MAX` is still looked up instead of silently going null.
let active = project.get("activeSequence").and_then(Value::as_u64);
let sequence = match active {
Some(id) => self.run("sequence.inspect", json!({"item": id})).await.ok(),
None => None,
};
Ok(json!({"project": project, "sequence": sequence}))
}
@@ -551,15 +564,21 @@ impl FilmcraftMcp {
impl FilmcraftMcp {
async fn screenshot(&self, b: &BridgeClient, panel: Option<String>, max: u32) -> Result<CallToolResult, McpError> {
let path = std::env::temp_dir().join(format!("filmcraft-mcp-{}.png", std::process::id()));
// The desktop bridge needs a pathname, so use a freshly-created,
// owner-only directory rather than exposing image bytes at a
// predictable shared-temp filename.
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => return Ok(fail(format!("screenshot: {e}"))),
};
let path = dir.path().join("screenshot.png");
if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy(), "panel": panel})).await {
return Ok(fail(e));
}
let bytes = match std::fs::read(&path) {
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) => return Ok(fail(format!("screenshot: {e}"))),
};
let _ = std::fs::remove_file(&path);
let bytes = match image::load_from_memory(&bytes) {
Ok(img) => {
let rgba = img.to_rgba8();
@@ -998,4 +1017,73 @@ mod tests {
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
/// `doc_inspect` composes `project.inspect` and `sequence.inspect` as two separate round
/// trips to the app. If the active sequence changes between them (another client, or the
/// app's own UI, switching tabs in that window), an unscoped `sequence.inspect` answers for
/// whichever sequence is active *now*, not the one `project.inspect` just reported, so the
/// pair comes back inconsistent (#1477, reproduced with a real bridge client and a flipping
/// `sequence.open` in `race_test.py`: ~300/300 calls mismatched). Pinning the second call to
/// the id the first one reported keeps `sequence`, when not null, always describing the
/// sequence `project.activeSequence` names.
#[tokio::test]
async fn doc_inspect_pins_the_sequence_to_the_reported_active_id() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let (r, mut w) = s.into_split();
let mut lines = tokio::io::BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let result = match req["params"]["command"].as_str().unwrap_or_default() {
"project.inspect" => json!({"activeSequence": 21}),
// The app's own active sequence has since moved on to 54; only an explicit
// `item` still gets 21's data (an unscoped call would wrongly answer for 54).
"sequence.inspect" => json!({"id": req["params"]["params"]["item"].as_i64().unwrap_or(54)}),
_ => Value::Null,
};
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
w.write_all(line.as_bytes()).await.unwrap();
}
});
let m = FilmcraftMcp::bridge(&addr).unwrap();
let v = m.document().await.unwrap();
assert_eq!(v["project"]["activeSequence"], json!(21), "{v}");
assert_eq!(v["sequence"]["id"], json!(21), "sequence must describe the reported active id, not whatever is active now: {v}");
}
/// `activeSequence` is a `u64` (`ItemId`'s representation; `item_p` parses the `item` param
/// with `u64_p`), so an id above `i64::MAX` must still round-trip through `document()`:
/// reading it with `as_i64` would silently return `None` and make `sequence` go null instead
/// of inspecting the (valid) id project.inspect reported.
#[tokio::test]
async fn doc_inspect_handles_an_active_sequence_id_above_i64_max() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
const HUGE: u64 = i64::MAX as u64 + 1;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let (s, _) = listener.accept().await.unwrap();
let (r, mut w) = s.into_split();
let mut lines = tokio::io::BufReader::new(r).lines();
while let Ok(Some(line)) = lines.next_line().await {
let req: Value = serde_json::from_str(&line).unwrap();
let result = match req["params"]["command"].as_str().unwrap_or_default() {
"project.inspect" => json!({"activeSequence": HUGE}),
// Echoes back whatever `item` it was actually asked for, so the test can
// check that `document()` asked for `HUGE` rather than nothing at all.
"sequence.inspect" => json!({"id": req["params"]["params"]["item"]}),
_ => Value::Null,
};
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
w.write_all(line.as_bytes()).await.unwrap();
}
});
let m = FilmcraftMcp::bridge(&addr).unwrap();
let v = m.document().await.unwrap();
assert_eq!(v["project"]["activeSequence"].as_u64(), Some(HUGE), "{v}");
assert_eq!(v["sequence"]["id"].as_u64(), Some(HUGE), "an id above i64::MAX must still be inspected, not dropped to a null sequence: {v}");
}
}
+13 -6
View File
@@ -43,12 +43,12 @@ reference) is used only as an external test oracle.
## Accuracy
`tests/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the
`tests/it/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the
in-loop filters; 64×64 and 128×128 superblocks; intra edge filter on and off) and compares every
decoded frame with libdav1d: **bit-exact** on all 13 fixtures (8- and 10-bit 4:2:0; 128×128 to
1280×720 including odd sizes; testsrc2, mandelbrot, gradients and noise; presets 1–8).
`tests/conformance.rs` downloads libaom's conformance test vectors on first use
`tests/it/conformance.rs` downloads libaom's conformance test vectors on first use
(storage.googleapis.com/aom-test-data) and compares with libdav1d: `av1-1-b8-02-allintra`
(39 frames, deblocking + CDEF + self-guided restoration), `05-mv`, `06-mfmv`, `24-monochrome`
and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
@@ -58,8 +58,8 @@ and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
with scaled inter-layer prediction), `24-monochrome` (8 and 10-bit) and
`16-intra_only-intrabc-extreme-dv` (1080p intra block copy), `23-film_grain-50` (8 and 10-bit).
`tests/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
`tests/it/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/it/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
OBMC / warped motion, motion-field projection, all loop filters; presets 3–8, 8- and 10-bit, odd
sizes), plus super-resolution (denominator 12) and film-grain streams, and compares every frame
with libdav1d: **bit-exact** on all 6 fixtures.
@@ -76,13 +76,13 @@ independent). Results never depend on scheduling (the oracle tests compare 1 thr
and libdav1d). With frame threads, pictures may be returned by a
later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it.
`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
`tests/it/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
(`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames,
GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters,
CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation.
Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p
fixtures from `tests/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame /
fixtures from `tests/it/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame /
post-filter threading. The machine was heavily loaded by other builds during both runs (load
average 200–400), so the all-core wall figures understate what an idle machine reaches and are
noisy; single-threaded figures use thread CPU time and are comparable.
@@ -125,6 +125,13 @@ motion vectors nor CDFs reach another frame — skip deblocking, CDEF and loop r
(super-resolution still runs, it sets the output size) and are flagged `Picture::draft`. In
SVT-AV1's hierarchical GOPs that is the top layer, half of the frames.
## Limits
Frames beyond the AV1 level 6.x limits (Annex A: at most 16384 wide, 8704 high and 35 651 584 luma samples
in total, so 16384 x 2176 or 8192 x 4352 at most) are refused with `Error::Invalid("frame size")`. The sequence
header can code up to 65536 x 65536, which would ask for gigabytes of buffers from a few bytes of hostile input;
no real stream comes close to level 6.3, the highest defined level.
## API
```rust
+46 -1
View File
@@ -23,6 +23,17 @@ use crate::stats::{DecodeStats, Stage, Timer};
use crate::tile::TileDecoder;
use crate::{Error, Picture, Result};
/// Largest accepted frame: the AV1 level 6.x limits (Annex A: MaxHSize 16384, MaxVSize 8704, MaxPicSize
/// 35 651 584 luma samples; level 6.3 is the highest defined). The sequence header allows 65536 x 65536
/// (gigabytes of buffers), so nothing larger than a level 6.3 frame is decoded.
const MAX_FRAME_WIDTH: u32 = 16384;
const MAX_FRAME_HEIGHT: u32 = 8704;
const MAX_FRAME_AREA: u64 = 35_651_584;
/// Whether a frame of `width` x `height` luma samples is within the decoder's resource limits.
fn frame_size_allowed(width: u32, height: u32) -> bool {
width <= MAX_FRAME_WIDTH && height <= MAX_FRAME_HEIGHT && u64::from(width) * u64::from(height) <= MAX_FRAME_AREA
}
const OBU_SEQUENCE_HEADER_T: u32 = 1;
const OBU_TEMPORAL_DELIMITER_T: u32 = 2;
const OBU_FRAME_HEADER_T: u32 = 3;
@@ -92,6 +103,8 @@ struct FrameJob {
ref_info: [RefInfo; NUM_REF_FRAMES],
/// Tile payloads (tile number, data).
tiles: Vec<(usize, Vec<u8>)>,
/// Each tile may be supplied by exactly one tile group.
tile_covered: Vec<bool>,
slot: Arc<FrameSlot>,
/// The output picture, when the frame is shown.
picture: Option<Arc<Slot<Arc<Picture>>>>,
@@ -505,7 +518,7 @@ impl Decoder {
}
// Encoders exist that code frames slightly larger than the sequence maximum (e.g. a
// 270-line stream coded as 272 lines); like other decoders we accept them.
if fh.upscaled_width > 65536 || fh.frame_height > 65536 {
if !frame_size_allowed(fh.upscaled_width, fh.frame_height) {
return Err(Error::Invalid("frame size"));
}
if !fh.frame_is_intra {
@@ -520,12 +533,14 @@ impl Decoder {
}
let picture = fh.show_frame.then(Slot::new);
let draft = self.draft && fh.show_frame && fh.refresh_frame_flags == 0;
let tile_count = fh.tile_info.cols.checked_mul(fh.tile_info.rows).ok_or(Error::Invalid("tile count overflows"))?;
self.pending = Some(Box::new(FrameJob {
seq,
fh,
refs: self.ref_frames.clone(),
ref_info: self.refs.clone(),
tiles: Vec::new(),
tile_covered: vec![false; tile_count],
slot: Slot::new(),
picture,
pts: self.pts,
@@ -568,13 +583,20 @@ impl Decoder {
v + 1
};
let end = pos.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
if job.tile_covered.get(tile_num).copied() != Some(false) {
return Err(Error::Invalid("tile is covered by multiple tile groups"));
}
job.tiles.push((tile_num, data[pos..end].to_vec()));
job.tile_covered[tile_num] = true;
pos = end;
}
if tg_end != num_tiles - 1 {
self.pending = Some(job);
return Ok(None);
}
if job.tile_covered.iter().any(|&covered| !covered) {
return Err(Error::Invalid("frame is missing tile coverage"));
}
self.seen_frame_header = false;
Ok(self.submit(job))
}
@@ -947,3 +969,26 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool {
}
true
}
#[cfg(test)]
mod tests {
use super::frame_size_allowed;
#[test]
fn frames_up_to_the_level_6_3_limits_are_allowed() {
assert!(frame_size_allowed(16384, 2176)); // exactly MaxPicSize
assert!(frame_size_allowed(4096, 8704)); // exactly MaxVSize
assert!(frame_size_allowed(8192, 4352)); // exactly MaxPicSize
assert!(frame_size_allowed(7680, 4320)); // 8K UHD
assert!(frame_size_allowed(1, 1));
}
#[test]
fn frames_beyond_the_limits_are_refused() {
assert!(!frame_size_allowed(16384, 2177)); // one row over MaxPicSize
assert!(!frame_size_allowed(16384, 8704)); // within both sides, four times MaxPicSize
assert!(!frame_size_allowed(4096, 8705)); // one row over MaxVSize
assert!(!frame_size_allowed(16385, 1)); // one column over MaxHSize
assert!(!frame_size_allowed(65536, 65536));
}
}
+4
View File
@@ -394,6 +394,10 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
fn decode_block(&mut self, r: usize, c: usize, sub_size: usize) -> Result<()> {
let (ssx, ssy) = (self.fs.ssx, self.fs.ssy);
let num_planes = self.fs.num_planes;
// Conformance requirement (4:2:2 has no chroma size for 4x16 and similar blocks); indexing the size tables with BLOCK_INVALID would panic.
if num_planes > 1 && self.fs.plane_residual_size(sub_size, 1) == BLOCK_INVALID {
return Err(crate::Error::Invalid("block size has no chroma size at this subsampling"));
}
let mut b = Block { mi_row: r, mi_col: c, mi_size: sub_size, ..Default::default() };
b.bw4 = NUM_4X4_BLOCKS_WIDE[sub_size] as usize;
b.bh4 = NUM_4X4_BLOCKS_HIGH[sub_size] as usize;
+156
View File
@@ -0,0 +1,156 @@
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
//! ffmpeg-generated AV1 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use filmcraft_av1::Decoder;
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// (name, encoder args, pix_fmt)
const SEEDS: &[(&str, &str, &str)] = &[("av1_fz_default", "-c:v libsvtav1 -g 4", "yuv420p"), ("av1_fz_10bit", "-c:v libsvtav1 -g 6", "yuv420p10le")];
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let Some(h) = data.get(6..8) else { return out };
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
while let Some(hd) = data.get(pos..pos + 12) {
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
pos += 12;
let end = pos.saturating_add(size).min(data.len());
out.push(data[pos..end].to_vec());
pos = end;
}
out
}
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
let out = filmcraft_testkit::fixtures_dir("av1").join(format!("{name}.ivf"));
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
let a: Vec<&str> = args.split_whitespace().collect();
matches!(
Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
.args(a)
.args(["-f", "ivf"])
.arg(tmp)
.stdin(Stdio::null())
.status(),
Ok(s) if s.success()
)
})?;
Some(ivf_frames(&std::fs::read(p).ok()?))
}
static PANICS: AtomicUsize = AtomicUsize::new(0);
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
fn count_panics() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
PANICS.fetch_add(1, Ordering::SeqCst);
eprintln!("PANIC: {info}");
}));
});
}
fn run_all(frames: &[Vec<u8>], label: &str) {
filmcraft_testkit::set_progress(label);
count_panics();
let before = PANICS.load(Ordering::SeqCst);
let res = catch_unwind(AssertUnwindSafe(|| {
for threads in [1, 3] {
let mut dec = Decoder::with_threads(threads);
for (i, f) in frames.iter().enumerate() {
let _ = dec.decode_pts(f, i as i64);
}
let _ = dec.flush_result();
}
}));
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
}
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
fn iters() -> usize {
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
}
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
const DEADLINE_SECS: u64 = 600;
#[test]
fn hostile_streams_never_panic() {
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
}
fn sweep() {
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("AV1 fuzz") else { return };
for &(name, args, pix) in SEEDS {
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libsvtav1") {
filmcraft_testkit::skip("AV1 fuzz", &format!("this ffmpeg has no libsvtav1 encoder (seed {name})"));
continue;
}
let Some(seed) = make(&ff, name, args, pix) else {
panic!("ffmpeg has libsvtav1 but could not encode the {name} seed");
};
if seed.is_empty() {
filmcraft_testkit::skip("AV1 fuzz", &format!("the {name} seed has no frames"));
continue;
}
// truncate each of the first frames at every length
for fi in 0..seed.len().min(3) {
let len = seed[fi].len();
for n in (0..len).step_by((len / 200).max(1)) {
let mut d = seed.clone();
d[fi].truncate(n);
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
}
}
// header region of every frame: every byte to a few values, every bit
for fi in 0..seed.len() {
for p in 0..seed[fi].len().min(96) {
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
let mut d = seed.clone();
d[fi][p] = v;
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
}
for b in 0..8 {
let mut d = seed.clone();
d[fi][p] ^= 1 << b;
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
}
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
for i in 0..iters() {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let fi = rng.below(d.len());
if d[fi].is_empty() {
continue;
}
let p = rng.below(d[fi].len());
if rng.below(2) == 0 {
d[fi][p] ^= 1 << rng.below(8);
} else {
d[fi][p] = rng.next() as u8;
}
}
run_all(&d, &format!("{name} random {i}"));
}
}
}
@@ -1,7 +1,7 @@
//! Compare an arbitrary IVF file with libdav1d:
//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test adhoc -- --ignored --nocapture`
//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test it adhoc:: -- --ignored --nocapture`
mod common;
use crate::common;
use common::*;
#[test]
@@ -1,6 +1,6 @@
//! libaom conformance test vectors (downloaded on first use), compared with libdav1d.
mod common;
use crate::common;
use common::*;
/// Vectors the decoder handles today (more are added as stages land).
@@ -17,7 +17,7 @@ fn conformance_vectors() {
}
/// The wider libaom set (slow: many downloads and decodes):
/// `cargo test -p filmcraft-av1 --test conformance -- --ignored`; `AV1_VECTORS=a,b` overrides it.
/// `cargo test -p filmcraft-av1 --test it conformance:: -- --ignored`; `AV1_VECTORS=a,b` overrides it.
#[test]
#[ignore]
fn conformance_vectors_extended() {
+161
View File
@@ -0,0 +1,161 @@
//! A few bytes must not make the decoder allocate gigabytes: oversized sequences are refused.
//! Hand-built (no ffmpeg): a reduced still-picture sequence header plus a lossless key frame whose
//! tiles are one zero byte each.
use filmcraft_av1::{Decoder, Error};
#[derive(Default)]
struct Bits {
bytes: Vec<u8>,
n: usize,
}
impl Bits {
fn put(&mut self, value: u32, bits: usize) {
for i in (0..bits).rev() {
if self.n.is_multiple_of(8) {
self.bytes.push(0);
}
if (value >> i) & 1 == 1
&& let Some(b) = self.bytes.last_mut()
{
*b |= 0x80 >> (self.n % 8);
}
self.n += 1;
}
}
}
fn obu(kind: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![kind << 3 | 0b10];
let mut n = payload.len();
loop {
let b = (n & 0x7f) as u8;
n >>= 7;
v.push(if n > 0 { b | 0x80 } else { b });
if n == 0 {
break;
}
}
v.extend_from_slice(payload);
v
}
fn tile_log2(blk: u32, target: u32) -> u32 {
let mut k = 0;
while (blk << k) < target {
k += 1;
}
k
}
/// Temporal delimiter, reduced still-picture sequence header of `w` x `h` (64x64 superblocks) and a
/// lossless key frame whose uniformly spaced tiles are one zero byte each.
fn stream(w: u32, h: u32) -> Vec<u8> {
let mut s = Bits::default();
s.put(0, 3); // seq_profile
s.put(1, 1); // still_picture
s.put(1, 1); // reduced_still_picture_header
s.put(0, 5); // seq_level_idx
s.put(15, 4); // frame_width_bits_minus_1
s.put(15, 4); // frame_height_bits_minus_1
s.put(w - 1, 16);
s.put(h - 1, 16);
s.put(0, 3 + 3 + 7 + 1); // sb size, filter intra, edge; superres, cdef, restoration; color config; film grain
s.put(1, 1); // trailing bit
let (sb_cols, sb_rows) = (w.div_ceil(64), h.div_ceil(64));
let cols_log2 = tile_log2(64, sb_cols); // MAX_TILE_WIDTH / 64 superblocks
let min_log2_tiles = cols_log2.max(tile_log2(2304, sb_rows * sb_cols));
let rows_log2 = min_log2_tiles.saturating_sub(cols_log2);
let tiles = (sb_cols.div_ceil(sb_cols.div_ceil(1 << cols_log2))) * (sb_rows.div_ceil(sb_rows.div_ceil(1 << rows_log2)));
let mut f = Bits::default();
f.put(0b0001, 4); // disable_cdf_update, allow_screen_content_tools, render_and_frame_size_different, uniform_tile_spacing_flag
f.put(0, 2); // no increment of the tile column / row counts
if tiles > 1 {
f.put(0, (cols_log2 + rows_log2) as usize + 2); // context_update_tile_id, tile_size_bytes_minus_1
}
f.put(0, 8 + 4 + 1 + 1); // base_q_idx 0 (lossless), delta flags, using_qmatrix, segmentation_enabled, reduced_tx_set
let mut frame = f.bytes;
frame.push(0); // tile_start_and_end_present_flag
for _ in 1..tiles {
frame.extend([0, 0]); // tile_size_minus_1 = 0, one data byte
}
frame.extend([0, 0]);
let mut out = obu(2, &[]);
out.extend(obu(1, &s.bytes));
out.extend(obu(6, &frame));
out
}
fn refused(w: u32, h: u32) {
let mut dec = Decoder::new();
let r = dec.decode(&stream(w, h)).and_then(|_| dec.flush_result());
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("frame size")), "{w}x{h} must be refused as too large, got {:?}", r.as_ref().map(|p| p.len()));
}
#[test]
fn very_wide_frame_is_refused() {
refused(65536, 16);
}
#[test]
fn very_tall_frame_is_refused() {
refused(16, 65536);
}
#[test]
fn huge_frames_are_refused_without_allocating() {
refused(65536, 65536);
refused(65535, 65535);
refused(30000, 30000);
}
/// Frames past the level 6.x limits (width 16384, height 8704, area 35 651 584) are refused. The accepted side of
/// each boundary is unit-tested on `frame_size_allowed` in `decoder.rs`: decoding a maximum-size frame end to end
/// needs gigabytes.
#[test]
fn frames_past_the_level_6_limits_are_refused() {
refused(16384, 2177);
refused(16384, 8704);
refused(4096, 8705);
}
/// A 64x48 4:2:0 key frame whose sequence profile bit was flipped to 2 (4:2:2): blocks such as 4x16 then have no
/// chroma size (BLOCK_INVALID) and used to index the size tables out of bounds.
const PROFILE_FLIPPED: &[u8] = &[
0x12, 0x00, 0x0a, 0x0b, 0x42, 0x00, 0x00, 0x05, 0x55, 0x7f, 0xbc, 0x6a, 0xf9, 0x00, 0x40, 0x32, 0x81, 0x05, 0x14, 0x00, 0xa5, 0xa0, 0x40, 0xd9, 0x52, 0xb9,
0xe7, 0x3c, 0xa6, 0xc1, 0xe4, 0xe9, 0xff, 0x6d, 0x12, 0xc3, 0xce, 0xac, 0x00, 0x92, 0x6f, 0x29, 0x6a, 0xee, 0x7e, 0x1a, 0x31, 0xfe, 0xe6, 0xb8, 0x2e, 0xe2,
0xcf, 0x3d, 0xce, 0x24, 0xe6, 0x30, 0x03, 0xcd, 0x89, 0x49, 0x1f, 0xa7, 0x8a, 0x75, 0xb3, 0x93, 0x27, 0x7b, 0x2f, 0x39, 0xc7, 0x60, 0xb7, 0xf8, 0x69, 0xbc,
0x53, 0x71, 0x30, 0x64, 0x20, 0x7e, 0xc1, 0x10, 0x17, 0xe4, 0xca, 0x34, 0xe9, 0xae, 0x47, 0x39, 0x68, 0x68, 0xef, 0x33, 0x9f, 0x26, 0xe2, 0x3f, 0xf1, 0xef,
0x02, 0xb7, 0xfd, 0xb1, 0x78, 0x4c, 0x35, 0x31, 0x17, 0x27, 0x11, 0x2f, 0xd7, 0x25, 0x01, 0x30, 0xa5, 0x7a, 0xaf, 0x9b, 0x5b, 0x3b, 0x8d, 0x9e, 0xd5, 0x23,
0x06, 0x22, 0x3a, 0xec, 0x57, 0xae, 0xdb, 0x4d, 0x00, 0x36, 0xff, 0xbd, 0x3b, 0xef, 0x0a, 0xf0, 0x39, 0x2a, 0xa9, 0x2a, 0x56, 0x9d, 0x7e, 0xc2, 0x05, 0x46,
0x21, 0x8b, 0xd9, 0x45, 0x33, 0x59, 0x3c, 0x40, 0xe0, 0x2b, 0xec, 0x51, 0x0a, 0xf2, 0xf0, 0xe5, 0x61, 0x99, 0xfd, 0x2e, 0xe5, 0x8c, 0x69, 0x72, 0x44, 0xb8,
0x92, 0x9e, 0x2e, 0x51, 0x0b, 0x8c, 0x16, 0x0f, 0xd9, 0xf2, 0x47, 0xc4, 0x36, 0x9d, 0x54, 0xc6, 0x9f, 0x26, 0x68, 0xf7, 0xfa, 0x80, 0xab, 0x68, 0xd6, 0x61,
0xd2, 0x59, 0x4b, 0x07, 0xc4, 0x7a, 0xde, 0x37, 0xfb, 0x02, 0xd5, 0x3d, 0xb0, 0xeb, 0x71, 0x1b, 0x07, 0x45, 0xdc, 0x03, 0x3b, 0x4f, 0xdf, 0x44, 0xa6, 0x28,
0xd4, 0xb0, 0xbb, 0x26, 0xa4, 0x4f, 0xee, 0xbb, 0xda, 0x59, 0x69, 0xfc, 0xf2, 0x4f, 0xf6, 0x6c, 0x0c, 0xd9, 0xe2, 0xc0, 0xa7, 0x64, 0x96, 0xd5, 0x7b, 0x1a,
0x71, 0xe0, 0xcc, 0x9e, 0xd7, 0xaf, 0xb9, 0x9a, 0x99, 0xcc, 0xe9, 0xa9, 0x79, 0xa3, 0xa8, 0xbc, 0xfe, 0xdb, 0xed, 0xa3, 0xd6, 0x7d, 0x31, 0xb0, 0x18, 0x3c,
0x9e, 0x55, 0x0f, 0x1a, 0xdf, 0x5d, 0x8c, 0x33, 0x5f, 0x2d, 0x00, 0xfc, 0x9d, 0x55, 0xc2, 0xc8, 0x10, 0x25, 0xc8, 0x7f, 0xa6, 0x47, 0xbf, 0xab, 0xf3, 0x97,
0xfb, 0xda, 0xd6, 0x6a, 0x14, 0x87, 0x9d, 0xef, 0x85, 0x12, 0x43, 0x31, 0xc4, 0x4c, 0x4c, 0x7a, 0x50, 0xd1, 0x87, 0xb8, 0x65, 0x2d, 0x3f, 0xf4, 0xa3, 0x54,
0xea, 0x19, 0xa6, 0xfe, 0x70, 0x30, 0x52, 0x7e, 0xbb, 0x9c, 0x3f, 0x2d, 0x10, 0x4b, 0x43, 0x04, 0x6c, 0x03, 0xc7, 0x15, 0xa8, 0x8e, 0x20, 0xde, 0x46, 0x9a,
0x12, 0xdf, 0xe5, 0xce, 0x08, 0x91, 0x3e, 0x74, 0xa4, 0x20, 0x78, 0x52, 0xfd, 0x4d, 0x9d, 0x5a, 0xca, 0xa2, 0xdc, 0xc8, 0x95, 0xa6, 0x69, 0x3a, 0x1a, 0x18,
0x48, 0x76, 0x8c, 0xfa, 0x58, 0xa0, 0x0d, 0xf4, 0xbd, 0xe1, 0x05, 0x37, 0xe6, 0xbb, 0x60, 0xa1, 0x83, 0x40, 0x08, 0xeb, 0x02, 0xa6, 0x6a, 0x7a, 0x16, 0x1f,
0xb3, 0xbb, 0x58, 0x66, 0x51, 0x79, 0xc7, 0x7a, 0xb2, 0xc2, 0x84, 0x69, 0x17, 0x4d, 0x27, 0xa7, 0xa4, 0xd9, 0xc2, 0xb4, 0x03, 0x57, 0x53, 0x44, 0xb9, 0x8f,
0x48, 0xd2, 0x1c, 0x74, 0xd9, 0xdd, 0x9b, 0x88, 0xdc, 0xda, 0xef, 0x70, 0xee, 0xf1, 0x04, 0xdf, 0x35, 0x39, 0x22, 0x49, 0xfe, 0x22, 0x9a, 0x0a, 0x33, 0x60,
0x49, 0xbe, 0x28, 0xfa, 0xdf, 0x71, 0xf6, 0x53, 0x87, 0x49, 0x01, 0x95, 0xfa, 0x22, 0x94, 0x7c, 0x0e, 0xf6, 0x7d, 0x7e, 0x1d, 0x41, 0x8d, 0xc9, 0x1b, 0xf9,
0x53, 0xc9, 0xea, 0x46, 0xee, 0xb2, 0x80, 0x75, 0xe1, 0xca, 0xa4, 0x0a, 0x9c, 0x2b, 0x7b, 0x18, 0x70, 0x56, 0x22, 0x76, 0xaf, 0x4d, 0x84, 0xdd, 0xad, 0x77,
0x0e, 0xea, 0x2e, 0xa9, 0xbf, 0x42, 0x95, 0xdf, 0x17, 0x61, 0xae, 0x46, 0x20, 0xb4, 0x57, 0xca, 0xe0, 0x78, 0x86, 0xd0, 0x51, 0xae, 0xfe, 0x51, 0x53, 0x0f,
0x36, 0x64, 0x5f, 0xb4, 0x5c, 0xac, 0x8b, 0x63, 0xe7, 0x5d, 0x98, 0x8e, 0x65, 0xe9, 0x93, 0xe4, 0x23, 0x10, 0xa3, 0x5b, 0x3b, 0x38, 0x5b, 0x8a, 0x7c, 0xe8,
0x60, 0x01, 0xc1, 0x0e, 0x53, 0x5e, 0x0e, 0x81, 0x33, 0x63, 0x8d, 0x53, 0x82, 0xa6, 0x7b, 0xbd, 0x02, 0x58, 0x1f, 0xe9, 0x50, 0x75, 0x24, 0x64, 0x80, 0x47,
0x02, 0x9e, 0x42, 0x28, 0xb0, 0xa2, 0xab, 0x8e, 0xd0, 0x81, 0xcc, 0xbd, 0x22, 0x87, 0x3f, 0xe4, 0x49, 0x4d, 0x5f, 0x6a, 0x5c, 0x5a, 0x5e, 0xe5, 0x30, 0x9d,
0x7f, 0xc5, 0xaf, 0xce, 0x6c, 0x9c, 0xaf, 0x09, 0x96, 0x1a, 0x62, 0x42, 0x61, 0xa3, 0x1d, 0x1c, 0xe6, 0xcf, 0xa8, 0x90, 0x4d, 0x05, 0x28, 0xef, 0x22, 0x81,
0x12, 0xa7, 0x70, 0x04, 0x0a, 0x0d, 0xe5, 0xa0, 0xd8,
];
#[test]
fn block_without_chroma_size_is_an_error_not_a_panic() {
let mut dec = Decoder::new();
let r = dec.decode(PROFILE_FLIPPED).and_then(|_| dec.flush_result());
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("chroma size")), "got {:?}", r.as_ref().map(|p| p.len()));
}
+7
View File
@@ -0,0 +1,7 @@
mod adhoc;
mod common;
mod conformance;
mod hostile_dims;
mod oracle_inter;
mod oracle_intra;
mod perf;
@@ -2,7 +2,7 @@
//! GOPs with hierarchical references, compound prediction, OBMC / warped motion, global motion,
//! motion-field projection and all loop filters.
mod common;
use crate::common;
use common::*;
fn specs() -> Vec<Spec> {
@@ -1,6 +1,6 @@
//! Intra-frame bit-exactness against libdav1d (run as an external ffmpeg process).
mod common;
use crate::common;
use common::*;
const NO_FILTERS: &str = "enable-cdef=0:enable-restoration=0:enable-dlf=0:keyint=1";
@@ -1,11 +1,11 @@
//! Decode throughput and per-stage breakdown (ignored; run with
//! `cargo test --release -p filmcraft-av1 --test perf -- --ignored --nocapture`).
//! `cargo test --release -p filmcraft-av1 --test it perf:: -- --ignored --nocapture`).
//!
//! The machine may be busy (other builds), so single-threaded work is also measured with the
//! thread CPU clock, which is robust to load; wall-clock figures are printed next to the load
//! average.
mod common;
use crate::common;
use common::*;
use filmcraft_av1::{DecodeStats, Decoder, Stage};
use std::time::Instant;
+33 -4
View File
@@ -17,8 +17,22 @@ dimensions; the element type is the narrowest of u8/i8/u16/i16/i32 that holds ev
import os
import re
import sys
from pathlib import Path
root = sys.argv[1]
def _spec_dir(arg):
"""Resolve the CLI argument and require it to be an existing directory."""
path = Path(arg).resolve()
if not path.is_dir():
sys.exit(f"not a directory: {arg}")
return path
if len(sys.argv) != 2:
sys.exit("usage: extract_tables.py <av1-spec checkout>")
# The argument is the operator's own spec checkout; _spec_dir requires it to be an existing
# directory before anything is read from it.
root = _spec_dir(sys.argv[1]) # NOSONAR
files = sorted(f for f in os.listdir(root) if f.endswith(".md"))
texts = {f: open(os.path.join(root, f), encoding="utf-8").read() for f in files}
@@ -35,7 +49,7 @@ def ev(expr):
# --- constants: symbol table (section 3) ---
pending = []
for line in texts["03.symbols.md"].split("\n"):
m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^|]+?)\s*\|", line)
m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^\s|](?:[^|]*[^|\s])?)\s*\|", line)
if m:
pending.append((m.group(1), m.group(2)))
# --- constants: value/name tables (section 6 semantics, section 8/9) ---
@@ -77,7 +91,7 @@ for name, val in pending:
def cells(text):
"""Split one initialiser cell. A missing comma between two operands on separate lines
(Split_Tx_Size in the spec text: `TX_32X32` / `TX_4X8`) yields two cells."""
parts = re.split(r"(?<=[A-Za-z0-9_])\s*\n\s*(?=[A-Za-z0-9_])", text.strip())
parts = re.split(r"(?<=[A-Za-z0-9_])[^\S\n]*\n[^\S\n]*(?=[A-Za-z0-9_])", text.strip())
if len(parts) > 1:
sys.stderr.write("note: missing comma in spec table between %s\n" % parts)
return parts
@@ -118,10 +132,25 @@ def shape(x):
return []
# `~~~~` opens a block on its own line (with an optional `c` marker) and the next run of
# tildes closes it. Walk the pairs with two linear searches instead of the lazy `(.*?)`
# with re.S, which rescans the whole remaining text at every position (super-linear).
FENCE_OPEN = re.compile(r"~~~~~?\s*c?\n")
FENCE_CLOSE = re.compile(r"~~~~~?")
tables = {}
for f in files:
t = texts[f]
for block in re.findall(r"~~~~~?\s*c?\n(.*?)~~~~~?", t, re.S):
pos = 0
while True:
m = FENCE_OPEN.search(t, pos)
if not m:
break
c = FENCE_CLOSE.search(t, m.end())
if not c:
break
block = t[m.end():c.start()]
pos = c.end()
b = re.sub(r"//[^\n]*", "", block)
b = re.sub(r"/\*.*?\*/", "", b, flags=re.S)
for m in re.finditer(r"(?m)^\s*([A-Z][A-Za-z0-9_]*)\s*((?:\[[^\]=]*\]\s*)+)=\s*", b):
+33 -10
View File
@@ -21,6 +21,8 @@ mod tests;
pub use source::ByteSource;
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// Not an AVI file (no `RIFF` … `AVI ` header).
@@ -175,6 +177,9 @@ pub struct OpenOptions {
/// Headers larger than this are refused (a real `hdrl` is a few kilobytes).
const MAX_HEADER: u64 = 16 << 20;
/// Total OpenDML standard-index bytes accepted while opening one file. This bounds hostile
/// repeated index references without constraining practical large-file indexes.
const MAX_ODML_INDEX_BYTES: u64 = 64 << 20;
/// Streams beyond this are ignored.
const MAX_STREAMS: usize = 64;
/// `LIST rec ` inside `movi` nests once; deeper is refused.
@@ -443,34 +448,52 @@ fn super_index(b: &[u8]) -> Vec<u64> {
/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream
/// that has data lacks one or none could be read.
fn read_odml<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream]) -> bool {
let limit = src.len() / 8;
let mut total = 0u64;
let entry_limit = src.len() / 8;
let mut entries = 0u64;
let mut index_bytes = 0u64;
let mut seen = HashSet::new();
for s in streams.iter_mut() {
s.chunks.clear();
s.keyframes_known = true;
for &at in &s.super_index {
// A super index can repeat an offset (even across streams). Read and process a
// standard index once, rather than multiplying its work and allocations.
if !seen.insert(at) {
continue;
}
let Ok((id, size, data)) = header_at(src, at) else { continue };
if &id[..2] != b"ix" {
continue;
}
let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue };
let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0));
// Validate the fixed header before allocating the variable entry body.
if size < 24 || index_bytes.checked_add(24).is_none_or(|n| n > MAX_ODML_INDEX_BYTES) {
return false;
}
let Ok(header) = read(src, data, 24) else { continue };
index_bytes += 24;
let (longs, subtype, itype) = (usize::from(le16(&header, 0)), header.get(2).copied().unwrap_or(0), header.get(3).copied().unwrap_or(0));
// AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD)
if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) {
continue;
}
let base = le64(&b, 12);
let step = longs * 4;
let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step);
let n = u64::from(le32(&header, 4)).min(size.saturating_sub(24) / step as u64);
let body = n.saturating_mul(step as u64);
let Some(next_bytes) = index_bytes.checked_add(body) else { return false };
let Some(next_entries) = entries.checked_add(n) else { return false };
if next_bytes > MAX_ODML_INDEX_BYTES || next_entries > entry_limit {
return false;
}
let Ok(b) = read(src, data, 24 + body) else { continue };
index_bytes = next_bytes;
entries = next_entries;
let base = le64(&b, 12);
let n = usize::try_from(n).unwrap_or(0);
for k in 0..n {
let e = 24 + k * step;
let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4));
let size = raw & 0x7FFF_FFFF;
let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue };
total += 1;
if total > limit {
return false;
}
s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 });
}
}
+15
View File
@@ -267,3 +267,18 @@ fn opendml_standard_indexes() {
}
}
}
#[test]
fn opendml_repeated_index_offsets_are_read_once() {
let (_, at) = avi_odml([0, 0]);
let (file, _) = avi_odml(at);
let mut streams = open(&file).unwrap().streams;
// A malicious super index repeats each valid standard-index address. It must not multiply
// its chunks, allocations, or traversal work.
for (stream, &index) in streams.iter_mut().zip(&at) {
stream.super_index = vec![index; 100];
}
assert!(read_odml(&file, &mut streams));
assert_eq!(streams[0].chunks.len(), 3);
assert_eq!(streams[1].chunks.len(), 2);
}
+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();
}
+1 -3
View File
@@ -9,6 +9,4 @@ rust-version.workspace = true
workspace = true
[dependencies]
[dev-dependencies]
proptest = { workspace = true }
craft-media = { workspace = true, features = ["bitstream"] }
+2 -430
View File
@@ -1,433 +1,5 @@
//! Bit-level reading and writing shared by FilmCraft's codec crates.
//!
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
//! Bit-level reading and writing: a re-export of [`craft_media::bitstream`] from craft-libs.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BitError {
/// Tried to read past the end of the buffer.
Eof,
/// An Exp-Golomb code longer than 32 leading zeros.
InvalidExpGolomb,
}
impl fmt::Display for BitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BitError::Eof => f.write_str("unexpected end of bitstream"),
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
}
}
}
impl std::error::Error for BitError {}
pub type Result<T> = std::result::Result<T, BitError>;
/// MSB-first bit reader over a byte slice.
#[derive(Clone)]
pub struct BitReader<'a> {
data: &'a [u8],
/// Position in bits.
pos: usize,
}
impl<'a> BitReader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
pub fn data(&self) -> &'a [u8] {
self.data
}
/// Bit position from the start.
pub fn position(&self) -> usize {
self.pos
}
pub fn bits_left(&self) -> usize {
(self.data.len() * 8).saturating_sub(self.pos)
}
pub fn is_byte_aligned(&self) -> bool {
self.pos.is_multiple_of(8)
}
pub fn byte_align(&mut self) {
self.pos = self.pos.div_ceil(8) * 8;
}
/// Byte offset of the current position (rounded down).
pub fn byte_pos(&self) -> usize {
self.pos / 8
}
pub fn seek_bits(&mut self, pos: usize) {
self.pos = pos;
}
/// Peek up to 32 bits without consuming (zero-padded past the end).
#[inline]
pub fn peek(&self, n: u32) -> u32 {
debug_assert!(n <= 32);
if n == 0 {
return 0;
}
let byte = self.pos / 8;
let shift = self.pos % 8;
let mut v: u64 = 0;
for i in 0..5 {
v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
}
// v holds 40 bits starting at `byte`
((v << (24 + shift)) >> (64 - n)) as u32
}
#[inline]
pub fn read_bits(&mut self, n: u32) -> Result<u32> {
if n as usize > self.bits_left() {
return Err(BitError::Eof);
}
let v = self.peek(n);
self.pos += n as usize;
Ok(v)
}
pub fn read_bits_u64(&mut self, n: u32) -> Result<u64> {
if n <= 32 {
return self.read_bits(n).map(u64::from);
}
let hi = self.read_bits(n - 32)? as u64;
let lo = self.read_bits(32)? as u64;
Ok((hi << 32) | lo)
}
#[inline]
pub fn read_bit(&mut self) -> Result<bool> {
self.read_bits(1).map(|b| b == 1)
}
pub fn read_flag(&mut self) -> Result<bool> {
self.read_bit()
}
pub fn skip(&mut self, n: usize) -> Result<()> {
if n > self.bits_left() {
return Err(BitError::Eof);
}
self.pos += n;
Ok(())
}
/// Unsigned Exp-Golomb `ue(v)`.
pub fn read_ue(&mut self) -> Result<u32> {
let mut zeros = 0u32;
while !self.read_bit()? {
zeros += 1;
if zeros > 32 {
return Err(BitError::InvalidExpGolomb);
}
}
if zeros == 0 {
return Ok(0);
}
let v = self.read_bits_u64(zeros)?;
let r = (1u64 << zeros) - 1 + v;
u32::try_from(r).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) })
}
/// Truncated Exp-Golomb `te(v)` with range `max`.
pub fn read_te(&mut self, max: u32) -> Result<u32> {
if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
}
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
pub fn more_rbsp_data(&self) -> bool {
let total = self.data.len() * 8;
if self.pos >= total {
return false;
}
// find last set bit (the rbsp_stop_one_bit)
let mut last = None;
for (i, &b) in self.data.iter().enumerate().rev() {
if b != 0 {
last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
break;
}
}
match last {
Some(stop) => self.pos < stop,
None => false,
}
}
}
/// MSB-first bit writer.
#[derive(Default, Clone)]
pub struct BitWriter {
buf: Vec<u8>,
acc: u64,
nbits: u32,
}
impl BitWriter {
pub fn new() -> Self {
Self::default()
}
pub fn write_bits(&mut self, value: u32, n: u32) {
debug_assert!(n <= 32);
if n == 0 {
return;
}
let v = (value as u64) & ((1u64 << n) - 1);
self.acc = (self.acc << n) | v;
self.nbits += n;
while self.nbits >= 8 {
self.nbits -= 8;
self.buf.push((self.acc >> self.nbits) as u8);
}
self.acc &= (1u64 << self.nbits) - 1;
}
pub fn write_bit(&mut self, b: bool) {
self.write_bits(b as u32, 1);
}
pub fn write_ue(&mut self, v: u32) {
let x = v as u64 + 1;
let len = 64 - x.leading_zeros();
let zeros = len - 1;
for _ in 0..zeros {
self.write_bit(false);
}
if len > 32 {
self.write_bits((x >> 32) as u32, len - 32);
self.write_bits(x as u32, 32);
} else {
self.write_bits(x as u32, len);
}
}
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);
}
pub fn bit_len(&self) -> usize {
self.buf.len() * 8 + self.nbits as usize
}
pub fn is_byte_aligned(&self) -> bool {
self.nbits == 0
}
/// Pad with zero bits to a byte boundary.
pub fn align_zero(&mut self) {
if self.nbits > 0 {
self.write_bits(0, 8 - self.nbits);
}
}
/// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
pub fn rbsp_trailing(&mut self) {
self.write_bit(true);
self.align_zero();
}
pub fn write_bytes(&mut self, bytes: &[u8]) {
if self.nbits == 0 {
self.buf.extend_from_slice(bytes);
} else {
for &b in bytes {
self.write_bits(b as u32, 8);
}
}
}
pub fn finish(mut self) -> Vec<u8> {
self.align_zero();
self.buf
}
pub fn bytes(&self) -> &[u8] {
&self.buf
}
}
/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
pub fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut zeros = 0;
for &b in data {
if zeros >= 2 && b == 3 {
zeros = 0;
continue;
}
out.push(b);
zeros = if b == 0 { zeros + 1 } else { 0 };
}
out
}
/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
pub fn escape_rbsp(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() + data.len() / 64);
let mut zeros = 0;
for &b in data {
if zeros >= 2 && b <= 3 {
out.push(3);
zeros = 0;
}
out.push(b);
zeros = if b == 0 { zeros + 1 } else { 0 };
}
out
}
/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
let mut starts = Vec::new();
let mut i = 0;
while i + 3 <= data.len() {
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
starts.push(i + 3);
i += 3;
} else {
i += 1;
}
}
let mut out = Vec::with_capacity(starts.len());
for (k, &s) in starts.iter().enumerate() {
let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
// trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
while e > s && data[e - 1] == 0 {
e -= 1;
}
if e > s {
out.push(&data[s..e]);
}
}
out
}
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
if i + length_size > data.len() {
return Err(BitError::Eof);
}
let mut len = 0usize;
for k in 0..length_size {
len = (len << 8) | data[i + k] as usize;
}
i += length_size;
if i + len > data.len() {
return Err(BitError::Eof);
}
out.push(&data[i..i + len]);
i += len;
}
Ok(out)
}
/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn reads_bits() {
let d = [0b1010_1100, 0b0101_0011];
let mut r = BitReader::new(&d);
assert_eq!(r.read_bits(1).unwrap(), 1);
assert_eq!(r.read_bits(3).unwrap(), 0b010);
assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
assert_eq!(r.bits_left(), 4);
assert_eq!(r.read_bits(5), Err(BitError::Eof));
}
#[test]
fn exp_golomb_known() {
// 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
let mut w = BitWriter::new();
for v in 0..5 {
w.write_ue(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in 0..5 {
assert_eq!(r.read_ue().unwrap(), v);
}
let mut w = BitWriter::new();
for v in [0, 1, -1, 2, -2, 1000, -1000] {
w.write_se(v);
}
let b = w.finish();
let mut r = BitReader::new(&b);
for v in [0, 1, -1, 2, -2, 1000, -1000] {
assert_eq!(r.read_se().unwrap(), v);
}
}
#[test]
fn emulation_prevention() {
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
let esc = escape_rbsp(&raw);
assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
assert_eq!(unescape_rbsp(&esc), raw);
}
#[test]
fn annexb_split() {
let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
let n = annexb_nals(&s);
assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
}
#[test]
fn more_rbsp() {
let d = [0b1100_0000];
let mut r = BitReader::new(&d);
assert!(r.more_rbsp_data());
r.read_bit().unwrap();
assert!(!r.more_rbsp_data());
}
#[test]
fn crc() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
}
proptest! {
#[test]
fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
let mut w = BitWriter::new();
for &(v, n) in &vals { w.write_bits(v, n); }
for &u in &ues { w.write_ue(u); }
let b = w.finish();
let mut r = BitReader::new(&b);
for &(v, n) in &vals {
let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
}
for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
}
#[test]
fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
let e = escape_rbsp(&data);
prop_assert_eq!(unescape_rbsp(&e), data);
for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
}
}
}
pub use craft_media::bitstream::*;
+30 -1
View File
@@ -23,6 +23,35 @@ 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 tick timing
When `ttp:tickRate` is omitted and `ttp:frameRate` is present, tick offsets use the effective
frame rate (`frameRate` times `frameRateMultiplier`) times `subFrameRate`. The rate stays
rational, so a 30-tick offset at 30 times 1000/1001 fps is exactly 1.001 seconds. An explicit
`tickRate` takes precedence; without `frameRate`, the default is one tick per second.
This follows [W3C TTML1, third edition (2018), §6.2.10](https://www.w3.org/TR/2018/REC-ttml1-20181108/#parameter-attribute-tickRate).
### 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
@@ -56,7 +85,7 @@ implementation's code was consulted.
- Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608
tables, PAC round trips, known SCC pop-on and roll-up streams, layout).
- `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers,
- `tests/it/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers,
settings, STYLE blocks), SCC and MCC (drop-frame and non-drop, gaps and back-to-back captions;
MCC also through CEA-708 alone), EBU STL (25 and 29.97 fps, accents, extension blocks), TTML
(23.976 / 25 / 29.97 / 59.94, ids, speakers) round-trip frame-exactly; DFXP round-trips after
+23
View File
@@ -286,6 +286,29 @@ mod tests {
assert!(o.x < 120, "{}", o.x);
}
#[test]
fn margin_is_a_fraction_of_frame_height() {
// #1542: on a portrait sequence, a bottom-anchored caption with margin 0.31 sits with
// its bottom edge 0.31 of the frame height above the bottom; the top anchor mirrors it.
let st = CaptionStyle { anchor: CaptionAnchor::Bottom, margin: 0.31, size: 78.0, ..Default::default() };
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
// the overlay pads the content by the outline grow (the default outline is 0, so by 1 px)
let bottom = (o.y as usize + o.h) as f32;
let expected = 1920.0 - 0.31 * 1920.0;
assert!((bottom - expected).abs() <= 2.0, "bottom edge {bottom}, expected {expected}");
let st = CaptionStyle { anchor: CaptionAnchor::Top, margin: 0.31, size: 78.0, ..Default::default() };
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
assert!((o.y as f32 - 0.31 * 1920.0).abs() <= 2.0, "top edge {}, expected {}", o.y, 0.31 * 1920.0);
// margin 0 and the 0.45 clamp stay inside the frame
for margin in [0.0, 0.31, 0.45, 9.0] {
let st = CaptionStyle { anchor: CaptionAnchor::Bottom, margin, size: 78.0, ..Default::default() };
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
// the outline-grow padding plus ceil rounding may reach 2 px past the edge; the
// composite clips it
assert!(o.y >= 0 && o.y as usize + o.h <= 1922, "margin {margin}: y {} h {}", o.y, o.h);
}
}
#[test]
fn wraps_and_composites() {
let long = "word ".repeat(60);
+9 -1
View File
@@ -34,7 +34,7 @@ pub fn parse_timecode(s: &str) -> Option<i64> {
let df = s.contains([';', '.', ',']);
let parts: Vec<&str> = s.split([':', ';', '.', ',']).collect();
let [h, m, sec, f] = parts.as_slice() else { return None };
let n = |p: &str| if (1..=3).contains(&p.len()) { p.parse::<i64>().ok() } else { None };
let n = |p: &str| if (1..=3).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_digit()) { p.parse::<i64>().ok() } else { None };
Some(fields_to_frames(n(h)?, n(m)?, n(sec)?, n(f)?, RATE, df))
}
@@ -484,6 +484,14 @@ mod tests {
Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() }
}
#[test]
fn timecode_rejects_signed_fields() {
assert_eq!(parse_timecode("00:00:-5:00"), None);
assert_eq!(parse_timecode("00:+1:00:00"), None);
assert_eq!(parse_timecode("00:00:00:"), None);
assert_eq!(parse_timecode("00:00:01:00"), Some(30));
}
#[test]
fn timecodes() {
assert_eq!(parse_timecode("00:01:00;02"), Some(1800));
+166 -28
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) }
}
@@ -181,7 +209,8 @@ struct Timing {
/// Nominal frames per second for `HH:MM:SS:FF`.
base: i64,
sub_frame_rate: i64,
tick_rate: i64,
/// Ticks per second, rational when derived from the effective frame rate.
tick_rate: FrameRate,
}
fn positive_integer(s: &str) -> Option<i64> {
@@ -203,6 +232,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}") };
@@ -218,9 +297,10 @@ fn timing(root: roxmltree::Node) -> Result<Timing> {
let rate = FrameRate::new(base.checked_mul(mn).ok_or_else(|| invalid("frameRateMultiplier"))?, md);
let sub = integer("subFrameRate", 1)?;
let tick_rate = match attr(root, "tickRate") {
Some(v) => positive_integer(v).ok_or_else(|| invalid("tickRate"))?,
None if attr(root, "frameRate").is_some() => base.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?,
None => 1,
Some(v) => FrameRate::new(positive_integer(v).ok_or_else(|| invalid("tickRate"))?, 1),
// TTML1 §6.2.10: absent tickRate uses the effective frame rate times subFrameRate.
None if attr(root, "frameRate").is_some() => FrameRate::new(rate.num.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?, rate.den),
None => FrameRate::new(1, 1),
};
Ok(Timing { rate, base, sub_frame_rate: sub, tick_rate })
}
@@ -228,13 +308,13 @@ fn timing(root: roxmltree::Node) -> Result<Timing> {
/// Seconds as an exact decimal string → ticks.
fn decimal_ticks(int: &str, frac: &str, unit_ticks: i128) -> Option<i128> {
let i: i128 = if int.is_empty() { 0 } else { int.parse().ok()? };
let mut t = i * unit_ticks;
let mut t = i.checked_mul(unit_ticks)?;
if !frac.is_empty() {
if frac.len() > 12 || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let f: i128 = frac.parse().ok()?;
t += f * unit_ticks / 10i128.pow(frac.len() as u32);
t = t.checked_add(f.checked_mul(unit_ticks)? / 10i128.pow(frac.len() as u32))?;
}
Some(t)
}
@@ -276,7 +356,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?;
let (v, unit) = s.split_at(unit_start);
let (vi, vf) = v.split_once('.').unwrap_or((v, ""));
if !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
if vi.is_empty() || !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
return None;
}
let t = match unit {
@@ -294,10 +374,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 * i128::from(tm.tick_rate.den))? / i128::from(tm.tick_rate.num),
_ => return None,
};
i64::try_from(t).ok().map(Tick)
@@ -411,6 +488,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 +506,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 +558,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 +572,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 +590,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 +604,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()
});
}
}
}
@@ -543,7 +627,7 @@ mod tests {
use super::*;
fn tm(rate: FrameRate) -> Timing {
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: 1 }
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: FrameRate::new(1, 1) }
}
#[test]
@@ -569,6 +653,8 @@ mod tests {
fn missing_timing_parameters_keep_defaults() {
for (params, begin, end) in [
("", "1s", "2s"),
(r#"ttp:frameRate="25" ttp:frameRateMultiplier="2 1""#, "50t", "100t"),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:subFrameRate="1001""#, "30000t", "60000t"),
(r#"ttp:frameRate="25""#, "25f", "50f"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2""#, "60t", "120t"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10""#, "10t", "20t"),
@@ -584,6 +670,30 @@ mod tests {
}
}
#[test]
fn default_tick_rate_preserves_fractional_frame_rate() {
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
for (params, begin, end, start, finish) in [
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001""#, "30t", "60t", 1001000, 2002000),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001""#, "1.5t", "3t", 50050, 100100),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:subFrameRate="2""#, "30t", "60t", 500500, 1001000),
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:tickRate="10""#, "10t", "20t", 1000000, 2000000),
(r#"ttp:frameRateMultiplier="2 1" ttp:subFrameRate="2""#, "1t", "2t", 1000000, 2000000),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="{begin}" end="{end}">Hello</p></div></body></tt>"#
);
let doc = crate::parse(xml.as_bytes(), format).unwrap();
assert_eq!(doc.cues.len(), 1, "{params}");
assert_eq!(doc.cues[0].start, Tick(start * (TICKS_PER_SECOND / 1_000_000)), "{params}");
assert_eq!(doc.cues[0].end, Tick(finish * (TICKS_PER_SECOND / 1_000_000)), "{params}");
}
}
let timing = Timing { tick_rate: FrameRate::new(1, i64::MAX), ..tm(FrameRate::FPS_25) };
assert_eq!(parse_time("999999999999999t", timing), None);
assert_eq!(parse_time("0.999999999999t", timing), None);
}
#[test]
fn time_expressions() {
let t = tm(FrameRate::FPS_25);
@@ -597,7 +707,7 @@ mod tests {
assert_eq!(parse_time("50f", t), Some(s(2.0)));
let ntsc = tm(FrameRate::FPS_29_97);
assert_eq!(parse_time("30f", ntsc), Some(FrameRate::FPS_29_97.tick_of(30)));
let ticks = Timing { tick_rate: 10_000_000, ..t };
let ticks = Timing { tick_rate: FrameRate::new(10_000_000, 1), ..t };
assert_eq!(parse_time("15000000t", ticks), Some(s(1.5)));
assert_eq!(parse_time("bogus", t), None);
assert_eq!(parse_time("1:2", t), None);
@@ -631,6 +741,34 @@ 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: FrameRate::new(3, 1), ..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 offset_time_without_integer_digits_is_rejected() {
let t = tm(FrameRate::FPS_25);
for expr in ["s", "ms", "h", "m", "t", "f", ".5s", ".5ms", ".s"] {
assert_eq!(parse_time(expr, t), None, "{expr}");
}
assert_eq!(parse_time("0s", t), Some(Tick(0)));
assert_eq!(parse_time("0.5s", t), Some(Tick(TICKS_PER_SECOND / 2)));
assert_eq!(parse_time("1ms", t), Some(Tick(TICKS_PER_SECOND / 1000)));
}
#[test]
fn reads_styles_spans_and_nesting() {
let x = r#"<?xml version="1.0"?>
+23 -2
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);
@@ -79,7 +79,7 @@ pub fn parse(text: &str) -> Result<Document> {
let mut seen_cue = false;
for b in blocks {
let first = b[0].trim();
if !first.contains("-->") && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
if !b.iter().any(|l| l.contains("-->")) && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
doc.blocks.push(b.join("\n"));
continue;
}
@@ -177,6 +177,27 @@ 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 note_prefixed_cue_id_is_a_cue() {
let d = parse("WEBVTT\n\nNOTE 1\n00:00:01.000 --> 00:00:02.000\nhello\n").unwrap();
assert_eq!(d.cues.len(), 1);
assert_eq!(d.cues[0].id.as_deref(), Some("NOTE 1"));
assert!(d.blocks.is_empty());
}
#[test]
fn without_signature() {
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();
+3
View File
@@ -0,0 +1,3 @@
mod roundtrip;
mod ttml_body_timing;
mod ttml_speakers;
+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""#);
}
+3
View File
@@ -50,3 +50,6 @@ chain is contiguous. Sibling trees are balanced binary search trees in [MS-CFB]
bytes) and multi-sector streams and up to 40 siblings; red-black invariants of every sibling tree;
a 7.5 MB stream that needs DIFAT sectors; Unicode and case-insensitive names; truncation at every
29 bytes and 1500 random mutations never panic and only return complete streams.
The writer rejects a duplicate sibling name with an O(1) hash lookup on the upper-cased name (a scan over the
siblings made n children cost O(n^2): 20 000 AAF markers took a minute); `adding_many_children_is_not_quadratic`
and `duplicate_names_are_still_rejected` (`tests/writer_scale.rs`) cover it.
+28 -4
View File
@@ -5,6 +5,8 @@
//! when the FAT needs more than the 109 header slots. Every chain is contiguous. Sibling trees are
//! balanced binary search trees coloured as valid red-black trees (§2.6.4).
use std::collections::HashSet;
use crate::{DIFSECT, ENDOFCHAIN, FATSECT, FREESECT, MINI_SECTOR_SIZE, MINI_STREAM_CUTOFF, NOSTREAM, Result, SIGNATURE, compare_names, validate_name};
/// File format version.
@@ -44,6 +46,15 @@ struct Node {
clsid: [u8; 16],
data: Vec<u8>,
children: Vec<usize>,
/// The children's names, upper-cased as sibling order compares them: an O(1) duplicate check.
/// (Scanning every sibling with `compare_names`, which allocates, made adding n children O(n^2):
/// 20 000 AAF markers took a minute.)
child_keys: HashSet<Vec<u16>>,
}
/// A name as sibling comparison sees it: its UTF-16 code units, upper-cased.
fn name_key(name: &str) -> Vec<u16> {
name.encode_utf16().map(crate::upper).collect()
}
/// Builds a compound file in memory.
@@ -64,7 +75,17 @@ impl Writer {
pub const ROOT: NodeId = NodeId(0);
pub fn new(version: Version) -> Self {
Writer { version, nodes: vec![Node { name: "Root Entry".into(), kind: Kind::Root, clsid: [0; 16], data: Vec::new(), children: Vec::new() }] }
Writer {
version,
nodes: vec![Node {
name: "Root Entry".into(),
kind: Kind::Root,
clsid: [0; 16],
data: Vec::new(),
children: Vec::new(),
child_keys: HashSet::new(),
}],
}
}
fn add(&mut self, parent: NodeId, name: &str, kind: Kind, clsid: [u8; 16], data: Vec<u8>) -> Result<NodeId> {
@@ -73,12 +94,15 @@ impl Writer {
if p.kind == Kind::Stream {
return Err(crate::Error::Invalid(format!("{:?} is a stream, not a storage", p.name)));
}
if p.children.iter().any(|&c| compare_names(&self.nodes[c].name, name).is_eq()) {
let key = name_key(name);
if p.child_keys.contains(&key) {
return Err(crate::Error::BadName(format!("duplicate name {name}")));
}
let id = self.nodes.len();
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new() });
self.nodes[parent.0].children.push(id);
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new(), child_keys: HashSet::new() });
let p = &mut self.nodes[parent.0];
p.children.push(id);
p.child_keys.insert(key);
Ok(NodeId(id))
}
+33
View File
@@ -0,0 +1,33 @@
//! The writer scales: adding many children to one storage, and the duplicate-name rules that check keeps.
use filmcraft_cfb::{Version, Writer};
/// Adding a child compared its name with every sibling (allocating twice per comparison), so n children
/// cost O(n^2): 20 000 AAF markers in one storage took about a minute. The duplicate check is a hash
/// lookup now. 40 000 plain names take milliseconds now and about 18 s before (a minute for real marker
/// objects), so the limit separates them by orders of magnitude on any machine.
#[test]
fn adding_many_children_is_not_quadratic() {
let mut w = Writer::new(Version::V3);
let start = std::time::Instant::now();
for i in 0..40_000 {
w.stream(Writer::ROOT, &format!("{i}"), vec![1]).expect("unique name");
}
assert!(start.elapsed() < std::time::Duration::from_secs(3), "{:?}", start.elapsed());
}
/// The faster duplicate check still means what sibling comparison means: case-insensitive, also beyond ASCII,
/// per storage (the same name in another storage is fine), and a name that differs only in length is distinct.
#[test]
fn duplicate_names_are_still_rejected() {
let mut w = Writer::new(Version::V3);
let a = w.storage(Writer::ROOT, "Alpha", [0; 16]).unwrap();
assert!(w.stream(Writer::ROOT, "ALPHA", vec![]).is_err(), "case-insensitive");
assert!(w.stream(Writer::ROOT, "alpha", vec![]).is_err());
assert!(w.stream(Writer::ROOT, "Alph", vec![]).is_ok());
assert!(w.stream(Writer::ROOT, "Alpha2", vec![]).is_ok());
assert!(w.stream(a, "Alpha", vec![]).is_ok(), "same name in another storage");
assert!(w.stream(a, "Alpha", vec![]).is_err());
w.stream(Writer::ROOT, "Ünï", vec![]).unwrap();
assert!(w.stream(Writer::ROOT, "ÜNÏ", vec![]).is_err(), "non-ASCII case");
}
+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))
+4
View File
@@ -50,6 +50,8 @@ fn mkv(asc: &[u8], rate: u32, units: &[Vec<u8>], lacing: LacingMode, gap: usize,
let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC");
spec.codec_private = asc.to_vec();
spec.audio = Some((rate as f64, 2, None));
// an access unit's duration, rounded to the nanosecond; without it the writer laces nothing
spec.default_duration_ns = Some((1024 * 1_000_000_000 + u64::from(rate) / 2) / u64::from(rate));
let opts = MuxOptions { lacing, ..MuxOptions::default() };
assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps");
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap();
@@ -116,6 +118,8 @@ fn matroska_millisecond_stamps_play_back_to_back() {
let frames = units.len().saturating_sub(1) * 1024;
for lacing in [LacingMode::None, LacingMode::Xiph] {
let bytes = mkv(&asc, rate, &units, lacing, usize::MAX, 0);
let laced = filmcraft_matroska::open(&bytes[..]).unwrap().tracks.iter().any(|t| t.samples.iter().any(|s| s.lace > 0));
assert_eq!(laced, !matches!(lacing, LacingMode::None), "{rate} Hz, {lacing:?}: laced blocks");
let src = MkvSource::open("tone.mkv", bytes.clone()).unwrap();
let got = played(&src, rate, frames);
let (n, first) = mismatches(&got, &want, 0);
+27
View File
@@ -123,6 +123,20 @@ pub trait VideoSamples: Sync {
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>>;
}
/// A container's sync sample for `i` when leading pictures need the GOP before: `sync` is the
/// index's own sync sample, `leads(i, key)` says `i` is shown before `key`. One step per GOP, so a
/// loop: a crafted index can chain tens of thousands of GOPs (#2803). A `sync` past `i` stops the
/// walk instead of looping on it.
pub(crate) fn open_gop_sync(mut i: usize, sync: impl Fn(usize) -> usize, leads: impl Fn(usize, usize) -> bool) -> usize {
loop {
let key = sync(i);
if key == 0 || key > i || !leads(i, key) {
return key;
}
i = key - 1;
}
}
/// The sync sample to start decoding at for sample `i`: its sync sample, or an earlier one when
/// `i` is a leading picture of an open GOP (stored after its random-access picture, shown before
/// it, like the RASL pictures after every HEVC CRA of iPhone video). Those are predicted from the
@@ -866,6 +880,19 @@ impl GopCache {
#[cfg(test)]
mod tests {
#[test]
fn open_gop_sync_walks_a_long_chain_of_leading_pictures_without_recursing() {
// Every GOP of 3 starts at a key whose leading picture is shown before it (#2803).
let gops = 200_000usize;
let sync = |i: usize| i - i % 3;
let leads = |i: usize, key: usize| i == key + 2;
assert_eq!(super::open_gop_sync(gops * 3 - 1, sync, leads), 0);
// A picture that is not a leading one keeps its own key.
assert_eq!(super::open_gop_sync(7, sync, |_, _| false), 6);
// An index whose sync sample lies past `i` ends the walk instead of looping.
assert_eq!(super::open_gop_sync(5, |_| 9, |_, _| true), 9);
}
use super::*;
use crate::video::DecodedFrame;
use std::collections::VecDeque;
+53 -6
View File
@@ -8,6 +8,7 @@
//! decode, and a hardware decoder that fails mid-stream switches to
//! [`crate::software_video_decoder`], so registering one never makes a file undecodable.
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use filmcraft_isobmff::{CodecConfig, SampleEntry};
@@ -176,7 +177,12 @@ impl StreamInfo {
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))
let configured: HashSet<&[u8]> = i.parameter_sets.iter().map(|p| without_trailing_zeros(p)).collect();
let mut checked = HashSet::new();
i.nals(sample).into_iter().any(|n| {
let n = without_trailing_zeros(n);
n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && checked.insert(n) && !configured.contains(n)
})
}
Self::Frame(i) => i.parameters_changed(sample),
}
@@ -199,6 +205,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 +416,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 +432,45 @@ 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");
}
#[test]
fn repeated_parameter_sets_are_checked_once_per_distinct_nal() {
let pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90];
let info = StreamInfo::Nal(hevc_info(std::iter::repeat_n(pps.clone(), 256).collect()));
let mut sample = Vec::new();
for _ in 0..256 {
sample.extend_from_slice(&(pps.len() as u32).to_be_bytes());
sample.extend_from_slice(&pps);
}
assert!(!info.parameters_changed(&sample));
}
}
+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,
])
})
+7 -6
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,
@@ -231,7 +231,7 @@ fn vp9_config(private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>) -> VpcC
}
p += 2 + len;
}
if let Some(col) = v.and_then(|v| v.colour.as_ref()) {
if let Some(col) = v.and_then(|v| v.color.as_ref()) {
if let Some(t) = col.transfer_characteristics {
c.transfer_characteristics = t as u8;
}
@@ -265,7 +265,7 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI
}
/// HDR static metadata from the Matroska `Colour` element.
fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMetadata> {
fn hdr_metadata(c: &filmcraft_matroska::Color) -> Option<filmcraft_color::HdrMetadata> {
if c.mastering.is_none() && c.max_cll.is_none() && c.max_fall.is_none() {
return None;
}
@@ -285,7 +285,7 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMe
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32, stream: Option<ColorCodes>) -> (ColorInfo, bool) {
// H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here
let code = |c: Option<u64>| c.unwrap_or(2);
let container = v.colour.as_ref().map(|col| {
let container = v.color.as_ref().map(|col| {
let range = match col.range {
Some(1) => Some(false),
Some(2) => Some(true),
@@ -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(),
@@ -404,7 +404,7 @@ impl MkvSource {
color,
has_alpha: v.alpha_mode != 0,
bitrate,
hdr: v.colour.as_ref().and_then(hdr_metadata),
hdr: v.color.as_ref().and_then(hdr_metadata),
}
});
let audio_streams: Vec<AudioStreamInfo> = atracks
@@ -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)),
+282 -18
View File
@@ -21,10 +21,15 @@ use crate::{CodecError, make_video_decoder};
/// Most bytes read from one AAC access unit when probing for HE-AAC at open (8 channels × 6144 bits).
const MAX_AAC_PROBE_UNIT: usize = 8 * 6144 / 8;
struct DecodedAudioPacket {
samples: Vec<Vec<f32>>,
failed: bool,
}
struct AudioState {
decoder: Option<PacketDecoder>,
/// Decoded packets by sample index.
packets: HashMap<usize, Arc<Vec<Vec<f32>>>>,
packets: HashMap<usize, Arc<DecodedAudioPacket>>,
order: Vec<usize>,
last_decoded: Option<usize>,
}
@@ -92,6 +97,20 @@ fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec
contiguous_starts(packets, (frame / 2).max(unit.saturating_mul(2).saturating_add(1)))
}
/// Return source-rate buffer indices overlapped by a failed packet's concealment span.
/// Packet positions are audio frames, but duration is raw track-timescale ticks
/// treated as a frame count here; the span is clipped to the request.
fn failed_audio_span(packet_start: i64, request_start: i64, duration: u32, request_len: usize) -> std::ops::Range<usize> {
let begin = usize::try_from(packet_start.saturating_sub(request_start).max(0)).unwrap_or(usize::MAX).min(request_len);
let end = if duration == 0 {
begin
} else {
let last = packet_start.saturating_add(i64::from(duration) - 1).saturating_sub(request_start);
usize::try_from((i128::from(last) + 1).max(0)).unwrap_or(usize::MAX).min(request_len)
};
begin..end
}
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
if md.is_none() && cll.is_none() {
@@ -347,6 +366,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 })
}
@@ -365,18 +385,18 @@ impl Mp4Source {
}
/// Decoded packet `i` of audio stream `a` (`ainfo` is its `AudioStreamInfo`; `st` its decoder state).
fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<DecodedAudioPacket>> {
if let Some(p) = st.packets.get(&i) {
return Ok(p.clone());
}
let ti = a.track;
let track = self.file.tracks.get(ti).ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
let entry = track.entries.first().ok_or_else(|| CodecError::Container("audio track without a sample description".into()))?;
let sample = track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
let pk_start = a.starts.get(i).copied().unwrap_or(0);
let data = self.read(ti, i)?;
let decoded = match &entry.codec {
CodecConfig::Pcm(p) => decode_pcm(&data, p),
let (samples, failed) = match &entry.codec {
CodecConfig::Pcm(p) => (decode_pcm(&data, p), false),
c => {
if st.decoder.is_none() {
st.decoder = Some(PacketDecoder::for_isobmff(c, ainfo.sample_rate)?);
@@ -397,15 +417,15 @@ impl Mp4Source {
}
}
}
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
let result = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
st.last_decoded = Some(i);
match r {
Ok(v) => v,
Err(_) => vec![vec![0.0; sample.duration as usize]; ainfo.channels.max(1) as usize],
match result {
Ok(samples) => (samples, false),
Err(_) => (Vec::new(), true),
}
}
};
let p = Arc::new(decoded);
let p = Arc::new(DecodedAudioPacket { samples, failed });
st.packets.insert(i, p.clone());
st.order.push(i);
if st.order.len() > 4096 {
@@ -475,14 +495,24 @@ impl MediaSource for Mp4Source {
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
let mut i = first;
while i < track.samples.len() {
while let Some(sample) = track.samples.get(i) {
let Some(&pk_start) = a.starts.get(i) else { break };
if pk_start >= s0.saturating_add(need) {
break;
}
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
if pk.failed {
let range = failed_audio_span(pk_start, s0, sample.duration, src.first().map_or(0, Vec::len));
for dst in &mut src {
if let Some(span) = dst.get_mut(range.clone()) {
span.fill(0.0);
}
}
i += 1;
continue;
}
for (c, dst) in src.iter_mut().enumerate() {
let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue };
let Some(chan) = pk.samples.get(c.min(pk.samples.len().saturating_sub(1))) else { continue };
for (k, v) in chan.iter().enumerate() {
let pos = pk_start.saturating_add(k as i64).saturating_sub(s0);
if pos >= 0 && (pos as usize) < dst.len() {
@@ -619,10 +649,16 @@ fn yuv_label(chroma_format_idc: u32, bits: u32) -> String {
format!("YUV {sub} {bits}-bit")
}
/// `chroma_format_idc` and luma bit depth of an H.264 track: from the `avcC` High-profile extension
/// (ISO/IEC 14496-15 §5.3.3), else from its first SPS. The profile alone can't say (High 4:2:2 is
/// 8 or 10-bit, High 10 can be 8-bit).
/// `chroma_format_idc` and luma bit depth of an H.264 track: from its first SPS, which is what the
/// decoder decodes, else from the `avcC` High-profile extension (ISO/IEC 14496-15 §5.3.3). The
/// extension is not trusted over the SPS: muxers have been seen writing `chroma_format` 0 (4:0:0)
/// into it for 4:2:0 files, which labelled them "4:0:0" (#1959). The profile alone can't say (High
/// 4:2:2 is 8 or 10-bit, High 10 can be 8-bit).
fn avc_format(a: &filmcraft_isobmff::AvcConfig) -> Option<(u32, u32)> {
let from_sps = a.sps.first().and_then(|n| n.get(1..)).and_then(|rbsp| filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(rbsp)).ok());
if let Some(sps) = from_sps {
return Some((sps.chroma_format_idc, sps.bit_depth_luma));
}
// reserved bits set: '111111' chroma_format, '11111' bit_depth_luma_minus8 (else not an extension)
if !matches!(a.profile, 66 | 77 | 88)
&& let &[chroma, depth, ..] = a.ext.as_slice()
@@ -631,14 +667,26 @@ fn avc_format(a: &filmcraft_isobmff::AvcConfig) -> Option<(u32, u32)> {
{
return Some(((chroma & 3) as u32, (depth & 7) as u32 + 8));
}
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(a.sps.first()?.get(1..)?)).ok()?;
Some((sps.chroma_format_idc, sps.bit_depth_luma))
None
}
/// `chroma_format_idc` and luma bit depth of an HEVC track: from its first SPS, which is what the
/// decoder decodes, else the `hvcC` record's own fields (which a muxer may leave at 0, i.e. "4:0:0").
fn hevc_format(h: &filmcraft_isobmff::HevcConfig) -> (u32, u32) {
let from_sps = h.sps().first().and_then(|n| n.get(2..)).and_then(|rbsp| filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(rbsp)).ok());
match from_sps {
Some(sps) => (sps.chroma_format_idc, sps.bit_depth_luma),
None => (h.chroma_format_idc as u32, h.bit_depth_luma as u32),
}
}
fn pixfmt_label(c: &CodecConfig) -> String {
match c {
CodecConfig::Avc(a) => avc_format(a).map(|(chroma, bits)| yuv_label(chroma, bits)).unwrap_or_default(),
CodecConfig::Hevc(h) => yuv_label(h.chroma_format_idc as u32, h.bit_depth_luma as u32),
CodecConfig::Hevc(h) => {
let (chroma, bits) = hevc_format(h);
yuv_label(chroma, bits)
}
CodecConfig::Vp9(c) => {
let sub = match c.chroma_subsampling {
2 => "4:2:2",
@@ -666,6 +714,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):
@@ -1000,6 +1108,84 @@ mod tests {
assert_eq!(pixfmt_label(&avc(244, high_sps(244, 3, 10), Vec::new())), "YUV 4:4:4 10-bit");
// an unreadable SPS and no extension: unknown, not a guess
assert_eq!(pixfmt_label(&avc(122, vec![0x67, 122], Vec::new())), "");
// the SPS outranks the extension: an avcC claiming 4:0:0 for a 4:2:0 stream labels 4:2:0 (#1959)
assert_eq!(pixfmt_label(&avc(100, high_sps(100, 1, 8), vec![0xFC, 0xF8, 0xF8, 0])), "YUV 4:2:0 8-bit");
// only without a readable SPS does the extension speak for itself
assert_eq!(pixfmt_label(&avc(100, vec![0x67, 100], vec![0xFC, 0xF8, 0xF8, 0])), "YUV 4:0:0 8-bit");
}
/// An HEVC Main SPS NAL unit (ITU-T H.265 §7.3.2.2) with the given chroma format and bit depth, 64×64.
fn hevc_sps(chroma: u32, bits: u32) -> Vec<u8> {
let mut w = filmcraft_bitstream::BitWriter::new();
w.write_bits(0, 4); // vps id
w.write_bits(0, 3); // max_sub_layers_minus1
w.write_bits(1, 1); // temporal_id_nesting
// profile_tier_level: Main, level 3.1
w.write_bits(0, 2);
w.write_bits(0, 1);
w.write_bits(1, 5);
w.write_bits(0x6000_0000, 32);
w.write_bits(0b1001, 4);
w.write_bits(0, 32);
w.write_bits(0, 11);
w.write_bits(0, 1);
w.write_bits(93, 8);
w.write_ue(0); // sps id
w.write_ue(chroma);
if chroma == 3 {
w.write_bit(false); // separate_colour_plane
}
w.write_ue(64);
w.write_ue(64);
w.write_bits(0, 1); // conformance window
w.write_ue(bits - 8); // luma
w.write_ue(bits - 8); // chroma
w.write_ue(4); // log2_max_poc_lsb_minus4
w.write_bits(1, 1); // sub_layer_ordering_info_present
w.write_ue(4);
w.write_ue(0);
w.write_ue(0);
w.write_ue(0); // min CB 8
w.write_ue(1); // CTB 16
w.write_ue(0); // min TB 4
w.write_ue(2); // max TB 16
w.write_ue(0);
w.write_ue(0);
w.write_bits(0, 1); // scaling lists
w.write_bits(0, 1); // amp
w.write_bits(0, 1); // sao
w.write_bits(0, 1); // pcm
w.write_ue(0); // num_short_term_ref_pic_sets
w.write_bits(0, 1); // long_term_ref_pics_present
w.write_bits(0, 1); // temporal mvp
w.write_bits(0, 1); // strong intra smoothing
w.write_bits(0, 1); // vui
w.write_bits(0, 1); // extensions
w.rbsp_trailing();
let mut nal = vec![33 << 1, 1];
nal.extend(filmcraft_bitstream::escape_rbsp(&w.finish()));
nal
}
/// The hvcC's own chroma/bit-depth fields can be left at 0 by a muxer: the SPS it carries says.
#[test]
fn hevc_pixel_format_prefers_the_sps_over_the_hvcc_fields() {
let with_sps = |chroma: u32, bits: u32, field_chroma: u8, field_bits: u8| {
let arrays = vec![filmcraft_isobmff::HevcNalArray { completeness: true, nal_type: 33, nalus: vec![hevc_sps(chroma, bits)] }];
CodecConfig::Hevc(filmcraft_isobmff::HevcConfig {
general_profile_idc: 1,
chroma_format_idc: field_chroma,
bit_depth_luma: field_bits,
arrays,
..Default::default()
})
};
assert_eq!(pixfmt_label(&with_sps(1, 8, 0, 0)), "YUV 4:2:0 8-bit", "hvcC fields at 0, SPS 4:2:0");
assert_eq!(pixfmt_label(&with_sps(2, 10, 1, 8)), "YUV 4:2:2 10-bit", "the SPS outranks the fields");
assert_eq!(pixfmt_label(&with_sps(3, 12, 3, 12)), "YUV 4:4:4 12-bit");
// no SPS in the record: its fields
let bare = CodecConfig::Hevc(filmcraft_isobmff::HevcConfig { general_profile_idc: 1, chroma_format_idc: 1, bit_depth_luma: 10, ..Default::default() });
assert_eq!(pixfmt_label(&bare), "YUV 4:2:0 10-bit");
}
/// A one-frame 32×16 ProRes MOV with the given profile, chroma format and fourcc.
@@ -1161,3 +1347,81 @@ mod he_aac_tests {
}
}
}
#[cfg(test)]
mod corrupt_audio_tests {
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use filmcraft_media::MediaSource;
#[test]
fn failed_packet_span_matches_per_sample_silence_at_extreme_timestamps() {
let min = i64::MIN;
let max = i64::MAX;
for start in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] {
for request in [min, min + 1, min + 2, -10, -1, 0, 1, 10, max - 2, max - 1, max] {
for duration in [0, 1, 2, 5, 128] {
let old: Vec<_> = (0..duration)
.map(|k| start.saturating_add(i64::from(k)).saturating_sub(request))
.filter(|&position| (0..8).contains(&position))
.map(|position| position as usize)
.collect();
let range = super::failed_audio_span(start, request, duration, 8);
let actual: Vec<_> = (0..8).filter(|position| range.contains(position)).collect();
let expected: Vec<_> = old.into_iter().collect::<std::collections::BTreeSet<_>>().into_iter().collect();
assert_eq!(actual, expected, "start={start}, request={request}, duration={duration}");
}
}
}
assert_eq!(super::failed_audio_span(0, 0, u32::MAX, 128), 0..128);
}
#[test]
fn failed_aac_packet_does_not_materialize_its_hostile_duration() {
// AAC-LC, 48 kHz stereo. The empty access unit cannot decode.
let asc = vec![0x11, 0x90];
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
let track = mux.add_track(TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000)).unwrap();
mux.write_sample(track, WriteSample { data: &[], duration: 1_000_000, composition_offset: 0, is_sync: true }).unwrap();
let source = super::Mp4Source::open("damaged.m4a", mux.finish().unwrap().into_inner().into()).unwrap();
let audio = &source.audios[0];
let info = &source.info.audio_streams[0];
let mut state = audio.state.lock().unwrap();
let packet = source.audio_packet(audio, info, &mut state, 0).unwrap();
assert!(packet.failed);
assert!(packet.samples.is_empty(), "failed packet must not allocate from its untrusted duration");
drop(state);
let window = source.audio(0, 128, 48_000).unwrap();
assert_eq!(window.channels.len(), 2);
assert!(window.channels.iter().all(|ch| ch.len() == 128 && ch.iter().all(|&v| v == 0.0)));
}
#[test]
fn failed_aac_packet_silences_overlap_independent_of_request_boundary() {
let rate = 48_000u32;
let tone: Vec<f32> = (0..8192).map(|i| 0.3 * (i as f32 * 0.13).sin()).collect();
let mut encoder = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 1, 64_000)).unwrap();
let mut units = encoder.encode(&[&tone]);
units.extend(encoder.flush());
assert!(units.len() >= 2);
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
let track = mux.add_track(TrackConfig::new(SampleEntry::aac(vec![0x11, 0x88], 1, rate), rate)).unwrap();
mux.write_sample(track, WriteSample { data: &units[0], duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
mux.write_sample(track, WriteSample { data: &units[1], duration: 400, composition_offset: 0, is_sync: true }).unwrap();
mux.write_sample(track, WriteSample { data: &[], duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
let source = super::Mp4Source::open("overlap.m4a", mux.finish().unwrap().into_inner().into()).unwrap();
// The failed packet starts at 1024 + 400 = 1424, after 124 frames of the crossing read.
let crossing = source.audio(1300, 256, rate).unwrap();
let isolated = source.audio(1424, 132, rate).unwrap();
let prior_packet_len = {
let audio = &source.audios[0];
let info = &source.info.audio_streams[0];
let mut state = audio.state.lock().unwrap();
source.audio_packet(audio, info, &mut state, 1).unwrap().samples.first().map_or(0, Vec::len)
};
assert!(prior_packet_len > 400, "valid packet must overlap the failed packet");
assert!(crossing.channels[0][..124].iter().any(|v| v.abs() > 0.001));
assert!(isolated.channels[0].iter().all(|&v| v == 0.0));
assert_eq!(&crossing.channels[0][124..], &isolated.channels[0]);
}
}
+37 -11
View File
@@ -369,15 +369,7 @@ impl MpegSource {
let raw: Vec<Option<i64>> = v.order.iter().map(|&i| units[v.units[i]].pts).collect();
let rate = self.info.frame_rate();
let dur = |k: i64| (k as i128 * 90_000 * rate.den as i128 / rate.num.max(1) as i128) as i64;
let known: Vec<usize> = (0..raw.len()).filter(|&i| raw[i].is_some()).collect();
(0..raw.len())
.map(|i| {
raw[i].or_else(|| {
let j = known.iter().rev().find(|&&j| j < i).or_else(|| known.iter().find(|&&j| j > i))?;
Some(raw[*j]? + dur(i as i64 - *j as i64))
})
})
.collect()
interpolate_pts(&raw, dur)
}
fn read_unit(&self, v: &VideoTrack, i: usize) -> crate::Result<Vec<u8>> {
@@ -690,6 +682,23 @@ impl MpegSource {
}
}
/// Fill missing timestamps from their closest preceding timestamp, or the next one when there is
/// no predecessor. The known positions are sorted, so binary search avoids rescanning them for
/// every missing frame.
fn interpolate_pts(raw: &[Option<i64>], dur: impl Fn(i64) -> i64) -> Vec<Option<i64>> {
let known: Vec<usize> = (0..raw.len()).filter(|&i| raw[i].is_some()).collect();
raw.iter()
.enumerate()
.map(|(i, &pts)| {
pts.or_else(|| {
let next = known.partition_point(|&j| j < i);
let j = next.checked_sub(1).or_else(|| known.get(next).map(|_| next))?;
Some(raw[known[j]]? + dur(i as i64 - known[j] as i64))
})
})
.collect()
}
fn chroma_name(c: filmcraft_frame::Chroma) -> &'static str {
match c {
filmcraft_frame::Chroma::C420 => "4:2:0",
@@ -760,10 +769,15 @@ impl VideoSamples for MpegVideo<'_> {
}
fn sync_before(&self, i: usize) -> usize {
let i = i.min(self.v.keys.len().saturating_sub(1));
let key = (0..=i).rev().find(|&k| self.v.keys[k]).unwrap_or(0);
let keys = &self.v.keys;
let pts = &self.v.pts;
// a leading picture of an open GOP (shown before its random-access picture) needs the
// previous GOP's pictures as references
if key > 0 && self.v.pts[i] < self.v.pts[key] { self.sync_before(key - 1) } else { key }
crate::gop::open_gop_sync(
i,
|i| (0..=i).rev().find(|&k| keys.get(k) == Some(&true)).unwrap_or(0),
|i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b),
)
}
fn sample_at(&self, t: i64) -> Option<usize> {
usize::try_from(t).ok().and_then(|t| self.v.order.get(t).copied())
@@ -1015,4 +1029,16 @@ mod tests {
assert_eq!(order, vec![0, 4, 5, 3]);
assert_eq!(pts, vec![0, -2, -3, 3, 1, 2]);
}
#[test]
fn timestamp_interpolation_handles_long_missing_runs_without_rescanning() {
let mut raw = vec![None; 10_000];
raw[4_000] = Some(40_000);
raw[9_999] = Some(99_990);
let pts = interpolate_pts(&raw, |frames| frames * 10);
assert_eq!(pts[0], Some(0));
assert_eq!(pts[3_999], Some(39_990));
assert_eq!(pts[4_001], Some(40_010));
assert_eq!(pts[9_998], Some(99_980));
}
}
+3 -2
View File
@@ -408,10 +408,11 @@ impl VideoSamples for MxfVideo<'_> {
self.src.vpts[i]
}
fn sync_before(&self, i: usize) -> usize {
let key = self.src.file.tracks[self.track].sync_before(i);
// a leading picture of an open GOP (stored after its random-access picture, shown
// before it) is predicted from the previous GOP too
if key > 0 && i < self.src.vpts.len() && self.src.vpts[i] < self.src.vpts[key] { self.sync_before(key - 1) } else { key }
let track = &self.src.file.tracks[self.track];
let pts = &self.src.vpts;
crate::gop::open_gop_sync(i, |i| track.sync_before(i), |i, key| matches!((pts.get(i), pts.get(key)), (Some(a), Some(b)) if a < b))
}
fn sample_at(&self, t: i64) -> Option<usize> {
usize::try_from(t).ok().and_then(|t| self.src.vorder.get(t).copied())
+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}");
}
}
}
@@ -3,7 +3,7 @@
//! both 7.1 (eight channels) and 11 is 6.1 (seven). Each channel carries its own tone, so a
//! dropped or swapped channel shows up against ffmpeg's decode.
mod common;
use crate::common;
use std::io::Cursor;
+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");
}
}
@@ -2,7 +2,7 @@
//! off by default, approximate frames only reach draft requests, and decoding for an export after
//! draft playback (same source, same GOP cache) still matches ffmpeg bit for bit.
mod common;
use crate::common;
use common::*;
use filmcraft_media::FrameRequest;
@@ -2,7 +2,7 @@
//! and muxes the files and decodes them as the oracle. Zero-bit mantissas carry decoder-specific
//! dither (A/52 §7.3.4), so the criterion is SNR against ffmpeg's decode, sample-aligned.
mod common;
use crate::common;
use common::*;
@@ -2,7 +2,7 @@
//! MP4 without a `colr` box unless asked, so for most files the bitstream is the only place the
//! primaries are described: stream info already read them, the decoded frames reported BT.709.
mod common;
use crate::common;
use common::*;
use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer};

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