832 Commits

Author SHA1 Message Date
Marcos Paulo 11b2bb42db Merge branch 'main' into debrand 2026-10-25 05:43:12 -03:00
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
Marcos Paulo fa1fe8ab21 chore: release 0.7.3 2026-10-12 21:41:01 -03: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
Marcos Paulo 30d0c72338 build: use the masi/craft-libs fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:57:17 -03:00
Marcos Paulo c2688bbcb0 build(windows): set working directory to repo root in package.ps1 2026-10-12 20:57:17 -03:00
Marcos Paulo 38d003341f build(windows): package standalone filmcraft-cli zip and auto-detect rc.exe 2026-10-12 20:57:17 -03:00
Marcos Paulo 66aef503e3 chore: debrand ArtCraft and Discord references
Remove the Discord header button, Help menu and macOS app-menu links, About and Home
website links, brand assets and wordmark; rewrite docs, packaging URLs and catalogs.
Add .debrand rules and the craft-skills submodule (debrand skill) to keep upstream
syncs clean.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:57:17 -03: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
Brandon Thomas 71438c627b Release 0.7.2: install the wasm-bindgen CLI for the web release build
The shared release workflow installs trunk, but FilmCraft's web build
runs the wasm-bindgen CLI directly (cargo xtask web), so the 0.7.1 Web
job failed with "wasm-bindgen CLI not found". In CI, package.sh now
installs the CLI at xtask's WASM_BINDGEN version, as the per-app
release.yml did before the move to craft-common-actions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #1145

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

Fixes #1101

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

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

Fixes #902

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

Fixes #1054

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

Fixes #895

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

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

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

Fixes #1055

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:31:36 +02:00
Claude 8e1fb41258 #371: split stereo halves from FCP7 XML play on their own side
Premiere writes a stereo clip to FCP7 XML as two mono clip items on two
tracks (sourcetrack trackindex 1 and 2). The importer already picks
channel 0 and channel 1 for them, but left both centred, and a centred
one-channel clip plays on both sides. So both channels summed on L and
on R: +3 dB per channel for an uncorrelated stereo effect (and its L-R
doubled), +6 dB for dual-mono dialogue.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:26:31 +02:00
vangel76 74e1b1eb68 #828: import OpenEXR stills and image sequences
Problem: FilmCraft exports OpenEXR sequences (#806) but could not open
.exr files at all, so EXR plates and renders from other apps could not
be imported (docs/gaps.md G10).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:15:53 +02:00
Vikram Bhaskaran 03bf8b1f33 Fix OS drops on an empty Timeline creating a sequence.
Dragging files from the explorer onto the Timeline imported them into the
Project bin but never placed them when no sequence was open, because only
the clip-area interact path consumed pending_timeline_drop. The empty
Timeline now creates a sequence from the first imported item and places
any further items after it, matching the existing Project-panel drop.
2026-10-11 10:09:44 +11:00
Vikram Bhaskaran 995053922e Show Parametric EQ in Effect Controls on linked A/V clips.
Audio effects applied to the video side of a pair now land on the linked audio, and Effect Controls lists that clip's parameters and Custom Setup while the picture is selected.
2026-10-11 10:09:36 +11:00
vangel76 bad190d234 Merge remote-tracking branch 'upstream/main' into gap-mp2-decoder
# Conflicts:
#	docs/codec-parity.md
#	docs/gaps.md
2026-10-11 01:07:54 +02:00
Vikram Bhaskaran d3444534a6 Fix Clear on Close Sequence also dropping File Close (Cmd+W).
File ▸ Close aliases Close Sequence. The shortcut editor targeted
sequence.close while Cmd+W lived on file.close (and Timeline), so Clear
left the chord bound. Treat the pair as one shortcut family.

Fixes storytold/filmcraft#773
2026-10-11 10:04:44 +11:00
vangel76 116364cf08 #820: fall back to DX12, then a software adapter, when the graphics start fails
Problem: a graphics driver that crashes inside wgpu instance or device
creation (Intel Vulkan igvk64.dll #512 #413 #793, AMD atio6axx.dll #477)
killed every launch the same way until the user set WGPU_BACKEND by hand;
a start-up error wgpu did report ended in an error dialog.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:58:45 +02:00
vangel76 39afd2f6c3 #809: own MPEG audio Layer I / II decoder plays MP2 at ffmpeg's default bit rates
Problem: symphonia rejects every Layer II frame whose bit rate / mode
combination ISO/IEC 11172-3 does not allow (mono above 192 kbit/s, stereo
below 64 kbit/s). ffmpeg writes mono MP2 at 384 kbit/s by default, so such
tracks played as silence in MPEG-TS / PS, Matroska and raw .mp2 files.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:59:15 +02:00
MrCloudy2 82242c3817 Merge origin/main into #260 (keep both new HostHooks fields: run_dialog, open_url)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:02:52 +02:00
awe cbd06b6c9a Project panel: dragging one of several selected items places them all, back to back, in one undo step
timeline.place takes items: [...] and file.newSequence takes fromItems: [...]; both run as
one undo step through the new Session::edit_group, which rolls back if any item fails.
Files dropped from the OS use the same command instead of one timeline.place per file,
so they are one undo step too.
2026-10-11 00:22:02 +07:00
JordyUSA 8ea94a8541 Merge main into feat/flac-export
No conflicts. main's new Chinese catalog requires every tl! string:
added zh-CN for the FLAC labels (format, preset, Audio Format entry,
Compression Level and its hint).

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:21:30 +07:00
MrCloudy2 7d6c53d03d Merge origin/main into #260; Chinese and Japanese for "A file dialog is already open"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:23:08 +02:00
JordyUSA 055e95ab17 Export: FLAC (clean-room encoder; .flac files and FLAC audio in MP4/MOV)
New L0 crate filmcraft-flac, a FLAC encoder written from RFC 9639:
constant / verbatim / fixed / LPC subframes (Levinson-Durbin on Tukey-
windowed autocorrelation, up to order 12), partitioned Rice residuals,
left/side, side/right and mid/side stereo, CRC-8/CRC-16, STREAMINFO with
MD5 (RFC 1321, own implementation), levels 0-8, all lossless.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:52:07 +02:00
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
KitsuneSemCalda 0bc4795083 Add OmaStore manifest 2026-10-09 22:50:29 -03:00
791 changed files with 73012 additions and 14452 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"
+20
View File
@@ -0,0 +1,20 @@
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^crates/ui-egui/src/i18n/.*\.tsv$ :: [^#\n][^\n]*\t[^\n]*(?:ArtCraft|Discord|getartcraft)
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
sub (?s)## First-party brand marks\n.*?(?=## Third-party source code) =>
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub drawn by the owner in ArtCraft => drawn by the owner by hand
sub in ArtCraft, then => by hand, then
sub drew it in ArtCraft as => drew it by hand as
sub owner's ArtCraft drawing => owner's drawing
sub ArtCraft,[ ]? =>
sub ArtCraft、 =>
sub https://getartcraft\.com/apps/filmcraft => https://github.com/storytold/filmcraft
sub https://discord\.gg/artcraft => https://github.com/storytold/filmcraft/issues
forbid (?i)artcraft(?! Team and the FilmCraft contributors)
forbid (?i)discord
sub Help menu, About and Discord links => Help menu and About links
sub \(artcraft#2093\) => (#2093)
+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
+7 -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 }}
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+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__/
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
+4 -6
View File
@@ -86,12 +86,8 @@ reproduce an image (for example, point lists traced from someone else's icon).
`crates/media`, procedural looks in `crates/render`) are original work under the project licence
and are listed in `ATTRIBUTION.md`.
7. **When in doubt, leave it out** and draw or generate it yourself.
8. **The one exception: first-party ArtCraft brand marks.** The ArtCraft name and logos in `docs/brand/`
are trademarks of the ArtCraft Team, not open source, usable only unmodified and only in the context of
FilmCraft under `docs/brand/LICENSE-brand.txt`; forks and modified versions must remove them. They still
need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other
non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub
and other logos stay out; draw a generic icon instead).
8. **No non-open assets.** Third-party marks (Adobe, GitHub and other logos) stay out; draw a generic icon
instead.
9. **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** Don't commit new font files (the small Latin UI fonts already in `assets/fonts/` stay). A
font FilmCraft needs is added to craft-fonts, which the app reads through the optional build input
@@ -127,6 +123,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
+25 -35
View File
@@ -9,7 +9,11 @@ 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 |
| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 |
| `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
@@ -24,28 +28,28 @@ 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 |
| `assets/app-icon/filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; canonical vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; canonical vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: FilmCraft macOS DMG window background, derived from the FilmCraft app icon (`assets/app-icon/filmcraft.svg`); rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
| `assets/app-icon/filmcraft-small.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; lighter vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft-1024.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; 1024 px render | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft-macos-512.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; macOS runtime Dock icon (512 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft.icns` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; macOS .icns | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft.ico` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Windows .ico (16-256 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 16 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 24 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 32 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 48 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 64 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 128 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 256 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme 512 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; Linux hicolor theme scalable | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `apps/filmcraft-web/web/favicon.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; 128 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft-small.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; lighter vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft-1024.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; 1024 px render | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft-macos-512.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; macOS runtime Dock icon (512 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft.icns` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; macOS .icns | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/filmcraft.ico` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Windows .ico (16-256 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 16 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 24 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 32 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 48 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 64 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 128 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 256 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.filmcraft.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme 512 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; Linux hicolor theme scalable | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `apps/filmcraft-web/web/favicon.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner by hand and vectorised; 128 px | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
## Golden test images
@@ -126,24 +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 |
## First-party brand marks
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and
are not covered by the project licence; they may be used only unmodified and only in the context of
FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must
remove them (AGENTS.md §1.8).
| File | Author | Source | Licence |
|---|---|---|---|
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
| 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 |
## Third-party source code
+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
+134 -55
View File
@@ -1011,10 +1011,23 @@ 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"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
dependencies = [
"egui",
"serde",
@@ -1674,7 +1687,7 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"arboard",
"chrono",
@@ -1696,8 +1709,10 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"pollster 0.4.0",
"rfd",
"serde_json",
"subtle",
"sys-locale",
"winit",
"winresource",
@@ -1706,7 +1721,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1716,7 +1731,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1724,7 +1739,7 @@ dependencies = [
[[package]]
name = "filmcraft-apv"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1734,14 +1749,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1751,13 +1766,14 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"tokio",
]
[[package]]
name = "filmcraft-av1"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1767,15 +1783,22 @@ dependencies = [
]
[[package]]
name = "filmcraft-bitstream"
version = "0.6.0"
name = "filmcraft-avi"
version = "0.12.0"
dependencies = [
"proptest",
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-bitstream"
version = "0.12.0"
dependencies = [
"craft-media",
]
[[package]]
name = "filmcraft-captions"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1788,11 +1811,11 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.6.0"
version = "0.12.0"
[[package]]
name = "filmcraft-cli"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@@ -1806,6 +1829,7 @@ dependencies = [
"filmcraft-platform",
"filmcraft-project",
"filmcraft-render",
"filmcraft-testkit",
"filmcraft-time",
"serde_json",
"tokio",
@@ -1813,12 +1837,13 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
"filmcraft-apv",
"filmcraft-av1",
"filmcraft-avi",
"filmcraft-bitstream",
"filmcraft-color",
"filmcraft-dnx",
@@ -1828,6 +1853,7 @@ dependencies = [
"filmcraft-isobmff",
"filmcraft-matroska",
"filmcraft-media",
"filmcraft-mpa",
"filmcraft-mpeg2v",
"filmcraft-mpegts",
"filmcraft-mxf",
@@ -1836,9 +1862,11 @@ dependencies = [
"filmcraft-prores",
"filmcraft-testkit",
"filmcraft-time",
"filmcraft-vp8",
"filmcraft-vp9",
"image",
"log",
"rayon",
"serde_json",
"symphonia",
"thiserror 2.0.21",
@@ -1847,15 +1875,24 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
]
[[package]]
name = "filmcraft-dng"
version = "0.12.0"
dependencies = [
"filmcraft-dng",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-dnx"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1865,7 +1902,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-geom",
"filmcraft-media",
@@ -1878,18 +1915,22 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-docpath",
"filmcraft-audio-dsp",
"filmcraft-captions",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-dng",
"filmcraft-edit",
"filmcraft-export",
"filmcraft-format",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-interchange",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-mxf",
"filmcraft-project",
@@ -1910,7 +1951,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-aac",
"filmcraft-apv",
@@ -1918,6 +1959,7 @@ dependencies = [
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-dnx",
"filmcraft-flac",
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-h264enc",
@@ -1939,21 +1981,32 @@ dependencies = [
]
[[package]]
name = "filmcraft-format"
version = "0.6.0"
name = "filmcraft-flac"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-format"
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.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -1963,7 +2016,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -1971,7 +2024,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1988,7 +2041,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-export",
@@ -2007,7 +2060,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2017,7 +2070,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2027,7 +2080,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2037,7 +2090,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2054,39 +2107,51 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"craft-media",
"filmcraft-testkit",
"proptest",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-matroska"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"craft-media",
"filmcraft-testkit",
"serde_json",
]
[[package]]
name = "filmcraft-media"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-dng",
"filmcraft-frame",
"filmcraft-testkit",
"filmcraft-time",
"image",
"png",
"rayon",
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-mpa"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-mpeg2v"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2095,7 +2160,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2103,28 +2168,29 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"chrono",
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-platform"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-codecs",
@@ -2150,7 +2216,7 @@ dependencies = [
[[package]]
name = "filmcraft-project"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2164,7 +2230,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2174,7 +2240,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2194,7 +2260,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"serde",
@@ -2203,7 +2269,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2221,18 +2287,20 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"chrono",
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-color",
"harfrust",
"skrifa",
"tempfile",
"unicode-bidi",
"unicode-linebreak",
"unicode-segmentation",
@@ -2240,7 +2308,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"proptest",
"serde",
@@ -2248,7 +2316,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2262,7 +2330,7 @@ dependencies = [
[[package]]
name = "filmcraft-tts-text"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"serde",
"thiserror 2.0.21",
@@ -2270,7 +2338,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"cpu-time",
"craft-ui",
@@ -2302,22 +2370,33 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"tempfile",
"toml 1.1.6+spec-1.1.0",
"unicode-bidi",
"web-time",
]
[[package]]
name = "filmcraft-vp8"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-vp9"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
[[package]]
name = "filmcraft-web"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -7342,7 +7421,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.12.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
+24 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -17,21 +17,26 @@ filmcraft-geom = { path = "crates/geom" }
filmcraft-color = { path = "crates/color" }
filmcraft-bitstream = { path = "crates/bitstream" }
filmcraft-isobmff = { path = "crates/isobmff" }
filmcraft-avi = { path = "crates/avi" }
filmcraft-matroska = { path = "crates/matroska" }
filmcraft-mxf = { path = "crates/mxf" }
filmcraft-cfb = { path = "crates/cfb" }
filmcraft-mpegts = { path = "crates/mpegts" }
filmcraft-mpeg2v = { path = "crates/mpeg2v" }
filmcraft-ac3 = { path = "crates/ac3" }
filmcraft-mpa = { path = "crates/mpa" }
filmcraft-ogg = { path = "crates/ogg" }
filmcraft-h264 = { path = "crates/h264" }
filmcraft-hevc = { path = "crates/hevc" }
filmcraft-vp8 = { path = "crates/vp8" }
filmcraft-vp9 = { path = "crates/vp9" }
filmcraft-prores = { path = "crates/prores" }
filmcraft-dnx = { path = "crates/dnx" }
filmcraft-apv = { path = "crates/apv" }
filmcraft-dng = { path = "crates/dng" }
filmcraft-av1 = { path = "crates/av1" }
filmcraft-aac = { path = "crates/aac" }
filmcraft-flac = { path = "crates/flac" }
filmcraft-opus = { path = "crates/opus" }
filmcraft-h264enc = { path = "crates/h264enc" }
filmcraft-audio-dsp = { path = "crates/audio-dsp" }
@@ -62,13 +67,17 @@ 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.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
@@ -84,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]
@@ -109,12 +122,18 @@ opt-level = 3
[profile.dev.package.filmcraft-apv]
opt-level = 3
[profile.dev.package.filmcraft-dng]
opt-level = 3
[profile.dev.package.filmcraft-av1]
opt-level = 3
[profile.dev.package.filmcraft-aac]
opt-level = 3
[profile.dev.package.filmcraft-flac]
opt-level = 3
# Audio effects: realtime-factor tests and long convolution / spectral runs.
[profile.dev.package.filmcraft-audio-dsp]
opt-level = 3
@@ -125,6 +144,9 @@ opt-level = 3
[profile.dev.package.filmcraft-opus]
opt-level = 3
[profile.dev.package.filmcraft-vp8]
opt-level = 3
[profile.dev.package.filmcraft-vp9]
opt-level = 3
+1 -5
View File
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
@@ -17,5 +13,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.
+55 -103
View File
@@ -1,53 +1,32 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</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>
By the ArtCraft team.
An open-source, clean-room take on the Adobe Premiere Pro workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.
</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>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/filmcraft"><b>FilmCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</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>
<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>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Digital, generative, music,
> games &mdash; if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#edit">Edit</a> ·
<a href="#color">Color</a> ·
@@ -66,7 +45,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 +63,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 +82,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 +94,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 +109,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">
@@ -175,15 +154,17 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
| **HEVC / H.265** | ✓ | | Main and Main 10, bit-exact on 41 streams (tiles, WPP, PCM, long-term references), about 225 fps at 1080p |
| **Apple ProRes** | ✓ | ✓ | Decodes 422 Proxy to 4444 XQ; export writes 422 HQ |
| **AAC-LC** | ✓ | ✓ | |
| **VP8** | ✓ | | Every coding tool, bitstream versions 0–3, hidden altref frames, alpha layer of transparent WebM; bit-exact with libvpx on its published VP8 test vectors; in WebM/Matroska and MP4 |
| **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 |
| **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 |
| **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska |
| **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 |
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet |
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF |
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
@@ -198,7 +179,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.
@@ -208,11 +189,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.
@@ -221,7 +202,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
@@ -260,7 +241,7 @@ git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft
```
The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft).
The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md).
## Documentation
@@ -275,28 +256,28 @@ 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.
- **AI features.** Few so far; speech to text is optional and off by default.
Bug reports with real footage are the most useful thing you can send us: [open an issue](https://github.com/storytold/filmcraft/issues) or tell us in [Discord](https://discord.gg/artcraft).
Bug reports with real footage are the most useful thing you can send us: [open an issue](https://github.com/storytold/filmcraft/issues).
## Architecture
@@ -304,7 +285,7 @@ A layered Cargo workspace:
| Layer | Crates |
|---|---|
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `bitstream` |
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `avi`, `bitstream` |
| Foundations | `time`, `geom`, `color`, `frame`, `media`, `text`, `project`, `audio-dsp` |
| Editing and interchange | `edit`, `codecs`, `captions`, `format` (project files), `interchange` |
| Rendering and output | `render`, `gpu`, `export` |
@@ -316,8 +297,6 @@ Nothing below the front ends depends on a UI toolkit or OS API. `cargo xtask ci`
## Downloads
**New to FilmCraft?** Download it from the [FilmCraft page on getartcraft.com](https://getartcraft.com/apps/filmcraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/filmcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/filmcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows
@@ -345,6 +324,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 |
@@ -395,44 +381,22 @@ emerge --ask media-video/filmcraft-bin
## The Crafting Apps
FilmCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
[storytold](https://github.com/storytold) team, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | **Video editing, color and sound · you are here** | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | **Video editing, color and sound · you are here** | [GitHub](https://github.com/storytold/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) |
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/filmcraft">FilmCraft</a>
</p>
<br>
---
## License and credits
@@ -446,22 +410,10 @@ 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.
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.
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)). ffmpeg is used only as an external test oracle.
<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">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/filmcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Ffilmcraft&type=date&legend=top-left)
+10 -5
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-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; scorecard, gaps, milestones and localization moved into docs/ per the craftrules progress-docs standard) · **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),
@@ -82,11 +82,12 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| Hindi | none (no Devanagari shaping in the interface) | 0% |
| Arabic | none (no RTL interface) | 0% |
| French | partial (whole interface; native review welcome) | ~100% |
| Portuguese (Brazil) | menus only | 9.9% |
| Indonesian | none | 0% |
| 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 | none | 0% |
| 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,9 @@ 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 |
| 2026-10-10 | minor | Stage banner links the core-workflow gate (all six workflows pass on macOS; stays alpha) |
+3
View File
@@ -33,3 +33,6 @@ filmcraft-ogg = { workspace = true }
filmcraft-automation = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+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()]);
+151 -22
View File
@@ -69,14 +69,16 @@ 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)
import <file>... import media into the project
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
[--gpu-rendering auto|off]
[--scale f] [--quality 0-100] [--no-audio] [--queue]
[--scale f] [--quality 0-100] [--no-audio] [--queue] [--allow-offline]
export the active sequence and wait for it to finish: with an
export preset (`export --list-presets`; built-in or the user's), or
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed
@@ -85,9 +87,12 @@ SUBCOMMANDS
preset; --scale renders at a fraction of the frame size (0.5 =
half); --quality 0-100 for the formats that take one; --no-audio
leaves the sound out; --queue adds to the export queue and runs it
instead
instead. Offline media (missing, made offline, unreadable) renders
as placeholder frames: the export then lists it in `offlineMedia`,
warns on stderr and exits 1, unless --allow-offline accepts it
export --list-presets [query] list export presets (name, category, format) as JSON
render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG
render --seconds S --out f.png [--scale 0.5] [--allow-offline]
render one Program frame to PNG (offline media: as for export)
probe <media> [--image-sequence]
media info as JSON, with MXF / Ogg / BWF details; with
--image-sequence <media> is the first numbered still of a sequence
@@ -113,7 +118,8 @@ VALUES
Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=.
EXIT STATUS
0 success · 1 a command failed · 2 usage error
0 success · 1 a command failed, or export/render drew offline media without --allow-offline
· 2 usage error
";
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
@@ -131,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)
@@ -165,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()),
}
@@ -244,6 +281,13 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability {
filmcraft_platform::register()
}
/// `path` made absolute against the working directory, as the project stores it. Unlike
/// `canonicalize`, `std::path::absolute` keeps symlinks and adds no Windows verbatim `\\?\`
/// prefix (#850). A path it cannot resolve is kept as given.
fn absolute(path: &str) -> String {
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
}
#[tokio::main]
async fn main() {
cli().await;
@@ -253,7 +297,8 @@ async fn main() {
}
async fn cli() {
let a = Args::parse(std::env::args().skip(1));
// `std::env::args` panics on an argument that is not Unicode (a Linux file name in Latin-1)
let a = Args::parse(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()));
// `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference:
// the parser takes it as a flag option, so it never reaches the subcommand match.
if a.flag("--help") {
@@ -281,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 {
@@ -337,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;
@@ -373,8 +418,7 @@ async fn cli() {
print(&a, &v);
}
"import" => {
let paths: Vec<String> =
a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect();
let paths: Vec<String> = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect();
if paths.is_empty() {
usage("import <file>...");
}
@@ -442,7 +486,9 @@ async fn cli() {
match r {
Ok(v) => {
print(&a, &v);
diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()));
let written = written_paths(&v, out).join(", ");
check_offline(&a, &offline_media(&v), &format!("export: {written}"));
diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64()));
}
Err(e) => fail(format!("export: {e}")),
}
@@ -452,15 +498,17 @@ 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 = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
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));
}
"mcp" => {
@@ -468,7 +516,7 @@ async fn cli() {
usage(format!("unknown mcp option `{opt}`"));
}
let server = match a.opt("--bridge") {
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)),
None => match Backend::open(&a) {
Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s),
Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"),
@@ -500,9 +548,56 @@ fn written_paths(result: &Value, requested: &str) -> Vec<String> {
if paths.is_empty() { vec![requested.to_string()] } else { paths }
}
/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale).
fn render_at_playhead(s: &Session, scale: f32) -> Result<filmcraft_engine::render::Image, String> {
s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string())
/// The offline items an export result reports (`offlineMedia` of a direct export, or of each
/// queued item), once each.
fn offline_media(result: &Value) -> Vec<Value> {
let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten());
let mut out: Vec<Value> = Vec::new();
for o in lists.filter_map(|r| r.get("offlineMedia").and_then(Value::as_array)).flatten() {
if !out.iter().any(|x| x.get("item") == o.get("item")) {
out.push(o.clone());
}
}
out
}
/// One stderr line per offline item that rendered as placeholder frames (#1054).
fn offline_warning(offline: &[Value]) -> String {
let s = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let mut msg = format!("filmcraft-cli: warning: {} offline media item(s) rendered as placeholder frames:", offline.len());
for o in offline {
let id = o.get("item").and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_default();
msg.push_str(&format!("\n item {id} {} ({}): {}", s(o, "name"), s(o, "status"), s(o, "path")));
}
msg
}
/// Warn about offline media on stderr; without `--allow-offline` that fails the run (exit 1), after
/// the output (`what`) was written, so scripts don't take placeholder frames for the real picture.
fn check_offline(a: &Args, offline: &[Value], what: &str) {
if offline.is_empty() {
return;
}
diag(format_args!("{}", offline_warning(offline)));
if !a.flag("--allow-offline") {
fail(format!("{what} has placeholder frames for offline media; relink it (exec media.relink …) or pass --allow-offline"));
}
}
/// 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> {
s.try_render_program_with_offline_at(scale, s.playhead()).map_err(|e| e.to_string())
}
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
@@ -531,7 +626,7 @@ mod format_tests {
let e = super::render_at_playhead(&s, scale).unwrap_err();
assert!(e.contains("finite and positive"), "{e}");
}
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8);
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8);
}
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
@@ -573,6 +668,19 @@ mod format_tests {
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
}
#[test]
fn offline_media_from_direct_and_queued_results() {
use serde_json::json;
let a = json!({"item": 8, "name": "a.mp4", "path": "/m/a.mp4", "status": "missing", "error": ""});
let b = json!({"item": 9, "name": "b.mov", "path": "/m/b.mov", "status": "offline", "error": ""});
assert_eq!(super::offline_media(&json!({"path": "x.mp4", "offlineMedia": [a]})), std::slice::from_ref(&a));
let queued = json!({"items": [{"offlineMedia": [a, b]}, {"offlineMedia": [a]}, {"offlineMedia": []}]});
assert_eq!(super::offline_media(&queued), [a.clone(), b.clone()]);
assert!(super::offline_media(&json!({"path": "x.mp4"})).is_empty());
let w = super::offline_warning(&[a, b]);
assert!(w.contains("2 offline media item(s)") && w.contains("item 8 a.mp4 (missing): /m/a.mp4") && w.contains("item 9 b.mov (offline)"), "{w}");
}
#[test]
fn export_lone_bound_is_forwarded_as_custom_range() {
use serde_json::json;
@@ -590,6 +698,27 @@ mod format_tests {
assert!(p.get("range").is_none());
}
/// `import` used `canonicalize`, which resolves symlinks and on Windows stores `\\?\C:\…`
/// verbatim paths in the project (#850).
#[test]
fn imported_paths_are_absolute_without_resolving_links() {
let cwd = std::env::current_dir().unwrap();
assert_eq!(super::absolute("clip.mov"), cwd.join("clip.mov").to_string_lossy());
assert!(!super::absolute("clip.mov").starts_with(r"\\?\"));
assert_eq!(super::absolute(""), "");
#[cfg(unix)]
{
let dir = std::env::temp_dir().join(format!("filmcraft-cli-abs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("real.mov"), b"x").unwrap();
let link = dir.join("link.mov");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(dir.join("real.mov"), &link).unwrap();
assert_eq!(super::absolute(&link.to_string_lossy()), link.to_string_lossy());
std::fs::remove_dir_all(&dir).unwrap();
}
}
#[test]
fn export_format_from_extension() {
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
@@ -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")
@@ -22,6 +22,9 @@ fn help_and_usage_errors() {
assert_eq!(cli(&[]).status.code(), Some(2));
assert_eq!(cli(&["frobnicate"]).status.code(), Some(2));
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
// Invalid --bridge is a usage error (exit 2) for both exec and mcp (#921).
assert_eq!(cli(&["--bridge", "0.0.0.0:9876", "exec", "inspect.project"]).status.code(), Some(2));
assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9876"]).status.code(), Some(2));
}
#[test]
@@ -147,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]
@@ -291,3 +311,76 @@ fn closed_stdout_still_runs_the_script_and_saves() {
assert_eq!(out.status.code(), Some(1));
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.
#[test]
fn export_with_missing_media_fails_unless_allowed() {
let dir = std::env::temp_dir().join(format!("fc-cli-1054-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = |n: &str| dir.join(n).to_str().unwrap().to_string();
let (still, proj) = (f("still.png"), f("p.fcproj"));
let o = cli(&["--demo", "render", "--seconds", "1", "--scale", "0.1", "--out", &still]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
json_out(&cli(&["exec", "file.newSequence", "name=s", "width=64", "height=36", "fps=24", "--save-as", &proj]));
let paths = format!("paths=[\"{}\"]", still.replace('\\', "\\\\"));
let item = json_out(&cli(&["--project", &proj, "--save", "exec", "file.import", &paths]))["items"][0].as_u64().unwrap();
json_out(&cli(&["--project", &proj, "--save", "exec", "timeline.place", &format!("item={item}"), "track=V1", "frame=0", "audio=false"]));
let export = |out: &str, extra: &[&str]| {
let mut args = vec!["--project", proj.as_str(), "--compact", "export", out, "--format", "png", "--start", "0", "--end", "0.05"];
args.extend_from_slice(extra);
cli(&args)
};
let on = export(&f("on.png"), &[]);
let v = json_out(&on);
assert_eq!(v["offlineMedia"], serde_json::json!([]), "{v}");
assert!(!String::from_utf8_lossy(&on.stderr).contains("warning"), "{}", String::from_utf8_lossy(&on.stderr));
std::fs::remove_file(&still).unwrap();
let off = export(&f("off.png"), &[]);
let err = String::from_utf8_lossy(&off.stderr);
assert_eq!(off.status.code(), Some(1), "{err}");
assert!(err.contains("still.png") && err.contains("missing") && err.contains("--allow-offline"), "{err}");
let v: Value = serde_json::from_slice(&off.stdout).expect("the result JSON is still printed");
assert_eq!(v["offlineMedia"][0]["item"], item, "{v}");
assert_eq!(v["offlineMedia"][0]["status"], "missing", "{v}");
let allowed = export(&f("allowed.png"), &["--allow-offline"]);
let err = String::from_utf8_lossy(&allowed.stderr);
assert!(allowed.status.success(), "{err}");
assert!(err.contains("still.png") && err.contains("missing"), "still warns: {err}");
assert_eq!(json_out(&allowed)["offlineMedia"][0]["status"], "missing");
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png")]);
let err = String::from_utf8_lossy(&render.stderr);
assert_eq!(render.status.code(), Some(1), "{err}");
assert!(err.contains("still.png") && err.contains("--allow-offline"), "{err}");
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png"), "--allow-offline"]);
assert!(render.status.success(), "{}", String::from_utf8_lossy(&render.stderr));
assert!(String::from_utf8_lossy(&render.stderr).contains("still.png"));
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -0,0 +1,2 @@
mod cli;
mod workflows;
+488
View File
@@ -0,0 +1,488 @@
//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent
//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`,
//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot:
//!
//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames);
//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3).
//!
//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross
//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes)
//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which
//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels.
//!
//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never
//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure).
//! ffmpeg and ffprobe are external test oracles only.
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use serde_json::{Value, json};
// ---------------------------------------------------------------------------------------------
// Fixtures
/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped
/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The
/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0
/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers.
fn phone_vfr_mp4(ffmpeg: &Path) -> Option<PathBuf> {
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4");
filmcraft_testkit::fixtures::generate(&out, |tmp| {
Command::new(ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"])
.args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"])
.args([
"-filter_complex",
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\
select='not(eq(mod(n,7),3))'[v];\
[1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]",
])
.args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"])
.args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
})
}
/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe,
/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3.
fn camera_long_gop_mkv(ffmpeg: &Path) -> Option<PathBuf> {
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv");
filmcraft_testkit::fixtures::generate(&out, |tmp| {
Command::new(ffmpeg)
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"])
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"])
.args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"])
.args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"])
.args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"])
.args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
})
}
/// A scratch directory under the workspace `target/`, removed when dropped.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Scratch(d)
}
fn path(&self, name: &str) -> String {
self.0.join(name).to_string_lossy().into_owned()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// ---------------------------------------------------------------------------------------------
// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio)
struct Mcp {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: u64,
}
impl Mcp {
/// Start the headless server (an empty project) and initialise the session. `data_dir` keeps
/// user presets away from the real data directory.
fn start(data_dir: &str) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli"))
.args(["--data-dir", data_dir, "mcp"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn filmcraft-cli mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut m = Mcp { child, stdin, stdout, next_id: 1 };
let init =
m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}}));
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}");
m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
m
}
fn send(&mut self, msg: &Value) {
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
}
/// Send a request and return its response (notifications in between are skipped).
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
loop {
let mut line = String::new();
assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending");
let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}"));
if v["id"] == id {
return v;
}
}
}
/// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error.
fn tool(&mut self, name: &str, arguments: Value) -> Value {
let r = self.request("tools/call", json!({"name": name, "arguments": arguments}));
let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}");
serde_json::from_str(&text).unwrap_or(Value::String(text))
}
/// `command_run`: one engine command by id.
fn run(&mut self, id: &str, params: Value) -> Value {
self.tool("command_run", json!({"id": id, "params": params}))
}
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
// ---------------------------------------------------------------------------------------------
// Oracles
fn probe(ffprobe: &Path, path: &str) -> Value {
let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap();
assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr));
serde_json::from_slice(&out.stdout).unwrap()
}
fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> {
j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect()
}
fn num(v: &Value) -> f64 {
v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN)
}
/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout.
fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec<u8> {
let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap();
assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr));
out.stdout
}
/// Size of the frames [`frames`] returns.
const FW: usize = 80;
const FH: usize = 60;
/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes
/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it).
fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec<Vec<u8>> {
let chain = match vf {
Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"),
None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"),
};
let bpp = if pix_fmt == "gray" { 1 } else { 3 };
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]);
raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect()
}
/// Mean absolute difference of two equally sized frames.
fn diff(a: &[u8], b: &[u8]) -> f64 {
a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.len().max(1) as f64
}
/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close.
fn assert_shows(got: &[u8], src: &[Vec<u8>], want: usize, what: &str) {
let d: Vec<f64> = src.iter().map(|s| diff(got, s)).collect();
let best = d.iter().copied().fold(f64::INFINITY, f64::min);
let at = d.iter().position(|x| *x == best).unwrap_or(0);
assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]);
}
/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz.
fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec<f32> {
let map = format!("0:a:{stream}");
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]);
raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
}
/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart.
fn onsets(x: &[f32]) -> Vec<f64> {
let peak = x.iter().fold(0f32, |m, v| m.max(v.abs()));
let mut out = Vec::new();
let mut last = f64::NEG_INFINITY;
for (i, block) in x.chunks(48).enumerate() {
let t = i as f64 * 48.0 / 48_000.0;
if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 {
out.push(t);
}
if block.iter().any(|v| v.abs() > peak / 4.0) {
last = t;
}
}
out
}
fn rms(x: &[f32], from: f64, to: f64) -> f64 {
let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default();
(s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::<f64>() / s.len().max(1) as f64).sqrt()
}
/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel).
fn tone(x: &[f32], at: f64, freq: f64) -> f64 {
let start = (at * 48_000.0) as usize;
let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default();
let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos();
let (mut s1, mut s2) = (0.0, 0.0);
for v in s {
let s0 = f64::from(*v) + c * s1 - s2;
s2 = s1;
s1 = s0;
}
(s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64
}
fn db(ratio: f64) -> f64 {
20.0 * ratio.log10()
}
fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec<Value> {
seq[kind][track]["items"].as_array().unwrap()
}
// ---------------------------------------------------------------------------------------------
// Workflows
/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the
/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and
/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame
/// holds the one before), the cut lands on the right frames, picture and sound stay in sync
/// across the cut, the title is in the picture only while it is on the timeline.
#[test]
fn phone_vfr_clip_extract_gain_title_to_h264_mp4() {
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
let Some(src) = phone_vfr_mp4(&ffmpeg) else {
return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)");
};
let src = src.to_string_lossy().into_owned();
let dir = Scratch::new("phone");
let mut m = Mcp::start(&dir.path("data"));
let imported = m.tool("media_import", json!({"paths": [src]}));
assert_eq!(imported["errors"], json!([]), "{imported}");
let item = imported["items"][0].as_u64().unwrap();
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
assert!(made["sequence"].is_u64(), "{made}");
let seq = m.tool("sequence_inspect", json!({}));
assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]);
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate");
assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps");
// Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain.
m.run("markers.markIn", json!({"seconds": 1.5}));
m.run("markers.markOut", json!({"seconds": 2.5}));
m.run("sequence.extract", json!({}));
m.run("edit.selectAll", json!({}));
let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0}));
assert_eq!(gain["clips"], 2, "both audio pieces: {gain}");
m.run("edit.deselectAll", json!({}));
m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5}));
let seq = m.tool("sequence_inspect", json!({}));
assert_eq!(seq["durationFrames"], 89, "{seq}");
assert_eq!(clips_on(&seq, "video", 0).len(), 2);
assert_eq!(clips_on(&seq, "audio", 0).len(), 2);
let out = dir.path("phone_edit.mp4");
let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true}));
assert_eq!(r["result"]["frames"], 89, "{r}");
// container and streams
let j = probe(&ffprobe, &out);
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240)));
assert_eq!(v[0]["r_frame_rate"], "30/1");
assert_eq!(v[0]["nb_read_frames"], "89");
assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)));
let duration = num(&j["format"]["duration"]);
assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}");
assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]);
// picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source
// as an editor plays it (CFR 30, a dropped frame holding the previous one)
let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray");
let got = frames(&ffmpeg, &out, None, "gray");
assert!(src_frames.len() >= 120, "{} source frames", src_frames.len());
assert_eq!(got.len(), 89);
for k in [20, 32, 40, 44, 45, 50, 70, 88] {
let want = if k < 45 { k } else { k + 31 };
assert_shows(&got[k], &src_frames, want, &format!("export frame {k}"));
}
// the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31
let mean = |f: &Vec<u8>| f.iter().map(|v| f64::from(*v)).sum::<f64>() / f.len() as f64;
let flashes: Vec<usize> = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect();
assert_eq!(flashes, [30, 31, 59], "white frames");
// the title: over frames 0..15 only (its pixels differ from the source there, nowhere after)
let blocks_changed = |k: usize| {
let mut n = 0;
for by in (0..FH).step_by(10) {
for bx in (0..FW).step_by(10) {
let mut d = 0.0;
for y in by..by + 10 {
for x in bx..bx + 10 {
d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x]));
}
}
n += usize::from(d / 100.0 > 15.0);
}
}
n
};
for k in [2, 8, 14] {
assert!(blocks_changed(k) >= 2, "title missing on frame {k}");
}
for k in [16, 25] {
assert_eq!(blocks_changed(k), 0, "title still on frame {k}");
}
// sound: beeps where the flashes are (A/V sync across the extract), 6 dB down
let src_pcm = pcm(&ffmpeg, &src, 0);
let got_pcm = pcm(&ffmpeg, &out, 0);
let beeps = onsets(&got_pcm);
assert_eq!(beeps.len(), 2, "beeps at {beeps:?}");
for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) {
assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s");
}
let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09));
assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB");
let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09));
assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB");
}
/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make
/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at
/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross
/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes.
/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio
/// tracks are in the mix at their own level and stop where the clip does.
#[test]
fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() {
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
let Some(src) = camera_long_gop_mkv(&ffmpeg) else {
return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)");
};
let src = src.to_string_lossy().into_owned();
let dir = Scratch::new("camera");
let data = dir.path("data");
let project = dir.path("camera.fcproj");
{
let mut m = Mcp::start(&data);
let imported = m.tool("media_import", json!({"paths": [src]}));
assert_eq!(imported["errors"], json!([]), "{imported}");
let item = imported["items"][0].as_u64().unwrap();
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
let clips: Vec<u64> = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}");
let seq = m.tool("sequence_inspect", json!({}));
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1}));
assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks");
let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap();
// 65 frames off the head and 15 off the tail, the linked audio following
m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65}));
m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15}));
let seq = m.tool("sequence_inspect", json!({}));
let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap();
for t in 0..2 {
let a = &clips_on(&seq, "audio", t)[0];
assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1);
}
let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap();
let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false}));
let matte_clip = placed["clips"][0].as_u64().unwrap();
m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"}));
let seq = m.tool("sequence_inspect", json!({}));
let tr = &seq["video"][0]["transitions"][0];
assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}");
m.run("file.saveAs", json!({"path": project}));
}
assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit");
// a fresh process opens the saved project and renders it
let out = dir.path("camera_edit.mov");
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap();
assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr));
let r: Value = serde_json::from_slice(&o.stdout).unwrap();
let total = 71 + 50;
assert_eq!(r["result"]["frames"], total, "{r}");
let j = probe(&ffprobe, &out);
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240)));
assert_eq!(v[0]["r_frame_rate"], "25/1");
assert_eq!(v[0]["nb_read_frames"], total.to_string());
assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2)));
let duration = num(&j["format"]["duration"]);
assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}");
// picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it
let src_frames = frames(&ffmpeg, &src, None, "rgb24");
let got = frames(&ffmpeg, &out, None, "rgb24");
assert_eq!((src_frames.len(), got.len()), (150, total as usize));
for k in [0, 1, 2, 30, 64] {
assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}"));
}
let red = &got[100];
assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red");
// the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut
let mix = |k: usize| {
let (cam, e) = (&src_frames[65 + k], &got[k]);
let (mut num, mut den) = (0.0, 0.0);
for ((c, r), e) in cam.iter().zip(red).zip(e) {
let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e));
num += (e - c) * (r - c);
den += (r - c) * (r - c);
}
num / den
};
let alphas: Vec<f64> = (64..=78).map(mix).collect();
assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}");
assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}");
assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}");
// sound: both tracks in the mix at their own level while the clip plays, silence on the matte
let got_pcm = pcm(&ffmpeg, &out, 0);
let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1));
for at in [0.2, 1.5, 2.6] {
let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0));
let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0));
assert!(
db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0,
"at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})"
);
}
assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte");
}
/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through
/// `filmcraft-cli --project … inspect sequence`.
fn end_frame_of(project: &str) -> Option<u64> {
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?;
let v: Value = serde_json::from_slice(&o.stdout).ok()?;
v["video"][0]["items"][0]["endFrame"].as_u64()
}
+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);
});
}
}
+4 -1
View File
@@ -263,7 +263,10 @@ pub fn install_hooks(app: &mut FilmcraftApp) {
}));
// Saving writes the file in memory and offers it as a download (`fs::WebServices`).
app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}"))));
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| Some(format!("/exports/{name}"))));
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| {
let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy());
Some(format!("/exports/{file}"))
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the
// hint's command runs once the user has chosen (#111).
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], hint: Option<RelinkHint>| {
+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]
@@ -1,7 +1,7 @@
asset: favicon.png
title: FilmCraft web app icon (favicon and loading image), 128 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
+6 -2
View File
@@ -29,18 +29,22 @@ 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).
# Help menu / About links in the system browser (MIT; as PhotoCraft).
open = "5"
[dev-dependencies]
glob = "0.3"
# graphics.rs: enumerate the real adapters in a test.
pollster = "0.4"
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"
+146 -11
View File
@@ -1,12 +1,16 @@
//! The desktop app's command line (`filmcraft --help`).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// `filmcraft --help`.
pub const USAGE: &str = "\
Usage: filmcraft [options] [project.fcproj | media files...]
Options:
--control <port> start the localhost JSON-lines control server
(also: FILMCRAFT_CONTROL_PORT)
(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
@@ -15,6 +19,9 @@ Options:
--no-recover start without asking about unsaved changes
--data-dir <dir> folder for auto-save, crash recovery and logs
(also: FILMCRAFT_DATA_DIR)
--graphics <mode> start with this graphics mode and keep it: default,
dx12 (Windows), software (Windows, Linux);
`--graphics default` undoes an automatic fallback
--version print the version and exit
-h, --help print this help and exit
";
@@ -28,7 +35,7 @@ pub enum Cli {
}
/// Options of a normal start.
#[derive(Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Launch {
pub control_port: Option<u16>,
pub control_input_only: bool,
@@ -39,6 +46,8 @@ pub struct Launch {
pub startup_flag: bool,
pub recover: Option<bool>,
pub data_dir: Option<std::path::PathBuf>,
/// `--graphics <mode>`: start with this graphics mode and keep it (see `graphics.rs`).
pub graphics: Option<crate::graphics::Mode>,
}
/// Parse the arguments after the program name. `env_control_port` is the value of
@@ -48,16 +57,31 @@ pub struct Launch {
/// mistyped flag helps nobody, and starting without the control server that was asked for leaves
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
/// too, and for a port from the environment exactly as for one from `--control`.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
///
/// The arguments come from `std::env::args_os`: not every argument is Unicode (a Linux file name
/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily
/// (its import then reports it missing); `--data-dir` keeps the folder exactly.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = OsString>) -> Result<Cli, String> {
let mut l = Launch {
control_port: None,
control_input_only: false,
files: Vec::new(),
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None,
};
let (mut help, mut version) = (false, false);
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
while let Some(raw) = args.next() {
let a = raw.to_string_lossy();
match a.as_ref() {
"-h" | "--help" => help = true,
"--version" => version = true,
"--control" => {
let port = args.next().ok_or("--control needs a port number")?;
let port = port.to_string_lossy();
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
}
"--demo" => (l.demo, l.startup_flag) = (true, true),
@@ -65,13 +89,17 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
"--empty" => (l.demo, l.startup_flag) = (false, true),
"--recover" => l.recover = Some(true),
"--no-recover" => l.recover = Some(false),
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
"--data-dir" => l.data_dir = Some(args.next().map(PathBuf::from).ok_or("--data-dir needs a folder")?),
"--graphics" => {
let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?;
l.graphics = Some(graphics_mode(&name.to_string_lossy())?);
}
// everything after `--` is a file, whatever it looks like
"--" => l.files.extend(args.by_ref()),
"--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())),
// macOS adds a process serial number when it launches an app bundle
_ if a.starts_with("-psn_") => {}
_ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")),
_ => l.files.push(a),
_ => l.files.push(a.into_owned()),
}
}
if help {
@@ -91,12 +119,45 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
Ok(Cli::Run(l))
}
/// Whether a launch file is a FilmCraft project. The extension's case does not matter: Windows file
/// associations and the Linux MIME glob start the app for `CUT.FCPROJ` too (#850).
pub fn is_project(path: &str) -> bool {
Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("fcproj"))
}
/// `path` made absolute against the working directory, so a project or media file named relative
/// to a terminal's folder is still found once the app runs from another one (Recent Projects, the
/// media paths saved in the project). Unlike `canonicalize`, `std::path::absolute` keeps symlinks
/// and adds no Windows verbatim `\\?\` prefix. A path it cannot resolve is kept as given.
pub fn absolute(path: &str) -> String {
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
}
/// The launch files as the project to open (the first one, see [`is_project`]) and the media files
/// to import (every file that is not a project), all made [`absolute`].
pub fn project_and_media(files: &[String]) -> (Option<String>, Vec<String>) {
let project = files.iter().find(|f| is_project(f)).map(|f| absolute(f));
let media = files.iter().filter(|f| !is_project(f)).map(|f| absolute(f)).collect();
(project, media)
}
/// A `--graphics` value: one of this platform's modes.
fn graphics_mode(name: &str) -> Result<crate::graphics::Mode, String> {
use crate::graphics::{LADDER, Mode};
let here = || LADDER.iter().map(|m| m.name()).collect::<Vec<_>>().join(", ");
let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?;
if !LADDER.contains(&mode) {
return Err(format!("--graphics {name} is not available on this platform (use one of {})", here()));
}
Ok(mode)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_env(env: Option<&str>, args: &[&str]) -> Result<Cli, String> {
parse(env.map(str::to_string), args.iter().map(|a| a.to_string()))
parse(env.map(str::to_string), args.iter().map(OsString::from))
}
fn parse_args(args: &[&str]) -> Result<Cli, String> {
@@ -166,7 +227,16 @@ mod tests {
let none = launch(&[]);
assert_eq!(
none,
Launch { control_port: None, control_input_only: false, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None }
Launch {
control_port: None,
control_input_only: false,
files: vec![],
demo: true,
startup_flag: false,
recover: None,
data_dir: None,
graphics: None
}
);
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
assert_eq!(l.control_port, Some(9876));
@@ -179,6 +249,52 @@ mod tests {
assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]);
}
/// `std::env::args` panics on an argument that is not Unicode, so such a file name (Latin-1 on
/// Linux) stopped the app before its window opened.
#[cfg(unix)]
#[test]
fn arguments_that_are_not_unicode_still_start_the_app() {
use std::os::unix::ffi::OsStringExt;
let dir = OsString::from_vec(b"/tmp/d\xe9".to_vec());
let file = OsString::from_vec(b"caf\xe9.mov".to_vec());
let Ok(Cli::Run(l)) = parse(None, [OsString::from("--data-dir"), dir.clone(), file.clone(), OsString::from("--"), file]) else {
panic!("should start the app");
};
assert_eq!(l.data_dir, Some(PathBuf::from(dir)), "the data folder is kept exactly");
assert_eq!(l.files, ["caf\u{fffd}.mov", "caf\u{fffd}.mov"]);
}
/// Windows file associations and the Linux MIME glob match `CUT.FCPROJ` too; it used to be
/// imported as a media file instead of opened (#850).
#[test]
fn a_project_is_recognised_whatever_the_case_of_its_extension() {
for p in ["cut.fcproj", "CUT.FCPROJ", "Cut.FcProj", "/edits/a b.fcproj", r"C:\Edits\CUT.FCPROJ"] {
assert!(is_project(p), "{p}");
}
for p in ["cut.mp4", "fcproj", ".fcproj", "a.fcproj.mov", "a.fcprojx", ""] {
assert!(!is_project(p), "{p}");
}
}
/// Relative launch paths were opened and imported as given, so the project's media paths and
/// Recent Projects pointed nowhere once the app ran from another folder (#850).
#[test]
fn launch_files_are_made_absolute_and_split_case_insensitively() {
let cwd = std::env::current_dir().unwrap();
let nested = Path::new("clips").join("a.mp4");
let already = cwd.join("b.wav").to_string_lossy().into_owned();
let files = ["Cut.FCPROJ".to_string(), nested.to_string_lossy().into_owned(), "other.fcproj".to_string(), already.clone()];
let (project, media) = project_and_media(&files);
assert_eq!(project, Some(cwd.join("Cut.FCPROJ").to_string_lossy().into_owned()));
assert_eq!(media, [cwd.join(&nested).to_string_lossy().into_owned(), already]);
for p in media.iter().chain(&project) {
assert!(Path::new(p).is_absolute() && !p.starts_with(r"\\?\"), "{p}");
}
assert_eq!(project_and_media(&[]), (None, vec![]));
// nothing to resolve against: kept as given rather than dropped
assert_eq!(absolute(""), "");
}
#[test]
fn isolated_input_is_explicit_and_requires_a_control_server() {
assert!(!launch(&["--control", "9876"]).control_input_only);
@@ -188,4 +304,23 @@ mod tests {
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
assert!(USAGE.contains("--control-input-only"));
}
#[test]
fn graphics_picks_one_of_this_platforms_modes() {
use crate::graphics::{LADDER, Mode};
assert_eq!(launch(&[]).graphics, None);
assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default));
for mode in LADDER {
assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode));
}
// a mode this platform has not is refused, not ignored
for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) {
assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available"));
}
assert!(parse_args(&["--graphics"]).is_err(), "missing mode");
assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan"));
assert!(parse_args(&["--graphics", "DEFAULT"]).is_err());
assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err());
assert!(USAGE.contains("--graphics"));
}
}
+295 -52
View File
@@ -1,18 +1,33 @@
//! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host
//! ("Device Class"), the output device, the I/O buffer size and the sample rate.
//!
//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which
//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by
//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound
//! resumes where it stopped instead of playback going silent (#840).
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex, PoisonError};
use std::sync::{Arc, Mutex, PoisonError, TryLockError};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use filmcraft_engine::settings::AudioHardwarePrefs;
use filmcraft_ui_egui::{AudioDevices, AudioOut};
/// The playback mixer callback handed to [`AudioOut::start`].
type Fill = Box<dyn FnMut(&mut [f32], usize) + Send>;
pub struct CpalOut {
stream: Option<cpal::Stream>,
/// The running playback's mixer, shared with the stream so a restarted stream continues it.
fill: Option<Arc<Mutex<Fill>>>,
played: Arc<AtomicU64>,
failed: Arc<AtomicBool>,
health: Arc<Health>,
watchdog: Watchdog,
/// Time base for the watchdog.
epoch: Instant,
rate: u32,
channels: u16,
hw: AudioHardwarePrefs,
@@ -128,8 +143,11 @@ impl CpalOut {
};
Self {
stream: None,
fill: None,
played: Arc::new(AtomicU64::new(0)),
failed: Arc::new(AtomicBool::new(false)),
health: Arc::new(Health::default()),
watchdog: Watchdog::default(),
epoch: Instant::now(),
rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0),
channels: cfg.as_ref().map_or(2, |c| c.channels()),
hw: AudioHardwarePrefs::default(),
@@ -153,19 +171,14 @@ impl CpalOut {
Ok(exact.unwrap_or(default))
}
fn converted_stream<T>(
&self,
dev: &cpal::Device,
config: &cpal::StreamConfig,
mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>,
) -> Result<cpal::Stream, cpal::BuildStreamError>
fn converted_stream<T>(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc<Mutex<Fill>>) -> Result<cpal::Stream, cpal::BuildStreamError>
where
T: cpal::SizedSample + cpal::FromSample<f32>,
{
let channels = usize::from(config.channels);
let played = self.played.clone();
let failed = self.failed.clone();
let error_flag = failed.clone();
let health = self.health.clone();
let error_health = health.clone();
let mut scratch = Vec::new();
dev.build_output_stream(
config,
@@ -173,39 +186,23 @@ impl CpalOut {
// Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread.
if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() {
buf.fill(T::EQUILIBRIUM);
if !failed.swap(true, Ordering::SeqCst) {
if !health.failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: unable to allocate the audio conversion buffer");
}
return;
}
scratch.resize(buf.len(), 0.0);
fill(&mut scratch, channels);
render(&fill, &mut scratch, channels, &played, &health);
convert_samples(&scratch, buf);
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
},
move |e| stream_error(&error_flag, e),
move |e| stream_error(&error_health, e),
None,
)
}
}
fn stream_error(failed: &AtomicBool, e: cpal::StreamError) {
if !failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: audio stream error: {e}");
}
}
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
for (sample, converted) in input.iter().zip(output) {
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
}
}
impl AudioOut for CpalOut {
fn start(&mut self, mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
self.stop();
/// Open the configured output device and play `fill` on it. `expect` is the (sample rate,
/// channels) a restarted stream must keep: the mixer was set up for them.
fn open(&mut self, fill: Arc<Mutex<Fill>>, expect: Option<(u32, u16)>) -> Result<(), String> {
let host = host(&self.hw.device_class);
let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?;
self.note = fallback;
@@ -214,6 +211,11 @@ impl AudioOut for CpalOut {
if channels == 0 || cfg.sample_rate().0 == 0 {
return Err("the output device returned an invalid channel count or sample rate".into());
}
if let Some((rate, ch)) = expect
&& (rate, ch) != (cfg.sample_rate().0, cfg.channels())
{
return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels()));
}
let mut config: cpal::StreamConfig = cfg.clone().into();
if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size()
&& (*min..=*max).contains(&self.hw.buffer_size)
@@ -222,20 +224,18 @@ impl AudioOut for CpalOut {
}
self.rate = cfg.sample_rate().0;
self.channels = cfg.channels();
self.played.store(0, Ordering::SeqCst);
self.failed.store(false, Ordering::SeqCst);
let played = self.played.clone();
let failed = self.failed.clone();
let stream = match cfg.sample_format() {
cpal::SampleFormat::F32 => dev.build_output_stream(
&config,
move |buf: &mut [f32], _| {
fill(buf, channels);
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
},
move |e| stream_error(&failed, e),
None,
),
cpal::SampleFormat::F32 => {
let played = self.played.clone();
let health = self.health.clone();
let error_health = health.clone();
dev.build_output_stream(
&config,
move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health),
move |e| stream_error(&error_health, e),
None,
)
}
cpal::SampleFormat::I8 => self.converted_stream::<i8>(&dev, &config, fill),
cpal::SampleFormat::I16 => self.converted_stream::<i16>(&dev, &config, fill),
cpal::SampleFormat::I24 => self.converted_stream::<cpal::I24>(&dev, &config, fill),
@@ -251,10 +251,172 @@ impl AudioOut for CpalOut {
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
self.stream = Some(stream);
Ok(())
}
/// Replace a dropped stream with a new one on the same mixer, clock and format.
fn restart(&mut self) -> Result<(), String> {
let fill = self.fill.clone().ok_or("nothing is playing")?;
let expect = (self.rate, self.channels);
// The old stream (and its audio thread) goes first: only one stream ever runs the mixer.
self.stream = None;
self.health.clear_errors();
self.open(fill, Some(expect))
}
}
/// What the audio thread reports to the UI thread.
#[derive(Default)]
struct Health {
/// The device went away (unplugged): restart the stream at once.
lost: AtomicBool,
/// A backend error since the last check (cpal keeps the stream alive after these): restart
/// only if the stream also stopped consuming samples.
errored: AtomicBool,
/// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out.
/// [`AudioOut::played_frames`] reports `None` and playback goes on without sound.
failed: AtomicBool,
/// The first error of a stream has been printed.
reported: AtomicBool,
}
impl Health {
fn clear_errors(&self) {
self.lost.store(false, Ordering::SeqCst);
self.errored.store(false, Ordering::SeqCst);
self.reported.store(false, Ordering::SeqCst);
}
}
/// How long a stream that reported an error may go without consuming samples before it is
/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock.
const STALL_RESTART: Duration = Duration::from_millis(300);
/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up.
const MAX_RESTARTS: usize = 3;
const RESTART_WINDOW: Duration = Duration::from_secs(10);
/// What [`Watchdog::check`] decides about the running stream.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Verdict {
Healthy,
Restart,
GiveUp,
}
/// Decides when a stream that reported errors is restarted (UI thread, every frame).
#[derive(Default)]
struct Watchdog {
/// Played-frame count last seen, and when it last changed.
seen: (u64, Duration),
/// An error was reported and the stream has not consumed samples since.
pending_error: bool,
/// When recent restarts happened (at most [`MAX_RESTARTS`] kept).
restarts: Vec<Duration>,
}
impl Watchdog {
fn reset(&mut self, now: Duration) {
*self = Self { seen: (0, now), ..Self::default() };
}
fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict {
if played != self.seen.0 {
self.seen = (played, now);
self.pending_error = false;
}
// An error that arrives with the stream still consuming samples is only a hiccup.
self.pending_error |= errored;
let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART;
if !lost && !(self.pending_error && stalled) {
return Verdict::Healthy;
}
self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW);
if self.restarts.len() >= MAX_RESTARTS {
return Verdict::GiveUp;
}
self.restarts.push(now);
self.seen = (played, now);
self.pending_error = false;
Verdict::Restart
}
}
/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a
/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never
/// expected: a restart drops the old stream first) plays silence without advancing the clock.
fn render(fill: &Mutex<Fill>, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) {
let mut guard = match fill.try_lock() {
Ok(guard) => guard,
Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(TryLockError::WouldBlock) => {
buf.fill(0.0);
return;
}
};
if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() {
buf.fill(0.0);
if !health.failed.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: the audio mixer failed; playing without sound");
}
return;
}
played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst);
}
fn stream_error(health: &Health, e: cpal::StreamError) {
match e {
cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst),
cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst),
}
if !health.reported.swap(true, Ordering::SeqCst) {
eprintln!("filmcraft: audio stream error: {e}");
}
}
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
for (sample, converted) in input.iter().zip(output) {
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
}
}
impl AudioOut for CpalOut {
fn start(&mut self, fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
self.stop();
self.played.store(0, Ordering::SeqCst);
self.health = Arc::new(Health::default());
self.watchdog.reset(self.epoch.elapsed());
let fill = Arc::new(Mutex::new(fill));
self.open(fill.clone(), None)?;
self.fill = Some(fill);
Ok(self.rate)
}
fn stop(&mut self) {
self.stream = None;
self.fill = None;
}
fn recover(&mut self) {
if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) {
return;
}
let lost = self.health.lost.swap(false, Ordering::SeqCst);
let errored = self.health.errored.swap(false, Ordering::SeqCst);
match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) {
Verdict::Healthy => {}
Verdict::Restart => {
log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" });
if let Err(e) = self.restart() {
log::warn!("the audio output stream could not be restarted: {e}");
self.stream = None;
self.health.failed.store(true, Ordering::SeqCst);
}
}
Verdict::GiveUp => {
log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs());
self.health.failed.store(true, Ordering::SeqCst);
}
}
}
fn sample_rate(&self) -> u32 {
self.rate
@@ -263,7 +425,7 @@ impl AudioOut for CpalOut {
self.channels as usize
}
fn played_frames(&self) -> Option<u64> {
self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
}
fn devices(&self) -> AudioDevices {
let h = host(&self.hw.device_class);
@@ -327,11 +489,92 @@ mod tests {
}
#[test]
fn failed_stream_relinquishes_the_playback_clock() {
let failed = AtomicBool::new(false);
stream_error(&failed, cpal::StreamError::DeviceNotAvailable);
assert!(failed.load(Ordering::SeqCst));
fn stream_errors_ask_for_a_restart_instead_of_failing_playback() {
let health = Health::default();
stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } });
assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst));
stream_error(&health, cpal::StreamError::DeviceNotAvailable);
assert!(health.lost.load(Ordering::SeqCst));
assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback");
let output = CpalOut::new();
assert_eq!(output.played_frames(), None);
assert_eq!(output.played_frames(), None, "no stream, no clock");
}
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
#[test]
fn an_error_while_samples_keep_flowing_is_only_a_hiccup() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy);
// the stream keeps consuming samples after the error: never restarted
for (i, t) in (20..2_000).step_by(20).enumerate() {
assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy);
}
}
#[test]
fn a_stream_that_errors_and_stops_consuming_is_restarted() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy);
// POLLERR: the backend reports errors and no longer calls back
assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy);
assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy);
assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart);
// the restarted stream gets a fresh grace period
assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy);
// a stall without any reported error is left to the UI's own watchdog
assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy);
}
#[test]
fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() {
let mut w = Watchdog::default();
w.reset(ms(0));
assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart);
assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp);
// sporadic failures far apart keep being recovered
assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart);
}
#[test]
fn the_device_callback_never_panics_and_counts_played_frames() {
let (played, health) = (AtomicU64::new(0), Health::default());
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25)));
let mut buf = [1.0_f32; 8];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4));
// a mixer still held elsewhere plays silence without moving the clock
{
let _held = fill.lock().unwrap();
render(&fill, &mut buf, 2, &played, &health);
}
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4));
// zero channels from a broken driver must not divide by zero
render(&fill, &mut buf, 0, &played, &health);
assert_eq!(played.load(Ordering::SeqCst), 12);
}
#[test]
fn a_panicking_mixer_plays_silence_and_fails_the_stream() {
let (played, health) = (AtomicU64::new(0), Health::default());
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| {
buf.fill(0.5);
panic!("mixer bug");
}));
let mut buf = [1.0_f32; 4];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!(buf, [0.0; 4]);
assert_eq!(played.load(Ordering::SeqCst), 0);
assert!(health.failed.load(Ordering::SeqCst));
// later callbacks stay silent instead of re-entering the broken mixer
buf = [1.0; 4];
render(&fill, &mut buf, 2, &played, &health);
assert_eq!(buf, [0.0; 4]);
}
}
+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
}
+858 -22
View File
@@ -1,33 +1,524 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback
//! when a start fails (docs/gaps.md G4, #820).
//!
//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
//!
//! # 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`,
//! so concurrent launches never write the same file) that is removed once the window has painted
//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened
//! by then. An attempt file found by a later launch therefore means a process that died within
//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs,
//! or is force-quit or crashes later, is not counted; neither is another instance that is still
//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After
//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next
//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A
//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once,
//! within the same process.
//!
//! # Getting back
//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`,
//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command
//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves
//! the saved state alone: the user is in charge.
//!
//! All files are written beside and renamed into place, so a reader never sees half a file; two
//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted
//! once less, a confirmation repeated by the next start), never corrupt it.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave
/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`)
/// names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
// out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends
// shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and
// leaves machines without DX12 with no backend at all.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{self, Backends},
};
/// The saved graphics state in the data folder: `{"mode": "<mode>", "failures": <n>}`.
pub const STATE_FILE: &str = "graphics-start.json";
/// The folder in the data folder with one attempt file per start that has not painted yet.
pub const ATTEMPTS_DIR: &str = "graphics-start";
/// How long after launch a start that has not painted may die and still count as failed. A
/// driver crash in instance or device creation happens within a second or two.
pub const DEATH_WINDOW: Duration = Duration::from_secs(15);
/// Failed starts in a row, in the same mode, that move on to the next mode.
pub const FAILURES_TO_MOVE_ON: u32 = 2;
/// Attempt files looked at per launch; anything beyond is left for a later launch.
const MAX_ATTEMPTS: usize = 64;
/// The state and attempt files are a few bytes; anything longer is not ours.
const MAX_FILE: u64 = 4096;
/// How the window's graphics device is created.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on
/// macOS, Vulkan or OpenGL elsewhere.
Default,
/// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up.
Dx12,
/// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then
/// composites on the CPU (`FilmcraftApp::set_wgpu`).
Software,
}
impl Mode {
/// The name in [`STATE_FILE`] and on the command line (`--graphics <name>`).
pub fn name(self) -> &'static str {
match self {
Mode::Default => "default",
Mode::Dx12 => "dx12",
Mode::Software => "software",
}
}
/// The English name shown in the interface (translated there).
pub fn label(self) -> &'static str {
match self {
Mode::Default => "default graphics",
Mode::Dx12 => "DirectX 12 only",
Mode::Software => "software rendering",
}
}
pub fn parse(name: &str) -> Option<Mode> {
[Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name)
}
}
/// The modes tried in order on this platform. macOS has only Metal, and no software adapter.
pub const LADDER: &[Mode] = if cfg!(target_os = "windows") {
&[Mode::Default, Mode::Dx12, Mode::Software]
} else if cfg!(target_os = "macos") {
&[Mode::Default]
} else {
&[Mode::Default, Mode::Software]
};
/// The mode after `mode` in `ladder`, if any.
fn next_in(ladder: &[Mode], mode: Mode) -> Option<Mode> {
let i = ladder.iter().position(|m| *m == mode)?;
ladder.get(i.checked_add(1)?).copied()
}
/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Saved {
mode: Mode,
failures: u32,
}
/// An attempt file left by a start: its process id (from the file name), mode and launch time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Attempt {
pid: u32,
mode: Mode,
/// Seconds since the Unix epoch.
started: u64,
}
/// What a launch makes of the saved state and the attempt files it found.
#[derive(Debug, PartialEq, Eq)]
struct Decision {
/// The state to start with and save.
saved: Saved,
/// Moved on to the next mode because the last starts failed.
moved_on: bool,
/// Per attempt: done with (counted, or not ours to count), delete it.
delete: Vec<bool>,
}
/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix
/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was
/// reused), `window` is [`DEATH_WINDOW`] in seconds.
fn decide(saved: Option<Saved>, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision {
let first = ladder.first().copied().unwrap_or(Mode::Default);
// Nothing saved, or a mode this platform does not use (a data folder copied from another OS).
let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 });
let mut failures = saved.failures;
let mut delete = Vec::with_capacity(attempts.len());
for a in attempts {
if a.started > now.saturating_add(window) {
// From the future (a clock that jumped back): unknown, and not worth keeping.
delete.push(true);
continue;
}
let dead = a.pid == pid || now.saturating_sub(a.started) >= window;
// A younger attempt is another instance starting right now: leave it to that instance.
delete.push(dead);
// Only failures of the mode we would start in count; older ones are history.
if dead && a.mode == saved.mode {
failures = failures.saturating_add(1);
}
}
if failures >= FAILURES_TO_MOVE_ON {
return match next_in(ladder, saved.mode) {
Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete },
// Nothing left to try: the last mode is still the best chance.
None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete },
};
}
Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete }
}
fn read_small(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?;
Some(text)
}
/// [`Saved`] from its text; `None` for anything else. A missing failure count is none.
fn parse_state(text: &str) -> Option<Saved> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let mode = Mode::parse(v.get("mode")?.as_str()?)?;
let failures = match v.get("failures") {
None => 0,
Some(n) => u32::try_from(n.as_u64()?).ok()?,
};
Some(Saved { mode, failures })
}
fn state_json(s: Saved) -> String {
serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string()
}
/// An attempt from its file name (`<pid>-<n>.json`) and text; `None` for anything else.
fn parse_attempt(file_name: &str, text: &str) -> Option<Attempt> {
let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?;
let v: serde_json::Value = serde_json::from_str(text).ok()?;
Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? })
}
fn attempt_json(mode: Mode, started: u64) -> String {
serde_json::json!({ "mode": mode.name(), "started": started }).to_string()
}
/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a
/// start that dies mid-write, or another instance reading, never sees half a file.
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(".{}.tmp", std::process::id()));
let tmp = PathBuf::from(tmp);
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
fn remove_quietly(path: &Path) {
match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()),
_ => {}
}
}
/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable.
fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option<Attempt>)> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
entries
.filter_map(Result::ok)
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
name.ends_with(".json").then(|| {
let path = e.path();
let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text));
(path, attempt)
})
})
.take(MAX_ATTEMPTS)
.collect()
}
fn unix_now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Attempt numbers within this process, so each attempt file has its own name.
static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0);
/// This launch's graphics mode and its files in the data folder.
#[derive(Clone, Debug)]
pub struct GraphicsStart {
/// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set.
state: Option<PathBuf>,
/// This start's attempt file (with `state`).
attempt: Option<PathBuf>,
mode: Mode,
/// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it.
reset: Arc<AtomicBool>,
}
impl GraphicsStart {
/// Pick this launch's mode from the data folder and record the attempt. `forced`
/// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set)
/// leaves the data folder alone and starts in `forced`, else [`Mode::Default`].
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>) -> Self {
let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id());
if let Some(path) = start.attempt.clone() {
expire_later(path);
}
start
}
/// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer.
fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>, now: u64, pid: u32) -> Self {
let first = LADDER.first().copied().unwrap_or(Mode::Default);
let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first });
let reset = Arc::new(AtomicBool::new(false));
let Some(dir) = data_dir.filter(|_| !explicit_backend) else {
return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset };
};
let state = dir.join(STATE_FILE);
let attempts_dir = dir.join(ATTEMPTS_DIR);
let saved = read_small(&state).and_then(|t| parse_state(&t));
let found = list_attempts(&attempts_dir);
let known: Vec<Attempt> = found.iter().filter_map(|(_, a)| *a).collect();
let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER);
let mut to_delete = decision.delete.iter();
for (path, attempt) in &found {
// unreadable files in our folder are debris (a different version, a damaged disk)
if attempt.is_none() || to_delete.next().copied().unwrap_or(false) {
remove_quietly(path);
}
}
let file = state.display();
let next = match forced {
Some(mode) => {
log::info!("graphics mode {} chosen with --graphics", mode.name());
Saved { mode, failures: 0 }
}
None => {
if decision.moved_on {
let failed = saved.map_or(first, |s| s.mode).name();
log::warn!(
"the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)",
decision.saved.mode.name()
);
} else if decision.saved.failures > 0 {
log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name());
} else if decision.saved.mode != Mode::Default {
log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name());
}
decision.saved
}
};
if saved != Some(next)
&& let Err(e) = write_atomic(&state, &state_json(next))
{
log::warn!("can't write {file}: {e}");
}
let start = Self { state: Some(state), attempt: None, mode: next.mode, reset };
start.with_new_attempt(&attempts_dir, now, pid)
}
/// Record a new attempt file for the current mode.
fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self {
let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{pid}-{n}.json"));
match write_atomic(&path, &attempt_json(self.mode, now)) {
Ok(()) => self.attempt = Some(path),
Err(e) => log::warn!("can't write {}: {e}", path.display()),
}
self
}
pub fn mode(&self) -> Mode {
self.mode
}
/// Running in a fallback mode, i.e. not the platform default.
pub fn degraded(&self) -> bool {
self.mode != Mode::Default
}
/// Move on to the next mode after a start-up error wgpu reported; `false` when none is left.
/// The error is certain, so the next mode is saved at once.
pub fn step_down(&mut self) -> bool {
let Some(next) = next_in(LADDER, self.mode) else {
return false;
};
log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name());
self.mode = next;
if let Some(state) = self.state.clone() {
self.save(&state, Saved { mode: next, failures: 0 });
if let Some(old) = self.attempt.take() {
remove_quietly(&old);
if let Some(dir) = old.parent() {
let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt;
if let Some(path) = &path {
expire_later(path.clone());
}
self.attempt = path;
}
}
}
true
}
/// The window is up: keep this mode, and the failed starts in a row are over.
pub fn confirm(&self) {
self.finish();
let Some(state) = &self.state else { return };
if self.reset.load(Ordering::Relaxed) {
return;
}
let ok = Saved { mode: self.mode, failures: 0 };
if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) {
self.save(state, ok);
}
}
/// This start is over, whether it painted or not (the window closed, or every mode failed
/// with an error that was reported): its attempt no longer means a crash.
pub fn finish(&self) {
if let Some(path) = &self.attempt {
remove_quietly(path);
}
}
/// Start with the default mode next time (the notice's reset button). This process keeps
/// its mode: the graphics device can't be swapped under a running window.
pub fn reset_to_default(&self) -> Result<(), String> {
self.reset.store(true, Ordering::Relaxed);
let Some(state) = &self.state else { return Ok(()) };
let first = LADDER.first().copied().unwrap_or(Mode::Default);
write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?;
log::info!("graphics mode reset to {} for the next start", first.name());
Ok(())
}
fn save(&self, path: &Path, s: Saved) {
if let Err(e) = write_atomic(path, &state_json(s)) {
log::warn!("can't write {}: {e}", path.display());
}
}
}
/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not
/// counted as a failed one, painted or not.
fn expire_later(path: PathBuf) {
let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || {
let _ = std::panic::catch_unwind(move || {
std::thread::sleep(DEATH_WINDOW);
remove_quietly(&path);
});
});
if let Err(e) = spawned {
log::warn!("can't start the graphics start timer: {e}");
}
}
/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override`
/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`.
pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>) {
apply_for(options, mode, backend_override, cfg!(target_os = "windows"));
}
fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>, windows: bool) {
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
return;
};
// 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
// selection or any Rust error handling runs, so the window never appears. Choosing an
// adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep
// Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects
// fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no
// backend at all.
Mode::Default if windows => {
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
// 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,
Mode::Software => {
create.instance_descriptor.backends = software_backends(windows);
create.native_adapter_selector = Some(Arc::new(pick_software));
}
}
}
/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on
/// Windows for the reason in [`apply_for`].
fn software_backends(windows: bool) -> Backends {
if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL }
}
/// The first software adapter that can draw to the window's surface.
fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result<wgpu::Adapter, String> {
adapters
.iter()
.find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s)))
.cloned()
.ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len()))
}
/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may
/// avoid (as opposed to a display, event-loop or app error).
pub fn is_graphics_failure(err: &eframe::Error) -> bool {
matches!(err, eframe::Error::Wgpu(_))
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_backends(backend_override: Option<Backends>) -> Backends {
const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software];
const LINUX: &[Mode] = &[Mode::Default, Mode::Software];
const MACOS: &[Mode] = &[Mode::Default];
fn configured(mode: Mode, backend_override: Option<Backends>, windows: bool) -> (Backends, bool) {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
apply_for(&mut options, mode, backend_override, windows);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
create.instance_descriptor.backends
(create.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]
@@ -37,16 +528,361 @@ mod tests {
panic!("default native options must create a wgpu instance");
};
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
let configured = configured_backends(None);
assert_eq!(configured, Backends::PRIMARY);
assert!(!configured.contains(Backends::GL));
assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN));
let (backends, selector) = configured(Mode::Default, None, true);
assert_eq!(backends, Backends::PRIMARY);
assert!(!backends.contains(Backends::GL));
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
// 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]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
assert_eq!(configured_backends(Some(backends)), backends);
assert_eq!(configured(Mode::Default, Some(backends), true).0, backends);
}
}
#[test]
fn fallback_modes_leave_out_the_crashing_backends() {
// DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413).
assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false));
// Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477).
assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true));
assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true));
// Off Windows the default is eframe's own.
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false));
}
const W: u64 = 15;
const NOW: u64 = 1_000_000;
const ME: u32 = 7;
fn saved(mode: Mode, failures: u32) -> Option<Saved> {
Some(Saved { mode, failures })
}
fn dead(mode: Mode) -> Attempt {
Attempt { pid: 1, mode, started: NOW - 100 }
}
fn mode_after(s: Option<Saved>, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) {
let d = decide(s, attempts, NOW, ME, W, ladder);
(d.saved.mode, d.saved.failures, d.moved_on)
}
#[test]
fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() {
// first launch
assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false));
// one start died: counted, but the same mode is tried again
assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false));
// the second in a row moves on: DX12, then software
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true));
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true));
// two dead instances found at once count as two
assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
// a saved mode is kept
assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false));
// nothing left to try: stay
assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false));
assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false));
// a mode this platform has not: start over
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false));
assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false));
assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false));
// a hostile count doesn't overflow
assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
}
#[test]
fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() {
let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 };
let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW };
let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 };
let older_mode = dead(Mode::Dx12);
let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 };
let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS);
// another instance still starting is left alone; one from the future is dropped; a
// failure in another mode is history; our own process id can only be a dead process
assert_eq!(d.delete, [false, false, true, true, true]);
assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false));
// the clock a little behind another instance's: still starting
let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 };
assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]);
}
#[test]
fn the_files_round_trip_and_reject_anything_else() {
for mode in WINDOWS {
for failures in [0, 1, 2, u32::MAX] {
assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures));
}
assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW }));
}
// without a count (an earlier build of this change wrote `pending`): none
assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0));
for bad in [
"",
"{",
"null",
"[]",
r#"{"mode":"vulkan","failures":1}"#,
r#"{"failures":1}"#,
r#"{"mode":1,"failures":1}"#,
r#"{"mode":"dx12","failures":"one"}"#,
r#"{"mode":"dx12","failures":-1}"#,
r#"{"mode":"dx12","failures":4294967296}"#,
] {
assert_eq!(parse_state(bad), None, "{bad}");
}
let good = attempt_json(Mode::Default, NOW);
for (name, text) in [
("12-0.json", "{"),
("12-0.json", r#"{"mode":"default"}"#),
("12-0.json", r#"{"mode":"default","started":-5}"#),
("x-0.json", good.as_str()),
("12.json", good.as_str()),
("-0.json", good.as_str()),
("99999999999-0.json", good.as_str()),
("12-0.txt", good.as_str()),
] {
assert_eq!(parse_attempt(name, text), None, "{name} {text}");
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn state_of(dir: &Path) -> Option<Saved> {
read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t))
}
fn attempt_files(dir: &Path) -> usize {
std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count())
}
/// A launch as another process (`pid`) at `now`.
fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart {
GraphicsStart::begin_at(Some(dir), false, None, now, pid)
}
#[test]
fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() {
let dir = temp_dir("paint");
let start = launch(&dir, 10, NOW);
assert_eq!(start.mode(), Mode::Default);
assert!(!start.degraded());
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 1);
start.confirm();
assert_eq!(attempt_files(&dir), 0);
// a window closed before it painted is no failure either
let start = launch(&dir, 11, NOW + 60);
start.finish();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() {
let dir = temp_dir("dead");
let second = LADDER.get(1).copied().unwrap_or(Mode::Default);
// dies without painting
let _ = launch(&dir, 20, NOW);
// the next launch counts it, tries the same mode again and dies too
let again = launch(&dir, 21, NOW + 60);
assert_eq!(again.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 1));
// two in a row: the next mode, kept
let moved = launch(&dir, 22, NOW + 120);
assert_eq!(moved.mode(), second);
assert_eq!(moved.degraded(), second != Mode::Default);
moved.confirm();
assert_eq!(launch(&dir, 23, NOW + 180).mode(), second);
// one failure, then a start that paints: the count starts over
let _ = std::fs::remove_dir_all(&dir);
let _ = launch(&dir, 30, NOW);
let painted = launch(&dir, 31, NOW + 60);
painted.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
let _ = launch(&dir, 32, NOW + 120);
assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn concurrent_launches_neither_count_nor_disturb_each_other() {
let dir = temp_dir("race");
// two instances start within the same seconds; neither has painted yet
let a = launch(&dir, 40, NOW);
let b = launch(&dir, 41, NOW + 1);
let c = launch(&dir, 42, NOW + 2);
assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file");
b.confirm();
a.confirm();
c.confirm();
assert_eq!(attempt_files(&dir), 0);
assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_huge_or_foreign_files_start_with_the_default() {
let dir = temp_dir("corrupt");
let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR));
let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000));
for text in ["garbage", huge.as_str()] {
let _ = std::fs::write(dir.join(STATE_FILE), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text);
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text);
let start = launch(&dir, 51, NOW);
assert_eq!(start.mode(), Mode::Default);
// replaced with a well-formed state; the unreadable attempt is gone, other files stay
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists());
assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists());
start.finish();
}
// a data folder that is a file: nothing can be written, the default still starts
let file = dir.join("not-a-folder");
let _ = std::fs::write(&file, "x");
let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert!(start.reset_to_default().is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn many_attempt_files_are_bounded_per_launch() {
let dir = temp_dir("many");
let attempts = dir.join(ATTEMPTS_DIR);
let _ = std::fs::create_dir_all(&attempts);
let n = MAX_ATTEMPTS + 10;
for pid in 0..n {
let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1));
}
// all of them belong to instances still starting: none is counted or removed
let start = launch(&dir, 60, NOW);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(attempt_files(&dir), n + 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() {
let dir = temp_dir("forced");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 }));
launch(&dir, 70, NOW).confirm();
// `--graphics default`
let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71);
assert_eq!(start.mode(), Mode::Default);
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default);
// `--graphics <the last mode>` is kept for the next launches
GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm();
assert_eq!(launch(&dir, 74, NOW + 240).mode(), last);
// a mode this platform has not starts with the first one
if !LADDER.contains(&Mode::Dx12) {
assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reset_to_default_wins_over_a_later_confirm() {
let dir = temp_dir("reset");
let last = LADDER.last().copied().unwrap_or(Mode::Default);
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 }));
let start = launch(&dir, 80, NOW);
assert_eq!(start.mode(), last);
// the notice's button, from a clone of the start (the app holds one)
assert_eq!(start.clone().reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
start.confirm();
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_explicit_backend_leaves_the_data_folder_alone() {
let dir = temp_dir("explicit");
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 }));
let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90);
assert_eq!(start.mode(), Mode::Default);
start.confirm();
let _ = start.step_down();
assert_eq!(start.reset_to_default(), Ok(()));
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
assert_eq!(attempt_files(&dir), 0);
// with --graphics too, that mode starts, still without touching the folder
let last = LADDER.last().copied().unwrap_or(Mode::Default);
assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last);
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
// no data folder: nothing is written, the default still starts
assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn step_down_walks_the_ladder_and_saves_each_mode_at_once() {
let dir = temp_dir("step");
let mut start = launch(&dir, ME, NOW);
for expected in LADDER.iter().skip(1) {
assert!(start.step_down());
assert_eq!(start.mode(), *expected);
assert_eq!(state_of(&dir), saved(*expected, 0));
assert_eq!(attempt_files(&dir), 1, "one attempt file per process");
}
assert!(!start.step_down(), "nothing after the last mode");
start.finish();
assert_eq!(attempt_files(&dir), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_software_selector_only_picks_a_software_adapter() {
if wgpu::Instance::enabled_backend_features().is_empty() {
return;
}
// only the backends software mode uses: no driver this test loads is one the app avoids
let backends = software_backends(cfg!(target_os = "windows"));
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
match pick_software(&adapters, None) {
Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()),
Err(why) => {
assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}");
}
}
assert!(pick_software(&[], None).is_err());
}
}
+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]
+322 -161
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.
//!
@@ -31,7 +32,6 @@ mod control_server;
#[cfg(target_os = "linux")]
mod dev_icon;
mod file_filters;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
@@ -44,6 +44,8 @@ use filmcraft_engine::Session;
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
use filmcraft_ui_egui::FilmcraftApp;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id.
const APP_ID: &str = "ai.storyteller.filmcraft";
@@ -80,7 +82,7 @@ fn main() -> eframe::Result {
let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
let launch = match args::parse(env_port, std::env::args_os().skip(1)) {
Ok(Cli::Run(l)) => l,
Ok(Cli::Help) => {
tell(args::USAGE, false);
@@ -95,7 +97,8 @@ fn main() -> eframe::Result {
std::process::exit(2);
}
};
let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR));
let data_root = launch.data_dir.clone().or_else(default_data_dir);
let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR));
log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
// The log file lives next to the crash logs; opened after the arguments, so `--help`,
// `--version` and usage errors leave no file behind. Records logged until now go in first.
@@ -118,165 +121,38 @@ fn main() -> eframe::Result {
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
register_hardware_decoders();
register_gpu_frame_renderer();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FilmCraft")
.with_inner_size([1600.0, 980.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
.with_icon(app_icon())
// Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`.
.with_app_id(APP_ID),
event_loop_builder: agent_event_loop(control_port.is_some()),
..Default::default()
// Which graphics backends and adapter the window starts with: `--graphics`, else the saved
// mode, which moves on after starts that died before their window painted (a driver crash),
// see graphics.rs.
let backend_override = eframe::wgpu::Backends::from_env();
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics);
// The GPU export renderer creates its own wgpu instance with every backend; after a start
// fell back, that would load the driver that failed. Exports then composite on the CPU.
if graphics.mode() == graphics::Mode::Default {
register_gpu_frame_renderer();
}
let started = loop {
let mut options = native_options(launch.control_port.is_some());
// Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs).
graphics::apply(&mut options, graphics.mode(), backend_override);
let app_created = Arc::new(AtomicBool::new(false));
let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created)));
let app_created = app_created.load(Ordering::Relaxed);
match &result {
// wgpu reported the failure (no adapter, device creation failed) before the app
// existed: try the next mode in this process.
Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => {
log::warn!("graphics start failed: {e}");
continue;
}
// Closed before the second frame: the window still came up.
Ok(()) if app_created => graphics.confirm(),
_ => {}
}
break result;
};
// Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs).
#[cfg(target_os = "windows")]
let options = {
let mut options = options;
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
options
};
let started = eframe::run_native(
"FilmCraft",
options,
Box::new(move |cc| {
let mut session = Session::default();
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
let mut cfg = AutosaveConfig::new(dir);
cfg.local_offset = local_offset;
if let Err(e) = session.start_autosave(cfg) {
log::warn!("auto-save and crash recovery unavailable: {e}");
}
}
// voice-over recording reads the microphone through cpal
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
match recent {
Some(p) => {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
}
None => {
let _ = session.execute("file.openDemoProject", json!({}));
}
}
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect();
if !media.is_empty() {
let _ = session.execute("file.import", json!({"paths": media}));
}
if recover == Some(true) && !session.recovery_candidates().is_empty() {
let id = session.recovery_candidates()[0].id.clone();
match session.execute("file.recover", json!({"id": id})) {
Ok(r) => log::info!("recovered {r}"),
Err(e) => log::error!("recovery failed: {e}"),
}
}
let mut app = FilmcraftApp::new(session);
if recover == Some(false) {
app.dialog = None;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
{
app.set_wgpu(rs);
}
// Keep device selection available even when the default device is unavailable.
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
app.audio = Some(Box::new(audio::CpalOut::new()));
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_folder_at =
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
app.hooks.open_path = Some(Box::new(open_path));
// Help menu, About and Discord links (#642): eframe is built without its `links`
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
// that waits for the browser never freezes the UI.
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
// compositor reports no theme to winit (no polling: see appearance.rs).
app.hooks.system_theme = appearance::service();
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
app.command_inbox = Some(rx);
app.hooks.shortcuts_changed = Some(update);
app.ui.show_menu_bar = false;
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
}
Ok(Box::new(app))
}),
);
// However it ended, this start no longer counts as one that died.
graphics.finish();
// The window could not be created (no usable GPU / graphics driver, no display): say so
// instead of exiting silently, and keep the reason in the crash log.
if let Err(e) = &started {
@@ -288,6 +164,189 @@ fn main() -> eframe::Result {
started
}
/// The main window's options; built for each start attempt (they hold a one-shot hook).
fn native_options(agent: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FilmCraft")
.with_inner_size([1600.0, 980.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(!agent)
.with_icon(app_icon())
// Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`.
.with_app_id(APP_ID),
event_loop_builder: agent_event_loop(agent),
..Default::default()
}
}
/// Creates the app once eframe has its window and graphics device; sets `created` when it runs,
/// and confirms the graphics mode once two frames have painted.
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
Box::new(move |cc| {
created.store(true, Ordering::Relaxed);
// A fallback graphics mode says so in the window, with a button back to the default.
let notice = graphics.degraded().then(|| {
let start = graphics.clone();
filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default()))
});
let confirmed = AtomicBool::new(false);
cc.egui_ctx.on_end_pass(
"graphics-start",
Arc::new(move |ui| {
if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) {
graphics.confirm();
}
}),
);
let mut session = Session::default();
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
let mut cfg = AutosaveConfig::new(dir);
cfg.local_offset = local_offset;
if let Err(e) = session.start_autosave(cfg) {
log::warn!("auto-save and crash recovery unavailable: {e}");
}
}
// voice-over recording reads the microphone through cpal
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
let (project, media) = args::project_and_media(&files);
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
match recent {
Some(p) => {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::error!("{e}");
}
}
None => {
let _ = session.execute("file.openDemoProject", json!({}));
}
}
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
if !media.is_empty() {
let _ = session.execute("file.import", json!({"paths": media}));
}
if recover == Some(true) && !session.recovery_candidates().is_empty() {
let id = session.recovery_candidates()[0].id.clone();
match session.execute("file.recover", json!({"id": id})) {
Ok(r) => log::info!("recovered {r}"),
Err(e) => log::error!("recovery failed: {e}"),
}
}
let mut app = FilmcraftApp::new(session);
app.graphics_notice = notice;
if recover == Some(false) {
app.dialog = None;
}
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
{
app.set_wgpu(rs);
}
// Keep device selection available even when the default device is unavailable.
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
app.audio = Some(Box::new(audio::CpalOut::new()));
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
// a full path (Quick Export, Export mode) opens the dialog in its folder
let path = std::path::Path::new(name);
let mut dialog = rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(path.file_name().map_or(name.into(), |n| n.to_string_lossy()));
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
dialog = dialog.set_directory(dir);
}
dialog.save_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_folder_at =
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
// Linux: the XDG portal call blocks until the user chooses, which would stop the frame
// loop (GNOME offers to force-quit the window after about five seconds, #260). Show the
// dialog on its own thread instead; the app keeps running and acts on the answer.
#[cfg(target_os = "linux")]
{
app.hooks.run_dialog = Some(Box::new(run_dialog_off_thread));
}
app.hooks.open_path = Some(Box::new(open_path));
// Help menu and About links (#642): eframe is built without its `links`
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
// that waits for the browser never freezes the UI.
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
// compositor reports no theme to winit (no polling: see appearance.rs).
app.hooks.system_theme = appearance::service();
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
app.command_inbox = Some(rx);
app.hooks.menu_changed = Some(update);
app.ui.show_menu_bar = false;
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
}
Ok(Box::new(app))
})
}
/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611).
fn clipboard_media() -> Option<filmcraft_ui_egui::ClipboardMedia> {
let mut clipboard = arboard::Clipboard::new().ok()?;
@@ -319,6 +378,7 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
}
c
} else {
// Linux file managers have no select-file flag: open the parent folder instead.
let mut c = std::process::Command::new("xdg-open");
let p = std::path::Path::new(path);
c.arg(if reveal { p.parent().unwrap_or(p) } else { p });
@@ -379,6 +439,80 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
None
}
/// Show `dialog` with rfd on a worker thread and answer on the returned channel (#260). Filter
/// names are translated here, on the UI thread (the interface language is per thread). A dialog
/// thread that can't start or panics drops the sender: the app takes that as cancelled.
#[cfg(target_os = "linux")]
fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> std::sync::mpsc::Receiver<Vec<String>> {
use filmcraft_ui_egui::native_dialogs::FileDialog;
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
let media = t("Media");
let project = t("FilmCraft Project");
let 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 || {
let path = |p: std::path::PathBuf| p.to_string_lossy().into_owned();
match dialog {
FileDialog::OpenFiles { exts } => {
rfd::FileDialog::new().add_filter(&media, &exts).pick_files().unwrap_or_default().into_iter().map(path).collect()
}
FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(),
FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(),
FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &project_exts).pick_file().map(path).into_iter().collect(),
FileDialog::SaveProject { name } => {
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()
}
FileDialog::Folder { at } => {
let d = rfd::FileDialog::new();
let d = match at {
Some(dir) if !dir.is_empty() => d.set_directory(dir),
_ => d,
};
d.pick_folder().map(path).into_iter().collect::<Vec<_>>()
}
}
});
match shown {
Ok(paths) => {
let _ = tx.send(paths);
}
Err(_) => log::warn!("the file dialog thread panicked; taken as cancelled"),
}
});
if let Err(e) = spawned {
log::warn!("could not start the file dialog thread: {e}");
}
rx
}
/// 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
@@ -392,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:?}");
}
}
+90 -22
View File
@@ -4,7 +4,7 @@
use std::sync::mpsc::{Receiver, channel};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items};
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items};
use muda::accelerator::Accelerator;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
@@ -19,34 +19,63 @@ fn accel(s: &str) -> Option<Accelerator> {
mapped.parse().ok()
}
/// Updates native key equivalents when the active keyboard shortcuts change.
pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
/// 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))
}
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, MenuUpdater) {
let items = menu_items(app);
let mut native: Vec<(String, MenuItem)> = Vec::new();
// each native item with the menu entry it currently shows, so updates touch only what changed
let mut native: Vec<(Item, MenuItem)> = Vec::new();
let mut titles: Vec<(String, Submenu)> = Vec::new();
let bar = Menu::new();
let app_menu = Submenu::new("FilmCraft", true);
let [settings_label, hide_label, hide_others_label, quit_label] = NATIVE_APP_MENU;
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
titles.push(("Settings".into(), settings.clone()));
let settings = Submenu::new(app.ui.language.tr(settings_label), true);
titles.push((settings_label.into(), settings.clone()));
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
let label = it.label.trim_end_matches('…').to_string();
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
native.push((it.id.clone(), mi.clone()));
let mi = MenuItem::with_id(it.id.clone(), label, it.enabled, it.shortcut.as_deref().and_then(accel));
native.push((it.clone(), mi.clone()));
let _ = settings.append(&mi);
}
// About repeats Help menu commands: same translated labels, updated with them
let label = |id: &str, english: &str| items.iter().find(|i| i.id == id).map_or_else(|| english.to_string(), |i| i.label.clone());
let about = MenuItem::with_id("app.about", label("app.about", "About FilmCraft"), true, None);
for (id, mi) in [("app.about", &about)] {
if let Some(it) = items.iter().find(|i| i.id == id) {
// recorded as built (enabled, no shortcut), so the first update applies the real state
native.push((Item { enabled: true, shortcut: None, ..it.clone() }, mi.clone()));
}
}
// the predefined items keep their native actions; our text lets them follow the language
let predefined = [
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
];
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(&[
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
&MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None),
&about,
&PredefinedMenuItem::separator(),
&settings,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
hide,
hide_others,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
&quit,
]);
let _ = bar.append(&app_menu);
for top in MENUS {
@@ -57,8 +86,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
// their first item appears
let mut subs: Vec<(Vec<String>, Submenu)> = Vec::new();
for it in mine {
let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel));
native.push((it.id.clone(), mi.clone()));
let mi = MenuItem::with_id(it.id.clone(), &it.label, it.enabled, it.shortcut.as_deref().and_then(accel));
native.push(((*it).clone(), mi.clone()));
let mut parent = sub.clone();
for depth in 1..it.path.len() {
let key = it.path[..=depth].to_vec();
@@ -91,7 +120,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
ctx.request_repaint();
}
});
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
let mut shown_language = app.ui.language;
let update: MenuUpdater = Box::new(move |items: &[Item]| {
let checked = |id: &str| items.iter().any(|it| it.id == id && it.checked == Some(true));
let language = if checked("app.language.japanese") {
filmcraft_ui_egui::i18n::Language::Ja
@@ -109,18 +139,56 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
filmcraft_ui_egui::i18n::Language::Ru
} else if checked("app.language.french") {
filmcraft_ui_egui::i18n::Language::Fr
} else if checked("app.language.indonesian") {
filmcraft_ui_egui::i18n::Language::Id
} else 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
};
for (title, submenu) in &titles {
submenu.set_text(language.tr(title));
if language != shown_language {
shown_language = language;
for (title, submenu) in &titles {
submenu.set_text(language.tr(title));
}
}
for (id, mi) in &native {
if let Some(it) = items.iter().find(|i| &i.id == id) {
mi.set_text(&it.label);
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 {
// Settings entries drop the trailing ellipsis, as when the menu was built
mi.set_text(if it.id.starts_with("app.settings.") { it.label.trim_end_matches('…') } else { &it.label });
}
if it.shortcut != shown.shortcut {
let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel));
}
if it.enabled != shown.enabled {
mi.set_enabled(it.enabled);
}
*shown = it.clone();
}
});
(rx, update)
}
#[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");
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ FilmCraft app icon (filmcraft.svg and every file derived from it in this directo
filmcraft-small.svg, filmcraft-1024.png, filmcraft-macos-512.png, filmcraft.icns, filmcraft.ico,
hicolor/, and apps/filmcraft-web/web/favicon.png).
Original artwork made for this project by the FilmCraft owner (Brandon Thomas) in ArtCraft, then
Original artwork made for this project by the FilmCraft owner (Brandon Thomas) by hand, then
vectorised. No third-party material. Licensed like the rest of FilmCraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+1 -1
View File
@@ -23,7 +23,7 @@ Exactly three colours:
## Provenance
The owner drew it in ArtCraft as a 2880 px engraving-style image. The original is kept in craftrules
The owner drew it by hand as a 2880 px engraving-style image. The original is kept in craftrules
at `craftrules/assets/app-icons/filmcraft/source.png` (not in this repo). It was vectorised with
craftrules `assets/logo-options/_tools/vectorize_tile.py`, which keys the drawing to the three-colour
palette and traces the ink with potrace. Licence: [LICENSE.txt](LICENSE.txt) (MIT OR Apache-2.0).
@@ -1,7 +1,7 @@
asset: filmcraft-1024.png
title: FilmCraft app icon, 1024 px render
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: filmcraft-macos-512.png
title: FilmCraft app icon, macOS runtime Dock icon (512 px)
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: filmcraft-small.svg
title: FilmCraft app icon, lighter vector
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
+1 -1
View File
@@ -1,7 +1,7 @@
asset: filmcraft.icns
title: FilmCraft app icon, macOS .icns
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
+1 -1
View File
@@ -1,7 +1,7 @@
asset: filmcraft.ico
title: FilmCraft app icon, Windows .ico (16-256 px)
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
+2 -2
View File
@@ -1,8 +1,8 @@
asset: filmcraft.svg
title: FilmCraft app icon, canonical vector (owl with ear tufts)
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
notes: SVG traced at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py from the owner's ArtCraft drawing (craftrules assets/app-icons/filmcraft/source.png). Three colours: ink #0b0b0c, paper #efe9dc, app colour #8b5cf6; 512 viewBox, rx=112. See assets/app-icon/README.md.
notes: SVG traced at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py from the owner's drawing (craftrules assets/app-icons/filmcraft/source.png). Three colours: ink #0b0b0c, paper #efe9dc, app colour #8b5cf6; 512 viewBox, rx=112. See assets/app-icon/README.md.
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 128 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 16 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 24 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 256 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 32 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 48 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 512 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.png
title: FilmCraft app icon, Linux hicolor theme 64 px
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
@@ -1,7 +1,7 @@
asset: ai.storyteller.filmcraft.svg
title: FilmCraft app icon, Linux hicolor theme scalable
author: Brandon Thomas (FilmCraft owner), for the FilmCraft project
source: original work (drawn by the owner in ArtCraft, then vectorised)
source: original work (drawn by the owner by hand, then vectorised)
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by Claude (agent) for Brandon Thomas
+14
View File
@@ -70,7 +70,21 @@ sync_github_name = false
[people.ossini]
sync_github_name = true
[people.opentechnepal]
sync_github_name = true
[people.vangel76]
real_name = "Valentin Klein"
display_name = "Valentin Klein"
sync_github_name = false
[people.n-delic]
display_name = "n-delic"
sync_github_name = false
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
+3
View File
@@ -48,6 +48,9 @@ from bitrate/quality), `tns`, `ms_stereo`, `window_shape` (sine/KBD), `adts` (pr
| 7 | 0 + PCE | C L R Ls Rs Cs LFE (PCE in the ASC; repeated in every AU when ADTS-framed) |
| 8 | 7 | C Lc Rc L R Ls Rs LFE |
The decoder also reads configurations 11 (6.1: C L R Ls Rs Cs LFE) and 12 (7.1: C L R Ls Rs Lsr Rsr
LFE) from ISO/IEC 14496-3 Amd 4; ffmpeg 9 writes 7.1 AAC as configuration 12.
`Decoder::layout()` reports the element layout (it follows a PCE for configuration 0).
## Encoder
+12 -2
View File
@@ -136,7 +136,8 @@ impl ProgramConfig {
}
}
/// Element layout of `channelConfiguration` 1..=7 (tags as conventionally assigned).
/// Element layout of `channelConfiguration` 1..=7, 11 and 12 (tags as conventionally assigned; 11 and
/// 12 from ISO/IEC 14496-3 Amd 4: 6.1 with a back centre, 7.1 with back surrounds).
pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
use ElementType::*;
Some(match channel_config {
@@ -146,7 +147,8 @@ pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
4 => vec![(Sce, 0), (Cpe, 0), (Sce, 1)],
5 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1)],
6 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Lfe, 0)],
7 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
7 | 12 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
11 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Sce, 1), (Lfe, 0)],
_ => return None,
})
}
@@ -388,6 +390,14 @@ mod tests {
assert_eq!(AudioSpecificConfig::parse(&a.to_bytes()).unwrap().channels(), 6);
}
#[test]
fn amd4_channel_configurations() {
// 6.1 and 7.1 back (#922); 22.2 and 7.1 with heights stay unsupported
let channels = |c| AudioSpecificConfig::parse(&AudioSpecificConfig::lc(3, c).to_bytes()).map(|a| a.channels()).ok();
assert_eq!([7, 11, 12].map(channels), [Some(8), Some(7), Some(8)]);
assert_eq!([13, 14].map(config_layout), [None, None]);
}
#[test]
fn asc_with_pce() {
let pce = ProgramConfig {
@@ -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 {
+4
View File
@@ -132,6 +132,10 @@ impl Biquad {
pub fn reset(&mut self) {
*self = Biquad::new();
}
/// Exact zero state, including the sign of zero, for identity-filter fast paths.
pub(crate) fn is_clear(&self) -> bool {
self.s1.to_bits() == 0 && self.s2.to_bits() == 0
}
/// Filter a buffer in place.
pub fn process(&mut self, c: &Coeffs, buf: &mut [f32]) {
for v in buf {
+167
View File
@@ -110,6 +110,57 @@ impl BandBank {
pub(crate) fn process(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
let n = block_len(channels);
let nch = channels.len().min(self.state.len());
if n == 0 {
return;
}
if !out_gain.is_smoothing()
&& self.bands.iter().all(|b| !b.dirty && !b.freq.is_smoothing() && !b.gain.is_smoothing() && !b.q.is_smoothing() && !b.mix.is_smoothing())
{
// Keep the exact per-sample arithmetic and recursive state; only the settled
// parameter/smoother bookkeeping is hoisted out of the hot loop.
let g = out_gain.value() as f64;
for (channel, state) in channels.iter_mut().take(nch).zip(&mut self.state) {
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
if b.mix.value() <= 0.0 {
st.reset();
}
}
// A settled unit transfer with zero state is exactly transparent for finite
// f32 input. Do not discard a tail or bypass a non-finite sample. Bounded
// coefficients also ensure intermediate f64 products cannot overflow.
let transparent = self.bands.iter().zip(state.iter()).all(|(b, st)| {
b.mix.value() <= 0.0
|| (st.is_clear()
&& b.coeffs.b0 == 1.0
&& b.coeffs.b1 == b.coeffs.a1
&& b.coeffs.b2 == b.coeffs.a2
&& b.coeffs.b1.abs() <= 4.0
&& b.coeffs.b2.abs() <= 4.0)
});
if transparent && channel.iter().take(n).all(|x| x.is_finite()) {
let active = self.bands.iter().any(|b| b.mix.value() > 0.0);
for sample in channel.iter_mut().take(n) {
let x = *sample as f64;
// An active identity biquad adds +0, which normalizes negative zero.
*sample = ((if active { x + 0.0 } else { x }) * g) as f32;
}
continue;
}
for sample in channel.iter_mut().take(n) {
let mut x = *sample as f64;
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
let m = b.mix.value() as f64;
if m > 0.0 {
let y = st.tick(&b.coeffs, x);
x += m * (y - x);
}
}
*sample = (x * g) as f32;
}
}
self.counter = (self.counter + (n % COEFF_INTERVAL as usize) as u32) % COEFF_INTERVAL;
return;
}
for i in 0..n {
if self.counter == 0 {
self.update_coeffs(false);
@@ -376,3 +427,119 @@ mod tests {
assert!((x[4799] - 0.5 * crate::db_to_gain(12.0)).abs() < 1e-4);
}
}
#[cfg(test)]
impl BandBank {
fn process_reference(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
let n = block_len(channels);
let nch = channels.len().min(self.state.len());
for i in 0..n {
if self.counter == 0 {
self.update_coeffs(false);
}
self.counter = (self.counter + 1) % COEFF_INTERVAL;
for b in &mut self.bands {
b.freq.tick();
b.gain.tick();
b.q.tick();
if b.freq.is_smoothing() || b.gain.is_smoothing() || b.q.is_smoothing() {
b.dirty = true;
}
}
let g = out_gain.tick();
// Tick the band mixes once per sample (shared by all channels).
let mut mixes = [0.0f32; MAX_BANDS];
for (m, b) in mixes.iter_mut().zip(self.bands.iter_mut()) {
*m = b.mix.tick();
}
for ch in 0..nch {
let mut x = channels[ch][i] as f64;
for (k, b) in self.bands.iter().enumerate() {
let m = mixes[k] as f64;
let st = &mut self.state[ch][k];
if m > 0.0 {
let y = st.tick(&b.coeffs, x);
x += m * (y - x);
} else {
st.reset();
}
}
channels[ch][i] = (x * g as f64) as f32;
}
}
}
}
#[cfg(test)]
mod throughput_tests {
use super::*;
#[test]
fn flat_eq_fast_path_preserves_samples_and_filter_tails() {
let mut fast = BandBank::new(48000.0, 2, 1);
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
fast.reset();
let mut reference = fast.clone();
let mut gain = Smoothed::new(1.0, 0);
let mut ref_gain = gain;
for input in
[vec![-0.0, 0.0, f32::MIN_POSITIVE, f32::from_bits(1), f32::MAX, -f32::MAX, 0.7], vec![0.0; 100], vec![f32::INFINITY, f32::NEG_INFINITY, f32::NAN]]
{
let mut a = [input.clone(), input];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar], &mut gain);
reference.process_reference(&mut [bl, br], &mut ref_gain);
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
assert_eq!(a.to_bits(), b.to_bits());
}
}
// A newly flat filter must drain its old state, not drop the tail.
fast.reset();
fast.set_band(0, true, FilterType::Peaking, 1000.0, 12.0, 0.7, true);
fast.update_coeffs(true);
fast.process(&mut [&mut [0.5], &mut [0.5]], &mut gain);
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
fast.update_coeffs(true);
reference = fast.clone();
let mut a = [vec![0.0; 4096], vec![0.0; 4096]];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar], &mut gain);
reference.process_reference(&mut [bl, br], &mut ref_gain);
assert_eq!(a, b);
assert!(a[0].iter().any(|x| *x != 0.0));
}
#[test]
fn settled_and_automated_eq_matches_original_loop() {
let mut fast = BandBank::new(48000.0, 2, 3);
fast.reset();
let mut reference = fast.clone();
let mut gain = Smoothed::with_ms(1.0, 48000.0, 20.0);
let mut ref_gain = gain;
for block in 0..500 {
if matches!(block, 2 | 90 | 180 | 270 | 360) {
let on = block != 180;
let db = if block == 270 { 0.0 } else { 9.0 };
for bank in [&mut fast, &mut reference] {
bank.set_band(0, on, FilterType::Peaking, 1733.0, db, 0.7, false);
}
gain.set(if block == 360 { 1.0 } else { 0.8 });
ref_gain.set(gain.target());
}
let n = [0, 1, 17, 64, 513][block % 5];
let input: Vec<f32> = (0..n).map(|i| if block > 400 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
let mut a = [input.clone(), input.clone()];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar], &mut gain);
reference.process_reference(&mut [bl, br], &mut ref_gain);
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
}
assert_eq!(fast.counter, reference.counter);
}
}
}
+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]
+167 -10
View File
@@ -5,12 +5,26 @@ use crate::{AudioEffect, ParamSpec, ParamValues, Smoothed, Unit, block_len, flus
/// Fractional read from a circular buffer: `delay` samples behind write position `w`.
#[inline]
fn read_frac(buf: &[f32], w: usize, delay: f32) -> f32 {
let len = buf.len();
read_prepared(buf, w, prepare_delay(buf.len(), delay))
}
#[inline]
fn prepare_delay(len: usize, delay: f32) -> (usize, f32) {
let d = delay.clamp(1.0, (len - 2) as f32);
let di = d.floor();
let frac = d - di;
let i0 = (w + len - di as usize) % len;
let i1 = (i0 + len - 1) % len;
// The delay is bounded by this ring's length, so wrapping needs no integer division.
// Keep the fallback for very large rings whose f32 length rounds upward.
let distance = di as usize;
let distance = if distance <= len { distance } else { distance % len };
(distance, frac)
}
#[inline]
fn read_prepared(buf: &[f32], w: usize, (distance, frac): (usize, f32)) -> f32 {
let len = buf.len();
let i0 = if w >= distance { w - distance } else { len - (distance - w) };
let i1 = if i0 == 0 { len - 1 } else { i0 - 1 };
buf[i0] + (buf[i1] - buf[i0]) * frac
}
@@ -103,7 +117,7 @@ impl AudioEffect for Delay {
buf[self.w] = flush32(x + fb * yd);
channels[ch][i] = x + mix * (yd - x);
}
self.w = (self.w + 1) % len;
self.w = if self.w + 1 == len { 0 } else { self.w + 1 };
}
}
}
@@ -132,6 +146,7 @@ pub struct Reverb {
len: [Smoothed; FDN],
lp: [f32; FDN],
gains: [f32; FDN],
gain_key: ([f32; FDN], f32),
damp: Smoothed,
rt60: Smoothed,
mix: Smoothed,
@@ -162,6 +177,7 @@ impl Reverb {
len: [sm(1.0); FDN],
lp: [0.0; FDN],
gains: [0.0; FDN],
gain_key: ([0.0; FDN], 0.0),
damp: sm(0.0),
rt60: sm(1.0),
mix: sm(0.0),
@@ -195,6 +211,11 @@ impl Reverb {
fn update_gains(&mut self) {
let rt = self.rt60.value().max(0.01);
let lengths = self.len.map(|l| l.value());
if self.gain_key == (lengths, rt) {
return;
}
self.gain_key = (lengths, rt);
for k in 0..FDN {
// Attenuation per pass so the loop decays 60 dB in rt seconds.
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
@@ -222,13 +243,20 @@ impl AudioEffect for Reverb {
let line_len = self.lines[0].len();
let pre_len = self.pre.len();
let hh = 2.0 / FDN as f32;
// During a settled block these positions cannot change. Preserve the original path
// while smoothing, and preserve the 32-sample gain update grid in both cases.
let fixed = self.len.iter().all(|l| !l.is_smoothing());
let delays = self.len.map(|l| prepare_delay(line_len, l.value()));
let predelay = (!self.pre_len.is_smoothing()).then(|| prepare_delay(pre_len, self.pre_len.value()));
for i in 0..n {
if self.counter == 0 {
self.update_gains();
}
self.counter = (self.counter + 1) % 32;
for l in &mut self.len {
l.tick();
if !fixed {
for l in &mut self.len {
l.tick();
}
}
self.rt60.tick();
let damp = self.damp.tick();
@@ -241,13 +269,16 @@ impl AudioEffect for Reverb {
}
mono /= nch as f32;
self.pre[self.pre_w] = mono;
let input = read_frac(&self.pre, self.pre_w, pd);
self.pre_w = (self.pre_w + 1) % pre_len;
let input = match predelay {
Some(delay) => read_prepared(&self.pre, self.pre_w, delay),
None => read_frac(&self.pre, self.pre_w, pd),
};
self.pre_w = if self.pre_w + 1 == pre_len { 0 } else { self.pre_w + 1 };
let mut v = [0.0f32; FDN];
let mut sum = 0.0;
for k in 0..FDN {
let d = read_frac(&self.lines[k], self.w, self.len[k].value());
let d = if fixed { read_prepared(&self.lines[k], self.w, delays[k]) } else { read_frac(&self.lines[k], self.w, self.len[k].value()) };
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
v[k] = self.lp[k] * self.gains[k];
sum += v[k];
@@ -259,7 +290,7 @@ impl AudioEffect for Reverb {
wl += SIGN_L[k] * v[k];
wr += SIGN_R[k] * v[k];
}
self.w = (self.w + 1) % line_len;
self.w = if self.w + 1 == line_len { 0 } else { self.w + 1 };
let (wl, wr) = (wl * 0.35, wr * 0.35);
for (c, ch) in channels.iter_mut().enumerate() {
let x = ch[i];
@@ -370,3 +401,129 @@ mod tests {
}
}
}
#[cfg(test)]
fn read_frac_reference(buf: &[f32], w: usize, delay: f32) -> f32 {
let len = buf.len();
let d = delay.clamp(1.0, (len - 2) as f32);
let di = d.floor();
let frac = d - di;
let i0 = (w + len - di as usize) % len;
let i1 = (i0 + len - 1) % len;
buf[i0] + (buf[i1] - buf[i0]) * frac
}
#[cfg(test)]
impl Reverb {
fn update_gains_reference(&mut self) {
let rt = self.rt60.value().max(0.01);
for k in 0..FDN {
// Attenuation per pass so the loop decays 60 dB in rt seconds.
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
}
}
fn process_reference(&mut self, channels: &mut [&mut [f32]]) {
let n = block_len(channels);
let nch = channels.len();
if nch == 0 {
return;
}
let line_len = self.lines[0].len();
let pre_len = self.pre.len();
let hh = 2.0 / FDN as f32;
for i in 0..n {
if self.counter == 0 {
self.update_gains_reference();
}
self.counter = (self.counter + 1) % 32;
for l in &mut self.len {
l.tick();
}
self.rt60.tick();
let damp = self.damp.tick();
let mix = self.mix.tick();
let pd = self.pre_len.tick();
let mut mono = 0.0;
for ch in channels.iter() {
mono += ch[i];
}
mono /= nch as f32;
self.pre[self.pre_w] = mono;
let input = read_frac_reference(&self.pre, self.pre_w, pd);
self.pre_w = (self.pre_w + 1) % pre_len;
let mut v = [0.0f32; FDN];
let mut sum = 0.0;
for k in 0..FDN {
let d = read_frac_reference(&self.lines[k], self.w, self.len[k].value());
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
v[k] = self.lp[k] * self.gains[k];
sum += v[k];
}
let (mut wl, mut wr) = (0.0, 0.0);
for k in 0..FDN {
let fbk = v[k] - hh * sum;
self.lines[k][self.w] = flush32(fbk + input * 0.5);
wl += SIGN_L[k] * v[k];
wr += SIGN_R[k] * v[k];
}
self.w = (self.w + 1) % line_len;
let (wl, wr) = (wl * 0.35, wr * 0.35);
for (c, ch) in channels.iter_mut().enumerate() {
let x = ch[i];
let wet = if nch == 1 {
0.5 * (wl + wr)
} else if c % 2 == 0 {
wl
} else {
wr
};
ch[i] = x + mix * (wet - x);
}
}
}
}
#[cfg(test)]
mod throughput_tests {
use super::*;
#[test]
fn circular_reads_match_modulo_reference() {
for len in [3, 4, 17, 257, 14078] {
let buf: Vec<f32> = (0..len).map(|i| (i as f32 * 0.17).sin()).collect();
for w in 0..len {
for delay in [-10.0, 0.0, 1.0, 1.25, 2.75, len as f32 - 2.0, len as f32, f32::INFINITY] {
assert_eq!(read_frac(&buf, w, delay).to_bits(), read_frac_reference(&buf, w, delay).to_bits());
}
}
}
}
#[test]
fn reverb_automation_and_tail_match_original_loop() {
let mut fast = Reverb::new(48000.0, 2);
let mut reference = Reverb::new(48000.0, 2);
for block in 0..800 {
if matches!(block, 1 | 80 | 200 | 320 | 440) {
for fx in [&mut fast, &mut reference] {
fx.set_param("size", (block % 101) as f32);
fx.set_param("decay", 0.1 + (block % 20) as f32);
fx.set_param("predelay", (block % 200) as f32);
}
}
if block == 600 {
fast.reset();
reference.reset();
}
let n = [0, 1, 17, 64, 513][block % 5];
let input: Vec<f32> = (0..n).map(|i| if block > 450 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
let mut a = [input.clone(), input];
let mut b = a.clone();
let [al, ar] = &mut a;
let [bl, br] = &mut b;
fast.process(&mut [al, ar]);
reference.process_reference(&mut [bl, br]);
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
}
}
}
}
+2 -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"
+141 -25
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
@@ -47,14 +56,14 @@ impl BridgeClient {
}
let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?;
let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) };
let result = loop {
let (result, offline) = loop {
tokio::time::sleep(JOB_POLL).await;
let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?;
let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else {
break Value::Null;
break (Value::Null, None);
};
if j["finished"].as_bool() == Some(true) {
break j["result"].clone();
break (j["result"].clone(), j.get("offlineMedia").cloned());
}
};
if let Some(e) = result.get("error").and_then(Value::as_str) {
@@ -62,27 +71,24 @@ impl BridgeClient {
}
if let Some(o) = start.as_object_mut() {
o.insert("result".into(), result);
if let Some(off) = offline {
o.insert("offlineMedia".into(), off);
}
}
Ok(start)
}
/// Call a control method; returns `result` or the app's error.
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
let mut guard = self.conn.lock().await;
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();
@@ -95,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)]
@@ -121,6 +138,11 @@ mod tests {
/// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports
/// running twice and then finished with `result`. Every request is recorded.
async fn fake_app(result: Value) -> (String, Arc<Mutex<Vec<Value>>>) {
fake_app_with(result, None).await
}
/// [`fake_app`] whose finished job also lists `offline` as its `offlineMedia`.
async fn fake_app_with(result: Value, offline: Option<Value>) -> (String, Arc<Mutex<Vec<Value>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let seen = Arc::new(Mutex::new(Vec::new()));
@@ -138,7 +160,11 @@ mod tests {
Some("jobs.list") => {
polls += 1;
let finished = polls >= 3;
json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}])
let mut j = json!({"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }});
if let Some(o) = offline.clone().filter(|_| finished) {
j["offlineMedia"] = o;
}
json!([j])
}
_ => json!(null),
};
@@ -165,6 +191,16 @@ mod tests {
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3);
}
/// The offline media a polled export drew comes back as in a blocking export's reply (#1054).
#[tokio::test]
async fn a_polled_export_reports_its_offline_media() {
let off = json!([{"item": 8, "status": "missing"}]);
let (addr, _) = fake_app_with(json!({"frames": 3}), Some(off.clone())).await;
let b = BridgeClient::new(addr).unwrap();
let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap();
assert_eq!(r["offlineMedia"], off, "{r}");
}
#[tokio::test]
async fn a_failed_blocking_export_is_an_error() {
let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await;
@@ -173,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() {
@@ -184,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");
}
}
+4
View File
@@ -72,6 +72,7 @@ impl FilmcraftMcp {
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
}
if finished {
let offline = state.as_ref().and_then(|j| j.get("offlineMedia").cloned());
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
if let Some(e) = result.get("error").and_then(Value::as_str) {
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
@@ -79,6 +80,9 @@ impl FilmcraftMcp {
let mut out = start;
if let Some(o) = out.as_object_mut() {
o.insert("result".into(), result);
if let Some(off) = offline {
o.insert("offlineMedia".into(), off);
}
}
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
}
+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}");
}
}
+19 -9
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.
@@ -70,16 +70,19 @@ with libdav1d: **bit-exact** on all 6 fixtures.
wasm32, or without the default `threads` feature, everything runs on the calling thread). Headers are parsed in order; each frame is
then decoded by a frame worker once the reference frames it reads are finished, so independent
frames (hierarchical GOP layers) decode concurrently; tiles of a frame decode in parallel into
private buffers that are merged afterwards. Results never depend on scheduling (the oracle tests
compare 1 thread, many threads and libdav1d). With frame threads, pictures may be returned by a
private buffers that are merged afterwards; the in-loop filters (deblocking, CDEF, loop
restoration) each run in parallel bands of 64 rows (`postfilter.rs` explains why the bands are
independent). Results never depend on scheduling (the oracle tests compare 1 thread, many threads
and libdav1d). With frame threads, pictures may be returned by a
later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it.
`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
(`Decoder::stats()`) and all-core fps for 1080p fixtures. The hot loops (sub-pixel filters,
`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.
@@ -122,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
+34 -19
View File
@@ -1,35 +1,45 @@
//! CDEF (7.15).
use crate::frame::{FrameBuf, Plane};
use crate::frame::{FrameBuf, RowBand};
use crate::par::Pool;
use crate::postfilter::bands;
use crate::spec_tables::*;
use crate::state::FrameState;
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
/// equals CurrFrame).
pub(crate) fn apply(fs: &FrameState) -> Option<FrameBuf> {
/// equals CurrFrame). Bands of 64 luma rows (whole 8x8 blocks) are filtered in parallel: every
/// block reads only CurrFrame and writes only its own samples.
pub(crate) fn apply(fs: &FrameState, pool: &Pool) -> Option<FrameBuf> {
let fh = &fs.fh;
if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef {
return None;
}
let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize);
let mut out = fs.cur.clone();
let mut any = false;
let mut r = 0;
while r < mi_rows {
let mut c = 0;
while c < mi_cols {
let idx = fs.cdef_idx(r & !15, c & !15);
if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) {
any = true;
let ranges = bands(pool, mi_rows * 4);
let mut planes = out.row_bands(&ranges);
let any = pool.map_mut(&mut planes, |b, band| {
let (start, end) = ranges[b];
let mut any = false;
let mut r = start / 4;
while r < mi_rows.min(end / 4) {
let mut c = 0;
while c < mi_cols {
let idx = fs.cdef_idx(r & !15, c & !15);
if idx >= 0 && cdef_block(fs, band, r, c, idx as usize) {
any = true;
}
c += 2;
}
c += 2;
r += 2;
}
r += 2;
}
any.then_some(out)
any
});
drop(planes);
any.contains(&true).then_some(out)
}
fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool {
fn cdef_block(fs: &FrameState, out: &mut [RowBand; 3], r: usize, c: usize, idx: usize) -> bool {
let mi = &fs.mi;
let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } };
let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1);
@@ -125,7 +135,7 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
}
#[allow(clippy::too_many_arguments)]
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
if pri == 0 && sec == 0 {
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
return;
@@ -169,7 +179,7 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
let dst = &mut out.planes[plane];
let dst = &mut out[plane];
if all_avail {
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
@@ -230,7 +240,12 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
/// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12
/// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width.
#[inline(always)]
fn filter_rows<const W: usize>(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(isize, i32, i32, u32); 12], dst: &mut Plane, (y0, x0, h): (usize, usize, usize)) {
fn filter_rows<const W: usize>(
buf: &[i32; PAD_STRIDE * PAD_STRIDE],
taps: &[(isize, i32, i32, u32); 12],
dst: &mut RowBand,
(y0, x0, h): (usize, usize, usize),
) {
let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE];
for (d, &s) in b16.iter_mut().zip(buf.iter()) {
*d = s as i16;
+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));
}
}
+89
View File
@@ -106,6 +106,48 @@ impl Plane {
dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s);
}
}
/// Disjoint mutable bands of whole rows, for filtering in parallel: one per `[start, end)`
/// range of rows (relative to `oy`; ascending and not overlapping, else the later band is
/// cut to what is left). Ranges are clipped to the stored rows.
pub fn row_bands(&mut self, ranges: &[(usize, usize)]) -> Vec<RowBand<'_>> {
let (stride, oy) = (self.stride.max(1), self.oy);
let mut out = Vec::with_capacity(ranges.len());
let mut rest = self.data.as_mut_slice();
// First row of `rest`.
let mut y = 0usize;
for &(start, end) in ranges {
let skip = start.saturating_sub(y).saturating_mul(stride).min(rest.len());
rest = std::mem::take(&mut rest).split_at_mut(skip).1;
y = y.saturating_add(skip / stride);
let len = end.saturating_sub(y).saturating_mul(stride).min(rest.len());
let (band, tail) = std::mem::take(&mut rest).split_at_mut(len);
out.push(RowBand { data: band, stride, y0: oy.saturating_add(y) });
y = y.saturating_add(len / stride);
rest = tail;
}
out
}
}
/// Mutable whole rows [y0, y0 + data.len() / stride) of a plane, addressed with the plane's
/// coordinates (see [`Plane::row_bands`]).
pub struct RowBand<'a> {
pub data: &'a mut [u16],
pub stride: usize,
pub y0: usize,
}
impl RowBand<'_> {
#[inline(always)]
pub fn set(&mut self, x: usize, y: usize, v: u16) {
self.data[(y - self.y0) * self.stride + x] = v;
}
#[inline(always)]
pub fn row_mut(&mut self, y: usize) -> &mut [u16] {
let o = (y - self.y0) * self.stride;
&mut self.data[o..o + self.stride]
}
}
/// A frame's three planes, sized to the macroblock-aligned coded area plus margins.
@@ -158,6 +200,16 @@ impl FrameBuf {
}
}
/// [`Plane::row_bands`] of all three planes for ranges of luma rows (chroma rows scaled
/// down): element `i` holds the bands of range `i`.
pub fn row_bands(&mut self, luma: &[(usize, usize)]) -> Vec<[RowBand<'_>; 3]> {
let ssy = self.subsampling_y;
let chroma: Vec<(usize, usize)> = luma.iter().map(|&(s, e)| (s >> ssy, e >> ssy)).collect();
let [p0, p1, p2] = &mut self.planes;
let (b1, b2) = (p1.row_bands(&chroma), p2.row_bands(&chroma));
p0.row_bands(luma).into_iter().zip(b1).zip(b2).map(|((y, u), v)| [y, u, v]).collect()
}
pub fn plane_width(&self, plane: usize) -> usize {
if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x }
}
@@ -285,3 +337,40 @@ impl MiInfo {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rows(b: &RowBand) -> (usize, usize) {
(b.y0, b.data.len() / b.stride)
}
#[test]
fn row_bands_split_whole_rows() {
let mut p = Plane::new(8, 20);
for (i, v) in p.data.iter_mut().enumerate() {
*v = (i / 8) as u16;
}
let bands = p.row_bands(&[(0, 8), (8, 16), (16, usize::MAX)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 8), (8, 8), (16, 4)]);
// Each band's row y holds the samples of plane row y.
for mut b in bands {
let y = b.y0 + 1;
assert!(b.row_mut(y).iter().all(|&v| v as usize == y));
}
// Gaps between bands (the loop filter's even / odd turns).
let bands = p.row_bands(&[(0, 4), (10, 14), (18, 30)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 4), (10, 4), (18, 2)]);
}
#[test]
fn row_bands_never_overlap_or_panic() {
let mut p = Plane::new(4, 10);
// Overlapping, descending and out-of-range ranges are cut to what is left.
let bands = p.row_bands(&[(2, 6), (4, 8), (1, 3), (9, 2), (50, 60), (usize::MAX, usize::MAX)]);
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(2, 4), (6, 2), (8, 0), (9, 0), (10, 0), (10, 0)]);
let mut empty = Plane::default();
assert!(empty.row_bands(&[(0, 64), (64, usize::MAX)]).iter().all(|b| b.data.is_empty()));
}
}
+138 -27
View File
@@ -1,20 +1,33 @@
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
//! super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::RowBand;
use crate::spec_tables::*;
use crate::state::FrameState;
use crate::stats::{DecodeStats, Stage, Timer};
/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used).
/// `draft`: only super-resolution (which sets the output size) runs.
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) {
/// Luma rows per band of the row-parallel filters: whole 64x64 CDEF filter blocks, and no
/// transform block crosses a band edge.
const BAND_ROWS: usize = 64;
/// The [start, end) luma rows of the bands covering `rows`: [`BAND_ROWS`] each (the last one
/// runs to the end of the plane), or one band for everything when `pool` is sequential.
pub(crate) fn bands(pool: &crate::par::Pool, rows: usize) -> Vec<(usize, usize)> {
let n = if pool.is_parallel() { rows.div_ceil(BAND_ROWS).max(1) } else { 1 };
(0..n).map(|b| (b * BAND_ROWS, if b + 1 == n { usize::MAX } else { (b + 1) * BAND_ROWS })).collect()
}
/// The post-filters run on the frame worker, each in row bands on `pool` (the result does not
/// depend on how the bands are scheduled). `draft`: only super-resolution (which sets the
/// output size) runs.
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, pool: &crate::par::Pool, draft: bool) {
let mut t = Timer::start();
let lvl = fs.fh.lf.level;
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
loop_filter(fs);
loop_filter(fs, pool);
}
stats.add(Stage::LoopFilter, t.lap());
let cdef = if draft { None } else { crate::cdef::apply(fs) };
let cdef = if draft { None } else { crate::cdef::apply(fs, pool) };
stats.add(Stage::Cdef, t.lap());
let (up_cur, up_cdef) = if fs.fh.use_superres {
let c = crate::restoration::upscale(fs, &fs.cur);
@@ -25,7 +38,7 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
};
stats.add(Stage::Superres, t.lap());
fs.cur = if fs.fh.lr.uses_lr && !draft {
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur), pool)
} else {
up_cdef.unwrap_or(up_cur)
};
@@ -35,28 +48,102 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
// ---------------------------------------------------------------------------------------------
// Loop filter (7.14)
fn loop_filter(fs: &mut FrameState) {
let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize);
for plane in 0..fs.num_planes {
if plane == 0 || fs.fh.lf.level[1 + plane] != 0 {
for pass in 0..2 {
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
let mut row = 0;
while row < mi_rows {
let mut col = 0;
while col < mi_cols {
edge(fs, plane, pass, row, col);
col += col_step;
}
row += row_step;
/// Luma rows above a band that its horizontal edges read or write (p6 .. p0 of the widest
/// filter, rounded up to whole 4x4 rows); chroma: `LF_MARGIN >> subsampling_y`.
const LF_MARGIN: usize = 8;
/// The loop filter in row bands, all of a frame's in one parallel pass (each join costs a
/// worker wake-up, which is slow on a busy machine).
///
/// Vertical edges (pass 0) only touch the 4 rows of their own edge. A horizontal edge (pass 1)
/// reads and writes at most `filter_size / 2` samples on each side, never more than the
/// transform blocks on either side are high, so no two edges touch the same sample and
/// any order gives the spec's raster-order result. Transform blocks never cross a band edge, so
/// the only edges reaching outside a band are those on its top row, whose p side lies in the
/// band above. Each band therefore also filters the vertical edges of a copy of the
/// `LF_MARGIN` rows above it (the same work as the band above does on them), runs its top
/// horizontal edges against that copy, and afterwards the samples those edges changed are
/// written back; the band above never touches them.
fn loop_filter(fs: &mut FrameState, pool: &crate::par::Pool) {
let luma = bands(pool, fs.fh.mi_rows as usize * 4);
let planes: Vec<usize> = (0..fs.num_planes).filter(|&p| p == 0 || fs.fh.lf.level[1 + p] != 0).collect();
let mut cur = std::mem::take(&mut fs.cur);
let f: &FrameState = fs;
// Unfiltered copies of the rows above each band but the first.
let margins: Vec<[Vec<u16>; 3]> = luma
.iter()
.map(|&(start, _)| {
std::array::from_fn(|p| {
let sub_y = if p == 0 { 0 } else { f.ssy };
let pl = &cur.planes[p];
let (y0, y1) = ((start.saturating_sub(LF_MARGIN)) >> sub_y, start >> sub_y);
let rows = pl.data.get(y0 * pl.stride..y1 * pl.stride);
if start == 0 || !planes.contains(&p) { Vec::new() } else { rows.map(<[u16]>::to_vec).unwrap_or_default() }
})
})
.collect();
let mut bands: Vec<([RowBand; 3], [Vec<u16>; 3])> = cur.row_bands(&luma).into_iter().zip(margins).collect();
let changed = pool.map_mut(&mut bands, |b, (band, margin)| {
let mut changed: [Vec<(usize, u16)>; 3] = Default::default();
for &p in &planes {
changed[p] = filter_band(f, p, luma[b], &mut band[p], &mut margin[p]);
}
changed
});
drop(bands);
for (&(start, _), ch) in luma.iter().zip(&changed) {
for (p, list) in ch.iter().enumerate() {
let sub_y = if p == 0 { 0 } else { f.ssy };
let pl = &mut cur.planes[p];
let first = (start.saturating_sub(LF_MARGIN) >> sub_y) * pl.stride;
for &(i, v) in list {
if let Some(d) = pl.data.get_mut(first + i) {
*d = v;
}
}
}
}
fs.cur = cur;
}
fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) {
/// Both loop filter passes over the band of `plane` holding luma rows [start, end); `margin` is
/// an unfiltered copy of the rows above the band (empty for the first band). Returns the samples
/// of `margin` that the band's top horizontal edges changed, as (index, value).
fn filter_band(fs: &FrameState, plane: usize, (start, end): (usize, usize), band: &mut RowBand, margin: &mut [u16]) -> Vec<(usize, u16)> {
let mi_rows = fs.fh.mi_rows as usize;
let (r0, r1) = (start / 4, mi_rows.min(end / 4));
let sub_y = if plane == 0 { 0 } else { fs.ssy };
let edges = |band: &mut RowBand, mut above: Option<&mut RowBand>, pass: usize, rows: std::ops::Range<usize>| {
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
let mi_cols = fs.fh.mi_cols as usize;
let mut row = rows.start;
while row < rows.end {
let mut col = 0;
while col < mi_cols {
edge(fs, band, above.as_deref_mut(), plane, pass, row, col);
col += col_step;
}
row += row_step;
}
};
edges(band, None, 0, r0..r1);
if margin.is_empty() {
edges(band, None, 1, r0..r1);
return Vec::new();
}
let stride = band.stride;
let y0 = start.saturating_sub(LF_MARGIN) >> sub_y;
let mut above = RowBand { data: margin, stride, y0 };
edges(&mut above, None, 0, r0.saturating_sub(LF_MARGIN / 4)..r0);
let before = above.data.to_vec();
edges(band, Some(&mut above), 1, r0..r1);
above.data.iter().zip(&before).enumerate().filter(|(_, (a, b))| a != b).map(|(i, (&a, _))| (i, a)).collect()
}
/// One loop filter edge (7.14.2) at mode info position (row, col): 4 lines of samples across
/// it. `above` holds the rows above `band` for horizontal edges on the band's top row.
fn edge(fs: &FrameState, band: &mut RowBand, above: Option<&mut RowBand>, plane: usize, pass: usize, row: usize, col: usize) {
let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) };
let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) };
let x = col * 4;
@@ -102,16 +189,40 @@ fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize)
return;
}
let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth);
let pl = &mut fs.cur.planes[plane];
let stride = pl.stride;
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
let base = yp * stride + xp;
let chroma = plane > 0;
let stride = band.stride;
if let Some(above) = above.filter(|_| pass == 1 && yp == band.y0) {
// The p side is in the rows above the band: filter each line in a copy that spans both.
let (n_above, n_band) = (above.data.len() / stride.max(1), band.data.len() / stride.max(1));
for k in 0..4 {
let x = xp + k;
let mut line = [0u16; 2 * LF_LINE];
for j in 1..=n_above.min(LF_LINE) {
line[LF_LINE - j] = above.data[(n_above - j) * stride + x];
}
for j in 0..n_band.min(LF_LINE) {
line[LF_LINE + j] = band.data[j * stride + x];
}
sample_filter(&mut line, LF_LINE, 1, chroma, filter_size, t);
for j in 1..=n_above.min(LF_LINE) {
above.data[(n_above - j) * stride + x] = line[LF_LINE - j];
}
for j in 0..n_band.min(LF_LINE) {
band.data[j * stride + x] = line[LF_LINE + j];
}
}
return;
}
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
let base = (yp - band.y0) * stride + xp;
for k in 0..4 {
sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t);
sample_filter(band.data, base + k * along, step, chroma, filter_size, t);
}
}
/// Samples on each side of an edge that the sample filter can read (at most p6 .. q6).
const LF_LINE: usize = 8;
/// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh).
fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) {
let mi = &fs.mi;
+53 -41
View File
@@ -1,6 +1,8 @@
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
use crate::frame::{FrameBuf, Plane};
use crate::frame::{FrameBuf, Plane, RowBand};
use crate::par::Pool;
use crate::postfilter::bands;
use crate::spec_tables::*;
use crate::state::FrameState;
@@ -70,56 +72,66 @@ impl LrCtx<'_> {
}
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
/// Bands of 64 luma rows are restored in parallel: every unit reads only the two input frames
/// and writes only its own samples.
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, pool: &Pool) -> FrameBuf {
let fh = &fs.fh;
let mut lr = cdef.clone();
let up_w = fh.upscaled_width as i32;
let fr_h = fh.frame_height as i32;
let bd = fs.bit_depth;
for plane in 0..fs.num_planes {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = 0;
while row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
_ => {}
}
}
col += 1;
let ranges = bands(pool, fh.frame_height as usize);
let mut planes = lr.row_bands(&ranges);
pool.map_mut(&mut planes, |b, band| {
// Rows of 4 luma samples in this band.
let (start, end) = ranges[b];
let (row0, row1) = ((start / 4) as i32, (end / 4).min(fh.frame_height as usize) as i32);
for (plane, out) in band.iter_mut().enumerate().take(fs.num_planes) {
let rtype_frame = fh.lr.frame_restoration_type[plane];
if rtype_frame == RESTORE_NONE as u8 {
continue;
}
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
let unit_rows = fs.lr_unit_rows[plane] as i32;
let unit_cols = fs.lr_unit_cols[plane] as i32;
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
let mut row = row0;
while row < row1 && row * 4 < fr_h {
let luma_y = row * 4;
let stripe_num = (luma_y + 8) / 64;
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
let mut col = 0;
while col * 4 < up_w {
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
let x = (col * 4) >> sub_x;
let y = (row * 4) >> sub_y;
let w = (4 >> sub_x).min(plane_end_x - x + 1);
let h = (4 >> sub_y).min(plane_end_y - y + 1);
if w > 0 && h > 0 {
let ui = (unit_row * unit_cols + unit_col) as usize;
match fs.lr.lr_type[plane][ui] {
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, plane, ui, x, y, w, h, bd),
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, plane, ui, x, y, w, h, bd),
_ => {}
}
}
col += 1;
}
row += 1;
}
row += 1;
}
}
});
drop(planes);
lr
}
#[allow(clippy::too_many_arguments)]
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
let round0: u32 = if bd == 12 { 5 } else { 3 };
let round1: u32 = if bd == 12 { 9 } else { 11 };
let coef = &fs.lr.lr_wiener[plane][ui];
@@ -163,7 +175,7 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
}
#[allow(clippy::too_many_arguments)]
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
let mut flt0 = [[0i32; 4]; 4];
let mut flt1 = [[0i32; 4]; 4];
+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]
@@ -16,6 +16,11 @@ pub fn ffmpeg_dav1d() -> Option<PathBuf> {
ffmpeg_with(&[("-decoders", "libdav1d")])
}
/// ffmpeg with libaom (4:2:2 and 4:4:4 fixtures, which SVT-AV1 cannot make) and libdav1d.
pub fn ffmpeg_libaom() -> Option<PathBuf> {
ffmpeg_with(&[("-encoders", "libaom-av1"), ("-decoders", "libdav1d")])
}
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
@@ -38,9 +43,11 @@ pub fn run(ff: &Path, args: &[&str]) {
assert!(out.status.success(), "ffmpeg {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
}
/// An AV1 fixture: a lavfi source encoded by ffmpeg's libsvtav1 into IVF.
/// An AV1 fixture: a lavfi source encoded by ffmpeg (libsvtav1 unless [`Spec::encoder`] says
/// otherwise) into IVF.
pub struct Spec {
pub name: &'static str,
pub encoder: &'static str,
pub lavfi: String,
pub frames: u32,
pub pix_fmt: &'static str,
@@ -50,7 +57,12 @@ pub struct Spec {
impl Spec {
pub fn new(name: &'static str, lavfi: impl Into<String>, frames: u32, pix_fmt: &'static str, enc: &[&str]) -> Spec {
Spec { name, lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
Spec { name, encoder: "libsvtav1", lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
}
pub fn encoder(mut self, encoder: &'static str) -> Spec {
self.encoder = encoder;
self
}
}
@@ -60,7 +72,7 @@ pub fn make(ff: &Path, spec: &Spec) -> PathBuf {
let tmp = filmcraft_testkit::temp_path(&path);
let frames = spec.frames.to_string();
let t = tmp.to_str().unwrap().to_string();
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", "libsvtav1"];
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", spec.encoder];
args.extend(spec.enc.iter().map(String::as_str));
args.extend(["-f", "ivf", t.as_str()]);
run(ff, &args);
@@ -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> {
@@ -101,3 +101,19 @@ fn inter_1080p() {
check_bit_exact(&ff, &s);
}
}
/// 4:2:2 and 4:4:4 (libaom; SVT-AV1 only makes 4:2:0): the post-filters run in row bands whose
/// chroma rows depend on the vertical subsampling, so these check the band edges there too.
#[test]
fn inter_chroma_444_422() {
let Some(ff) = ffmpeg_libaom() else { return };
let p = ["-cpu-used", "8", "-crf", "40", "-enable-restoration", "1"];
let src = "testsrc2=s=416x400:r=25,noise=alls=8:allf=t";
for s in [
Spec::new("inter_aom_444", src, 6, "yuv444p", &p).encoder("libaom-av1"),
Spec::new("inter_aom_422", src, 6, "yuv422p", &p).encoder("libaom-av1"),
Spec::new("inter_aom_444_10bit", src, 6, "yuv444p10le", &p).encoder("libaom-av1"),
] {
check_bit_exact(&ff, &s);
}
}
@@ -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;
@@ -51,8 +51,8 @@ fn run(frames: &[Vec<u8>], threads: usize) -> Run {
}
fn bench(spec: &Spec) {
// AV1_PERF_ONLY=<substring> limits the run to matching fixtures.
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !spec.name.contains(f.as_str())) {
// AV1_PERF_ONLY=<substring>[,<substring>...] limits the run to matching fixtures.
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !f.split(',').any(|f| spec.name.contains(f))) {
return;
}
let Some(ff) = ffmpeg() else { return };
@@ -68,7 +68,6 @@ fn bench(spec: &Spec) {
}
}
let b = best.unwrap();
let n = b.stats.frames.max(1) as f64;
let cpu_fps = b.cpu.map(|c| format!("{:6.1}", b.shown as f64 / c)).unwrap_or_else(|| " ?".into());
println!(
"{:<26} {:>3} shown {:>3} decoded {:>3} tiles/frame | 1 thread: {} fps (thread CPU) {:6.1} fps (wall)",
@@ -79,24 +78,35 @@ fn bench(spec: &Spec) {
cpu_fps,
b.shown as f64 / b.wall
);
let total = b.stats.total_secs();
let mut line = String::from(" ms/frame:");
println!("{}", stage_line(" ms/frame:", &b.stats));
// All cores: best of 3 by wall time.
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let mut best: Option<Run> = None;
for _ in 0..3 {
let r = run(&frames, threads);
assert_eq!(r.shown, b.shown);
if best.as_ref().is_none_or(|b| r.wall < b.wall) {
best = Some(r);
}
}
let m = best.unwrap();
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / m.wall, load_average());
// Per-stage wall time on the frame workers (stages of different frames overlap).
println!("{}", stage_line(&format!(" {threads} threads, ms/frame:"), &m.stats));
}
/// Per-stage milliseconds per frame and share of the total.
fn stage_line(label: &str, stats: &DecodeStats) -> String {
let n = stats.frames.max(1) as f64;
let total = stats.total_secs();
let mut line = String::from(label);
for s in Stage::ALL {
let v = b.stats.secs(s);
let v = stats.secs(s);
if v > 0.0005 {
line += &format!(" {} {:.2} ({:.0}%)", s.name(), 1e3 * v / n, 100.0 * v / total);
}
}
println!("{line}");
// All cores: best of 3 by wall time.
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let mut best_wall = f64::INFINITY;
for _ in 0..3 {
let r = run(&frames, threads);
assert_eq!(r.shown, b.shown);
best_wall = best_wall.min(r.wall);
}
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / best_wall, load_average());
line
}
#[test]
@@ -110,4 +120,9 @@ fn decode_throughput() {
// Higher quality (more coefficients, finer partitions) and a multi-tile stream (4x2 tiles).
bench(&Spec::new("perf_1080p_gop_hq", src, 60, "yuv420p", &p("18", "keyint=60")));
bench(&Spec::new("perf_1080p_gop_tiles", src, 60, "yuv420p", &p("30", "keyint=60:tile-columns=2:tile-rows=1")));
// Low delay (each frame references the previous one, as screen and camera recorders encode):
// frames cannot overlap, so a frame's own parallelism decides the speed.
bench(&Spec::new("perf_1080p_lowdelay", src, 60, "yuv420p", &p("30", "keyint=60:pred-struct=1")));
// One tile at 2160p: the post-filters' row bands are most of the parallelism there.
bench(&Spec::new("perf_2160p_gop", "testsrc2=s=3840x2160:r=25,noise=alls=6:allf=t", 30, "yuv420p", &p("30", "keyint=30")));
}
+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):
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "filmcraft-avi"
description = "Clean-room AVI (RIFF) and OpenDML demuxer"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
[dev-dependencies]
filmcraft-testkit = { workspace = true }

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