Compare commits

..
Author SHA1 Message Date
Brandon Thomas 4f06306e45 Merge pull request #31 from storytold/pr/release-0.2.0
Release: FilmCraft v0.2.0
2026-10-05 02:45:48 -04:00
Brandon ThomasandClaude Opus 5.5 798b1591ba Release: FilmCraft v0.2.0
Bump the workspace version 0.1.1 -> 0.2.0 (cargo xtask version set 0.2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:45:36 -04:00
Brandon Thomas 694e40546c Merge pull request #27 from storytold/pr/never-crash-clean-crates
Never crash: deny the panicking lints in the already-clean crates and apps
2026-10-04 16:19:27 -04:00
Brandon Thomas 72edec524d Merge pull request #26 from storytold/pr/never-crash-ui
ui-egui: never crash (panels without expect/unreachable)
2026-10-04 16:19:08 -04:00
Brandon Thomas bc57ebee66 Merge pull request #25 from storytold/pr/never-crash-guard
engine: last-resort guard around commands and background jobs
2026-10-04 16:18:49 -04:00
Brandon Thomas 0171afa89d Merge pull request #24 from storytold/pr/never-crash-engine
engine, edit: never crash (commands without expect/unwrap/panic)
2026-10-04 16:18:29 -04:00
Brandon Thomas b081fd20df Merge pull request #21 from storytold/pr/never-crash-export-render
render, gpu, cli: never crash (no expect/unreachable, deny the panicking lints)
2026-10-04 16:18:11 -04:00
Brandon Thomas 1fb70e6841 Merge pull request #22 from storytold/pr/never-crash-h264enc
h264enc: never crash (encode returns Result; no unwrap/expect/unreachable)
2026-10-04 16:17:52 -04:00
Brandon Thomas 9cf892a7b0 Merge pull request #20 from storytold/pr/never-crash-importers
Importers: never crash (interchange, captions, project, text, automation)
2026-10-04 16:17:33 -04:00
Brandon Thomas de57e91c80 Merge pull request #18 from storytold/pr/never-crash-codecs
codecs: never crash (damaged MP4/TS headers; no expect/unreachable)
2026-10-04 16:17:14 -04:00
Brandon Thomas 28563e8030 Merge pull request #16 from storytold/pr/never-crash-codecs-small
Codecs: never crash (ac3, audio-dsp, opus, mpeg2v, prores, dnx)
2026-10-04 16:16:55 -04:00
Brandon Thomas e62fabb1ea Merge pull request #15 from storytold/pr/never-crash-containers
Containers: never crash (mpegts, ogg, mxf, matroska)
2026-10-04 16:16:36 -04:00
Brandon Thomas 08ebb8fd46 Merge pull request #14 from storytold/pr/never-crash-av1
av1: never crash (no expect, deny the panicking lints)
2026-10-04 16:16:18 -04:00
Brandon Thomas a366c41d7d Merge pull request #13 from storytold/pr/never-crash-hevc
hevc: never crash (no unwrap/expect, deny the panicking lints)
2026-10-04 16:15:58 -04:00
Brandon Thomas 4436d4d887 Merge pull request #12 from storytold/pr/never-crash-h264
h264: never crash (no unwrap/expect, deny the panicking lints)
2026-10-04 16:15:39 -04:00
Brandon Thomas 51e5adc627 Merge pull request #11 from storytold/pr/never-crash-vp9
vp9: never crash (no unwrap/expect/panic, deny them)
2026-10-04 16:15:19 -04:00
Brandon Thomas 9fdb8598a3 Merge pull request #17 from storytold/pr/never-crash-time
time: never crash (zero rates and timebases, huge timecodes)
2026-10-04 16:15:00 -04:00
Brandon ThomasandClaude Opus 5.5 b16e0889f3 Never crash: deny the panicking lints in the already-clean crates and apps
aac, bitstream, cfb, color, format, frame, geom, isobmff, media, scopes, speech, the desktop
app and the web app have no production unwrap/expect/panic/unreachable (the web app's two
`expect`s — frame-cache eviction and chunked file reads — become `let … else`); each now
carries `#![deny(clippy::unwrap_used, …)]` outside tests so it stays that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:23 -04:00
Brandon ThomasandClaude Opus 5.5 d40a371724 ui-egui: never crash — panels without expect/unreachable
- Timeline, monitors, Reference, Effect Controls and Export mode: a missing active sequence,
  track or clip (stale ids from a damaged project, or a clip deleted mid-drag) shows the empty
  state or skips the row instead of `expect`.
- Frame queue pops without `expect`; Graphics text editing, clip dialogs, Lumetri panel and
  Project rename without `expect`/`unreachable!`.
- The deliberate fault-injection panics (UI pass and frame jobs, used to test the guard) go
  through one documented `crash::injected_fault`.
- `#![deny(clippy::unwrap_used, …)]` outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:07:12 -04:00
Brandon ThomasandClaude Opus 5.5 3a392ae8da Clippy 1.99: fix new lints so cargo xtask ci is green again
Rust/clippy 1.99 added `chunks_exact_to_as_chunks` and tightened
`needless_late_init` and `drain_collect`, failing `clippy -D warnings`
on main.

- `chunks_exact(N)` / `chunks_exact_mut(N)` with a constant N become
  `as_chunks::<N>()` / `as_chunks_mut::<N>()` (fixed-size arrays, so
  several byte reads drop their per-element indexing). relink's
  fingerprint uses the `(chunks, remainder)` split; its hash output is
  unchanged.
- Late-initialised `let`s in ac3 bit allocation and opus CELT
  bands/rate become `let x = if/match …`.
- `dpb.drain(..).collect()` becomes `std::mem::take(&mut dpb)`.

No behaviour change; `cargo xtask ci` passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:55:23 -04:00
Brandon ThomasandClaude Opus 5.5 75ed688e09 engine: last-resort guard around commands and background jobs
The UI already turns a panic in the UI pass into an error window (ui-egui `crash` hook +
`catch_unwind` around the frame), and frame workers survive panics. Two gaps remained:
- commands run headless (CLI, MCP over stdio) or over the control channel had no guard: a panic
  in one command ended the process and the unsaved session;
- background jobs (export, render previews/audio, proxies, Project Manager, mask tracking,
  scene detection) ran their body unguarded: a panic killed the thread and left the job
  "running" forever.

`Session::execute` now runs the command under `catch_unwind` and returns
`EngineError::Other("internal error in `<id>` …")`; every job body is wrapped by
`export_tools::guard_job`, which marks the job finished with an error. Regression test
`export_tools::guard_tests::panicking_job_is_reported_as_failed`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:23:18 -04:00
Brandon ThomasandClaude Opus 5.5 31ae72f015 engine, edit: never crash — commands without expect/unwrap/panic
- Commands: place/nest/sequence settings/import/multicam/subclip/audio channels/extract audio,
  graphics reset duration, dynamic trim and Make Subsequence return errors instead of `expect`
  when an item, sequence or track is missing (stale ids from a damaged project or a racing
  agent); File ▸ Import detects caption/interchange documents without a second `expect`.
- `find_spec` (graphics) returns `UnknownCommand` instead of `expect`.
- Built-in effect lookups (presets, demo project) fall back to a bare instance instead of
  `panic!`/`expect`; the demo project falls back to an empty one.
- Multicam record base, shortcut presets and field parsing without `expect`.
- edit: caption merge/move and transcript caption blocks without `expect`.
- Both crates deny unwrap/expect/panic/unimplemented/todo/unreachable outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:02:01 -04:00
Brandon ThomasandClaude Opus 5.5 b05833ef67 h264enc: never crash — no unwrap/expect/unreachable, encode returns Result
- `Encoder::encode` returns `Result` (it was `try_encode(..).expect(..)`, panicking on short
  planes); tests and the example unwrap explicitly.
- P/B macroblock analysis without a reference codes intra instead of `expect`/`unwrap`;
  spatial direct without a list-1 picture predicts zero motion.
- Rate control prior, DPB sliding window, DTS queue, reconstruction buffers, CAVLC writer,
  SAD rows and forward 8x8 transforms without `unwrap`/`expect`; skip-macroblock arms in
  `write_mb` debug-assert instead of `unreachable!`.
- `#![deny(clippy::unwrap_used, …)]` outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:14:06 -04:00
Brandon ThomasandClaude Opus 5.5 5628fc55bc render, gpu, cli: never crash — no expect/unreachable, deny the panicking lints
- gpu: `composite` returns its output view by value (a cheap handle clone) taken from the
  render-target setup, so the accumulation/output views no longer `expect` their slots.
- render: lightning bolt subdivision, tracker pyramid and the Lumetri preset instance without
  `expect`.
- cli: dotted parameter insertion without `expect`; the render/mcp backend arms report a usage
  error instead of `unreachable!`.
- Each crate denies unwrap/expect/panic/unimplemented/todo/unreachable outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:04:42 -04:00
Brandon ThomasandClaude Opus 5.5 f0e6b12819 Importers: never crash — interchange, captions, project, text, automation
- interchange: exporters (FCP7 XML, FCPXML, OTIO) skip a missing nested sequence or media with a
  report warning instead of `expect`/`unwrap`; EDL event grouping and M2 lines, the XML writer
  and the AAF/OMF dispatch arms have no panics. The WAVE header saturates instead of overflowing
  for >4 GiB descriptors (found by fuzzing: a mutated EDL exported to OMF), regression test
  `wav::tests::huge_wav_header_saturates`.
- captions: EBU STL frame rate read with `get()`; MCC rows, TTML text and the STL dispatch arm
  without `expect`/`unreachable!`.
- project: bin moves, 5.1 pan lanes and text/shape layer constructors without `expect`.
- text: font resolution and line layout without `expect` (the bundled-face lookup keeps one
  documented `#[allow(clippy::expect_used)]`: the faces are compiled in).
- automation: the MCP bridge returns an error instead of `expect` when not connected.
- Each crate denies unwrap/expect/panic/unimplemented/todo/unreachable outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:38:13 -04:00
Brandon ThomasandClaude Opus 5.5 6aa3c37342 codecs: never crash — damaged MP4/TS headers, no expect/unreachable
Mutation fuzzing (mutated ffmpeg-made files through `open_bytes`) found three import crashes:
- an MP4/MOV track with an empty sample-description list: `entries[0]` index out of bounds
  (video and audio paths);
- an MP4/MOV track with timescale 0: division by zero in the duration;
- an MPEG-TS stream whose PTS span gave a ~0 fps rate: division by zero in `tick_of`.

Tracks now need samples, a sample description and a timescale to count as playable; frame rates
that round to zero fall back to the default; the TS/PS rate choice is a small function that never
returns a zero rate. Regression tests: `mp4::tests::damaged_track_headers_never_panic`,
`mpeg::tests::damaged_timestamps_never_give_a_zero_rate`.

Also: GOP cache, MKV/MP4/Ogg/MPEG audio paths, MXF and VP9 conversion without
`expect`/`unreachable!`; the crate denies the panicking lints outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:20:30 -04:00
Brandon ThomasandClaude Opus 5.5 0f678aa105 h264: dequantisation wraps instead of overflowing on huge levels
Mutation fuzzing found a corrupt H.264 transport stream whose chroma DC levels overflowed
`chroma_dc_dequant_420` ("attempt to multiply with overflow" in debug builds, a decoder worker
panic). The 4x4 / 8x8 / DC dequantisers now use wrapping arithmetic, which is what release
builds always computed. Regression test `huge_levels_dequantise_without_overflow_panics`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:56:22 -04:00
Brandon ThomasandClaude Opus 5.5 953f0aca8b time: never crash — total conversions for zero rates, saturating timecodes
Mutation fuzzing found two crashes on import: an MP4 track with timescale 0 panicked in
`Tick::from_rational` (division by zero) and an MPEG-TS stream whose timestamps gave a zero
frame rate panicked in `FrameRate::tick_of`. A FrameRate with a zero num/den can also come from a
project file (it's serde).

- `Tick::{from_units, from_rational, to_rational_floor, mul_ratio}` return 0 for a zero
  denominator instead of dividing by zero.
- `FrameRate::sane()`: an invalid (zero / negative) rate counts as the default rate in
  `frame_duration`, `frame_at`, `tick_of` and `timecode_base`.
- `Tick::clamp` no longer panics when the bounds cross.
- Timecode parsing and drop-frame conversion use saturating math (huge typed or agent-sent
  timecodes overflowed).
- Regression test `hostile_rates_and_timecodes_never_panic`.
- `#![deny(clippy::unwrap_used, …)]` outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:51:31 -04:00
Brandon ThomasandClaude Opus 5.5 46b286edaf Codecs: never crash — ac3, audio-dsp, opus, mpeg2v, prores, dnx
- ac3: LFE channel taken without `expect`.
- audio-dsp: the channel matrix match is exhaustive (no `unreachable!`); an unknown internal
  parameter reads as 0 (debug-asserted) instead of `panic!`.
- opus: dual-stereo bands and SILK frames take the optional buffer / range decoder with `if let`.
- mpeg2v: 64-bit peek through `get()` + `first_chunk`; the literal VLC table parser no longer
  panics (debug-asserted).
- prores, dnx: IDCT rows through `as_chunks`; ProRes block store and alpha slices without
  unwrap/expect; DNx header timecode through `get()`.
- dnx: `Encoder::new` returns `Result` instead of panicking on a raster the profile can't code
  (e.g. 0x0), with a regression test; missing CID table is an error.
- Each crate denies unwrap/expect/panic/unimplemented/todo/unreachable outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:43:19 -04:00
Brandon ThomasandClaude Opus 5.5 fa40a3c030 Containers: never crash — mpegts, ogg, mxf, matroska without expect/unreachable
- mpegts: a PES unit is built before it is stored (no `as_mut().expect`); stream maps are
  drained with `let … else` instead of `remove().expect`.
- ogg: completing a packet and reading the page granule no longer expect.
- mxf writer: the picture descriptor uses `let … else` on the picture configuration.
- matroska: the `Lacing::None` arm returns the single frame instead of `unreachable!`.
- Each crate denies unwrap/expect/panic/unimplemented/todo/unreachable outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:37:58 -04:00
Brandon ThomasandClaude Opus 5.5 7190fb9009 av1: never crash — no expect in the decoder, deny the panicking lints
- CDEF and inter-prediction filter rows: fixed-size views through `get()` + `first_chunk(_mut)`
  instead of slice + `try_into().expect()`.
- A missing reference frame predicts from the current frame instead of `expect`.
- Frame slots, output and in-flight queues, scalable-layer selection: no `expect`.
- `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented,
  clippy::todo, clippy::unreachable)]` outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:35:48 -04:00
Brandon ThomasandClaude Opus 5.5 970653628f hevc: never crash — no expect/unwrap in the decoder, deny them
- SAO runs and 8-tap filter rows: fixed-size lanes through `get()` + `first_chunk(_mut)`
  instead of slice + `try_into().expect()`.
- Decoder: a slice segment with no started picture is an error; the output queue pop no longer
  expects.
- Weighted prediction without a parsed table falls back to the default (unweighted) prediction
  instead of `expect`.
- Tile boundaries accumulate without `last().unwrap()`.
- `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented,
  clippy::todo, clippy::unreachable)]` outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:31:02 -04:00
Brandon ThomasandClaude Opus 5.5 a87ba26844 h264: never crash — no unwrap/expect in the decoder, deny them
- I_PCM samples, deblocking lines, transform rows, block copies: fixed-size views through
  `get()` + `first_chunk(_mut)` instead of slice + `try_into().unwrap()`. A truncated I_PCM
  macroblock is `Error::Invalid` from the read itself.
- Decoder: a missing parameter set or a slice without a started picture is an error, not an
  `expect`; the output queue pop no longer expects.
- CAVLC table builder parses its literal codes without `from_str_radix().unwrap()`.
- `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented,
  clippy::todo, clippy::unreachable)]` outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:30:55 -04:00
Brandon ThomasandClaude Opus 5.5 d499b0c20e vp9: never crash — no unwrap/expect/panic in the decoder, deny them
- Transforms, 8-tap filters, probability tables: fixed-size views through `first_chunk(_mut)`
  instead of slice + `try_into().unwrap()/expect()` (same code on the happy path).
- Tile sizes read with `get()`; a short tile header is `Error::Invalid`.
- Buffer pool tolerates a poisoned lock.
- A failed post-processing thread spawn no longer panics: the job reaches the thread over a
  channel, so it is still in hand and runs inline.
- Without the `threads` feature an unpublished reference band reads as empty instead of panicking.
- `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented,
  clippy::todo, clippy::unreachable)]` outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:15:05 -04:00
Brandon Thomas 7b96f22684 Merge pull request #10 from storytold/pr/never-crash-steering
Rules: never crash (AGENTS.md §0, clippy.toml, forbid unsafe)
2026-10-04 11:14:08 -04:00
Brandon ThomasandClaude Opus 5.5 d0048b0256 Merge PR #8 from storybored379
Timeline: Slide moves linked audio with the video

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:15 -04:00
Brandon ThomasandClaude Opus 5.5 ff62f8af27 Merge PR #7 from storybored379
Timeline: transitions stay on their cuts after ripple trim, roll, slide and ripple speed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:14 -04:00
Brandon ThomasandClaude Opus 5.5 3c5e913879 Merge PR #6 from storybored379
Export: start/end times without a range mean a custom range

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:13 -04:00
Brandon ThomasandClaude Opus 5.5 7dd3ee4d77 Merge PR #5 from storybored379
Media: AIFF files import (enable symphonia's aiff reader)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:12 -04:00
storybored379andClaude Opus 5.5 a80769c096 Timeline: Slide moves linked audio with the video
timeline.slide (the Slide tool drag and the MCP/control-channel command)
only moved the clip it was given, so sliding a linked video clip left its
audio partner behind: the audio kept its old start and source In, a
visible A/V desync of the slide distance. Slip, Trim, Rate Stretch and the
keyboard slide (timeline.slideLeft/Right) already include linked partners.

Slide every linked partner (with_links) by a common delta, clamped across
the partners like timeline.trim does, so a shorter handle on one track
cannot slide the pair apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:47:34 -04:00
storybored379andClaude Opus 5.5 d9a35e7130 Timeline: transitions stay on their cuts after ripple trim, roll, slide and ripple speed
Ripple trims (trim in ripple mode and ripple_trim_group), rolling edits,
slides and Speed/Duration with ripple moved clips but left every
transition at its old timeline position. After e.g. a 1 s ripple trim the
dip to black between the next two clips played 1 s after their cut,
dissolving into the outgoing clip's handle mid-shot; a rolled cut kept its
dissolve where the cut used to be and then hard-cut later.

After these edits each transition now shifts by as much as its anchor moved
(the incoming clip's start, or the outgoing clip's end for a fade to
nothing), keeping its alignment on the edit point. Insert, extract, ripple
delete and close gap already shift transitions and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
storybored379andClaude Opus 5.5 8776168a63 Export: start/end times without a range mean a custom range
file.exportMedia (and export.resolve / export.quick / export.queue.add)
document startSeconds/endSeconds next to range, but bare start/end times
without "range": "custom" were silently ignored and the whole sequence
(or In/Out) was exported. Start or end times now imply a custom range; a
start without an end is a clean error from the custom-range check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
storybored379andClaude Opus 5.5 320b6eede8 Media: AIFF files import (enable symphonia's aiff reader)
AIFF is listed as an importable audio format and FilmCraft exports AIFF,
but the symphonia build had only the wav reader enabled, so every .aif /
.aiff import (including FilmCraft's own AIFF exports) failed with
"unsupported media: end of stream". Enable symphonia's aiff reader (the
same MPL-2.0 symphonia-format-riff crate already used for WAV; Cargo.lock
is unchanged). The new test decodes a synthetic 16-bit stereo AIFF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
198 changed files with 1232 additions and 595 deletions

No files matched your search

Generated
+45 -45
View File
@@ -1655,7 +1655,7 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"chrono",
"cpal",
@@ -1676,7 +1676,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1694,14 +1694,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1717,7 +1717,7 @@ dependencies = [
[[package]]
name = "filmcraft-av1"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1728,14 +1728,14 @@ dependencies = [
[[package]]
name = "filmcraft-bitstream"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
]
[[package]]
name = "filmcraft-captions"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1748,11 +1748,11 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.1.1"
version = "0.2.0"
[[package]]
name = "filmcraft-cli"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@@ -1768,7 +1768,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@@ -1800,7 +1800,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -1808,7 +1808,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1818,7 +1818,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1830,7 +1830,7 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -1861,7 +1861,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-aac",
"filmcraft-audio-dsp",
@@ -1890,7 +1890,7 @@ dependencies = [
[[package]]
name = "filmcraft-format"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1902,7 +1902,7 @@ dependencies = [
[[package]]
name = "filmcraft-frame"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -1912,7 +1912,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -1920,7 +1920,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1937,7 +1937,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1954,7 +1954,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1964,7 +1964,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1974,7 +1974,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1984,7 +1984,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2001,7 +2001,7 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2012,7 +2012,7 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
@@ -2020,7 +2020,7 @@ dependencies = [
[[package]]
name = "filmcraft-media"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -2033,7 +2033,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2042,7 +2042,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2050,28 +2050,28 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-project"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2085,7 +2085,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2095,7 +2095,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2115,7 +2115,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"serde",
@@ -2124,7 +2124,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"candle-core",
"candle-nn",
@@ -2142,14 +2142,14 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-color",
"harfrust",
@@ -2160,7 +2160,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@@ -2168,7 +2168,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
@@ -2200,7 +2200,7 @@ dependencies = [
[[package]]
name = "filmcraft-vp9"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2209,7 +2209,7 @@ dependencies = [
[[package]]
name = "filmcraft-web"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
@@ -7140,7 +7140,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.1"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+3 -1
View File
@@ -88,7 +88,9 @@ fn insert_dotted(m: &mut Map<String, Value>, key: &str, v: Value) {
if !e.is_object() {
*e = Value::Object(Map::new());
}
insert_dotted(e.as_object_mut().expect("object"), tail, v);
if let Value::Object(child) = e {
insert_dotted(child, tail, v);
}
}
}
}
+4 -2
View File
@@ -2,6 +2,8 @@
//!
//! Run `filmcraft-cli help` for the full reference (also in docs/agents.md).
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod args;
mod probe;
@@ -339,7 +341,7 @@ async fn main() {
if a.opt("--bridge").is_some() {
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 { unreachable!() };
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 out = a.opt("--out").unwrap_or("frame.png");
@@ -356,7 +358,7 @@ async fn main() {
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
None => match Backend::open(&a) {
Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s),
Backend::Bridge(_) => unreachable!(),
Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"),
},
};
if let Err(e) = server.serve_stdio().await {
+7 -2
View File
@@ -289,20 +289,25 @@ impl ByteReader for BlobReader {
}
return Err(filmcraft_media::pending::would_block());
}
FS.with(|fs| {
let complete = FS.with(|fs| {
let mut fs = fs.borrow_mut();
fs.clock += 1;
let t = fs.clock;
let mut pos = 0usize;
for c in c0..=c1 {
let (d, used) = fs.chunks.get_mut(&(self.id, c)).expect("checked above");
// Present (checked above); evicted in between would be a bug, reported as an error.
let Some((d, used)) = fs.chunks.get_mut(&(self.id, c)) else { return false };
*used = t;
let a = if c == c0 { (offset - c * CHUNK) as usize } else { 0 };
let n = (d.len() - a).min(buf.len() - pos);
buf[pos..pos + n].copy_from_slice(&d[a..a + n]);
pos += n;
}
true
});
if !complete {
return Err(filmcraft_media::pending::would_block());
}
// read-ahead for sequential access
for c in c1 + 1..=(c1 + READ_AHEAD).min(last) {
request(self.id, c);
+1
View File
@@ -16,6 +16,7 @@
//!
//! Everything here is `wasm32`-only; on other targets the crate is empty.
#![cfg(target_arch = "wasm32")]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod api;
pub mod audio;
+1 -1
View File
@@ -463,7 +463,7 @@ impl WcSource {
c.frames.insert(pts, Arc::new(f));
}
while c.frames.len() > CACHE_FRAMES {
let far = c.frames.keys().copied().max_by_key(|p| (p - want).abs()).expect("non-empty");
let Some(far) = c.frames.keys().copied().max_by_key(|p| (p - want).abs()) else { break };
c.frames.remove(&far);
}
// the session's own frame came out: other requests may restart it now
+2
View File
@@ -15,6 +15,8 @@
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
//! Unsaved Changes…).
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod app_nap;
mod audio;
mod audio_in;
+2
View File
@@ -31,6 +31,8 @@
//! # Ok::<(), filmcraft_aac::Error>(())
//! ```
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod config;
mod decoder;
mod encoder;
+7 -6
View File
@@ -13,6 +13,8 @@
//!
//! E-AC-3 (bsid 11-16) is recognised and refused.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod tables;
use std::sync::OnceLock;
@@ -359,8 +361,7 @@ fn bit_allocation(exp: &[u8; 256], p: &AllocParams, g: &Globals, bap: &mut [u8;
let bndend = tables::MASKTAB[end - 1] as usize + 1;
let mut excite = [0i32; 50];
let (mut fastleak, mut slowleak) = p.leak.unwrap_or((0, 0));
let begin;
if bndstrt == 0 {
let begin = if bndstrt == 0 {
// the last band of the LFE channel (bndend 7) has no band above it
let lfe_last = |bin: usize| !(bndend == 7 && bin == 6);
let mut lowcomp = 0;
@@ -391,10 +392,10 @@ fn bit_allocation(exp: &[u8; 256], p: &AllocParams, g: &Globals, bap: &mut [u8;
slowleak = slowleak.max(bndpsd[bin] - sgain);
excite[bin] = (fastleak - lowcomp).max(slowleak);
}
begin = 22;
22
} else {
begin = bndstrt;
}
bndstrt
};
for bin in begin..bndend {
fastleak -= fdecay;
fastleak = fastleak.max(bndpsd[bin] - p.fgain);
@@ -1030,7 +1031,7 @@ impl Decoder {
/// Coded channel order (Table 5.8) → WAV / SMPTE order: fronts L R C, LFE, surrounds.
fn wav_order(mut pcm: Vec<Vec<f32>>, acmod: usize, lfeon: bool) -> Vec<Vec<f32>> {
let nf = NFCHANS[acmod];
let lfe = lfeon.then(|| pcm.pop().expect("lfe"));
let lfe = if lfeon { pcm.pop() } else { None };
let mut fbw = pcm;
// acmod 3, 5, 7 code L C R …: move C after R
if acmod & 1 == 1 && acmod != 1 {
+1 -1
View File
@@ -99,7 +99,7 @@ fn ffmpeg_encoded_streams_decode() {
}
}
let o = Command::new(&ff).args(["-v", "error", "-i"]).arg(&f).args(["-f", "f32le", "-"]).output().unwrap();
let want: Vec<f32> = o.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let want: Vec<f32> = o.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let ch = s.channels as usize;
let n = want.len() / ch;
assert_eq!(n, ours[0].len(), "{}: sample count", s.name);
+1 -2
View File
@@ -123,7 +123,7 @@ pub fn matrix(from: Layout, to: Layout, mixdown: Mixdown) -> Vec<Vec<f32>> {
let (ni, no) = (from.channels(), to.channels());
let mut m = vec![vec![0.0f32; ni]; no];
match (from, to) {
(a, b) if a == b => {
(Layout::Mono, Layout::Mono) | (Layout::Stereo, Layout::Stereo) | (Layout::Surround51, Layout::Surround51) => {
for (i, row) in m.iter_mut().enumerate() {
row[i] = 1.0;
}
@@ -159,7 +159,6 @@ pub fn matrix(from: Layout, to: Layout, mixdown: Mixdown) -> Vec<Vec<f32>> {
m[1][LFE] = K;
}
}
_ => unreachable!("all layout pairs are covered"),
}
m
}
+6 -5
View File
@@ -16,6 +16,8 @@
//! denormals never appear (no FTZ/DAZ CPU flags needed, which keeps the crate `unsafe`-free and
//! portable to wasm).
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod biquad;
pub mod channels;
pub mod design;
@@ -169,12 +171,11 @@ impl ParamValues {
pub(crate) fn get(&self, id: &str) -> Option<f32> {
self.index_of(id).map(|i| self.values[i])
}
/// Value of a parameter that is known to exist (panics otherwise — programming error).
/// Value of a parameter that is known to exist (0 otherwise — a programming error caught by
/// debug builds).
pub(crate) fn v(&self, id: &str) -> f32 {
match self.get(id) {
Some(v) => v,
None => panic!("unknown parameter {id}"),
}
debug_assert!(self.get(id).is_some(), "unknown parameter {id}");
self.get(id).unwrap_or(0.0)
}
pub(crate) fn on(&self, id: &str) -> bool {
self.v(id) >= 0.5
+3 -1
View File
@@ -42,7 +42,9 @@ impl BridgeClient {
*guard = Some((BufReader::new(r), w));
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let conn = guard.as_mut().expect("connected");
let Some(conn) = guard.as_mut() else {
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
};
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
let res: Result<Value, AutomationError> = async {
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
+2
View File
@@ -8,6 +8,8 @@
//! (`filmcraft --control <port>`), so agents can also inspect, screenshot, click, drag, scroll
//! and type in the live UI — every menu, panel, timeline gesture and keystroke.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod bridge;
pub mod server;
+6 -2
View File
@@ -237,12 +237,16 @@ fn filter_rows<const W: usize>(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(is
}
for i in 0..h {
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
let x: [i16; W] = b16[ci as usize..ci as usize + W].try_into().expect("centre row");
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
return;
};
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
for &(off, weight, thr, adj) in taps {
let (weight, thr) = (weight as i16, thr as i16);
let start = (ci + off) as usize;
let p: &[i16; W] = b16[start..start + W].try_into().expect("tap row");
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
return;
};
for j in 0..W {
// constrain(): .max / .min instead of clamp (whose bound check panics), and
// the sign applied branch-free, so the loop vectorises.
+9 -5
View File
@@ -72,10 +72,12 @@ impl<T: Clone> Slot<T> {
fn wait(&self) -> Result<T> {
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
while g.is_none() {
loop {
if let Some(v) = g.as_ref() {
return v.clone();
}
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
}
g.as_ref().expect("slot value").clone()
}
}
@@ -308,7 +310,7 @@ impl Decoder {
if !o.is_ready() && self.out.len() <= max_queue {
break;
}
let o = self.out.pop_front().expect("front");
let Some(o) = self.out.pop_front() else { break };
pics.push(o.take()?);
}
parsed?;
@@ -358,7 +360,9 @@ impl Decoder {
match last {
Some((lt, lsid)) if lt == t => {
if sid >= lsid {
*sel.last_mut().expect("picture") = o;
if let Some(l) = sel.last_mut() {
*l = o;
}
last = Some((t, sid));
}
}
@@ -612,7 +616,7 @@ impl Decoder {
self.in_flight.push_back(slot);
// Bound the frames in flight (memory): wait for the oldest.
while self.in_flight.len() > self.max_in_flight {
let s = self.in_flight.pop_front().expect("in flight");
let Some(s) = self.in_flight.pop_front() else { break };
let _ = s.wait();
}
self.in_flight.retain(|s| !s.is_set());
+17 -5
View File
@@ -180,7 +180,11 @@ impl TileDecoder<'_, '_> {
}
fn ref_plane(&self, ref_idx: i32, plane: usize) -> &Plane {
if ref_idx < 0 { &self.t.cur.planes[plane] } else { &self.fs.refs[ref_idx as usize].as_ref().expect("reference frame").buf.planes[plane] }
// A missing reference (rejected earlier for valid streams) predicts from the current frame.
match usize::try_from(ref_idx).ok().and_then(|i| self.fs.refs.get(i)).and_then(|r| r.as_ref()) {
Some(r) => &r.buf.planes[plane],
None => &self.t.cur.planes[plane],
}
}
/// Block inter prediction process (7.11.3.4) into `pred` (stride PRED_STRIDE); `tmp` holds
@@ -672,12 +676,16 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
let mut acc = [half; W];
for t in 0..8 {
let f = hf[t] as i32;
let p: &[u16; W] = src[t..t + W].try_into().expect("row");
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
return;
};
for j in 0..W {
acc[j] += f * p[j] as i32;
}
}
let o: &mut [i32; W] = (&mut out[..W]).try_into().expect("row");
let Some(o) = out.first_chunk_mut::<W>() else {
return;
};
for j in 0..W {
o[j] = acc[j] >> sh;
}
@@ -689,12 +697,16 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
let mut acc = [half; W];
for t in 0..8 {
let f = vf[t] as i32;
let p: &[i32; W] = rows[t * W..t * W + W].try_into().expect("row");
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
return;
};
for j in 0..W {
acc[j] += f * p[j];
}
}
let o: &mut [i32; W] = (&mut out[..W]).try_into().expect("row");
let Some(o) = out.first_chunk_mut::<W>() else {
return;
};
for j in 0..W {
o[j] = acc[j] >> sh;
}
+1
View File
@@ -5,6 +5,7 @@
// Items used only by stages still being written (inter prediction, post filters).
#![allow(dead_code)]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod bits;
mod cdef;
+1 -1
View File
@@ -76,7 +76,7 @@ pub fn reference(ff: &Path, path: &Path, pix_fmt: &str) -> Vec<u16> {
.expect("run ffmpeg");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
if pix_fmt.ends_with("le") {
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
} else {
out.stdout.iter().map(|&b| b as u16).collect()
}
+2
View File
@@ -5,6 +5,8 @@
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+4 -2
View File
@@ -15,6 +15,8 @@
//! - **Burn-in:** [`burn`] lays a caption out with the track style and draws it with the
//! `filmcraft-text` engine (Inter SemiBold, shaped and kerned).
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod burn;
pub mod cea608;
pub mod mcc;
@@ -212,7 +214,7 @@ pub fn parse(bytes: &[u8], format: Format) -> Result<Document> {
Format::Scc => scc::parse(&text),
Format::Mcc => mcc::parse(&text),
Format::Ttml | Format::Dfxp => ttml::parse(&text),
Format::Stl => unreachable!("handled above"),
Format::Stl => stl::parse(bytes),
}
}
@@ -253,7 +255,7 @@ pub fn decode_text(bytes: &[u8]) -> String {
} else if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
let le = bytes[0] == 0xff;
let units: Vec<u16> =
bytes[2..].chunks_exact(2).map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
bytes[2..].as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
String::from_utf16_lossy(&units)
} else {
match std::str::from_utf8(bytes) {
+3 -3
View File
@@ -411,10 +411,10 @@ impl Dec708 {
}
fn put(&mut self, c: char) {
let w = self.w();
if w.rows.is_empty() {
w.rows.push(String::new());
match w.rows.last_mut() {
Some(row) => row.push(c),
None => w.rows.push(c.to_string()),
}
w.rows.last_mut().expect("row").push(c);
}
fn bitmap(&mut self, m: u8, f: impl Fn(&mut Win)) {
for (i, w) in self.win.iter_mut().enumerate() {
+4 -2
View File
@@ -251,7 +251,9 @@ pub fn parse(bytes: &[u8]) -> Result<Document> {
if !sniff(bytes) {
return Err(Error::NotFormat("EBU STL"));
}
let rate = rate_of(&bytes[3..11]).expect("sniffed");
let Some(rate) = bytes.get(3..11).and_then(rate_of) else {
return Err(Error::NotFormat("EBU STL"));
};
let gsi = &bytes[..GSI];
// start of programme (HHMMSSFF, ASCII)
let tcp = ascii_field(&gsi[256..264]);
@@ -277,7 +279,7 @@ pub fn parse(bytes: &[u8]) -> Result<Document> {
}
}
};
for blk in bytes[GSI..].chunks_exact(TTI) {
for blk in bytes[GSI..].as_chunks::<TTI>().0 {
let sn = u16::from_le_bytes([blk[1], blk[2]]);
let ebn = blk[3];
let cf = blk[15];
+1 -1
View File
@@ -103,7 +103,7 @@ fn inline(text: &str) -> String {
rest = &after[end + 1..];
continue;
}
let ch = rest.chars().next().expect("non-empty");
let Some(ch) = rest.chars().next() else { break };
if ch == '\n' {
out.push_str("<br/>");
} else {
+2
View File
@@ -15,6 +15,8 @@
//!
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod read;
mod write;
+1 -1
View File
@@ -143,7 +143,7 @@ impl<'a> CompoundFile<'a> {
// Mini FAT and mini stream.
if first_minifat <= MAXREGSECT {
let mf = cf.read_chain(first_minifat, None)?;
cf.minifat = mf.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
cf.minifat = mf.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
}
let root = &cf.entries[0];
if root.start <= MAXREGSECT && root.size > 0 {
+1 -1
View File
@@ -36,7 +36,7 @@ log = { workspace = true }
web-time = "1"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
# Bootstrap audio decoding (MPL-2.0, used unmodified); replaced by own decoders over time.
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "mp1", "mp2", "mp3", "flac", "alac", "pcm", "wav", "ogg", "vorbis", "isomp4"] }
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "mp1", "mp2", "mp3", "flac", "alac", "pcm", "wav", "aiff", "ogg", "vorbis", "isomp4"] }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
+12 -7
View File
@@ -240,8 +240,7 @@ impl GopCache {
}
// evict frames far from the most recent (keep a window around the working position)
while st.bytes > budget && st.frames.len() > 2 {
let first = *st.frames.keys().next().expect("non-empty");
let last = *st.frames.keys().next_back().expect("non-empty");
let (Some(&first), Some(&last)) = (st.frames.keys().next(), st.frames.keys().next_back()) else { break };
let victim = if pts - first > last - pts { first } else { last };
if let Some(v) = st.frames.remove(&victim) {
st.drafts.remove(&victim);
@@ -323,7 +322,7 @@ impl GopCache {
// every sample): step back to a sample the decoder can start from.
while key > 0 {
let data = s.read(key)?;
if st.decoder.as_ref().expect("decoder").is_random_access(&data) != Some(false) {
if st.decoder.as_ref().and_then(|d| d.is_random_access(&data)) != Some(false) {
break;
}
key = s.sync_before(key - 1);
@@ -337,7 +336,10 @@ impl GopCache {
}
let limit = (i.max(st.next) + 64).min(n);
let late = late_before.unwrap_or(i64::MIN).min(want_pts);
st.decoder.as_mut().expect("decoder").set_draft(draft);
let Some(decoder) = st.decoder.as_mut() else {
return Err(CodecError::Decode("no video decoder".into()));
};
decoder.set_draft(draft);
while st.next < limit {
if filmcraft_media::cancel::cancelled() {
// The decoder state stays consistent (`next`, `out_max`): a later request continues.
@@ -345,12 +347,15 @@ impl GopCache {
}
let k = st.next;
let data = s.read(k)?;
if k != i && s.pts(k) < late && st.decoder.as_ref().expect("decoder").is_disposable(&data) {
if k != i && s.pts(k) < late && st.decoder.as_ref().is_some_and(|d| d.is_disposable(&data)) {
st.next += 1;
SKIPPED.fetch_add(1, Ordering::Relaxed);
continue;
}
let out = timed(|| st.decoder.as_mut().expect("decoder").decode(&data, s.pts(k)))?;
let Some(decoder) = st.decoder.as_mut() else {
return Err(CodecError::Decode("no video decoder".into()));
};
let out = timed(|| decoder.decode(&data, s.pts(k)))?;
st.next += 1;
DECODED.fetch_add(1, Ordering::Relaxed);
self.store_output(&mut st, out);
@@ -359,7 +364,7 @@ impl GopCache {
}
}
if st.cached(want_pts, draft).is_none() {
let out = timed(|| st.decoder.as_mut().expect("decoder").flush());
let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default();
self.store_output(&mut st, out);
st.next = usize::MAX;
}
+2
View File
@@ -16,6 +16,8 @@
//! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …).
//! - [`openers`]: the openers to register with the engine's media pool.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod audio;
pub mod gop;
pub mod mkv;
+4 -4
View File
@@ -369,9 +369,9 @@ impl MkvSource {
if let Some(p) = st.packets.get(&i) {
return Ok(p.clone());
}
let ti = self.atrack.expect("audio");
let ti = self.atrack.ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
let track = &self.file.tracks[ti];
let ainfo = self.info.audio.as_ref().expect("audio info");
let ainfo = self.info.audio.as_ref().ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
let data = self.read(ti, i)?;
let decoded = match &track.codec {
Codec::Pcm { float, big_endian, bits } => {
@@ -392,7 +392,7 @@ impl MkvSource {
// Non-sequential access: reset and prime with the preceding packets (codec pre-roll:
// one packet, or `SeekPreRoll` worth for Opus).
if st.last_decoded.is_none_or(|l| l + 1 != i) {
let d = st.decoder.as_mut().expect("decoder");
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
d.reset();
let from = if self.audio_preroll > 0 {
self.audio_starts.partition_point(|&x| x <= self.audio_starts[i] - self.audio_preroll).saturating_sub(1)
@@ -405,7 +405,7 @@ impl MkvSource {
}
}
}
let r = st.decoder.as_mut().expect("decoder").decode(&data, self.audio_starts[i].max(0) as u64);
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, self.audio_starts[i].max(0) as u64);
st.last_decoded = Some(i);
r.unwrap_or_default()
}
+38 -6
View File
@@ -107,8 +107,11 @@ impl Mp4Source {
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
let bytes = crate::Src(reader);
let file = filmcraft_isobmff::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
let vtrack = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty());
let atrack = file.tracks.iter().position(|t| t.kind == TrackKind::Audio && !t.samples.is_empty());
// A track needs samples, a sample description and a timescale to be playable (damaged
// files can lack them: indexing `entries[0]` or dividing by the timescale used to panic).
let playable = |t: &&filmcraft_isobmff::Track, kind| t.kind == kind && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0;
let vtrack = file.tracks.iter().position(|t| playable(&t, TrackKind::Video));
let atrack = file.tracks.iter().position(|t| playable(&t, TrackKind::Audio));
if vtrack.is_none() && atrack.is_none() {
return Err(CodecError::Unsupported("no playable tracks".into()));
}
@@ -126,6 +129,8 @@ impl Mp4Source {
durs.sort_unstable();
let d = durs.get(durs.len() / 2).copied().unwrap_or(1).max(1);
let rate = FrameRate::from_f64(t.timescale as f64 / d as f64);
// A rate that rounds to zero (one sample per huge duration) can't be divided by.
let rate = if rate.num > 0 && rate.den > 0 { rate } else { FrameRate::default() };
color = color_from(entry, w, h);
if entry
.video
@@ -264,9 +269,9 @@ impl Mp4Source {
if let Some(p) = st.packets.get(&i) {
return Ok(p.clone());
}
let ti = self.atrack.expect("audio");
let ti = self.atrack.ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
let track = &self.file.tracks[ti];
let entry = &track.entries[0];
let entry = track.entries.first().ok_or_else(|| CodecError::Container("audio track without a sample description".into()))?;
let data = self.read(ti, i)?;
let decoded = match &entry.codec {
CodecConfig::Pcm(p) => decode_pcm(&data, p),
@@ -277,7 +282,7 @@ impl Mp4Source {
// Non-sequential access: reset and prime with the preceding packets (codec pre-roll:
// one packet, or `OPUS_PRE_ROLL` worth for Opus).
if st.last_decoded.is_none_or(|l| l + 1 != i) {
let d = st.decoder.as_mut().expect("decoder");
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
d.reset();
let from = if self.audio_preroll > 0 {
self.audio_starts.partition_point(|&x| x <= self.audio_starts[i] - self.audio_preroll).saturating_sub(1)
@@ -290,7 +295,7 @@ impl Mp4Source {
}
}
}
let r = st.decoder.as_mut().expect("decoder").decode(&data, self.audio_starts[i].max(0) as u64);
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, self.audio_starts[i].max(0) as u64);
st.last_decoded = Some(i);
match r {
Ok(v) => v,
@@ -513,6 +518,33 @@ mod tests {
b.into()
}
/// Overwrite the big-endian u32 `skip` bytes after the first `fourcc` box type.
fn patch_u32(b: &mut [u8], fourcc: &[u8; 4], skip: usize, v: u32) {
let k = b.windows(4).position(|x| x == fourcc).expect("box") + 4 + skip;
b[k..k + 4].copy_from_slice(&v.to_be_bytes());
}
/// Damaged MOVs found by mutation fuzzing panicked on import: a track with timescale 0
/// ("attempt to divide by zero" in the duration) and one whose sample description list is
/// empty ("index out of bounds" on `entries[0]`). Both must open as an error, or as media
/// that answers requests without panicking.
#[test]
fn damaged_track_headers_never_panic() {
let identity = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000];
// mdhd v0: version/flags, creation, modification, then timescale
let mut zero_timescale = rotated_mov(identity).to_vec();
patch_u32(&mut zero_timescale, b"mdhd", 12, 0);
// stsd: version/flags, then entry_count
let mut no_entries = rotated_mov(identity).to_vec();
patch_u32(&mut no_entries, b"stsd", 4, 0);
for (name, b) in [("ts0.mov", zero_timescale), ("stsd0.mov", no_entries)] {
if let Ok(s) = Mp4Source::open(name, b.into()) {
let _ = s.video_frame(FrameRequest::full(Tick::ZERO));
let _ = s.audio(0, 1024, 48_000);
}
}
}
/// Mean luma of the top and bottom halves of the decoded frame, and its size.
fn halves(src: &Mp4Source) -> (u32, u32, u8, u8) {
let f = src.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame");
+41 -15
View File
@@ -123,6 +123,19 @@ fn audio_supported(c: &Codec) -> bool {
matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3)
}
/// The coded rate unless the timestamps say otherwise (3:2 pulldown, field-rate coding). Never
/// zero: damaged timestamps spread over a huge span give a rate that rounds to 0 fps, which the
/// duration computation divided by.
fn stream_rate(coded: Option<FrameRate>, from_pts: Option<f64>) -> FrameRate {
let rate = match (coded, from_pts) {
(Some(r), Some(p)) if (r.as_f64() - p).abs() / p > 0.01 => FrameRate::from_f64(p),
(Some(r), _) => r,
(None, Some(p)) => FrameRate::from_f64(p),
(None, None) => FrameRate::FPS_25,
};
if rate.num > 0 && rate.den > 0 { rate } else { FrameRate::FPS_25 }
}
/// Display-order keys of samples (decode order): the PTS when every unit has one, else (MPEG
/// video) GOP start + temporal_reference, else the decode order.
fn display_keys(units: &[&Unit], mpeg: bool) -> Vec<i64> {
@@ -242,8 +255,9 @@ impl MpegSource {
src.file = Some(file);
src.video = Some(track);
src.describe_video(rate_from_pts)?;
let file = src.file.take().expect("file");
src.setup_audio(file, astream, origin);
if let Some(file) = src.file.take() {
src.setup_audio(file, astream, origin);
}
}
None => {
src.unsupported_video = Some(format!("{} video in {} (FilmCraft has no decoder for it)", st.codec.name(), file.format.name()));
@@ -369,14 +383,14 @@ impl MpegSource {
if let Some((data, aus)) = &self.es {
return Ok(data[aus[u].clone()].to_vec());
}
let file = self.file.as_ref().expect("file");
let file = self.file.as_ref().ok_or_else(|| CodecError::Container("no stream file".into()))?;
file.read_unit(&self.bytes, v.stream, u).map_err(|e| CodecError::Container(e.to_string()))
}
/// Fill in the video stream info from the first picture.
fn describe_video(&mut self, rate_from_pts: Option<f64>) -> crate::Result<()> {
let mut seq_header = None;
let v = self.video.as_ref().expect("video");
let v = self.video.as_ref().ok_or_else(|| CodecError::Unsupported("no video stream".into()))?;
let first = self.read_unit(v, 0)?;
let n = v.order.len();
let (width, height, par, codec, pixel_format, mut color);
@@ -432,12 +446,7 @@ impl MpegSource {
}
}
// the coded rate unless the timestamps say otherwise (3:2 pulldown, field-rate coding)
let rate = match (rate, rate_from_pts) {
(Some(r), Some(p)) if (r.as_f64() - p).abs() / p > 0.01 => FrameRate::from_f64(p),
(Some(r), _) => r,
(None, Some(p)) => FrameRate::from_f64(p),
(None, None) => FrameRate::FPS_25,
};
let rate = stream_rate(rate, rate_from_pts);
let duration = rate.tick_of(n as i64);
let total_bytes: u64 = match (&self.es, &self.file) {
(Some((_, aus)), _) => v.units.iter().map(|&u| aus[u].len() as u64).sum(),
@@ -445,8 +454,10 @@ impl MpegSource {
_ => 0,
};
let secs = duration.seconds();
if let Some(h) = seq_header {
self.video.as_mut().expect("video").header = h;
if let Some(h) = seq_header
&& let Some(v) = self.video.as_mut()
{
v.header = h;
}
self.info.duration = duration;
self.info.video = Some(VideoStreamInfo {
@@ -525,7 +536,7 @@ impl MpegSource {
return self.audio_unsupported(&codec_name, "DVD LPCM: no format header".into());
}
},
_ => unreachable!("audio_supported"),
_ => return self.audio_unsupported(&codec_name, format!("{codec_name} audio")),
};
if codec_is_ac3_unsupported(codec) {
return self.audio_unsupported(&codec_name, format!("{codec_name} audio (FilmCraft has no AC-3 decoder yet)"));
@@ -608,12 +619,12 @@ impl MpegSource {
if let Some(p) = st.cache.get(&i) {
return Ok(p.clone());
}
let file = self.file.as_ref().expect("file");
let file = self.file.as_ref().ok_or_else(|| CodecError::Container("no stream file".into()))?;
let read = |k: usize| file.read_unit(&self.bytes, a.stream, k).map_err(|e| CodecError::Container(e.to_string()));
if st.dec.is_none() {
st.dec = Some(AudioDecoder::new(a.codec, a.rate, a.channels, a.latm.clone())?);
}
let dec = st.dec.as_mut().expect("decoder");
let dec = st.dec.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
if st.last.is_none_or(|l| l + 1 != i) {
dec.reset();
if i > 0
@@ -840,6 +851,21 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe
mod tests {
use super::*;
/// A mutated transport stream whose PTS span gave ~0 fps panicked on open with "attempt to
/// divide by zero" (`FrameRate::tick_of`).
#[test]
fn damaged_timestamps_never_give_a_zero_rate() {
for (coded, p) in
[(None, Some(1e-9)), (Some(FrameRate::FPS_25), Some(1e-9)), (Some(FrameRate::new(0, 0)), None), (None, Some(0.0)), (None, Some(f64::NAN))]
{
let r = stream_rate(coded, p);
assert!(r.num > 0 && r.den > 0, "{coded:?} {p:?} -> {r:?}");
let _ = r.tick_of(1000);
}
assert_eq!(stream_rate(Some(FrameRate::FPS_25), Some(25.0)), FrameRate::FPS_25);
assert_eq!(stream_rate(None, Some(50.0)), FrameRate::from_f64(50.0));
}
#[test]
fn presentation_drops_leading_pictures_and_orders_by_key() {
// decode order I(2) B(0) B(1) P(5) B(3) B(4): the leading B pictures are not presented
+2 -2
View File
@@ -333,7 +333,7 @@ impl MxfSource {
let mut channels = 0;
for &ai in &atracks {
let t = &file.tracks[ai];
let si = t.sound.as_ref().expect("sound info");
let Some(si) = t.sound.as_ref() else { continue };
if si.sample_rate != rate {
continue;
}
@@ -424,7 +424,7 @@ impl VideoSamples for MxfVideo<'_> {
return Err(CodecError::Unsupported(why.clone()));
}
let t = &self.src.file.tracks[self.track];
let v = self.src.info.video.as_ref().expect("video info");
let v = self.src.info.video.as_ref().ok_or_else(|| CodecError::Unsupported("no video track".into()))?;
let (w, h) = (v.width.min(u16::MAX as u32) as u16, v.height.min(u16::MAX as u32) as u16);
match t.codec {
Codec::Avc { .. } => Ok(Box::new(crate::video::H264Decoder::annexb())),
+2 -2
View File
@@ -133,7 +133,7 @@ impl OggSource {
}
// Non-sequential access: reset and decode the pre-roll (RFC 7845 §4.6; see OPUS_PRE_ROLL).
if st.last_decoded.is_none_or(|l| l + 1 != i) {
let d = st.decoder.as_mut().expect("decoder");
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
d.reset();
let from = timing.starts.partition_point(|&x| x <= timing.starts[i] - OPUS_PRE_ROLL as i64).saturating_sub(1);
for j in from..i {
@@ -143,7 +143,7 @@ impl OggSource {
}
}
let data = read(i)?;
let r = st.decoder.as_mut().expect("decoder").decode(&data, 0);
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, 0);
st.last_decoded = Some(i);
let p = Arc::new(r.unwrap_or_default());
st.packets.insert(i, p.clone());
+28 -1
View File
@@ -88,6 +88,33 @@ fn mp3_file_decodes() {
assert!(a.peaks()[0] > 0.1);
}
#[test]
fn aiff_file_decodes() {
// 0.1 s of 48 kHz 16-bit stereo PCM: left a ramp, right silent (AIFF, big-endian samples)
let frames = 4800u32;
let mut b = b"FORM".to_vec();
b.extend((4 + 26 + 16 + frames * 4).to_be_bytes());
b.extend(b"AIFFCOMM");
b.extend(18u32.to_be_bytes());
b.extend(2u16.to_be_bytes());
b.extend(frames.to_be_bytes());
b.extend(16u16.to_be_bytes());
b.extend([0x40, 0x0E, 0xBB, 0x80, 0, 0, 0, 0, 0, 0]); // 48000 as an 80-bit extended float
b.extend(b"SSND");
b.extend((8 + frames * 4).to_be_bytes());
b.extend([0; 8]);
for i in 0..frames {
b.extend(((i as i16) * 4).to_be_bytes());
b.extend(0i16.to_be_bytes());
}
let src = crate::open_bytes("tone.aif", b.into()).unwrap();
assert!((src.info().duration.seconds() - 0.1).abs() < 1e-3, "{}", src.info().duration.seconds());
let a = src.audio(0, 4800, 48_000).unwrap();
assert_eq!(a.channels.len(), 2);
assert!((a.channels[0][1000] - 4000.0 / 32768.0).abs() < 1e-4, "{}", a.channels[0][1000]);
assert_eq!(a.channels[1][1000], 0.0);
}
#[test]
fn seek_backwards_and_forwards_mjpeg() {
let Some(b) = fixture("counter_mjpeg.mov", &["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=3", "-c:v", "mjpeg"]) else { return };
@@ -445,7 +472,7 @@ fn opus_reference(name: &str) -> Option<Vec<f32>> {
if !st.success() {
return None;
}
Some(std::fs::read(out).ok()?.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect())
Some(std::fs::read(out).ok()?.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect())
}
fn snr_db(reference: &[f32], test: &[f32]) -> f64 {
+7 -6
View File
@@ -386,7 +386,8 @@ impl Vp9Decoder {
}
}
fn convert(&self, p: filmcraft_vp9::Picture) -> DecodedFrame {
/// `None` for a picture whose planes mix sample types (never produced by the decoder).
fn convert(&self, p: filmcraft_vp9::Picture) -> Option<DecodedFrame> {
use filmcraft_color::{Matrix, Primaries, Range};
use filmcraft_frame::{Chroma, PixelData};
use filmcraft_vp9::Plane;
@@ -423,7 +424,7 @@ impl Vp9Decoder {
}
let mut frame = VideoFrame::rgba8(p.width, p.height, rgba);
frame.color = filmcraft_color::ColorInfo { range: Range::Full, transfer: filmcraft_color::Transfer::Srgb, ..color };
return DecodedFrame { pts, frame, draft };
return Some(DecodedFrame { pts, frame, draft });
}
// 4:4:0 has no frame format of its own: chroma rows are repeated to 4:4:4.
let (chroma, rows_440) = match (p.subsampling_x, p.subsampling_y) {
@@ -455,22 +456,22 @@ impl Vp9Decoder {
bits: p.bit_depth,
alpha: None,
},
_ => unreachable!("VP9 planes share one sample type"),
_ => return None,
};
// render_size (the intended display size) is not applied: the container's display
// dimensions / pixel aspect describe the presentation.
let par = (1, 1);
DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO }, draft }
Some(DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO }, draft })
}
}
impl VideoDecoder for Vp9Decoder {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?;
Ok(pics.into_iter().map(|p| self.convert(p)).collect())
Ok(pics.into_iter().filter_map(|p| self.convert(p)).collect())
}
fn flush(&mut self) -> Vec<DecodedFrame> {
self.dec.flush().into_iter().map(|p| self.convert(p)).collect()
self.dec.flush().into_iter().filter_map(|p| self.convert(p)).collect()
}
fn reset(&mut self) {
self.dec.reset();
+2 -2
View File
@@ -48,7 +48,7 @@ pub fn ffmpeg_audio_f32(ff: &Path, file: &Path, extra: &[&str]) -> Vec<f32> {
let mut args = vec!["-i", file.to_str().unwrap()];
args.extend_from_slice(extra);
args.extend_from_slice(&["-map", "0:a:0", "-f", "f32le", "-"]);
ffmpeg_out(ff, &args).chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect()
ffmpeg_out(ff, &args).as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect()
}
/// Our frame's three planes widened to u16 (and their sizes).
@@ -63,7 +63,7 @@ pub fn planes(f: &VideoFrame) -> Vec<Vec<u16>> {
/// Split one raw frame of `ffmpeg_frames` into planes (`bps` bytes per sample, chroma `cw`×`ch`).
pub fn raw_planes(raw: &[u8], w: usize, h: usize, cw: usize, ch: usize, bps: usize) -> Vec<Vec<u16>> {
let get = |b: &[u8]| -> Vec<u16> {
if bps == 1 { b.iter().map(|&v| v as u16).collect() } else { b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() }
if bps == 1 { b.iter().map(|&v| v as u16).collect() } else { b.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect() }
};
let y = w * h * bps;
let c = cw * ch * bps;
+1 -1
View File
@@ -92,7 +92,7 @@ fn decode_both(ff: &Path, file: &Path, decoder: &[&str]) -> (OggSource, Vec<f32>
let ch = a.channels as usize;
let mut args: Vec<&str> = decoder.to_vec();
args.extend_from_slice(&["-i", file.to_str().unwrap(), "-map", "0:a:0", "-f", "f32le", "-"]);
let want: Vec<f32> = ffmpeg_out(ff, &args).chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let want: Vec<f32> = ffmpeg_out(ff, &args).as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let n = want.len() / ch;
let frames = src.info().duration.to_units_floor(a.sample_rate as i64) as usize;
assert_eq!(frames, n, "{}: our length = ffmpeg's ({} samples)", file.display(), n);
+2
View File
@@ -5,6 +5,8 @@
//! The compositor works in **linear-light, premultiplied RGBA f32** in the sequence working space
//! (Rec.709 primaries by default). Decoded frames carry [`ColorInfo`] so conversions are explicit.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
+1 -1
View File
@@ -38,7 +38,7 @@ let f = decode_frame_with(&sample, &DecodeOptions { threads: false, ..Default::d
let hdr: FrameHeader = probe(&sample)?; // cid, raster, depth, chroma…
let name = cid_name(hdr.cid); // "DNxHR HQX", "DNxHD 1080i 8-bit (CID 1242)"
let mut enc = Encoder::new(Profile::Hq, 1920, 1080); // or Encoder::with_config(EncoderConfig { .. })
let mut enc = Encoder::new(Profile::Hq, 1920, 1080)?; // or Encoder::with_config(EncoderConfig { .. })
let sample: Vec<u8> = enc.encode(&frame)?; // exactly enc.frame_size() bytes (CBR)
```
+1 -2
View File
@@ -19,8 +19,7 @@ pub(crate) fn idct8x8(block: &mut [f32; 64]) {
let mut nz = [false; 8];
let mut dc_only = true;
// Horizontal pass: t[r][x] = Σ_u F[r][u] · B[u][x]
for r in 0..8 {
let c: &[f32; 8] = block[r * 8..r * 8 + 8].try_into().unwrap();
for (r, c) in block.as_chunks::<8>().0.iter().enumerate() {
if c[1..].iter().all(|&v| v == 0.0) {
if c[0] != 0.0 {
t[r] = [c[0] * BASIS[0][0]; 8];
+4 -3
View File
@@ -61,8 +61,9 @@ struct Prepared {
}
impl Encoder {
pub fn new(profile: Profile, width: u32, height: u32) -> Encoder {
Encoder::with_config(EncoderConfig::new(profile, width, height)).expect("valid default configuration")
/// An encoder with the default configuration; fails on rasters the profile can't code.
pub fn new(profile: Profile, width: u32, height: u32) -> Result<Encoder> {
Encoder::with_config(EncoderConfig::new(profile, width, height))
}
pub fn with_config(cfg: EncoderConfig) -> Result<Encoder> {
@@ -79,7 +80,7 @@ impl Encoder {
if !ok_depth {
return Err(Error::Input(format!("{} does not support {}-bit", cfg.profile.name(), cfg.bit_depth)));
}
let info = cid_info(cfg.profile.cid()).expect("RI CID");
let info = cid_info(cfg.profile.cid()).ok_or_else(|| Error::Input(format!("no compression table for {}", cfg.profile.name())))?;
let frame_size = cfg.profile.frame_size(cfg.width, cfg.height);
let header_size = header::header_size_for(cfg.height);
Ok(Encoder { cfg, info, frame_size, header_size, last_q: 8 })
+2 -2
View File
@@ -127,7 +127,7 @@ fn probe_weights() {
let px: Vec<f64> = if depth == 8 {
out.stdout.iter().map(|&b| b as f64).collect()
} else {
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
};
let (off, stride) = if blk == 0 { (0, w as usize) } else { ((w * h) as usize, w as usize / 2) };
let mid = (1 << (depth - 1)) as f64;
@@ -224,7 +224,7 @@ fn probe_reconstruction() {
let px: Vec<f64> = if depth == 8 {
out.stdout.iter().map(|&b| b as f64).collect()
} else {
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
};
let mid = (1 << (depth - 1)) as f64;
let mut sum = 0f64;
+1 -1
View File
@@ -152,7 +152,7 @@ pub fn parse(d: &[u8]) -> Result<FrameHeader> {
2 => ColorVolume::Bt2020Cl,
_ => ColorVolume::OutOfBand,
};
let timecode = (d[0x30] & 0x80 != 0).then(|| d[0x31..0x39].try_into().unwrap());
let timecode = if d[0x30] & 0x80 != 0 { d.get(0x31..).and_then(|t| t.first_chunk::<8>()).copied() } else { None };
let mb_rows = be16(d, 0x16C);
if width == 0 || lines == 0 || width > 16384 || lines > 16384 {
return Err(Error::Invalid("raster size"));
+2
View File
@@ -11,6 +11,8 @@
//!
//! See the crate README for accuracy, performance and limitations.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod bits;
mod dct;
mod decode;
+7 -4
View File
@@ -91,7 +91,7 @@ pub fn reference(ff: &Path, mov: &Path, pix_fmt: &str) -> Vec<u16> {
.expect("run ffmpeg");
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
if pix_fmt.ends_with("le") {
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
} else {
out.stdout.iter().map(|&b| b as u16).collect()
}
@@ -247,7 +247,7 @@ pub fn ffmpeg_decode(ff: &Path, mov: &Path, pix_fmt: &str) -> Vec<u16> {
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(out.status.success() && err.trim().is_empty(), "ffmpeg failed: {err}");
if pix_fmt.ends_with("le") {
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
} else {
out.stdout.iter().map(|&b| b as u16).collect()
}
@@ -270,8 +270,11 @@ pub fn source_frames(ff: &Path, name: &str, lavfi: &str, frames: u32, w: u32, h:
std::fs::rename(&tmp, &path).unwrap();
}
let bytes = std::fs::read(&path).unwrap();
let s: Vec<u16> =
if depth == 8 { bytes.iter().map(|&b| b as u16).collect() } else { bytes.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
let s: Vec<u16> = if depth == 8 {
bytes.iter().map(|&b| b as u16).collect()
} else {
bytes.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
};
let proto = Frame::new(w, h, chroma, depth, false);
let per = frame_samples(&proto);
assert_eq!(s.len(), per * frames as usize);
+5 -2
View File
@@ -68,17 +68,20 @@ fn roundtrip_all_profiles() {
#[test]
fn rejects_bad_input() {
let mut enc = Encoder::new(Profile::Hq, 64, 64);
let mut enc = Encoder::new(Profile::Hq, 64, 64).unwrap();
assert!(enc.encode(&Frame::new(32, 64, ChromaFormat::Yuv422, 8, false)).is_err());
assert!(enc.encode(&Frame::new(64, 64, ChromaFormat::Yuv444, 8, false)).is_err());
assert!(Encoder::with_config(EncoderConfig { bit_depth: 10, ..EncoderConfig::new(Profile::Hq, 64, 64) }).is_err());
assert!(decode_frame(&[0u8; 100]).is_err());
assert!(decode_frame(&[0u8; 1000]).is_err());
// An unsupported raster is an error, not a panic.
assert!(Encoder::new(Profile::Hq, 0, 0).is_err());
assert!(Encoder::new(Profile::Hq, 20_000, 64).is_err());
}
#[test]
fn corrupt_streams_never_panic() {
let mut enc = Encoder::new(Profile::Hq, 256, 128);
let mut enc = Encoder::new(Profile::Hq, 256, 128).unwrap();
let good = enc.encode(&picture(256, 128, ChromaFormat::Yuv422, 8, 1)).unwrap();
let mut seed = 0x1234_5678u32;
let mut rnd = || {
+4 -3
View File
@@ -105,8 +105,9 @@ pub fn merge_captions(seq: &mut Sequence, ids: &[ClipId]) -> Result<ClipId> {
if idx.windows(2).any(|w| w[1] != w[0] + 1) {
return Err(EditError::Other("captions to merge must be next to each other".into()));
}
let first = idx[0];
let last = *idx.last().expect("non-empty");
let (Some(&first), Some(&last)) = (idx.first(), idx.last()) else {
return Err(EditError::Nothing);
};
let end = tr.captions[last].end();
let text: Vec<String> = idx.iter().map(|&i| tr.captions[i].text.trim().to_string()).filter(|t| !t.is_empty()).collect();
let keep = tr.captions[first].id;
@@ -205,7 +206,7 @@ pub fn move_captions(seq: &mut Sequence, ids: &[ClipId], delta: Tick) -> Result<
return Err(EditError::Nothing);
}
for tid in tracks {
let tr = seq.caption_track_mut(tid).expect("checked");
let Some(tr) = seq.caption_track_mut(tid) else { continue };
for c in tr.captions.iter_mut().filter(|c| ids.contains(&c.id)) {
c.start += d;
}
+40
View File
@@ -15,6 +15,8 @@
//!
//! Keyframes are stored in media time, so trims and splits never need to move them.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod captions;
pub mod multicam;
pub mod through;
@@ -181,6 +183,34 @@ pub fn remove_orphan_transitions(track: &mut Track) {
});
}
/// Where a transition sits: the incoming clip's start, or the outgoing clip's end for a fade to nothing.
fn transition_anchor(track: &Track, tr: &Transition) -> Option<Tick> {
match (tr.from, tr.to) {
(_, Some(to)) => track.item(to).map(|i| i.start),
(Some(from), None) => track.item(from).map(|i| i.end()),
(None, None) => None,
}
}
/// Keep transitions on their edit points after an edit moved cuts (ripple trim, roll, slide, ripple
/// speed change): each transition shifts by as much as its anchor clip edge moved.
fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) {
for track in after.all_tracks_mut() {
let Some(old) = before.track(track.id) else { continue };
let shifts: Vec<Tick> = track
.transitions
.iter()
.map(|tr| match (transition_anchor(old, tr), transition_anchor(track, tr)) {
(Some(a), Some(b)) => b - a,
_ => Tick::ZERO,
})
.collect();
for (tr, d) in track.transitions.iter_mut().zip(shifts) {
tr.start += d;
}
}
}
fn place(track: &mut Track, item: TrackItem) {
let idx = track.items.partition_point(|i| i.start <= item.start);
track.items.insert(idx, item);
@@ -520,6 +550,7 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
tr.sort();
}
}
transitions_follow_cuts(seq, &mut work);
}
work.check().map_err(EditError::Other)?;
*seq = work;
@@ -597,6 +628,7 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
}
tr.sort();
}
transitions_follow_cuts(seq, &mut work);
work.check().map_err(EditError::Other)?;
*seq = work;
Ok(d)
@@ -620,6 +652,7 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
return Ok(d);
}
let (ls, rs) = (l.speed.abs(), r.speed.abs());
let before = seq.clone();
{
let (_, l) = seq.find_item_mut(left).ok_or(EditError::NoItem(left))?;
l.duration += d;
@@ -631,6 +664,7 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
r.source_in += src_of(d, rs);
}
let _ = ls;
transitions_follow_cuts(&before, seq);
Ok(d)
}
@@ -673,6 +707,7 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
if d == Tick::ZERO {
return Ok(d);
}
let before = seq.clone();
let t = seq.track_mut(tid).ok_or(EditError::NoTrack(tid))?;
if let Some(p) = prev {
let pi = t.item_mut(p.id).ok_or(EditError::NoItem(p.id))?;
@@ -686,6 +721,7 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
}
let me = t.item_mut(clip).ok_or(EditError::NoItem(clip))?;
me.start += d;
transitions_follow_cuts(&before, seq);
Ok(d)
}
@@ -728,6 +764,7 @@ pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ri
new_dur = new_dur.min(next_start - it.start);
}
let delta = new_dur - it.duration;
let before = ripple.then(|| seq.clone());
{
let (_, it) = seq.find_item_mut(clip).ok_or(EditError::NoItem(clip))?;
it.speed = speed;
@@ -746,6 +783,9 @@ pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ri
}
}
}
if let Some(before) = before {
transitions_follow_cuts(&before, seq);
}
Ok(())
}
+47
View File
@@ -210,6 +210,53 @@ fn roll_slip_slide() {
fx.seq.check().unwrap();
}
#[test]
fn transitions_stay_on_their_cuts() {
let mut fx = Fx::new();
let v1 = fx.v(0);
let a = fx.put(v1, 0, 10, 10);
let b = fx.put(v1, 10, 10, 10);
let c = fx.put(v1, 20, 10, 10);
let cross = |id, start, from, to| Transition {
id: TransitionId(id),
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
start: f(start),
duration: f(4),
from,
to,
align: Default::default(),
reverse: false,
};
let t = fx.seq.track_mut(v1).unwrap();
t.transitions = vec![cross(1, 8, Some(a), Some(b)), cross(2, 18, Some(b), Some(c)), cross(3, 26, Some(c), None)];
// every transition is centred on its cut (or ends with the clip it fades out)
let check = |fx: &Fx, what: &str| {
let t = fx.seq.track(v1).unwrap();
for tr in &t.transitions {
let cut = match tr.to {
Some(to) => t.item(to).unwrap().start,
None => t.item(tr.from.unwrap()).unwrap().end() - f(2),
};
assert_eq!(tr.start + f(2), cut, "{what}: transition {:?} left its cut", tr.id);
}
assert_eq!(t.transitions.len(), 3, "{what}");
};
let mut n = fx.next;
trim(&mut fx.seq, a, Edge::Out, TrimMode::Ripple, f(4), &mut Fx::ctx(&mut n)).unwrap();
check(&fx, "ripple trim out");
trim(&mut fx.seq, b, Edge::In, TrimMode::Ripple, f(3), &mut Fx::ctx(&mut n)).unwrap();
check(&fx, "ripple trim in");
ripple_trim_group(&mut fx.seq, &[b], Edge::Out, -f(2), &mut Fx::ctx(&mut n)).unwrap();
check(&fx, "ripple trim group");
roll(&mut fx.seq, b, c, f(2), &mut Fx::ctx(&mut n)).unwrap();
check(&fx, "roll");
slide(&mut fx.seq, b, -f(3), &mut Fx::ctx(&mut n)).unwrap();
check(&fx, "slide");
set_speed(&mut fx.seq, b, 0.5, false, true, &mut Fx::ctx(&mut n)).unwrap();
check(&fx, "ripple speed change");
fx.seq.check().unwrap();
}
#[test]
fn rate_stretch_and_speed() {
let mut fx = Fx::new();
+4 -2
View File
@@ -344,7 +344,9 @@ pub fn caption_blocks(words: &[SeqWord], rules: &CaptionRules, rate: FrameRate)
continue;
}
if fits_line {
*lines.last_mut().expect("non-empty") += 1 + len;
if let Some(l) = lines.last_mut() {
*l += 1 + len;
}
} else {
lines.push(len);
}
@@ -362,7 +364,7 @@ pub fn caption_blocks(words: &[SeqWord], rules: &CaptionRules, rate: FrameRate)
for g in groups {
let mut text_lines: Vec<String> = vec![String::new()];
for w in &words[g.clone()] {
let l = text_lines.last_mut().expect("non-empty");
let Some(l) = text_lines.last_mut() else { break };
if l.is_empty() {
l.push_str(&w.text);
} else if l.chars().count() + 1 + w.text.chars().count() <= max_chars {
+3 -3
View File
@@ -1097,7 +1097,7 @@ fn subclip_plan(s: &Session, p: &Value) -> Result<(ItemId, TimeRange, String, La
} else {
let item = item_p(p, "item").or_else(|| subclip_source(s)).ok_or_else(|| bad("clip.makeSubclip", "open a clip in the Source monitor"))?;
let (root, m, sub) = media_root(&s.project, item).ok_or_else(|| bad("clip.makeSubclip", "only media clips can be subclipped"))?;
let pi = s.project.item(item).expect("resolved");
let pi = s.project.item(item).ok_or_else(|| EngineError::Other("no such item".into()))?;
let rate = m.frame_rate();
let still = s.prefs.timeline.still_duration(rate);
let is_still = matches!(m.info.kind, filmcraft_media::MediaKind::Still);
@@ -1246,7 +1246,7 @@ fn audio_channels(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = Vec::new();
let mut maps = Vec::new();
for i in &items {
let m = s.project.item(*i).and_then(|it| it.as_media()).expect("filtered");
let Some(m) = s.project.item(*i).and_then(|it| it.as_media()) else { continue };
let n = m.info.audio.as_ref().map_or(2, |a| a.channels) as u16;
let fmt = format.unwrap_or_else(|| m.interpret.audio_channels.as_ref().map_or(AudioChannels::Stereo, |a| a.format));
let map = match &custom {
@@ -1603,7 +1603,7 @@ fn extract_audio(s: &mut Session, p: &Value) -> Result<Value> {
})
.ok_or_else(|| EngineError::Other("save the project first: extracted audio is written next to it".into()))?,
};
let pi = s.project.item(item).expect("resolved");
let pi = s.project.item(item).ok_or_else(|| EngineError::Other("no such item".into()))?;
let name = pi.name.clone();
let stem = std::path::Path::new(&name).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
let src = s.media.full_res_source(&s.project, root, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: media is offline")))?;
+3 -3
View File
@@ -33,7 +33,7 @@ fn lumetri_text(s: &Session, clip: ClipId, p: &str) -> String {
fn mean(img: &filmcraft_render::Image) -> [f32; 3] {
assert!(img.px.iter().any(|v| *v > 0.01), "blank render");
let mut m = [0f64; 3];
for p in img.px.chunks_exact(4) {
for p in img.px.as_chunks::<4>().0 {
for k in 0..3 {
m[k] += p[k] as f64;
}
@@ -213,7 +213,7 @@ fn lumetri_presets_list_apply_and_thumbnails() {
assert!(it.effects[k + 1..].iter().all(|e| e.def().is_some_and(|d| d.intrinsic)) || k + 1 == it.effects.len());
assert_eq!(it.effects[k].param("saturation").unwrap().value, ParamValue::Float(0.0));
let img = s.render_program(0.25).unwrap();
let grey = img.px.chunks_exact(4).filter(|p| p[3] > 0.5).all(|p| (p[0] - p[1]).abs() < 0.01 && (p[1] - p[2]).abs() < 0.01);
let grey = img.px.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.5).all(|p| (p[0] - p[1]).abs() < 0.01 && (p[1] - p[2]).abs() < 0.01);
assert!(grey, "the program is black and white");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.len(), before);
@@ -242,7 +242,7 @@ fn hdr_lumetri_and_mastering_metadata_through_commands() {
for k in 0..3 {
assert!((m1[k] / m0[k] - 2.0).abs() < 0.08, "channel {k}: {m0:?} → {m1:?}");
}
assert!(up.px.chunks_exact(4).any(|p| p[1] > 1.2), "values above reference white survive");
assert!(up.px.as_chunks::<4>().0.iter().any(|p| p[1] > 1.2), "values above reference white survive");
// HDR White / HDR Specular / HDR Range are ordinary Lumetri parameters
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "hdr_white", "value": 2000.0})).unwrap();
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "hdr_specular", "value": -50.0})).unwrap();
+44 -27
View File
@@ -362,7 +362,7 @@ pub(crate) fn place_item(
let frame = (seq.settings.width, seq.settings.height);
let mut placements = Vec::new();
let link = if has_v && has_a { Some(p.alloc_id()) } else { None };
if has_v {
if let Some(vdest) = vdest.filter(|_| has_v) {
let mut v = p.make_track_item(item, TrackKind::Video, at, range, rate).ok_or_else(|| bad(label, "bad item"))?;
v.link = link;
for e in &mut v.effects {
@@ -371,10 +371,9 @@ pub(crate) fn place_item(
if is_media && src_size != frame {
crate::settings::apply_media_scaling(&mut v, &scaling, frame, src_size);
}
placements.push((vdest.expect("checked"), v));
placements.push((vdest, v));
}
if has_a {
let adest = adest.expect("checked");
if let Some(adest) = adest.filter(|_| has_a) {
let (arange, aat) = audio_split.unwrap_or((range, at));
let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?;
// Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below
@@ -675,27 +674,27 @@ fn build() -> Vec<CommandSpec> {
Ok(id) => ids.push(id.0),
Err(e) => errors.push(format!("{path}: {e}")),
},
Ok(b) if crate::captions::detect(&path, &b).is_some() => {
let fmt = crate::captions::detect(&path, &b).expect("detected");
match crate::captions::import(s, &path, &b, fmt, None) {
Ok(r) => reports.push(r),
Err(e) => errors.push(format!("{path}: {e}")),
}
}
Ok(b) if crate::interchange::detect(&path, &b).is_some() => {
let fmt = crate::interchange::detect(&path, &b).expect("detected");
match crate::interchange::import(s, &path, &b, fmt) {
Ok(r) => {
sequences.extend(r["sequences"].as_array().cloned().unwrap_or_default());
reports.push(r);
Ok(b) => {
if let Some(fmt) = crate::captions::detect(&path, &b) {
match crate::captions::import(s, &path, &b, fmt, None) {
Ok(r) => reports.push(r),
Err(e) => errors.push(format!("{path}: {e}")),
}
} else if let Some(fmt) = crate::interchange::detect(&path, &b) {
match crate::interchange::import(s, &path, &b, fmt) {
Ok(r) => {
sequences.extend(r["sequences"].as_array().cloned().unwrap_or_default());
reports.push(r);
}
Err(e) => errors.push(format!("{path}: {e}")),
}
} else {
match import_bytes(s, &path, b.into(), bin) {
Ok(id) => ids.push(id.0),
Err(e) => errors.push(format!("{path}: {e}")),
}
Err(e) => errors.push(format!("{path}: {e}")),
}
}
Ok(b) => match import_bytes(s, &path, b.into(), bin) {
Ok(id) => ids.push(id.0),
Err(e) => errors.push(format!("{path}: {e}")),
},
Err(e) => errors.push(format!("{path}: {e}")),
}
}
@@ -1280,7 +1279,7 @@ fn build() -> Vec<CommandSpec> {
let p = p.clone();
s.edit("Sequence Settings", |pr, _| {
if let Some(n) = str_p(&p, "name") {
pr.item_mut(id).expect("seq").name = n.to_string();
pr.item_mut(id).ok_or(EngineError::NoSequence)?.name = n.to_string();
}
let q = pr.sequence_mut(id).ok_or(EngineError::NoSequence)?;
if let Some(w) = u64_p(&p, "width") {
@@ -1793,7 +1792,21 @@ fn build() -> Vec<CommandSpec> {
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide(q, c, d, ctx)?))?;
let clips = with_links(s, &[c]);
s.edit_sequence("Slide", |q, ctx, _| {
// clamp across all linked partners, then slide them by the common delta
let mut dd = d;
for c in &clips {
let x = edit::slide(&mut q.clone(), *c, dd, ctx)?;
if x.abs() < dd.abs() {
dd = x;
}
}
for c in &clips {
edit::slide(q, *c, dd, ctx)?;
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| {
@@ -2440,7 +2453,7 @@ fn nest(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Nest", |pr, st| {
let nid = pr.new_sequence(&name, q.settings.clone(), q.video_tracks.len(), q.audio_tracks.len(), None);
{
let nq = pr.sequence_mut(nid).expect("new");
let nq = pr.sequence_mut(nid).ok_or(EngineError::NoSequence)?;
for (k, ti, it) in &items {
let mut it = it.clone();
it.start -= start;
@@ -2451,11 +2464,15 @@ fn nest(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let rate = q.settings.frame_rate;
let mut v = pr.make_track_item(nid, TrackKind::Video, start, TimeRange::new(Tick::ZERO, end - start), rate).expect("item");
let mut v = pr
.make_track_item(nid, TrackKind::Video, start, TimeRange::new(Tick::ZERO, end - start), rate)
.ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
for e in &mut v.effects {
resolve_auto_points(e, (q.settings.width, q.settings.height), (q.settings.width, q.settings.height));
}
let a = pr.make_track_item(nid, TrackKind::Audio, start, TimeRange::new(Tick::ZERO, end - start), rate).expect("item");
let a = pr
.make_track_item(nid, TrackKind::Audio, start, TimeRange::new(Tick::ZERO, end - start), rate)
.ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
let link = pr.alloc_id();
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
edit::delete_items(seq, &sel);
+20 -11
View File
@@ -44,6 +44,15 @@ pub fn add_generator(p: &mut Project, pool: &MediaPool, src: GeneratorSource, na
/// Build the demo project. Returns (project, main sequence).
pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
build_demo(pool).unwrap_or_else(|| {
// Only reachable if the built-in items could not be placed: an empty demo, not a crash.
let mut p = Project::new("FilmCraft Demo");
let seq = p.new_sequence("Main Edit", SequenceSettings::default(), 3, 3, None);
(p, seq)
})
}
fn build_demo(pool: &MediaPool) -> Option<(Project, ItemId)> {
let mut p = Project::new("FilmCraft Demo");
let footage = p.add_bin("Footage", None);
let audio_bin = p.add_bin("Audio", None);
@@ -108,8 +117,8 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
for (ci, sin, dur) in cuts {
let src_range = TimeRange::new(r.snap(Tick::from_seconds_f64(sin)), r.snap(Tick::from_seconds_f64(dur)));
let link = p.alloc_id();
let mut v = p.make_track_item(clips[ci], TrackKind::Video, t, src_range, r).expect("item");
let mut a = p.make_track_item(clips[ci], TrackKind::Audio, t, src_range, r).expect("item");
let mut v = p.make_track_item(clips[ci], TrackKind::Video, t, src_range, r)?;
let mut a = p.make_track_item(clips[ci], TrackKind::Audio, t, src_range, r)?;
v.link = Some(link);
a.link = Some(link);
for e in &mut v.effects {
@@ -118,14 +127,14 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
a.effect_mut("volume").map(|e| e.params.get_mut("level").map(|l| l.value = ParamValue::Float(-8.0)));
let dur_t = v.duration;
placed.push((v.id, a.id, t, dur_t));
let s = p.sequence_mut(seq).expect("seq");
let s = p.sequence_mut(seq)?;
s.video_tracks[0].items.push(v);
s.audio_tracks[0].items.push(a);
t += dur_t;
}
// A lower-third-ish overlay on V2: the plasma clip scaled down in the corner with a drop shadow.
let ov_range = TimeRange::new(Tick::ZERO, r.snap(Tick::from_seconds_f64(4.0)));
let mut ov = p.make_track_item(clips[4], TrackKind::Video, r.snap(Tick::from_seconds_f64(6.0)), ov_range, r).expect("item");
let mut ov = p.make_track_item(clips[4], TrackKind::Video, r.snap(Tick::from_seconds_f64(6.0)), ov_range, r)?;
for e in &mut ov.effects {
resolve_auto_points(e, (1920, 1080), (1920, 1080));
}
@@ -152,18 +161,18 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
lum.params.get_mut("temperature").map(|v| v.value = ParamValue::Float(18.0));
lum.params.get_mut("contrast").map(|v| v.value = ParamValue::Float(22.0));
lum.params.get_mut("vignette_amount").map(|v| v.value = ParamValue::Float(-1.4));
let s = p.sequence_mut(seq).expect("seq");
let s = p.sequence_mut(seq)?;
if let Some(it) = s.video_tracks[0].items.get_mut(3) {
it.effects.insert(0, lum);
}
}
let s = p.sequence_mut(seq).expect("seq");
let s = p.sequence_mut(seq)?;
s.video_tracks[1].items.push(ov);
// Transitions: cross dissolve between shots 1-2, dip to black 3-4, push 5-6, fade in at start.
let tr_len = r.tick_of(24);
let mk = |id: u64, eff: &str, from: Option<filmcraft_project::ClipId>, to: Option<filmcraft_project::ClipId>, start: Tick| Transition {
id: TransitionId(id),
effect: find_effect(eff).expect("fx").instance(),
effect: crate::presets::effect_instance(eff),
start,
duration: tr_len,
from,
@@ -181,7 +190,7 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
s.video_tracks[0].transitions = trs;
s.audio_tracks[0].transitions.push(Transition {
id: TransitionId(900_010),
effect: find_effect("constant_power").expect("fx").instance(),
effect: crate::presets::effect_instance("constant_power"),
start: cut(1) - tr_len.mul_ratio(1, 2),
duration: tr_len,
from: Some(placed[0].1),
@@ -207,13 +216,13 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
});
}
// Music on A2 (under the whole edit), ducked -14 dB.
let mut m = p.make_track_item(music, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, end), r).expect("music");
let mut m = p.make_track_item(music, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, end), r)?;
m.effect_mut("volume").map(|e| e.params.get_mut("level").map(|l| l.value = ParamValue::Float(-14.0)));
m.label = Label::Caribbean;
let s = p.sequence_mut(seq).expect("seq");
let s = p.sequence_mut(seq)?;
s.audio_tracks[1].items.push(m);
for tr in s.all_tracks_mut() {
tr.sort();
}
(p, seq)
Some((p, seq))
}
+3
View File
@@ -411,6 +411,9 @@ fn ranges_resolve_and_sidecar_captions_are_written() {
assert_eq!(frames(&mut s, json!({"preset": "PNG Sequence"})), io, "In/Out is the default");
assert!(s.execute("export.resolve", json!({"range": "workArea"})).is_err(), "no work area");
assert_eq!(frames(&mut s, json!({"preset": "Animated GIF 640×360", "range": "custom", "startSeconds": 0, "endSeconds": 2})), 30, "15 fps");
let bare = frames(&mut s, json!({"preset": "Animated GIF 640×360", "startSeconds": 0, "endSeconds": 2}));
assert_eq!(bare, 30, "start/end times without `range` are a custom range, not the In/Out or whole sequence");
assert!(s.execute("export.resolve", json!({"startSeconds": 1})).is_err(), "a start without an end is an error");
let res = s.execute("export.resolve", json!({"preset": "YouTube 2160p 4K Ultra HD"})).unwrap();
assert_eq!(res["output"]["width"], 3840);
assert!(res["summary"]["video"].as_str().unwrap().contains("3840x2160"));
+39 -1
View File
@@ -244,12 +244,14 @@ pub fn sequence_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
}
/// Export ▸ Range: `entire` | `inOut` | `workArea` | `custom` (`start…` / `end…` times).
/// None = the default (In/Out when set, else the whole sequence).
/// None = the default (In/Out when set, else the whole sequence); bare `start…` / `end…` times
/// without a `range` mean `custom`.
pub fn range_param(s: &Session, project: &Project, seq: ItemId, v: Option<&Value>, p: &Value, cmd: &str) -> Result<Option<TimeRange>> {
let q = project.sequence(seq).ok_or(EngineError::NoSequence)?;
let fd = q.settings.frame_rate.frame_duration();
let whole = TimeRange::from_bounds(Tick::ZERO, q.duration().max(fd));
let mode = match v {
None | Some(Value::Null) if time_p(s, p, "start").is_some() || time_p(s, p, "end").is_some() => "custom".to_string(),
None | Some(Value::Null) => return Ok(None),
Some(Value::String(m)) => m.to_ascii_lowercase().replace(['-', '_', ' ', '/'], ""),
Some(o @ Value::Object(_)) => return range_param(s, project, seq, o.get("mode").or(Some(&json!("custom"))), o, cmd),
@@ -385,6 +387,7 @@ pub fn spawn_export(s: &mut Session, project: Arc<Project>, seq: ItemId, mut set
}
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
};
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
if wait || cfg!(target_arch = "wasm32") {
run();
@@ -394,6 +397,24 @@ pub fn spawn_export(s: &mut Session, project: Arc<Project>, seq: ItemId, mut set
Ok(id)
}
/// Last-resort guard for a background job body (export, previews, proxies, …): a panic marks the
/// job failed with an error instead of killing its thread and leaving the job "running" forever.
/// The panic hook has already logged where it happened.
pub(crate) fn guard_job(
progress: Arc<filmcraft_export::Progress>,
result: Arc<std::sync::Mutex<Option<std::result::Result<filmcraft_export::Report, String>>>>,
run: impl FnOnce() + Send + 'static,
) -> impl FnOnce() + Send + 'static {
move || {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).is_err() {
let msg = "internal error (see the crash log); the job was stopped".to_string();
*progress.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(msg.clone());
progress.finished.store(true, Ordering::Relaxed);
*result.lock().unwrap_or_else(|x| x.into_inner()) = Some(Err(msg));
}
}
}
fn file_name(path: &str) -> String {
Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
@@ -1008,3 +1029,20 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
spec("export.quick", "Quick Export", r#"{"preset":str?,"path":str?,"wait":bool?, …settings params}"#, has_seq, quick, true),
]
}
#[cfg(test)]
mod guard_tests {
use super::*;
/// A panicking job body must leave the job finished with an error, not "running" forever.
#[test]
fn panicking_job_is_reported_as_failed() {
let progress: Arc<filmcraft_export::Progress> = Arc::default();
let result: Arc<std::sync::Mutex<Option<std::result::Result<filmcraft_export::Report, String>>>> = Arc::default();
let run = guard_job(progress.clone(), result.clone(), || panic!("job bug"));
std::thread::spawn(run).join().expect("the guard catches the panic");
assert!(progress.finished.load(Ordering::Relaxed));
assert!(progress.error.lock().unwrap().is_some());
assert!(matches!(&*result.lock().unwrap(), Some(Err(_))));
}
}
+9 -9
View File
@@ -157,15 +157,15 @@ pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec<u8>) {
let mut p = Project::new("thumb");
let rate = filmcraft_time::FrameRate::FPS_30;
let g = p.add_item("G", Label::Rose, ItemKind::Graphic { width: t.canvas[0], height: t.canvas[1], rate }, None);
let mut it = p
.make_track_item(
g,
filmcraft_project::TrackKind::Video,
Tick::ZERO,
filmcraft_time::TimeRange::new(Tick::ZERO, t.duration.max(rate.frame_duration())),
rate,
)
.expect("item");
let Some(mut it) = p.make_track_item(
g,
filmcraft_project::TrackKind::Video,
Tick::ZERO,
filmcraft_time::TimeRange::new(Tick::ZERO, t.duration.max(rate.frame_duration())),
rate,
) else {
return (w, h, vec![0; w as usize * h as usize * 4]);
};
it.effects.extend(t.layers.iter().cloned());
it.graphic = Some(Box::new(meta));
// a moment into the clip, so intro animations have settled a little
+5 -5
View File
@@ -551,11 +551,11 @@ macro_rules! arrange_cmd {
fn new_shape_cmd(s: &mut Session, p: &Value, shape: &str) -> Result<Value> {
let mut q = p.clone();
q["shape"] = json!(shape);
(find_spec("graphics.newShape").run)(s, &q)
(find_spec("graphics.newShape")?.run)(s, &q)
}
fn find_spec(id: &str) -> CommandSpec {
commands().into_iter().find(|c| c.id == id).expect("graphics command")
fn find_spec(id: &str) -> Result<CommandSpec> {
commands().into_iter().find(|c| c.id == id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))
}
/// Graphics menu commands (New Layer shapes, Align/Distribute/Arrange/Select submenus, resets).
@@ -576,7 +576,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|s, p| {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["vertical"] = json!(true);
(find_spec("graphics.newText").run)(s, &p)
(find_spec("graphics.newText")?.run)(s, &p)
},
),
spec(
@@ -731,7 +731,7 @@ fn menu_commands() -> Vec<CommandSpec> {
let rate = q.settings.frame_rate;
let want = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration());
let tr = q.video_tracks.iter_mut().find(|tr| tr.items.iter().any(|i| i.id == clip)).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let i = tr.items.iter().position(|i| i.id == clip).expect("found");
let i = tr.items.iter().position(|i| i.id == clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let start = tr.items[i].start;
let limit = tr.items.get(i + 1).map_or(want, |n| n.start - start);
let d = want.min(limit).max(rate.frame_duration());
+7 -1
View File
@@ -8,6 +8,8 @@
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots (cheap
//! thanks to structural sharing of untouched items).
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod aaf_omf;
pub mod autosave;
pub mod captions;
@@ -599,7 +601,11 @@ impl Session {
return Err(e);
}
self.exec_depth += 1;
let r = (spec.run)(self, &params);
// Last-resort guard: a panic in a command (a bug that escaped the never-crash rules)
// becomes an error for the caller (UI, CLI, control channel, MCP) instead of taking the
// session and its unsaved project down. The panic hook has logged where it happened.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &params)))
.unwrap_or_else(|_| Err(EngineError::Other(format!("internal error in `{id}` (see the crash log); the command did not complete"))));
self.exec_depth -= 1;
// source graphics: an edited instance updates the shared layers and the other instances
if r.is_ok() && spec.journal && id.starts_with("graphics.") && !self.project.source_graphics.is_empty() {
+1
View File
@@ -634,6 +634,7 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
prog.finished.store(true, Ordering::Relaxed);
*lock(&res) = Some(r);
};
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
s.mask_jobs.push(PendingTrack { job: id, target: MaskSel { clip, effect: ei, mask: mi }, seq: seq_id, keys, applied: 0 });
let wait = crate::commands::bool_p(p, "wait").unwrap_or(false);
+1 -1
View File
@@ -101,7 +101,7 @@ pub fn psnr(a: &[u8], b: &[u8]) -> f64 {
assert_eq!(a.len(), b.len());
let mut se = 0.0;
let mut n = 0.0;
for (x, y) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
for (x, y) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0) {
for c in 0..3 {
let d = x[c] as f64 - y[c] as f64;
se += d * d;
+11 -7
View File
@@ -554,7 +554,7 @@ fn merge_clips(s: &mut Session, p: &Value) -> Result<Value> {
a.link = Some(link);
a_items.push(a);
}
let q = pr.sequence_mut(sid).expect("new sequence");
let q = pr.sequence_mut(sid).ok_or(EngineError::NoSequence)?;
for (t, it) in q.video_tracks.iter_mut().zip(v_items) {
t.items.push(it);
}
@@ -616,7 +616,7 @@ fn create_multicam(s: &mut Session, p: &Value) -> Result<Value> {
let (mut vi, mut ai) = (0usize, 0usize);
let mut placed: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
let (vtracks, atracks): (Vec<TrackId>, Vec<TrackId>) = {
let q = pr.sequence(sid).expect("new sequence");
let q = pr.sequence(sid).ok_or(EngineError::NoSequence)?;
(q.video_tracks.iter().map(|t| t.id).collect(), q.audio_tracks.iter().map(|t| t.id).collect())
};
for (k, c) in clips.iter().enumerate() {
@@ -640,7 +640,7 @@ fn create_multicam(s: &mut Session, p: &Value) -> Result<Value> {
cameras.push(cam);
}
{
let q = pr.sequence_mut(sid).expect("new sequence");
let q = pr.sequence_mut(sid).ok_or(EngineError::NoSequence)?;
for (kind, idx, it) in placed {
q.tracks_mut(kind)[idx].items.push(it);
}
@@ -892,10 +892,14 @@ fn record_start(s: &mut Session, p: &Value) -> Result<Value> {
fn apply_pass(s: &mut Session, end: Tick) -> Result<usize> {
let Some(seq_id) = s.mcrec.seq else { return Ok(0) };
let merging = s.history.merge_key.as_deref() == Some(s.mcrec.key.as_str()) && s.history.undo.last().is_some_and(|u| u.0 == RECORD_LABEL);
if !merging || s.mcrec.base.is_none() {
s.mcrec.base = Some(s.project.clone());
}
let base = s.mcrec.base.clone().expect("set above");
let base = match s.mcrec.base.clone() {
Some(b) if merging => b,
_ => {
let b = s.project.clone();
s.mcrec.base = Some(b.clone());
b
}
};
let (tracks, cuts, key) = (s.mcrec.tracks.clone(), s.mcrec.cuts.clone(), s.mcrec.key.clone());
let Some(source) = s.mcrec.source else { return Ok(0) };
let media = s.media.clone();
+15 -1
View File
@@ -151,8 +151,22 @@ fn kf(t_secs: f64, v: ParamValue) -> Keyframe {
Keyframe::new(Tick((t_secs * TICKS_PER_SECOND as f64).round() as i64), v)
}
/// A fresh instance of a built-in effect (a bare instance named `id` if the registry lacks it,
/// which the preset tests rule out).
pub(crate) fn effect_instance(id: &str) -> EffectInstance {
filmcraft_project::find_effect(id).map(|d| d.instance()).unwrap_or_else(|| EffectInstance {
effect: id.to_string(),
enabled: true,
params: Default::default(),
masks: Vec::new(),
post_fader: false,
essential: false,
layer: None,
})
}
fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance {
let mut e = filmcraft_project::find_effect(id).unwrap_or_else(|| panic!("built-in preset uses unknown effect {id}")).instance();
let mut e = effect_instance(id);
for (k, v) in params {
if let Some(p) = e.params.get_mut(*k) {
p.value = v.clone();
+3 -1
View File
@@ -543,6 +543,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
prog.finished.store(true, Ordering::Relaxed);
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
};
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
if wait || cfg!(target_arch = "wasm32") {
@@ -629,6 +630,7 @@ pub fn render_audio(s: &mut Session, p: &Value) -> Result<Value> {
prog.finished.store(true, Ordering::Relaxed);
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
};
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
if p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32") {
run();
@@ -680,7 +682,7 @@ pub fn read_wav_f32(b: &[u8]) -> Option<Vec<f32>> {
if !float_stereo {
return None;
}
return Some(body.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect());
return Some(body.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect());
}
i += 8 + len + (len & 1);
}
+1
View File
@@ -229,6 +229,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
prog.finished.store(true, Ordering::Relaxed);
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
};
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
if bool_p(p, "wait").unwrap_or(false) || cfg!(target_arch = "wasm32") {
run();
+1
View File
@@ -241,6 +241,7 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st
prog.finished.store(true, std::sync::atomic::Ordering::Relaxed);
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
};
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
s.media_jobs.push(PendingJob { job: id, on_done, outputs });
if wait || cfg!(target_arch = "wasm32") {
+3 -4
View File
@@ -35,12 +35,11 @@ pub fn fingerprint(size: u64, head: &[u8], tail: &[u8]) -> u64 {
h ^= h >> 29;
};
for part in [head, tail] {
let mut chunks = part.chunks_exact(8);
for c in &mut chunks {
mix(u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]));
let (chunks, r) = part.as_chunks::<8>();
for c in chunks {
mix(u64::from_le_bytes(*c));
}
let mut last = [0u8; 8];
let r = chunks.remainder();
last[..r.len()].copy_from_slice(r);
mix(u64::from_le_bytes(last) ^ (r.len() as u64) << 56);
mix(part.len() as u64);
+1
View File
@@ -206,6 +206,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
*lock(&res) = Some(r);
prog.finished.store(true, Ordering::Relaxed);
};
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
s.jobs.push(job);
s.scene_jobs.push(PendingScene { job: id, seq: seq_id, apply, results: results.clone() });
let wait = bool_p(p, "wait").unwrap_or(false);
+4 -1
View File
@@ -381,7 +381,10 @@ fn make_subsequence(s: &mut Session, p: &Value) -> Result<Value> {
let offset = range.map(|r| r.start).unwrap_or_else(|| items.iter().map(|i| i.2.start).min().unwrap_or_default());
let name = match str_p(p, "name") {
Some(n) => n.to_string(),
None => (1..).map(|k| format!("{seq_name}_Sub_{k:02}")).find(|n| !s.project.items.values().any(|i| &i.name == n)).expect("free name"),
None => (1..)
.map(|k| format!("{seq_name}_Sub_{k:02}"))
.find(|n| !s.project.items.values().any(|i| &i.name == n))
.unwrap_or_else(|| format!("{seq_name}_Sub")),
};
let id = s.edit("Make Subsequence", |pr, st| {
let nid = pr.new_sequence(&name, q.settings.clone(), q.video_tracks.len(), q.audio_tracks.len(), None);
+4 -2
View File
@@ -441,7 +441,9 @@ fn bindable(c: &CommandSpec) -> bool {
}
_ => {}
}
fields.last_mut().expect("field").push(ch);
if let Some(f) = fields.last_mut() {
f.push(ch);
}
}
fields.iter().all(|f| f.contains('?') || f.contains('='))
}
@@ -505,7 +507,7 @@ impl Shortcuts {
dir: None,
session_start: None,
};
s.bindings = s.builtin(DEFAULT_PRESET).expect("default preset");
s.bindings = s.builtin(DEFAULT_PRESET).unwrap_or_default();
s
}
+12
View File
@@ -83,6 +83,18 @@ fn trim_linked_and_ripple_delete() {
assert_eq!(q.video_tracks[0].items[0].start, s.sequence_rate().tick_of(12), "the trim gap remains");
}
#[test]
fn slide_moves_linked_audio_with_the_video() {
let mut s = demo();
let v = s.active_sequence().unwrap().video_tracks[0].items[1].clone();
s.execute("timeline.slide", json!({"clip": v.id.0, "deltaFrames": 5})).unwrap();
let q = s.active_sequence().unwrap();
let (_, nv) = q.find_item(v.id).unwrap();
let a = q.audio_tracks[0].items.iter().find(|i| i.link == v.link).unwrap();
assert_eq!(nv.start, v.start + s.sequence_rate().tick_of(5));
assert_eq!((a.start, a.source_in), (nv.start, nv.source_in), "linked audio slides too and stays in sync");
}
#[test]
fn zero_fps_is_refused() {
let mut s = demo();
+1 -1
View File
@@ -433,7 +433,7 @@ pub fn shuttle(s: &mut Session, p: &Value) -> Result<Value> {
tick(s, clock)?;
}
let rate = s.sequence_rate();
let d = s.trim_play.dynamic.as_mut().expect("dynamic trim");
let d = s.trim_play.dynamic.as_mut().ok_or_else(|| EngineError::Other("dynamic trimming is not active".into()))?;
let speed = if slow {
0.25 * dir
} else if d.speed * dir > 0.0 && !d.at_limit {
+1 -1
View File
@@ -568,7 +568,7 @@ impl VideoEncoder for MjpegEncoder {
self.rate.num as u32
}
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
let rgb: Vec<u8> = f.rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
let rgb: Vec<u8> = f.rgba.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let mut out = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, self.quality);
enc.encode(&rgb, f.width, f.height, image::ExtendedColorType::Rgb8).map_err(|e| ExportError::Encode(e.to_string()))?;
+2 -2
View File
@@ -225,7 +225,7 @@ fn ffmpeg_oracle_op1a_and_op_atom() {
let at = mx.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap();
let ours = mx.read_pcm(&bytes, at, 0, 48_000).unwrap();
assert_eq!(pcm.len(), 48_000 * 2 * 4, "{codec:?}");
for (i, c) in pcm.chunks_exact(4).enumerate() {
for (i, c) in pcm.as_chunks::<4>().0.iter().enumerate() {
let s = i32::from_le_bytes([c[0], c[1], c[2], c[3]]) >> 8;
assert_eq!(s as f32 / 8_388_608.0, ours[i % 2][i / 2], "{codec:?}: sample {i}");
}
@@ -255,7 +255,7 @@ fn ffmpeg_oracle_op1a_and_op_atom() {
assert_eq!((s["codec_name"].as_str(), s["channels"].as_u64(), s["sample_rate"].as_str()), (Some("pcm_s24le"), Some(1), Some("48000")), "{a}");
let mono = ffmpeg_raw(&ffmpeg, a, "0:a:0", &["-f", "s32le", "-acodec", "pcm_s32le"]);
assert_eq!(mono.len(), 48_000 * 4, "{a}");
let want: Vec<u8> = stereo.chunks_exact(8).flat_map(|f| f[k * 4..k * 4 + 4].to_vec()).collect();
let want: Vec<u8> = stereo.as_chunks::<8>().0.iter().flat_map(|f| f[k * 4..k * 4 + 4].to_vec()).collect();
assert!(mono == want, "{a}: channel {k} differs from the WAV export");
}
}
+4 -4
View File
@@ -111,7 +111,7 @@ impl Pipeline {
if let Some(tf) = self.out_tf.as_ref().filter(|_| self.hdr_out) {
let mut out = vec![0f32; img.w * img.h * 3];
out.par_chunks_mut(img.w * 3).zip(img.px.par_chunks(img.w * 4)).for_each(|(o, s)| {
for (o, p) in o.chunks_exact_mut(3).zip(s.chunks_exact(4)) {
for (o, p) in o.as_chunks_mut::<3>().0.iter_mut().zip(s.as_chunks::<4>().0) {
o.copy_from_slice(&tf.encode([p[0], p[1], p[2]]));
}
});
@@ -239,7 +239,7 @@ fn load_overlay(path: &str, width: f32) -> Result<Image> {
let rgba = image::load_from_memory(&bytes).map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8();
let (iw, ih) = (rgba.width() as usize, rgba.height() as usize);
let mut img = Image::new(iw, ih);
for (o, p) in img.px.chunks_exact_mut(4).zip(rgba.as_raw().chunks_exact(4)) {
for (o, p) in img.px.as_chunks_mut::<4>().0.iter_mut().zip(rgba.as_raw().as_chunks::<4>().0) {
let a = p[3] as f32 / 255.0;
for c in 0..3 {
o[c] = filmcraft_color::srgb_to_linear(p[c] as f32 / 255.0) * a;
@@ -310,7 +310,7 @@ fn resample_axis(src: &Image, n: usize, horizontal: bool) -> Image {
pub fn limit_rgba8(rgba: &mut [u8], min_percent: f32, max_percent: f32) {
let (lo, hi) = (min_percent.clamp(0.0, 100.0) / 100.0, max_percent.clamp(0.0, 100.0) / 100.0);
rgba.par_chunks_mut(4 * 1024).for_each(|chunk| {
for p in chunk.chunks_exact_mut(4) {
for p in chunk.as_chunks_mut::<4>().0 {
let mut c = [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0];
limit_px(&mut c, lo, hi);
for i in 0..3 {
@@ -323,7 +323,7 @@ pub fn limit_rgba8(rgba: &mut [u8], min_percent: f32, max_percent: f32) {
fn limit_f32(rgb: &mut [f32], min_percent: f32, max_percent: f32) {
let (lo, hi) = (min_percent.clamp(0.0, 100.0) / 100.0, max_percent.clamp(0.0, 100.0) / 100.0);
rgb.par_chunks_mut(3 * 1024).for_each(|chunk| {
for p in chunk.chunks_exact_mut(3) {
for p in chunk.as_chunks_mut::<3>().0 {
let mut c = [p[0], p[1], p[2]];
limit_px(&mut c, lo, hi);
p.copy_from_slice(&c);
+1 -1
View File
@@ -204,7 +204,7 @@ fn read_wav16(path: &str) -> Vec<Vec<f32>> {
let ch = u16::from_le_bytes([b[22], b[23]]) as usize;
let data = &b[44..];
let mut out = vec![Vec::new(); ch];
for (i, s) in data.chunks_exact(2).enumerate() {
for (i, s) in data.as_chunks::<2>().0.iter().enumerate() {
out[i % ch].push(i16::from_le_bytes([s[0], s[1]]) as f32 / 32768.0);
}
out
+1 -1
View File
@@ -110,7 +110,7 @@ fn decode(path: &str) -> Option<Vec<Vec<f64>>> {
let out =
std::process::Command::new(ffmpeg).args(["-v", "error", "-i", path, "-map", "0:a:0", "-f", "s32le", "-acodec", "pcm_s32le", "-"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let ints: Vec<i32> = out.stdout.chunks_exact(4).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let ints: Vec<i32> = out.stdout.as_chunks::<4>().0.iter().map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let n = ints.len() / 6;
Some((0..6).map(|c| (0..n).map(|i| ints[i * 6 + c] as f64).collect()).collect())
}
+2
View File
@@ -34,6 +34,8 @@
//! per-character text styles (`TrackItem::graphic`, `EffectInstance::layer`) and source graphics
//! (`Project::source_graphics`). No-op step; older builds would drop these fields when saving.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod atomic;
pub mod autosave;
+4 -2
View File
@@ -5,6 +5,8 @@
//! compositor. Pixel data is `Arc`-shared so caches, monitors and export share frames for free.
//! - [`AudioBuffer`]: planar f32 samples.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use std::sync::Arc;
use filmcraft_color::{ColorInfo, DecodeTable, Matrix, Range, linear_to_srgb_u8, normalize_c, normalize_y, srgb_u8_to_linear_table, to_linear, ycbcr_to_rgb};
@@ -383,14 +385,14 @@ impl VideoFrame {
pub fn luma8(&self) -> Vec<u8> {
match &self.data {
PixelData::Yuv8 { planes, .. } => planes[0].as_ref().clone(),
_ => self.to_rgba8().chunks_exact(4).map(|p| (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) as u8).collect(),
_ => self.to_rgba8().as_chunks::<4>().0.iter().map(|p| (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) as u8).collect(),
}
}
}
/// Convert a row of premultiplied linear f32 RGBA into straight sRGB RGBA8.
pub fn linear_premul_to_srgb8(src: &[f32], dst: &mut [u8]) {
for (s, o) in src.chunks_exact(4).zip(dst.chunks_exact_mut(4)) {
for (s, o) in src.as_chunks::<4>().0.iter().zip(dst.as_chunks_mut::<4>().0) {
let a = s[3].clamp(0.0, 1.0);
if a <= 0.0 {
o.copy_from_slice(&[0, 0, 0, 0]);
+2
View File
@@ -1,5 +1,7 @@
//! 2D geometry for FilmCraft: vectors, rectangles and affine transforms (Motion effect math).
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use serde::{Deserialize, Serialize};
use std::ops::{Add, Mul, Sub};
+5 -3
View File
@@ -185,7 +185,9 @@ impl MediaSource for Encoded {
let m = filmcraft_color::spaces::to_f32(&filmcraft_color::spaces::gamut_matrix(Gamut::Bt709, self.target.gamut()));
let curve = self.target.curve();
let out: Vec<u8> = rgba
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let lin = [0, 1, 2].map(|k| filmcraft_color::srgb_to_linear(p[k] as f32 / 255.0) * self.gain);
let c = filmcraft_color::spaces::apply3(&m, lin);
@@ -723,7 +725,7 @@ fn lumetri_presets_grid() {
fn scenes_are_distinct() {
let imgs: Vec<(&str, Rgba8)> = scenes().into_iter().map(|(n, _, f)| (n, render_cpu(&f()))).collect();
for (n, img) in &imgs {
let lit = img.px.chunks_exact(4).filter(|p| p[0] as u32 + p[1] as u32 + p[2] as u32 > 30).count();
let lit = img.px.as_chunks::<4>().0.iter().filter(|p| p[0] as u32 + p[1] as u32 + p[2] as u32 > 30).count();
assert!(lit > img.px.len() / 4 / 4, "{n}: mostly black");
}
for i in 0..imgs.len() {
@@ -758,7 +760,7 @@ fn gpu_matches_cpu_on_golden_scenes() {
let kind = if matches!(plan, filmcraft_render::plan::FramePlan::Layers { .. }) { "layers" } else { "cpu image" };
c.composite(&plan);
let (gw, gh, mut px) = c.read_output().expect("GPU readback");
px.chunks_exact_mut(4).for_each(|p| p[3] = 255);
px.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
let gpu = Rgba8::new(gw, gh, px);
let d = diff(&cpu, &gpu).unwrap();
// mean over RGB only (alpha is equal by construction)
+17 -16
View File
@@ -13,6 +13,8 @@
//!
//! The CPU plan executor (`filmcraft_render::plan::execute_cpu`) is the oracle; tests compare.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use std::collections::HashMap;
use std::sync::Arc;
@@ -135,7 +137,7 @@ const CHUNK: usize = 1 << 15;
fn f32_to_f16_bytes(v: &[f32]) -> Vec<u8> {
let mut out = vec![0u8; v.len() * 2];
out.par_chunks_mut(CHUNK * 2).zip(v.par_chunks(CHUNK)).for_each(|(o, s)| {
for (o, x) in o.chunks_exact_mut(2).zip(s) {
for (o, x) in o.as_chunks_mut::<2>().0.iter_mut().zip(s) {
o.copy_from_slice(&f32_to_f16(*x).to_le_bytes());
}
});
@@ -157,7 +159,7 @@ fn codes_to_f16_bytes(v: &[u16], bits: u32) -> Vec<u8> {
let table = code_table(bits);
let mut out = vec![0u8; v.len() * 2];
out.par_chunks_mut(CHUNK * 2).zip(v.par_chunks(CHUNK)).for_each(|(o, s)| {
for (o, c) in o.chunks_exact_mut(2).zip(s) {
for (o, c) in o.as_chunks_mut::<2>().0.iter_mut().zip(s) {
o.copy_from_slice(&table[*c as usize]);
}
});
@@ -279,9 +281,9 @@ impl GpuCompositor {
h: u32,
format: wgpu::TextureFormat,
extra: wgpu::TextureUsages,
) {
if slot.as_ref().is_some_and(|s| s.2 == (w, h)) {
return;
) -> wgpu::TextureView {
if let Some(s) = slot.as_ref().filter(|s| s.2 == (w, h)) {
return s.1.clone();
}
let t = device.create_texture(&wgpu::TextureDescriptor {
label: Some("filmcraft-target"),
@@ -294,7 +296,8 @@ impl GpuCompositor {
view_formats: &[],
});
let v = t.create_view(&Default::default());
*slot = Some((t, v, (w, h)));
*slot = Some((t, v.clone(), (w, h)));
v
}
fn plane_texture(&mut self, w: u32, h: u32, format: wgpu::TextureFormat, bytes: &[u8], bpp: u32) -> wgpu::TextureView {
@@ -443,13 +446,13 @@ impl GpuCompositor {
}
/// Composite a plan; returns the output view (sRGB, over black) and its size.
pub fn composite(&mut self, plan: &FramePlan) -> (&wgpu::TextureView, (u32, u32)) {
pub fn composite(&mut self, plan: &FramePlan) -> (wgpu::TextureView, (u32, u32)) {
self.composite_prepared(plan, None)
}
/// [`composite`](Self::composite) with texel conversions already done by [`prepare`] (the
/// result is identical; only the upload work on this thread differs).
pub fn composite_prepared(&mut self, plan: &FramePlan, prep: Option<&PreparedPlan>) -> (&wgpu::TextureView, (u32, u32)) {
pub fn composite_prepared(&mut self, plan: &FramePlan, prep: Option<&PreparedPlan>) -> (wgpu::TextureView, (u32, u32)) {
let owned;
let (w, h, layers): (u32, u32, &[PlanLayer]) = match plan {
FramePlan::Layers { width, height, layers } => (*width as u32, *height as u32, layers.as_slice()),
@@ -469,8 +472,8 @@ impl GpuCompositor {
}
};
let (w, h) = (w.max(1), h.max(1));
Self::target(&self.device, &mut self.accum, w, h, ACCUM_FORMAT, wgpu::TextureUsages::empty());
Self::target(&self.device, &mut self.output, w, h, OUTPUT_FORMAT, wgpu::TextureUsages::COPY_SRC);
let accum_view = Self::target(&self.device, &mut self.accum, w, h, ACCUM_FORMAT, wgpu::TextureUsages::empty());
let out_view = Self::target(&self.device, &mut self.output, w, h, OUTPUT_FORMAT, wgpu::TextureUsages::COPY_SRC);
let keys: Vec<(usize, u32, u32)> = layers
.iter()
.enumerate()
@@ -503,19 +506,17 @@ impl GpuCompositor {
});
bind_groups.push(bg);
}
let accum_view = &self.accum.as_ref().expect("accum").1;
let out_view = &self.output.as_ref().expect("output").1;
let final_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("final"),
layout: &self.final_bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(accum_view) }],
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&accum_view) }],
});
let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("filmcraft-composite") });
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("layers"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: accum_view,
view: &accum_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
@@ -535,7 +536,7 @@ impl GpuCompositor {
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("resolve"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: out_view,
view: &out_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
@@ -550,7 +551,7 @@ impl GpuCompositor {
pass.draw(0..3, 0..1);
}
self.queue.submit([enc.finish()]);
(&self.output.as_ref().expect("output").1, (w, h))
(out_view, (w, h))
}
/// Read the output back as RGBA8 (tests / screenshots / thumbnails).
+1 -1
View File
@@ -112,7 +112,7 @@ impl GpuLut {
let _ = d.poll(wgpu::PollType::wait_indefinitely());
rx.recv().ok()?.ok()?;
let data = slice.get_mapped_range().ok()?;
let out = data.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let out = data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
drop(data);
rb.unmap();
Some(out)
+1 -1
View File
@@ -162,7 +162,7 @@ impl GpuMask {
let _ = d.poll(wgpu::PollType::wait_indefinitely());
rx.recv().ok()?.ok()?;
let data = slice.get_mapped_range().ok()?;
let v = data.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let v = data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
drop(data);
b.unmap();
Some(v)
+1 -1
View File
@@ -172,7 +172,7 @@ fn gpu_lut_matches_cpu_tetrahedral() {
}
let gpu = g.apply(&lut, &px).expect("gpu lut");
let mut worst = 0f32;
for (i, p) in px.chunks_exact(4).enumerate() {
for (i, p) in px.as_chunks::<4>().0.iter().enumerate() {
let c = lut.apply([p[0], p[1], p[2]]);
for k in 0..3 {
worst = worst.max((c[k] - gpu[i * 4 + k]).abs());
+3 -1
View File
@@ -527,7 +527,9 @@ impl SliceDecoder<'_> {
let pos = c.bit_pos().div_ceil(8);
let data = c.data();
ensure!(data.len() >= pos + 384, "truncated I_PCM macroblock");
let samples: [u8; 384] = data[pos..pos + 384].try_into().unwrap();
let Some(&samples) = data.get(pos..).and_then(|d| d.first_chunk::<384>()) else {
return invalid("truncated I_PCM macroblock");
};
c.set_bit_pos((pos + 384) * 8);
c.init_engine()?;
self.finish_pcm();
+1 -1
View File
@@ -32,7 +32,7 @@ impl VlcTable {
}
let suffix = &code[lz + 1..];
assert!(suffix.len() <= 5, "suffix too long in {code}");
let sbits = u32::from_str_radix(if suffix.is_empty() { "0" } else { suffix }, 2).unwrap();
let sbits = suffix.bytes().fold(0u32, |a, b| (a << 1) | u32::from(b == b'1'));
let free = 5 - suffix.len();
for ext in 0..(1u32 << free) {
let s = (sbits << free) | ext;
+10 -3
View File
@@ -232,7 +232,10 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
let mut w = [0u64; N];
for (i, w) in w.iter_mut().enumerate() {
let o = (y + i) * stride + x - 4;
*w = u64::from_le_bytes(pix[o..o + 8].try_into().unwrap());
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
return;
};
*w = u64::from_le_bytes(b);
}
let mut s = [[0i16; N]; 8];
for (k, row) in s.iter_mut().enumerate() {
@@ -256,7 +259,9 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
let base = (y - 4) * stride + x;
let mut s = [[0i16; N]; 8];
for (k, row) in s.iter_mut().enumerate() {
let src: &[u8; N] = pix[base + k * stride..base + k * stride + N].try_into().unwrap();
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
return;
};
for i in 0..N {
row[i] = src[i] as i16;
}
@@ -264,7 +269,9 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
filter_lanes(&mut s, ep, chroma);
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
let dst: &mut [u8; N] = (&mut pix[base + k * stride..base + k * stride + N]).try_into().unwrap();
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
return;
};
for i in 0..N {
dst[i] = row[i] as u8;
}
+7 -3
View File
@@ -267,7 +267,7 @@ impl Decoder {
if !o.frame.is_published(o.frame.mb_h() - 1) && self.out_queue.len() <= max_queue {
break;
}
let o = self.out_queue.pop_front().expect("front exists");
let Some(o) = self.out_queue.pop_front() else { break };
pics.push(make_picture(&o));
}
Ok(pics)
@@ -353,7 +353,9 @@ impl Decoder {
found = Some((pps.clone(), sps.clone()));
Ok((&**pps, &**sps))
})?;
let (pps, sps) = found.expect("lookup succeeded");
let Some((pps, sps)) = found else {
return Err(Error::MissingParameterSet("PPS".into()));
};
sps.check_supported()?;
if pps.num_slice_groups > 1 {
return unsupported("slice groups (FMO)");
@@ -379,7 +381,9 @@ impl Decoder {
self.start_picture(&sh, &sps, pts)?;
}
let ls = self.level_scale(&pps);
let pending = self.pending.as_mut().expect("picture started");
let Some(pending) = self.pending.as_mut() else {
return invalid("slice without a started picture");
};
let refs = self.dpb.build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num())?;
if !sh.slice_type.is_intra() {
ensure!(!refs[0].is_empty(), "no reference pictures available for inter slice");
+3 -3
View File
@@ -54,9 +54,9 @@ fn clip1(v: i32) -> u8 {
#[inline(always)]
fn copy_n(dst: &mut [u8], src: &[u8], n: usize) {
fn fixed<const N: usize>(dst: &mut [u8], src: &[u8]) {
let s: &[u8; N] = src[..N].try_into().unwrap();
let d: &mut [u8; N] = (&mut dst[..N]).try_into().unwrap();
*d = *s;
if let (Some(d), Some(s)) = (dst.first_chunk_mut::<N>(), src.first_chunk::<N>()) {
*d = *s;
}
}
match n {
16 => fixed::<16>(dst, src),
+1
View File
@@ -17,6 +17,7 @@
// Index loops over fixed-size blocks read more clearly than iterator chains in codec code.
#![allow(clippy::needless_range_loop, clippy::too_many_arguments)]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod cabac;
mod cabac_mb;
+5 -3
View File
@@ -277,9 +277,11 @@ impl Frame {
/// bytes is copied instead (cheaper than a variable-length copy; `out` must then have room for `N`).
#[inline(always)]
fn copy_line<const N: usize>(line: &[u8], x0: i32, out: &mut [u8], w: usize) {
if w <= N && x0 >= 0 && x0 as usize + N <= line.len() && out.len() >= N {
let src: &[u8; N] = line[x0 as usize..x0 as usize + N].try_into().unwrap();
let dst: &mut [u8; N] = (&mut out[..N]).try_into().unwrap();
if w <= N
&& x0 >= 0
&& let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::<N>())
&& let Some(dst) = out.first_chunk_mut::<N>()
{
*dst = *src;
} else {
copy_clamped(line, x0, &mut out[..w]);
+12 -4
View File
@@ -888,7 +888,9 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (raster & 3) * 4;
let py = self.mb_y * 16 + (raster >> 2) * 4;
let blk: &mut [i32; 16] = (&mut self.s.coef[raster * 16..raster * 16 + 16]).try_into().unwrap();
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
return;
};
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
blk.fill(0);
}
@@ -900,7 +902,9 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (b8 & 1) * 8;
let py = self.mb_y * 16 + (b8 >> 1) * 8;
let blk: &mut [i32; 64] = (&mut self.s.coef[b8 * 64..b8 * 64 + 64]).try_into().unwrap();
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
return;
};
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
blk.fill(0);
}
@@ -917,7 +921,9 @@ impl<'a> SliceDecoder<'a> {
}
let px = self.mb_x * 8 + (b & 1) * 4;
let py = self.mb_y * 8 + (b >> 1) * 4;
let blk: &mut [i32; 16] = (&mut self.s.coef_c[c][b * 16..b * 16 + 16]).try_into().unwrap();
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
continue;
};
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
transform::idct4_add(blk, &mut plane[py * stride + px..], stride);
blk.fill(0);
@@ -1216,7 +1222,9 @@ impl<'a> SliceDecoder<'a> {
let pos = r.byte_pos();
let data = r.data();
ensure!(data.len() >= pos + 384, "truncated I_PCM macroblock");
let samples: [u8; 384] = data[pos..pos + 384].try_into().unwrap();
let Some(&samples) = data.get(pos..).and_then(|d| d.first_chunk::<384>()) else {
return invalid("truncated I_PCM macroblock");
};
r.skip(384 * 8)?;
self.finish_pcm();
self.write_pcm(&samples);
+31 -13
View File
@@ -32,13 +32,13 @@ impl LevelScale {
/// Scale one 4x4 AC coefficient (8.5.12.1, not the DC of Intra16x16 / chroma blocks).
#[inline(always)]
pub fn scale4(c: i32, ls: i32, qp: i32) -> i32 {
if qp >= 24 { (c * ls) << (qp / 6 - 4) } else { (c * ls + (1 << (3 - qp / 6))) >> (4 - qp / 6) }
if qp >= 24 { c.wrapping_mul(ls) << (qp / 6 - 4) } else { c.wrapping_mul(ls).wrapping_add(1 << (3 - qp / 6)) >> (4 - qp / 6) }
}
/// Scale one 8x8 coefficient (8.5.13.1).
#[inline(always)]
pub fn scale8(c: i32, ls: i32, qp: i32) -> i32 {
if qp >= 36 { (c * ls) << (qp / 6 - 6) } else { (c * ls + (1 << (5 - qp / 6))) >> (6 - qp / 6) }
if qp >= 36 { c.wrapping_mul(ls) << (qp / 6 - 6) } else { c.wrapping_mul(ls).wrapping_add(1 << (5 - qp / 6)) >> (6 - qp / 6) }
}
/// Intra16x16 luma DC: inverse Hadamard + scaling (8.5.10). `c` is raster order (row*4+col);
@@ -65,20 +65,21 @@ pub fn luma_dc_dequant(c: &mut [i32; 16], qp: i32, ls00: i32) {
let d23 = t[8 + j] - t[12 + j];
let f = [s01 + s23, s01 - s23, d01 - d23, d01 + d23];
for i in 0..4 {
c[i * 4 + j] = if qp >= 36 { (f[i] * ls00) << (qp / 6 - 6) } else { (f[i] * ls00 + (1 << (5 - qp / 6))) >> (6 - qp / 6) };
c[i * 4 + j] =
if qp >= 36 { f[i].wrapping_mul(ls00) << (qp / 6 - 6) } else { f[i].wrapping_mul(ls00).wrapping_add(1 << (5 - qp / 6)) >> (6 - qp / 6) };
}
}
}
/// 4:2:0 chroma DC: 2x2 transform + scaling (8.5.11). `c` = [c00, c01, c10, c11].
pub fn chroma_dc_dequant_420(c: &mut [i32; 4], qp: i32, ls00: i32) {
let s0 = c[0] + c[1];
let d0 = c[0] - c[1];
let s1 = c[2] + c[3];
let d1 = c[2] - c[3];
let f = [s0 + s1, d0 + d1, s0 - s1, d0 - d1];
let s0 = c[0].wrapping_add(c[1]);
let d0 = c[0].wrapping_sub(c[1]);
let s1 = c[2].wrapping_add(c[3]);
let d1 = c[2].wrapping_sub(c[3]);
let f = [s0.wrapping_add(s1), d0.wrapping_add(d1), s0.wrapping_sub(s1), d0.wrapping_sub(d1)];
for k in 0..4 {
c[k] = ((f[k] * ls00) << (qp / 6)) >> 5;
c[k] = (f[k].wrapping_mul(ls00) << (qp / 6)) >> 5;
}
}
@@ -113,7 +114,9 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
o[3][j] = e - h;
}
for (i, row) in o.iter().enumerate() {
let line: &mut [u8; 4] = (&mut dst[i * stride..i * stride + 4]).try_into().unwrap();
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<4>()) else {
return;
};
for (p, &v) in line.iter_mut().zip(row) {
*p = clip_u8(*p as i32 + ((v + 32) >> 6));
}
@@ -156,8 +159,8 @@ fn idct8_1d(d: [i32; 8]) -> [i32; 8] {
/// the column pass and reconstruction across all columns at once (they vectorise).
pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
let mut t = [[0i32; 8]; 8];
for (i, row) in t.iter_mut().enumerate() {
*row = idct8_1d(d[i * 8..i * 8 + 8].try_into().unwrap());
for (row, src) in t.iter_mut().zip(d.as_chunks::<8>().0) {
*row = idct8_1d(*src);
}
let mut o = [[0i32; 8]; 8];
for j in 0..8 {
@@ -167,7 +170,9 @@ pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
}
}
for (i, row) in o.iter().enumerate() {
let line: &mut [u8; 8] = (&mut dst[i * stride..i * stride + 8]).try_into().unwrap();
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<8>()) else {
return;
};
for (p, &v) in line.iter_mut().zip(row) {
*p = clip_u8(*p as i32 + ((v + 32) >> 6));
}
@@ -283,4 +288,17 @@ mod tests {
assert_eq!(scale8(1, 320, 36), 320);
assert_eq!(scale8(1, 320, 30), (320 + 1) >> 1);
}
/// Corrupt streams can carry huge coefficient levels: dequantisation wraps (as release
/// builds always did) instead of panicking with "attempt to multiply with overflow" in
/// debug builds (found by mutation fuzzing an H.264 transport stream).
#[test]
fn huge_levels_dequantise_without_overflow_panics() {
let mut c = [i32::MAX / 2, i32::MAX / 3, -i32::MAX / 2, 7];
chroma_dc_dequant_420(&mut c, 51, 16 * 25);
let _ = scale4(i32::MAX, 16 * 25, 51);
let _ = scale4(i32::MAX, 16 * 25, 0);
let _ = scale8(i32::MIN, 16 * 25, 51);
let _ = scale8(i32::MIN, 16 * 25, 0);
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ fn main() {
enc.set_recon_capture(false);
}
for (i, f) in frames.iter().enumerate() {
pk.extend(enc.encode(&f.frame(), i as i64));
pk.extend(enc.encode(&f.frame(), i as i64).unwrap());
}
pk.extend(enc.flush());
let el = t0.elapsed().as_secs_f64();
+2 -1
View File
@@ -296,7 +296,8 @@ pub fn write_mb<S: BinSink>(s: &mut S, nb: &NbCtx, c: &MbCode) {
s.decision(if bins[1] != 1 { 16 } else { 17 }, bins[2]);
}
MbKind::BDirect | MbKind::BInter => write_b_bins(s, nb, B_BINS[c.b_type_number() as usize]),
_ => unreachable!("skip/none macroblocks are not written with write_mb"),
// Skip / none macroblocks are not written with write_mb.
_ => debug_assert!(false, "write_mb on a skipped macroblock"),
}
let is_inxn = c.kind.is_inxn();
if c.kind == MbKind::PInter && c.part == Part::P8x8 {
+2 -1
View File
@@ -151,7 +151,8 @@ pub fn write_mb(w: &mut BitWriter, nb: &NbCtx, c: &MbCode) {
Part::P8x8 => 3,
}),
MbKind::BDirect | MbKind::BInter => w.write_ue(c.b_type_number()),
_ => unreachable!(),
// Skip / none macroblocks are not written with write_mb.
_ => debug_assert!(false, "write_mb on a skipped macroblock"),
}
if c.kind == MbKind::PInter && c.part == Part::P8x8 {
for _ in 0..4 {
+3 -2
View File
@@ -16,8 +16,9 @@ pub fn sad(a: &[u8], sa: usize, b: &[u8], sb: usize, w: usize, h: usize) -> u32
pub fn sad16(a: &[u8], sa: usize, b: &[u8], sb: usize, h: usize) -> u32 {
let mut s = 0u32;
for r in 0..h {
let ra: &[u8; 16] = a[r * sa..r * sa + 16].try_into().unwrap();
let rb: &[u8; 16] = b[r * sb..r * sb + 16].try_into().unwrap();
let (Some(ra), Some(rb)) = (a.get(r * sa..).and_then(|x| x.first_chunk::<16>()), b.get(r * sb..).and_then(|x| x.first_chunk::<16>())) else {
break;
};
let mut acc = 0u16;
for i in 0..16 {
acc += ra[i].abs_diff(rb[i]) as u16;
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more