Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 83481c146f Docs: hardware decode was only active in the desktop app since #41; log the 2026-10-06 community batch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:39:41 -04:00
Brandon Thomas 5c8471c9f4 Merge pull request #41 from justjohn12345/pr/decode-memory
MEM1–MEM4 + HW1: clip memory on a timeline from 12 GB to 1.9 GB; hardware decode actually on
2026-10-06 11:38:03 -04:00
Brandon Thomas 37361900a9 Merge pull request #38 from midasdf/feat/japanese-interface-language
i18n: Japanese interface language (uses an installed system font)
2026-10-06 11:37:57 -04:00
Brandon Thomas e89feeac4d Merge pull request #58 from punkhop/fix/speed-ripples-linked-clips-once
edit: a speed change with ripple moves later clips once
2026-10-06 11:37:52 -04:00
Brandon Thomas 555bfe7c18 Merge pull request #57 from punkhop/fix/ripple-delete-split-edit
edit: ripple delete closes one gap for a clip with a split edit
2026-10-06 11:37:47 -04:00
Brandon Thomas 5dd09386f6 Merge pull request #56 from punkhop/fix/ripple-trim-keeps-split-edit
edit: a ripple trim takes a later split edit along
2026-10-06 11:37:42 -04:00
Brandon Thomas 475c294678 Merge pull request #45 from punkhop/fix/move-linked-partners
engine: timeline.move takes linked partners along
2026-10-06 11:37:38 -04:00
Brandon Thomas 51026bd2b2 Merge pull request #60 from punkhop/fix/place-on-a-sound-track
engine: timeline.place on an audio track places the sound there
2026-10-06 11:37:32 -04:00
Brandon Thomas d886d2f322 Merge pull request #44 from punkhop/fix/unknown-track-is-an-error
engine: naming a track that does not exist is an error
2026-10-06 11:37:28 -04:00
Brandon Thomas 39ef62607d Merge pull request #47 from punkhop/fix/app-help-and-unknown-flags
filmcraft: --help prints usage and an unknown option is an error
2026-10-06 11:37:22 -04:00
Brandon Thomas 8dfa8fff4c Merge pull request #52 from punkhop/fix/explicit-targets-enable
engine: a command whose targets are named in the parameters does not need a selection
2026-10-06 11:37:16 -04:00
Brandon Thomas 8a795324f4 Merge pull request #50 from punkhop/fix/replace-reports-short-media
engine: Replace With Clip reports clips that run past the new media
2026-10-06 11:37:11 -04:00
Brandon Thomas eb3ca3e811 Merge pull request #49 from punkhop/fix/replace-from-bin-keep-source-in
engine: clip.replaceFromBin can keep the clip's source In
2026-10-06 11:37:05 -04:00
Brandon Thomas 950879ef10 Merge pull request #59 from punkhop/fix/inspect-reports-reverse
engine: sequence.inspect reports a reversed clip
2026-10-06 11:36:58 -04:00
Brandon Thomas 7377def476 Merge pull request #48 from punkhop/fix/sequence-inspect-readback
engine: sequence.inspect reports clip ends, gain and marker comments
2026-10-06 11:36:53 -04:00
Brandon Thomas 5bc07026c7 Merge pull request #43 from punkhop/fix/import-auto-points
engine: a project saved after an interchange import opens again
2026-10-06 11:36:46 -04:00
Brandon Thomas a3b077877f Merge pull request #51 from punkhop/fix/interchange-export-reports-dropped-graphics
interchange: an export names every title it cannot carry
2026-10-06 11:36:41 -04:00
Brandon Thomas a407099019 Merge pull request #46 from punkhop/fix/export-interchange-unknown-format
engine: file.exportInterchange refuses an unknown format
2026-10-06 11:36:36 -04:00
Brandon Thomas bccc3dd031 Merge pull request #42 from punkhop/fix/mp4-audio-thumps
codecs: resampled MP4 audio reads apply the edit list once
2026-10-06 11:36:31 -04:00
Brandon Thomas bd40764756 Merge pull request #69 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 10:10:16 -04:00
Brandon ThomasandClaude Opus 5.5 e6d693cc24 Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:06 -04:00
Brandon ThomasandClaude Opus 5.5 ac906666d9 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:38 -04:00
Brandon ThomasandClaude Opus 5.5 6a9fdce2ef codecs: released decoders are dropped off the cache lock; dirty-pool bit-exact test
trim_decoders dropped the decoders it took from idle caches while the requesting cache's lock was held. Dropping a decoder can block (a VideoToolbox session waits for its frames in flight), so the render path paid that latency under the lock. trim_decoders now returns the released decoders and frame_late keeps them in a local declared before its lock guard, so they drop after the guard on every return path; the cap is enforced exactly as before.

New tests/pool_bitexact.rs: primes the frame pool with 0xAA-filled plane buffers, then decodes an H.264 fixture into them; every frame must match ffmpeg bit for bit, and the pool must report the reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:16:09 -04:00
Brandon ThomasandClaude Opus 5.5 47c6b03681 filmcraft: --help, --version and argument errors show a message box on Windows
Windows release builds have no console (windows_subsystem = "windows", added on main after this branch), so --help printed nothing and an unknown option or a bad FILMCRAFT_CONTROL_PORT quit with exit code 2 and no visible message. The text is still printed, and on Windows release builds also shown in a message box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:05:59 -04:00
Brandon Thomas 39d81aee14 Merge remote-tracking branch 'origin/main' into pr-47-fix 2026-10-06 08:46:53 -04:00
Brandon ThomasandClaude Opus 5.5 2f49131f64 edit: speed changes refuse NaN and never overflow the timeline
set_speed_group accepted NaN (speed <= 0.0 is false for NaN), and a tiny speed (1e-7) saturated the new duration to i64::MAX, after which the ripple shift overflowed (panic in debug, wraparound in release). Non-finite speeds are refused, the new duration is capped at what the timeline can hold, and the ripple shift uses checked arithmetic; a refused edit changes nothing (it works on a copy). Regression test with hostile speeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:46:30 -04:00
midasdfandClaude Opus 5.5 bab130c358 i18n: Japanese interface language using an installed system font
Edit > Language switches English/Japanese and persists in UI state
(`app.language.english` / `app.language.japanese`, reachable through the
control channel). Core native and in-window menu labels are translated;
untranslated labels stay English. The in-window header fits the document
title beside the localized menus.

No font is bundled. Japanese text uses a Japanese font already installed on
the system (Hiragino, Yu Gothic, Meiryo, Noto Sans CJK JP, Source Han Sans,
IPA, … or any installed face covering the menus' characters), added to every
theme font family as the last fallback. Without one, switching to Japanese is
refused with a message and a saved Japanese setting falls back to English
(the web build cannot read system fonts). Titles already fall back to
installed fonts through the text engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:41:17 -04:00
Brandon Thomas 61ab6f5c7d Merge pull request #63 from storytold/pr/release-0.2.1
Release: FilmCraft v0.2.1
2026-10-06 08:01:17 -04:00
Brandon ThomasandClaude Opus 5.5 e659671bdf Release: FilmCraft v0.2.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank, and the shortcut description was wrong. Release builds no longer open a console window behind the app. Export can no longer panic (filmcraft-export never-crash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:01:04 -04:00
Brandon Thomas bc1523a33f Merge pull request #62 from storytold/pr/never-crash-export
Never crash: filmcraft-export without expect()/unreachable!()
2026-10-06 07:58:18 -04:00
Brandon ThomasandClaude Opus 5.5 136424dc93 Never crash: filmcraft-export without expect()/unreachable!()
The five 'mux created' / 'audio' expects in the stepped exporter now return
an ExportError::Encode instead of panicking if the writer or audio pipeline
is missing, and the non-stepped dispatch returns Unsupported instead of
unreachable!(). The crate root gets the never-crash deny attribute, so every
FilmCraft production crate now carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:58:07 -04:00
Brandon Thomas 6bf2756cde Merge pull request #61 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:54:51 -04:00
Brandon ThomasandClaude Opus 5.5 c60d3f9c4c release.yml: explicit if for the wasm-bindgen install (shellcheck SC2015)
packaging-lint has failed on main since this step was added; same behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:53:46 -04:00
Brandon ThomasandClaude Opus 5.5 7fee797ee7 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what FilmCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:51:02 -04:00
Brandon Thomas 508a5a933f Merge pull request #28 from storybored379/pr/mcp-conventions
MCP: tool annotations, core tools, resources, export progress and cancellation
2026-10-06 07:27:58 -04:00
Brandon Thomas 5012fbd598 Merge pull request #19 from storybored379/pr/deterministic-export
Export: same file on every machine (fixed mux interleave, H.264 slices by frame size)
2026-10-06 07:27:44 -04:00
Brandon Thomas 1f7e4f802e Merge pull request #39 from storybored379/fix/ttml-timing
Captions: report invalid TTML timing parameters on import
2026-10-06 07:27:28 -04:00
punkhopandClaude Opus 5.5 a8e4c815eb edit: ripple delete closes one gap for a clip with a split edit
Problem: `edit.rippleDelete` on a clip whose linked sound has a
different edge than its picture (a J or an L cut) treated the two
ranges as two gaps and closed both on every affected track. With two or
more clips after it the later ones were moved up twice and the edit was
refused ("items overlap"). With one clip after it the delete went
through, and sequence markers after it were moved by both gaps (one
that landed inside the second gap was removed).

Solution: one set of stretches closes on every track: the time the
deleted clips covered, less whatever stays on the tracks that lose a
clip. Those tracks move up by the same amount, into time that is free
on all of them, so later clips stay in sync and nothing is overwritten.
For a J or an L cut that is the picture's range. Straight cuts close as
before, also with the sounds on alternating tracks. A track that is
locked loses nothing and does not count. `ripple_delete_items` returns
the stretches it closed and the command ripples the markers by those,
so the two cannot disagree.

Things to know. A gap can stay on one track when a clip that stays on
another of the tracks covers part of the deleted time: a sound shorter
than its picture with other sound right up against it, or unlinked
clips on different tracks deleted in one call. When nothing can close
at all the edit is refused and nothing changes; some such calls went
through before, moving clips by a gap another track did not have. A
selection wholly on locked tracks is refused as locked; before, it
deleted nothing and still shifted the sync-locked tracks.

Tests: an edit-crate test (a split on both sides, two neighbours,
clips apart in time, sounds on alternating tracks, a short sound with
and without neighbours, a locked track, the refusal); engine tests
through `edit.rippleDelete` for a J cut with a marker, undo and redo,
an L cut, two neighbours with a split between them, two neighbours with
sounds on alternating tracks, and a straight cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:23:27 -07:00
punkhopandClaude Opus 5.5 4a0ab32480 edit: a ripple trim takes a later split edit along
Problem: `timeline.trim` with `mode: "ripple"` on the clip before a
split edit moved the later picture and left its linked sound behind.
With a J cut (the next clip's sound starts before its picture, on a
sync-locked track where the trimmed clip has no partner) a sound that
led its picture by 12 frames led by 20 after lengthening by 8, with no
error. Shortening was refused as a sync-lock conflict, because the
early sound sits in the stretch that closes.

Solution: in `ripple_trim_group`, a clip that starts before the cut,
reaches past it and is linked to a clip that shifts now shifts with it,
on the tracks that ripple (the members' tracks and sync-locked tracks).
It no longer counts as material in the way of a shortening. When it has
no room to follow (another clip right before it, or the sequence start)
the trim is refused with the sync-lock error and nothing changes (an
overlap on any track that ripples now reports that error instead of
the generic "items overlap"). A
linked clip that ends before the cut is left alone, as before, and so
are the trimmed clips' own partners. A transition into a follower from
a clip that stays behind would be left over a gap; it is removed.

Tests: an edit-crate test for longer, shorter, both refusals, the
clip that ends before the cut, the In edge, the trimmed clip's own
partners and the transition; engine tests through `timeline.trim` for
longer and shorter with undo and redo, and for both refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:23:27 -07:00
punkhopandClaude Opus 5.5 4df7e75085 engine: timeline.place on an audio track places the sound there
Problem: `timeline.place {item, track: "A3"}` (an audio track given as
`track`). For an item with picture and sound it put a video item on the
audio track A3 and a second, sound clip on the default audio track. For
a sound-only item it ignored `track` and placed on the default audio
track.

Solution: `track` is where the picture goes and `audioTrack` where the
sound goes. An audio track given as `track` now places the sound alone,
on that track. What cannot be meant is a parameter error that says why,
and changes nothing: an audio track as `track` together with
`audioTrack`; a video track as `audioTrack`; an audio track for an item
with no sound; a video track alone for an item with no picture. Calls
that name a video track as `track` and an audio track as `audioTrack`
(the timeline panel's drop does) are unchanged. The parameter hint and
`docs/agents.md` say so.

Tests: track_params_tests.rs places a clip with picture and sound and a
sound-only item on audio tracks by name and by id, checks each refusal
for its error kind, its text, an untouched project and undo history,
and checks the usual forms still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:14:24 -07:00
punkhopandClaude Opus 5.5 f06bfc4648 edit: a speed change with ripple moves later clips once
Problem: `clip.speedDuration` with `ripple: true` on a clip that has
linked sound set the speed on the picture and then on its sound, and
each one rippled the later material on its own. Slower (50%) moved the
later clips by twice the change and left a double gap. Faster (200%)
was refused ("items overlap") when more than one clip followed: the
second ripple pulled the farther clips onto the nearer ones.

Solution: `set_speed_group` changes a clip and its linked partners on
other tracks as one edit and ripples once, on the members' tracks and
on sync-locked tracks. The command groups the selected clips by link
(one clip per track in a group); clips that are not linked, or linked
clips of one track, still change one after the other. When the members'
lengths change by different amounts (a split edit), later material
moves by the largest change, so no member is overlapped and the tracks
stay in sync; the shorter member is followed by a gap. The group edit
works on a copy, so a failure changes nothing. `set_speed` keeps its
signature and behaviour for one clip.

Tests: an edit-crate test (slower, faster, a split edit both ways, two
clips of one track and a missing clip refused with nothing changed);
engine tests through `clip.speedDuration` for slower with undo and
redo, faster, two clips in one call, no ripple, picture and sound both
named with Linked Selection off, linked clips of one track, a split
edit, and a clip that is not in the sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:14:24 -07:00
punkhopandClaude Opus 5.5 7eb4f1fc7b engine: sequence.inspect reports a reversed clip
Problem: a clip set to play backward (`clip.speedDuration` with
`reverse: true`) read back from `sequence.inspect` exactly like a
forward clip: same `speed`, same source range, nothing that said it was
reversed. An agent could set it and not confirm it.

Solution: clips gain `reverse` (bool) in `sequence.inspect`, named as
in the project file and in the command. Additive; existing keys are
unchanged. `docs/agents.md` lists it and says what it means for the
source range.

Tests: sequence_inspect_tests.rs reverses a demo clip through the
command and checks that only that clip reports `reverse: true`, and
none after undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:45:21 -07:00
punkhopandClaude Opus 5.5 ff8aa8749d engine: a project saved after an interchange import opens again
Problem: a Final Cut 7 XML whose Basic Motion filter sets only `scale`
makes the importer add the Motion effect with its defaults, where
`position` and `anchor` are the NaN "auto" sentinel. Placing a clip
resolves those with `resolve_auto_points`; the interchange import did
not (any format: the clips are built with the effect defaults).
serde_json writes NaN as `null`, and the reader then refused the file:
"project file is damaged: invalid type: null, expected f64". The project
could be saved but never opened again.

Solution:
- project: a point parameter's value (`ParamValue::Vec2`) reads a `null`
  coordinate back as NaN, so "auto" survives a save. Only there: `Vec2`
  itself is unchanged, and a `null` in a mask vertex or any other point
  is still a damaged file.
- project: `Project::resolve_placed_auto_points` resolves the points of
  the clips in chosen sequences. Points measured in the frame use the
  sequence size. `anchor` is measured in the clip's source
  (`Project::source_size`, a subclip through its parent, as the renderer
  resolves it) and is resolved only for sources the caller says have
  their final size.
- engine: the import resolves in the edits it already makes, so the undo
  steps are what they were. In "Import ...": everything but the anchors
  of file media, whose size is only what the document says so far (an
  EDL says nothing and the importer assumes the sequence size). In each
  "Link Media": the anchors of that file's clips, from the probed size.
  A clip whose file is not found keeps an "auto" anchor, which the
  renderer centres in whatever size the media has, as before this
  change.
- engine: a later Link Media (relink.rs `apply`, behind `media.relink`
  and `media.autoRelink`) resolves those waiting anchors in its own undo
  step, when it has read the file. Nothing else resolves them: a file
  that merely comes back at its old path, or a forced relink that does
  not read the file, leaves the anchor "auto" (still rendered centred).

Why no schema step and no repair on load: the file's shape is not
changed. Files with `null` points were written by every build before
this one, at the current schema version, so a migration (keyed on the
version) cannot select them, and a bump would only make every new
project unreadable to those builds. They open now and their points are
"auto", exactly as in the session that wrote them; resolving them on
load would mean guessing the size of media that was never linked.
docs/project-files.md describes `null` points.

Tests: project
`point_params_keep_auto_through_json_and_no_other_point_reads_null` and
`placed_auto_points_resolve_once_the_source_size_is_final`; format
`auto_points_written_as_null_open_again`; engine
(interchange_auto_points_tests.rs): import with the media missing, save,
reopen, undo, redo; a linked file whose real size differs from the
declared and the sequence size (undo of Link Media and of the import,
redo); an EDL clip smaller than its sequence; import with the media
missing, save, reopen, relink (anchor becomes a number, undo, redo, a
second relink leaves it). All fail without the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:28:54 -07:00
punkhopandClaude Opus 5.5 8f74d18829 engine: timeline.move takes linked partners along
Behaviour change to the default: with Linked Selection on (the default),
`timeline.move` now also moves the linked partners of the listed clips.
A partner lands on its own track in the same mode as the listed clip, so
a caller that lists only a picture clip can now overwrite audio at the
destination time. `"linked": false` gives the old behaviour (only the
listed clips move); the timeline panel passes it, so dragging is
unchanged.

Problem: `timeline.move {"moves": [{clip, track, time}]}` moved only the
listed clip. Its linked sound stayed where it was, out of sync, although
Linked Selection was on and `timeline.trim`, slide and select all act on
linked partners. Separately, a `time` near i64::MAX overflowed the tick
addition in the edit (a panic in debug builds, a wrapped position in
release).

Solution: a linked partner that is not itself in `moves` moves by the
same time offset and stays on its own track. A partner that is listed is
moved once, to where the list says. Two kinds of partner are left
exactly as they are: one on a locked track (the listed clip still moves,
as it did before), and one whose position would not change, which is the
case when the listed clip only changes track. Re-placing such a partner
would drop the transitions at its edges and report a move that did not
happen. A new optional `linked` parameter overrides the toggle for one
call. The command returns `{"moved": [ids]}`, the clips that moved.

No clip is moved before the sequence start. When one would be (a split
edit whose sound starts earlier, or several listed clips asked too far
left), all clips of the call land later by the same amount, so the
spacing asked for is kept. This replaces the per-clip clamp for a call
that lists several clips with a time below zero; a single clip still
lands at zero. The offsets are summed in i128 and checked on the way
back: a start past Tick::MAX is a parameter error and nothing moves.

Tests (timeline_move_tests.rs): partners follow, undo / redo, both
listed, `linked: false`, toggle off, `linked: true`; a locked partner; a
picture moved to another track at the same time leaves its sound and the
sound's crossfade untouched; the common stop at the sequence start with
a split edit and with two listed clips; hostile times (i64::MAX,
i64::MIN, junk). All fail without the change, the hostile one with the
overflow panic. docs/control-protocol.md and docs/agents.md describe the
default, the `linked` parameter and the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:24:23 -07:00
punkhopandClaude Opus 5.5 6de389c75a engine: a command whose targets are named in the parameters does not need a selection
Problem: a command gated on the selection refused to run although the
caller named what to act on. `clip.replaceFromBin {clips, item}` answered
"select a clip in the Project panel"; `edit.clear`, `edit.rippleDelete`,
`edit.pasteAttributes`, `clip.enable`, `clip.link`, `clip.speedDuration`
and `clip.audioGain` with `clips` answered "no clips selected". A script
or agent had to change the user's selection first. The cause is the order
in Session::execute: `enabled()` runs before the parameters are read, and
it only knows the selection.

Fix: Session::execute asks enablement per call. When `enabled()` passes,
nothing changes. When it fails and the parameters name targets under a
key the command documents as ids (`clips` / `clip`: clips of the active
sequence, `items` / `item`: project items), the same `enabled()` runs
once more with those targets standing in for the selection, and the
selections are put back before the command runs. So only the selection
condition is answered by the named targets: no sequence, an empty
clipboard or the wrong kind of clip still disable the command, ids of
nothing do not count, and a command that does not document the key
(`edit.cut`) still needs a selection. This works for every command
because the predicates already resolve their targets the way `run` does
with empty parameters. `is_enabled` (menus, `engine.commands`) is
unchanged.

`clip.audioGain` already read `clips`; its parameter doc now says so.

Tests (explicit_targets_tests.rs): the commands above with and without
parameters, undo/redo, the selections left untouched, menu enablement
unchanged, and the cases that must still be refused. Four of the five
fail without the change in execute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:05:12 -07:00
punkhopandClaude Opus 5.5 4aa3f79953 interchange: FCP7 XML import reports a clip item whose file is not defined
Problem: a clip item whose `<file id="...">` reference has no definition
anywhere in the document was dropped without a word, so a sequence could
come back one clip short with an empty import report. FilmCraft's own
export writes titles and adjustment layers this way.

Solution: the importer adds a warning that names the clip item: `clip
"Title" refers to a file the document does not define; skipped`. Nothing
else about the import changes.

Test: `fcp7_import_reports_a_clip_item_whose_file_is_not_defined`
(FilmCraft's own export, a hand-written document, and a document whose
files are all defined).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:02:27 -07:00
punkhopandClaude Opus 5.5 6899f15e51 interchange: an export names every title it cannot carry
Problem: exporting a sequence with a graphic (title) clip to Final Cut
Pro 7 XML returned a report that listed other losses and said nothing
about the title. The XML holds a clip item for it that points at a file
the document never defines, and importing the XML gives the sequence
back without the title. OTIO (a clip with a missing media reference,
read back as offline media) and EDL (an ordinary event) were silent in
the same way; FCPXML said "clip without media skipped" and AAF "graphics,
titles and adjustment layers are not exported" without saying which clip.
Adjustment layers take the same paths.

Solution: every exporter names each graphic clip and adjustment layer
it cannot carry, one warning per clip, with what was written instead:
`graphic clip "Title" at frame 48 has no Final Cut Pro 7 XML equivalent:
it is written as a clip item without media and is not read back on
import`. One helper (`common::uncarried_clip`) builds the name for all
formats. What is written is not changed, byte for byte.

Why only a warning: FCP7 XML has a Text generator, but this crate's
importer does not read one (unknown generators become Black Video), and
a title here is a stack of text and shape layers with per-character
styles, pins and roll settings that the generator's few parameters
cannot hold. Writing one would need a new import path and would still
lose most of a title, so the honest result for now is to say what
happens. crates/interchange/README.md has the table per format.

Tests: crates/interchange/tests/uncarried_clips.rs (each format names
the title and the layer once; two titles are two lines; nothing is said
without such clips; a re-import gives back what the report says), and
the engine's `interchange_export_reports_the_title_it_cannot_carry`
through `file.exportInterchange`. The two naming tests fail without the
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:01:34 -07:00
punkhopandClaude Opus 5.5 5bf2203bb8 codecs: resampled MP4 audio reads apply the edit list once
Problem: a clip with AAC audio in MP4 whose sample rate differs from the
sequence's (48 kHz clip in a 44.1 kHz sequence) exported with loud
low-frequency thumps, several times full scale, recurring through the clip.
Every exported container was affected (AAC clips them into thumps, PCM
clips at full scale); a WAV export had a single one at the clip's start.

Cause: Mp4Source::audio decodes a window starting at s0, the requested
presentation sample minus the edit-list offset, then resamples it. The
resampler derived its sub-sample phase from s0, so the edit-list offset
(-1024 samples of AAC priming) was applied a second time: the read
position started about 1024 samples before the window, was clamped to
index 0 with a large negative fraction, and linear interpolation
extrapolated a ramp over the first ~940 samples of every request. Export
asks for audio one batch of video frames at a time, so the ramp repeated
at each request. Sources at the sequence rate take the copy path and
were unaffected, as were tracks without an edit list.

Fix: take the phase from the presentation sample the window starts at
(before the edit-list offset is subtracted).

Test: aac_mp4_resampled_reads_follow_the_edit_list
(crates/codecs/tests/mp4_oracle.rs, with `require_ffmpeg!` and the
shared fixture helper as docs/testing.md asks of new oracle tests) reads
an ffmpeg AAC fixture at 44.1 kHz in frame-sized pieces and compares it
with the 48 kHz decode interpolated at the same times (peak was 7.35
against a source level of 0.15 before the fix). docs/testing.md lists it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:29:26 -07:00
punkhopandClaude Opus 5.5 d3ee1dfd22 engine: Replace With Clip reports clips that run past the new media
Problem: when the replacement is shorter than the clip's slot, the clip
silently runs past the end of its media and the renderer holds the last
frame. Nothing in the command's result says so, so a script or agent
that swaps a take cannot tell.

Solution: the result of the three Replace With Clip commands
(`clip.replaceFromSource`, `clip.replaceFromSourceMatchFrame`,
`clip.replaceFromBin`) gains `short`: one `{clip, shortByFrames}` per
replaced clip that now plays past the end of its media, in sequence
frames and never more than the clip's own length. It is empty when none
does; stills and other sources without an end, and held frames, are
never short. The edit itself is not changed, and the parameter docs are
untouched (results are described in docs/control-protocol.md).

Test: `replace_with_clip_reports_clips_that_run_past_the_new_media`
(From Bin with a 12-frame subclip, undo / redo, From Source Monitor,
Match Frame, the cases that are not short, and an In past the end of the
new media capped at the clip's length).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:28:20 -07:00
punkhopandClaude Opus 5.5 0e3aebee5d engine: clip.replaceFromBin can keep the clip's source In
Problem: `clip.replaceFromBin` always restarts the clip at the new
item's In point (or its start). That matches Premiere's Replace With
Clip > From Bin, but a script or agent swapping one take for another has
no way to keep the clip's place in the media.

Solution: the default is unchanged, and so is its result. An optional
`keepSourceIn` (default false) keeps each clip's current source In. A
subclip that restricts trims has no media outside its range, so there a
kept In outside the range is moved to the subclip's first or last frame;
with `keepSourceIn` the result gains `sourceInClamped`, the clips this
was done for. The parameter doc lists the new parameter only (a result
description in it would take the command out of the Keyboard Shortcuts
list, which reads the doc to see whether a command needs parameters).

Tests: `replace_from_bin_keeps_the_source_in_on_request` (default and
its result, keepSourceIn, undo / redo, the disabled case, subclips before,
after and around the In, an unrestricted subclip, junk values), and
`list_searches_labels_and_keys` now asserts the three Replace With Clip
commands are bindable. docs/control-protocol.md describes the commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:25:50 -07:00
punkhopandClaude Opus 5.5 22229df105 filmcraft: --help prints usage and an unknown option is an error
Problem: `filmcraft --help` opened the app. Every argument the parser
did not know was taken as a file to open, so `--help`, `-h` or a
mistyped flag started a window (and tried to import "--help" as media).
`--control` with a missing or invalid port, and an invalid
`FILMCRAFT_CONTROL_PORT`, started the app without the control server and
said nothing, which leaves a script waiting for a server that never
comes.

Solution: argument parsing moves into its own module (`args.rs`,
`args::parse`), which runs before anything else starts. `--help` / `-h`
print the usage (the options the app parses: --control, --demo, --empty,
--recover, --no-recover, --data-dir, --version) and exit 0. An unknown
option, or `--control` / `--data-dir` without a usable value, prints the
error and the usage on stderr and exits 2. The whole line is checked, so
`--version --bogus` is an error too. A `FILMCRAFT_CONTROL_PORT` that is
not a port is the same error as a bad `--control` (exit 2), for the same
reason; an empty value counts as unset, and the value is not looked at
when `--control` is given or for `--help` / `--version`. `--` ends the
options, so a file whose name starts with a dash can still be opened; a
lone `-` and the `-psn_...` argument macOS adds to app launches are not
treated as options.

Tests (in args.rs): `help_and_version_do_not_start_the_app`,
`unknown_options_are_errors`,
`a_bad_port_in_the_environment_is_an_error_like_a_bad_control_flag`,
`known_options_and_files_parse_as_before`. docs/contributing.md
mentions `--help` and the errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:21:56 -07:00
punkhopandClaude Opus 5.5 285da5c66b engine: file.exportInterchange refuses an unknown format
Problem: `file.exportInterchange {"format": "<anything unknown>"}` wrote
FCP7 XML and returned ok, so a typo or an unsupported format produced a
file of the wrong kind under the name that was asked for.

Solution: only a missing `format` means FCP7 XML. A given format must be
one of the six documented names (edl, xml, fcpxml, otio, aaf, omf: each
format's extension, in either case). Anything else, including a value
that is not a string, is a parameter error that lists exactly those
names, and nothing is written. No other spelling is accepted (".xml",
"fcpxmld", "omfi"): none was accepted before either, they silently
became XML. The parameter doc now lists aaf and omf, which the command
already took, and the default; docs/architecture.md says the same.

Test: `export_interchange_refuses_an_unknown_format`, next to the
interchange round-trip test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:20:02 -07:00
punkhopandClaude Opus 5.5 51ecd70763 engine: sequence.inspect reports clip ends, gain and marker comments
Problem: `sequence.inspect` is how an agent verifies an edit, but it
left out where a clip ends in the sequence and in its media, a clip's
audio gain, and the comment of a marker, so those edits could not be
checked from the readback.

Solution: each clip also reports `end`, `endFrame`, `sourceOut` (which
depends on speed, so it cannot be derived from `sourceIn` + `duration`)
and `gainDb`; each sequence marker also reports `comment`. Every
existing key stays. docs/agents.md lists what the readback holds.

Test: `sequence_inspect_reports_clip_ends_gain_and_marker_comments`
(sequence_inspect_tests.rs), on a clip at 200 %, a clip with gain and a
marker with a comment made through the commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:17:52 -07:00
punkhopandClaude Opus 5.5 9d3975a697 engine: naming a track that does not exist is an error
Problem: `timeline.place {"audioTrack": "A2"}` on a sequence that has
only A1 placed the clip on A1, overwrote what was there and returned ok.
`track_p` returned `None` both for "no track asked for" and for "that
track does not exist", so callers fell back to the source-patched track.
It also took any first letter other than V as an audio track, accepted
track ids that are not in the sequence, and `split_at(1)` panicked on an
empty or non-ASCII name.

Solution: `track_p` returns `Result<Option<TrackId>>` and takes the
command id, like the other fallible parameter helpers. An absent (or
null) parameter is `Ok(None)` and keeps every default as it was. A name
or id the active sequence does not have is a parameter error
(`EngineError::BadParams`): "invalid parameters for `timeline.place`: no
track A2 in this sequence (`audioTrack`)". All callers propagate it:
timeline.place (`track`, `audioTrack`), timeline.move, timeline.razor,
timeline.setTrack, timeline.setTargeting, sequence.closeGap,
sequence.deleteTrack, sequence.deleteTracks, the go-to-gap commands,
sequence.transcribe and the multicam sync reference track.

Not changed: a `timeline.move` entry without a `clip`, `track` or `time`
is still skipped, as before; only a track that was named and is missing
is an error.

Test: `naming_a_missing_track_is_an_error_not_a_default` in
track_params_tests.rs (missing and hostile names, the error kind, nothing
edited, no undo step, existing tracks by name and id still resolve).
docs/agents.md notes the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:10:19 -07:00
justjohn12345andClaude Opus 5.5 4f3e119ac2 MEM4: decode into the planes of evicted frames
With MEM1-MEM3 twelve clips on a timeline still took 5.1 GB, only 2.5 GB of
it live: each decoded picture allocated three planes, each eviction freed
them, and the system allocator kept the freed pages.

filmcraft_frame::pool keeps the planes of frames the GOP caches evict (up to
192 MiB per sample type; a frame something else still holds is left alone).
The H.264 decoder, through a plane allocator hook that keeps the crate free
of dependencies, and the VideoToolbox path take their output planes from it.

perf.stats reports decode.planePoolMB and decode.planesReused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:57:16 -04:00
Brandon ThomasandClaude Opus 5.5 5d1165d06b MCP: cancelled exports delete only their own files
remove_partial removed every '<stem>[digits].<any ext>' file in the output folder modified since the export started, so cancelling an export to trailer.mp4 also deleted trailer.aep or trailer2.psd if they were saved during the export. It now removes only the output file, numbered stills with the output's extension, and the .srt/.vtt caption sidecar. Regression test with decoy files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:54:24 -04:00
justjohn12345andClaude Opus 5.5 b999b69f07 HW1: register the hardware decoders in every desktop run
The desktop app called filmcraft_platform::register() as an argument of
log::info!. A log macro does not evaluate its arguments while no logger takes
its level, and the app installs none, so VideoToolbox was never registered:
perf.stats showed hardware decoding enabled with 0 sessions, 0 declined and
every frame decoded in software. The CLI and the bench register directly and
were not affected.

Registration is now a statement of its own, with a start-up test that runs
without a logger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:42:28 -04:00
justjohn12345andClaude Opus 5.5 4486652f07 MEM3: one frame budget shared by all sources
Each GOP cache keeps up to 384 MB of decoded frames (at least 64), and
nothing bounded the total: twelve clips on a timeline held 3.8 GB of frames
nobody was looking at, and no perf.stats counter showed it.

The pool now counts the frames of every cache against a 1 GiB budget. Over
it, caches idle for 3 s give up frames, least recently used cache first.
Caches in use keep their own budget, since a frame evicted before it is
shown costs a re-decode from the keyframe.

perf.stats reports decode.cacheMB and decode.cacheBudgetMB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:48 -04:00
justjohn12345andClaude Opus 5.5 517b6b041b MEM2: idle sources give up their decoders beyond a cap
Every source kept its decoder for good once it had decoded a frame, with its
reference pictures and the pictures its frame threads hold in flight: about
290 MB per H.264 clip, 3.5 GB for twelve clips on a timeline.

The GOP caches now share a pool. Beyond four live decoders, the least
recently used caches that have been idle for 3 s drop theirs and make a new
one when asked again (a restart at the sync sample, as after a seek). Caches
in use keep their decoder whatever the count, so a composite of many layers
never restarts decoders per frame. Other caches are only try-locked, so two
caches trimming each other cannot deadlock.

perf.stats reports decode.liveDecoders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:48 -04:00
justjohn12345andClaude Opus 5.5 ac8279d293 MEM1: desktop reads media files in place
The desktop host had no Services::reader, so MediaPool::open_file read every
clip whole and kept its bytes for as long as the source stayed open: 2.9 GB
for twelve clips on a timeline (malloc_history, 15 allocations in open_file).

FsServices::reader now returns a FileReader that keeps the file open and
serves positional reads, so MP4/MOV, Matroska and the other reader openers
load the index and then single samples, as the web host already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:48 -04:00
Brandon Thomas ada55eb625 Merge pull request #35 from storytold/pr/gpu-effects
GPU2: standard effects on the GPU compositor (#30)
2026-10-05 16:27:05 -04:00
Brandon ThomasandClaude Opus 5.5 adfd9a66de docs: point agents to the shared test corpora (#36)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:17 -04:00
storybored379andOpenAI Codex 9c06722119 Captions: report invalid TTML timing parameters on import
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-04 21:48:25 -07:00
storybored379andClaude Opus 5.5 cad16825e3 MCP: progress notifications and cancellation for blocking exports
command_run file.exportMedia with wait: true (headless) runs as the
background export job and uses the MCP progress and cancellation utilities:

- with _meta.progressToken, notifications/progress reports frames done /
  total with the job status, at most every 100 ms (none without a token);
- notifications/cancelled stops the job at the next batch (jobs.cancel),
  deletes the partial output and sends no response;
- the session lock is only held for short polls, so ping, doc_inspect,
  jobs.list etc. are answered while it encodes.

Without wait, and in bridge mode, nothing changes. Test
export_progress_and_cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:51:38 -07:00
storybored379andClaude Opus 5.5 8ee7658303 MCP: filmcraft://document and filmcraft://commands resources, with MCP 2026-07-28 cache hints
- resources/list and resources/read serve the project (tree + active
  sequence, as doc_inspect) and the command catalog (as command_list) as
  JSON, for clients that attach context from resources.
- Results are built with rmcp's types, so resultType follows the negotiated
  revision; for 2026-07-28 clients (current Claude Code) they also carry
  ttlMs and cacheScope, like the generated tools/list. The resource list is
  fixed (10 min), document reads are never cached (0).
- get_info now declares the resources capability; the instructions text
  moves to a const, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:51:28 -07:00
storybored379andClaude Opus 5.5 0f70015b93 MCP: tool titles and annotations, command_batch / doc_inspect / render_preview, strict arguments
- Every tool has a title and readOnly / destructive / idempotent / openWorld
  hints, set in its #[tool] attribute, so clients can run the read-only
  tools without asking and ask before edits.
- New tools: command_batch (several commands, stop at the first error by
  default), doc_inspect (project tree + active sequence in one call) and
  render_preview (= render_frame).
- An unknown tool argument is a -32602 error naming it and the accepted
  ones, instead of being ignored.
- A line that is not JSON gets a -32700 reply (id null) instead of being
  dropped without an answer: serve_io filters lines in front of rmcp.
- A panicking command no longer poisons the session lock for the rest of the
  session ("session lock poisoned"); it is reported as "internal error: …"
  (AGENTS.md §0.6).
- docs/agents.md § Conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:51:21 -07:00
storybored379andClaude Opus 5.5 a55d20c454 export: same file on every machine (fixed mux interleave, H.264 slices by frame size)
Exporting the same project twice could give different files, because two
export parameters followed the number of cores the process sees:

- The muxer got one chunk per render batch, and a batch is one frame per
  rayon thread. The chunk layout (stsc/stco, PCM packet sizes) changed with
  the core count; the samples themselves did not.
- The H.264 encoder used one slice per core (capped at one per four MB rows),
  so the bitstream and every decoded picture changed with the core count.

On a host whose CPU quota changes (containers, power limits), two exports in a
row differed. Now packets are muxed in fixed groups of 16 output frames
(INTERLEAVE), each followed by its audio, whatever the batch size, and H.264
exports use one slice per four macroblock rows. Hosts with 17 or more threads
already used these values, so their 1080p output does not change.

Tests: determinism_tests (fail on main): same bytes for ProRes, H.264 and
Motion-JPEG at 2/3/5/16 cores and 1/7 frames per step; 64 slices for a
4096-line H.264 export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:32:49 -07:00
82 changed files with 5184 additions and 308 deletions

No files matched your search

+4
View File
@@ -47,6 +47,10 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/filmcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/filmcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.filmcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.filmcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
+6 -2
View File
@@ -112,6 +112,9 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -221,8 +224,9 @@ jobs:
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen crate version exactly (WASM_BINDGEN in xtask/src/main.rs).
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
fi
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: filmcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: filmcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'FilmCraft'
foreach ($exe in 'filmcraft.exe', 'filmcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'filmcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "filmcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok filmcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'filmcraft.exe')) { throw 'uninstall left filmcraft.exe behind' }
Write-Output 'ok uninstalled'
+6
View File
@@ -120,6 +120,12 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
as PhotoCraft does with `cargo xtask corpus`.
## See also
- [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/contributing.md](docs/contributing.md): setup, gates, commits, how to add things
Generated
+46 -46
View File
@@ -1655,7 +1655,7 @@ dependencies = [
[[package]]
name = "filmcraft"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"chrono",
"cpal",
@@ -1677,7 +1677,7 @@ dependencies = [
[[package]]
name = "filmcraft-aac"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1687,7 +1687,7 @@ dependencies = [
[[package]]
name = "filmcraft-ac3"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -1695,14 +1695,14 @@ dependencies = [
[[package]]
name = "filmcraft-audio-dsp"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-automation"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"base64 0.22.1",
"filmcraft-engine",
@@ -1718,7 +1718,7 @@ dependencies = [
[[package]]
name = "filmcraft-av1"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1729,14 +1729,14 @@ dependencies = [
[[package]]
name = "filmcraft-bitstream"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"proptest",
]
[[package]]
name = "filmcraft-captions"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"filmcraft-project",
@@ -1749,11 +1749,11 @@ dependencies = [
[[package]]
name = "filmcraft-cfb"
version = "0.2.0"
version = "0.2.1"
[[package]]
name = "filmcraft-cli"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-automation",
"filmcraft-codecs",
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "filmcraft-codecs"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-aac",
"filmcraft-ac3",
@@ -1802,7 +1802,7 @@ dependencies = [
[[package]]
name = "filmcraft-color"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -1810,7 +1810,7 @@ dependencies = [
[[package]]
name = "filmcraft-dnx"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1820,7 +1820,7 @@ dependencies = [
[[package]]
name = "filmcraft-edit"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1832,7 +1832,7 @@ dependencies = [
[[package]]
name = "filmcraft-engine"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -1863,7 +1863,7 @@ dependencies = [
[[package]]
name = "filmcraft-export"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-aac",
"filmcraft-audio-dsp",
@@ -1892,7 +1892,7 @@ dependencies = [
[[package]]
name = "filmcraft-format"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-media",
"filmcraft-project",
@@ -1904,7 +1904,7 @@ dependencies = [
[[package]]
name = "filmcraft-frame"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"filmcraft-time",
@@ -1914,7 +1914,7 @@ dependencies = [
[[package]]
name = "filmcraft-geom"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -1922,7 +1922,7 @@ dependencies = [
[[package]]
name = "filmcraft-golden"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1939,7 +1939,7 @@ dependencies = [
[[package]]
name = "filmcraft-gpu"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -1956,7 +1956,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1966,7 +1966,7 @@ dependencies = [
[[package]]
name = "filmcraft-h264enc"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1976,7 +1976,7 @@ dependencies = [
[[package]]
name = "filmcraft-hevc"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -1986,7 +1986,7 @@ dependencies = [
[[package]]
name = "filmcraft-interchange"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-cfb",
"filmcraft-color",
@@ -2003,7 +2003,7 @@ dependencies = [
[[package]]
name = "filmcraft-isobmff"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2014,7 +2014,7 @@ dependencies = [
[[package]]
name = "filmcraft-matroska"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
"serde_json",
@@ -2022,7 +2022,7 @@ dependencies = [
[[package]]
name = "filmcraft-media"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
@@ -2035,7 +2035,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpeg2v"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
"rayon",
@@ -2044,7 +2044,7 @@ dependencies = [
[[package]]
name = "filmcraft-mpegts"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
"thiserror 2.0.21",
@@ -2052,28 +2052,28 @@ dependencies = [
[[package]]
name = "filmcraft-mxf"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-ogg"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-opus"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-platform"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-codecs",
"filmcraft-color",
@@ -2091,7 +2091,7 @@ dependencies = [
[[package]]
name = "filmcraft-project"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"filmcraft-geom",
@@ -2105,7 +2105,7 @@ dependencies = [
[[package]]
name = "filmcraft-prores"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
@@ -2115,7 +2115,7 @@ dependencies = [
[[package]]
name = "filmcraft-render"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-audio-dsp",
"filmcraft-captions",
@@ -2135,7 +2135,7 @@ dependencies = [
[[package]]
name = "filmcraft-scopes"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"serde",
@@ -2144,7 +2144,7 @@ dependencies = [
[[package]]
name = "filmcraft-speech"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"candle-core",
"candle-nn",
@@ -2162,14 +2162,14 @@ dependencies = [
[[package]]
name = "filmcraft-testkit"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-text"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-color",
"harfrust",
@@ -2180,7 +2180,7 @@ dependencies = [
[[package]]
name = "filmcraft-time"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"proptest",
"serde",
@@ -2188,7 +2188,7 @@ dependencies = [
[[package]]
name = "filmcraft-ui-egui"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "filmcraft-vp9"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"filmcraft-bitstream",
"rayon",
@@ -2230,7 +2230,7 @@ dependencies = [
[[package]]
name = "filmcraft-web"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"eframe",
"egui",
@@ -7211,7 +7211,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.2.1"
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.2.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+7 -1
View File
@@ -36,7 +36,7 @@ before choosing work.
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
| **Export** | ~92% | **~70%** | Delivery: H.264 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS only (VideoToolbox, #33). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects, export and encoding are CPU. Linux and Windows have no hardware path. 8K and AV1 aren't real-time. The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS only (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects, export and encoding are CPU. Linux and Windows have no hardware path. 8K and AV1 aren't real-time. The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
@@ -159,6 +159,8 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
## Log
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
- **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8).
- **2026-10-04 (later):** interchange + MXF export (M11.4/M6.6): own compound-file container (`crates/cfb`), AAF Edit Protocol import/export (embedded/linked/consolidated audio, video mixdown, handles, breakout to mono), OMF 2.0 export (Bento), MXF OP1a/OP-Atom writer (DNxHR/ProRes/H.264/PCM) as export formats. 1605 tests.
@@ -200,3 +202,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
- **2026-09-30 (midday):** HEVC decoder, Matroska/WebM demuxer and audio DSP merged; HEVC import wired; asset rules (AGENTS.md, ATTRIBUTION.md, `cargo xtask assets`); README with hero screenshot and the Craft family.
- **2026-09-30 (late morning):** keyframe value/velocity graphs; xtask gates; H.264 MP4 export; Lumetri curves, wheels, looks, HSL secondary.
- **2026-09-30 (early morning):** H.264 decoder, ProRes, AAC; GPU compositor; MCP; Premiere 26 visual fidelity pass.
### Interface localization
Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses a Japanese font already installed on the system (none is bundled); without one, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
+171
View File
@@ -0,0 +1,171 @@
//! The desktop app's command line (`filmcraft --help`).
/// `filmcraft --help`.
pub const USAGE: &str = "\
Usage: filmcraft [options] [project.fcproj | media files...]
Options:
--control <port> start the localhost JSON-lines control server
(also: FILMCRAFT_CONTROL_PORT)
--demo open the demo project
--empty start with an empty project
--recover recover the newest unsaved changes without asking
--no-recover start without asking about unsaved changes
--data-dir <dir> folder for auto-save, crash recovery and logs
(also: FILMCRAFT_DATA_DIR)
--version print the version and exit
-h, --help print this help and exit
";
/// What the command line asks for.
#[derive(Debug, PartialEq)]
pub enum Cli {
Run(Launch),
Help,
Version,
}
/// Options of a normal start.
#[derive(Debug, PartialEq)]
pub struct Launch {
pub control_port: Option<u16>,
/// A project and / or media files to open.
pub files: Vec<String>,
pub demo: bool,
/// `--demo` / `--empty` given: skip Settings ▸ General ▸ At Startup.
pub startup_flag: bool,
pub recover: Option<bool>,
pub data_dir: Option<std::path::PathBuf>,
}
/// Parse the arguments after the program name. `env_control_port` is the value of
/// `FILMCRAFT_CONTROL_PORT`, used when `--control` is not given (an empty value counts as unset).
///
/// An option we don't know, or one without a usable value, is an error: opening a window for a
/// mistyped flag helps nobody, and starting without the control server that was asked for leaves
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
/// too, and for a port from the environment exactly as for one from `--control`.
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
let (mut help, mut version) = (false, false);
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"-h" | "--help" => help = true,
"--version" => version = true,
"--control" => {
let port = args.next().ok_or("--control needs a port number")?;
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
}
"--demo" => (l.demo, l.startup_flag) = (true, true),
"--empty" => (l.demo, l.startup_flag) = (false, true),
"--recover" => l.recover = Some(true),
"--no-recover" => l.recover = Some(false),
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
// everything after `--` is a file, whatever it looks like
"--" => l.files.extend(args.by_ref()),
// macOS adds a process serial number when it launches an app bundle
_ if a.starts_with("-psn_") => {}
_ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")),
_ => l.files.push(a),
}
}
if help {
return Ok(Cli::Help);
}
if version {
return Ok(Cli::Version);
}
if l.control_port.is_none()
&& let Some(port) = env_control_port.as_deref().map(str::trim).filter(|p| !p.is_empty())
{
l.control_port = Some(port.parse().map_err(|_| format!("FILMCRAFT_CONTROL_PORT needs a port number, got `{port}`"))?);
}
Ok(Cli::Run(l))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_env(env: Option<&str>, args: &[&str]) -> Result<Cli, String> {
parse(env.map(str::to_string), args.iter().map(|a| a.to_string()))
}
fn parse_args(args: &[&str]) -> Result<Cli, String> {
parse_env(None, args)
}
fn launch(args: &[&str]) -> Launch {
match parse_args(args) {
Ok(Cli::Run(l)) => l,
other => panic!("{args:?} should start the app, got {other:?}"),
}
}
/// `filmcraft --help` used to open the app with "--help" as a media file to import.
#[test]
fn help_and_version_do_not_start_the_app() {
assert_eq!(parse_args(&["--help"]), Ok(Cli::Help));
assert_eq!(parse_args(&["-h"]), Ok(Cli::Help));
assert_eq!(parse_args(&["--empty", "--help"]), Ok(Cli::Help));
assert_eq!(parse_args(&["--version"]), Ok(Cli::Version));
assert_eq!(parse_args(&["--version", "--help"]), Ok(Cli::Help));
assert_eq!(parse_args(&["a.mp4", "--version"]), Ok(Cli::Version));
for flag in ["--control", "--demo", "--empty", "--recover", "--no-recover", "--data-dir", "--version", "--help"] {
assert!(USAGE.contains(flag), "usage lists {flag}");
}
}
#[test]
fn unknown_options_are_errors() {
assert_eq!(parse_args(&["--frobnicate"]), Err("unknown option `--frobnicate`".to_string()));
assert!(parse_args(&["a.mp4", "-x"]).is_err());
assert!(parse_args(&["--Help"]).is_err());
assert!(parse_args(&["--control"]).is_err(), "missing port");
assert!(parse_args(&["--control", "abc"]).unwrap_err().contains("abc"));
assert!(parse_args(&["--control", "70000"]).is_err(), "not a port");
assert!(parse_args(&["--data-dir"]).is_err(), "missing folder");
// the whole line is checked, whatever comes first
assert_eq!(parse_args(&["--version", "--bogus"]), Err("unknown option `--bogus`".to_string()));
assert_eq!(parse_args(&["--bogus", "--version"]), Err("unknown option `--bogus`".to_string()));
assert_eq!(parse_args(&["--help", "--bogus"]), Err("unknown option `--bogus`".to_string()));
assert!(parse_args(&["--help", "--control"]).is_err());
}
/// A bad `FILMCRAFT_CONTROL_PORT` used to start the app without the control server, silently.
#[test]
fn a_bad_port_in_the_environment_is_an_error_like_a_bad_control_flag() {
for bad in ["abc", "70000", "-1", "98 76", "9876x", "\u{fffd}"] {
let e = parse_env(Some(bad), &[]).unwrap_err();
assert!(e.contains("FILMCRAFT_CONTROL_PORT") && e.contains(bad), "{bad}: {e}");
assert!(parse_env(Some(bad), &["--empty", "a.mp4"]).is_err(), "{bad}");
// the flag wins, so the environment's value is not looked at; nor for --help / --version
assert_eq!(parse_env(Some(bad), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true), "{bad}");
assert_eq!(parse_env(Some(bad), &["--help"]), Ok(Cli::Help));
assert_eq!(parse_env(Some(bad), &["--version"]), Ok(Cli::Version));
}
// unset or empty: no control server
assert_eq!(launch(&[]).control_port, None);
assert_eq!(parse_env(Some(""), &[]), Ok(Cli::Run(launch(&[]))));
assert_eq!(parse_env(Some(" "), &[]), Ok(Cli::Run(launch(&[]))));
// a good one is the default
assert_eq!(parse_env(Some("1234"), &[]), Ok(Cli::Run(Launch { control_port: Some(1234), ..launch(&[]) })));
assert_eq!(parse_env(Some("1234"), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true));
}
#[test]
fn known_options_and_files_parse_as_before() {
let none = launch(&[]);
assert_eq!(none, Launch { control_port: None, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None });
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
assert_eq!(l.control_port, Some(9876));
assert_eq!((l.demo, l.startup_flag, l.recover), (false, true, Some(false)));
assert_eq!(l.data_dir, Some(std::path::PathBuf::from("/tmp/fc")));
assert_eq!(l.files, ["cut.fcproj", "a.mp4"]);
assert_eq!((launch(&["--demo"]).demo, launch(&["--demo"]).startup_flag, launch(&["--recover"]).recover), (true, true, Some(true)));
// `--` ends the options; a lone `-` and macOS' process serial number are not options
assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]);
assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]);
}
}
+63 -28
View File
@@ -1,23 +1,26 @@
//! FilmCraft desktop app.
//!
//! Usage: `filmcraft [--control <port>] [--demo|--empty] [--recover|--no-recover] [--data-dir <dir>]
//! [project.fcproj | media files…]`
//! [project.fcproj | media files…]`; `--help` prints the options, `--version` the version. An
//! unknown option is an error (exit code 2), not a file to open.
//!
//! Without a project, `--demo` or `--empty`, Settings ▸ General ▸ At Startup decides: Show Home
//! (the demo project), Open Most Recent, or an empty project.
//!
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `filmcraft_ui_egui::control` for the methods.
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
//! either source.
//!
//! Auto-save and the crash-recovery journal run in every session (data in `--data-dir`, else
//! `FILMCRAFT_DATA_DIR`, else the per-user application data folder). If a previous session died
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
//! Unsaved Changes…).
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod app_nap;
mod args;
mod audio;
mod audio_in;
mod control_server;
@@ -25,6 +28,7 @@ mod control_server;
mod native_menu;
mod window_raise;
use args::{Cli, Launch};
use chrono::TimeZone;
use filmcraft_engine::Session;
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
@@ -45,37 +49,47 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
fn main() -> eframe::Result {
app_nap::disable();
let mut control_port: Option<u16> = std::env::var("FILMCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
// --demo / --empty given: skip Settings ▸ General ▸ At Startup
let mut startup_flag = false;
let mut recover: Option<bool> = None;
let mut data_dir = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => (demo, startup_flag) = (true, true),
"--empty" => (demo, startup_flag) = (false, true),
"--recover" => recover = Some(true),
"--no-recover" => recover = Some(false),
"--data-dir" => data_dir = args.next().map(std::path::PathBuf::from),
"--version" => {
println!("filmcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
/// Print `text` (stdout, or stderr for an error). A Windows release build has no console
/// (`windows_subsystem = "windows"`), so there it is also shown in a message box: otherwise `--help`
/// would print nothing and a bad option would quit without a word.
fn tell(text: &str, error: bool) {
if error {
eprint!("{text}");
} else {
print!("{text}");
}
#[cfg(all(target_os = "windows", not(debug_assertions)))]
{
let level = if error { rfd::MessageLevel::Error } else { rfd::MessageLevel::Info };
let _ = rfd::MessageDialog::new().set_level(level).set_title("FilmCraft").set_description(text).show();
}
}
fn main() -> eframe::Result {
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
Ok(Cli::Run(l)) => l,
Ok(Cli::Help) => {
tell(args::USAGE, false);
return Ok(());
}
Ok(Cli::Version) => {
tell(&format!("filmcraft {}\n", env!("CARGO_PKG_VERSION")), false);
return Ok(());
}
Err(e) => {
tell(&format!("filmcraft: {e}\n\n{}", args::USAGE), true);
std::process::exit(2);
}
};
app_nap::disable();
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
log::info!("hardware decoding: {:?}", filmcraft_platform::register());
register_hardware_decoders();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FilmCraft")
@@ -225,6 +239,15 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
cmd.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
}
/// Put the OS hardware video decoders in front of our own. Registered in a statement of its own:
/// a log macro does not evaluate its arguments while no logger takes its level, which left the
/// hardware decoders out of every run.
fn register_hardware_decoders() -> filmcraft_platform::Availability {
let hardware = filmcraft_platform::register();
log::info!("hardware decoding: {hardware:?}");
hardware
}
/// Local UTC offset at a unix time (auto-save file names and recovery times use local time).
fn local_offset(unix: i64) -> i32 {
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)
@@ -243,3 +266,15 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
let _ = agent;
None
}
#[cfg(test)]
mod tests {
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
/// installed here, as in a normal run).
#[test]
fn startup_registers_the_hardware_decoders_without_a_logger() {
assert!(!log::log_enabled!(log::Level::Info));
let hardware = super::register_hardware_decoders();
assert_eq!(filmcraft_platform::registered(), cfg!(target_os = "macos"), "{hardware:?}");
}
}
+16 -3
View File
@@ -25,10 +25,12 @@ pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
let items = menu_items(app);
let mut native: Vec<(String, MenuItem)> = Vec::new();
let mut titles: Vec<(String, Submenu)> = Vec::new();
let bar = Menu::new();
let app_menu = Submenu::new("FilmCraft", true);
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
let settings = Submenu::new("Settings", true);
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
titles.push(("Settings".into(), settings.clone()));
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
let label = it.label.trim_end_matches('…').to_string();
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
@@ -48,7 +50,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
]);
let _ = bar.append(&app_menu);
for top in MENUS {
let sub = Submenu::new(top, true);
let sub = Submenu::new(app.ui.language.tr(top), true);
titles.push((top.into(), sub.clone()));
let mine: Vec<&Item> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top) && !i.id.starts_with("app.settings.")).collect();
// submenus by path prefix (e.g. Clip ▸ Video Options ▸ Time Interpolation), created where
// their first item appears
@@ -62,7 +65,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
parent = match subs.iter().find(|(k, _)| *k == key) {
Some((_, s)) => s.clone(),
None => {
let s = Submenu::new(&it.path[depth], true);
let s = Submenu::new(app.ui.language.tr(&it.path[depth]), true);
titles.push((it.path[depth].clone(), s.clone()));
let _ = parent.append(&s);
subs.push((key, s.clone()));
s
@@ -88,8 +92,17 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
}
});
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
let language = if items.iter().any(|it| it.id == "app.language.japanese" && it.checked == Some(true)) {
filmcraft_ui_egui::i18n::Language::Ja
} else {
filmcraft_ui_egui::i18n::Language::En
};
for (title, submenu) in &titles {
submenu.set_text(language.tr(title));
}
for (id, mi) in &native {
if let Some(it) = items.iter().find(|i| &i.id == id) {
mi.set_text(&it.label);
let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel));
}
}
+1
View File
@@ -11,6 +11,7 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod bridge;
pub mod long_job;
pub mod server;
pub use bridge::BridgeClient;
+183
View File
@@ -0,0 +1,183 @@
//! Long exports over MCP (docs/agents.md § Long exports): `file.exportMedia` with `wait: true`
//! runs as a background engine job; the server reports its progress as MCP
//! `notifications/progress` (when the request carried a `progressToken`) and stops it on
//! `notifications/cancelled`, deleting the partial output. The session lock is only held for
//! short polls, so other requests are answered while the export runs.
use std::path::Path;
use std::time::{Duration, SystemTime};
use rmcp::RoleServer;
use rmcp::model::{CallToolResult, ContentBlock as Content, ProgressNotificationParam, ProgressToken};
use rmcp::service::{Peer, RequestContext};
use serde_json::{Value, json};
use crate::server::FilmcraftMcp;
/// Engine commands that block until a render is written when called with `wait: true`.
pub const LONG_COMMANDS: &[&str] = &["file.exportMedia"];
/// How often the job is polled (and at most how often progress is reported).
const POLL: Duration = Duration::from_millis(100);
/// Whether `command_run {id, params}` is a long call this module runs.
pub fn is_long(id: &str, params: &Value) -> bool {
LONG_COMMANDS.contains(&id) && params.get("wait").and_then(Value::as_bool) == Some(true)
}
/// Sends `notifications/progress` for one request; progress only ever increases.
struct Reporter {
peer: Peer<RoleServer>,
token: Option<ProgressToken>,
last: f64,
}
impl Reporter {
async fn report(&mut self, done: f64, total: f64, message: &str) {
let Some(token) = self.token.clone() else { return };
if total <= 0.0 || done <= self.last {
return;
}
self.last = done;
let mut p = ProgressNotificationParam::new(token, done).with_total(total);
if !message.is_empty() {
p = p.with_message(message);
}
let _ = self.peer.notify_progress(p).await;
}
}
impl FilmcraftMcp {
/// Run `id` (a [`LONG_COMMANDS`] entry, `wait: true`) as a job, reporting progress and
/// honouring cancellation.
pub(crate) async fn run_long(&self, id: &str, mut params: Value, context: &RequestContext<RoleServer>) -> CallToolResult {
let started = SystemTime::now();
if let Some(p) = params.as_object_mut() {
p.insert("wait".into(), json!(false));
}
let start = match self.run(id, params).await {
Ok(v) => v,
Err(e) => return CallToolResult::error(vec![Content::text(e.to_string())]),
};
let Some(job) = start.get("job").and_then(Value::as_u64) else {
return CallToolResult::error(vec![Content::text(format!("{id} started no job: {start}"))]);
};
let path = start.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
let mut rep = Reporter { peer: context.peer.clone(), token: context.meta.get_progress_token(), last: 0.0 };
loop {
let state = self.job_state(job).await;
let finished = state.as_ref().is_none_or(|j| j["finished"].as_bool() == Some(true));
if let Some(j) = &state {
let (done, total) = (j["done"].as_f64().unwrap_or(0.0), j["total"].as_f64().unwrap_or(0.0));
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
}
if finished {
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
if let Some(e) = result.get("error").and_then(Value::as_str) {
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
}
let mut out = start;
if let Some(o) = out.as_object_mut() {
o.insert("result".into(), result);
}
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
}
tokio::select! {
_ = context.ct.cancelled() => break,
_ = tokio::time::sleep(POLL) => {}
}
}
// Cancelled: stop the encode at the next batch, wait for the worker, delete the partial file.
let _ = self.run("jobs.cancel", json!({"job": job})).await;
for _ in 0..600 {
if self.job_state(job).await.is_none_or(|j| j["finished"].as_bool() == Some(true)) {
break;
}
tokio::time::sleep(POLL).await;
}
remove_partial(&path, started);
CallToolResult::error(vec![Content::text("cancelled")])
}
/// The job's `jobs.list` entry.
async fn job_state(&self, job: u64) -> Option<Value> {
let jobs = self.run("jobs.list", json!({})).await.ok()?;
jobs.as_array()?.iter().find(|j| j["id"].as_u64() == Some(job)).cloned()
}
}
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
/// stills of an image sequence (`<stem><digits>.<same extension>`) and the caption sidecar
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, …) are never touched, even when they
/// were saved during the export.
pub fn remove_partial(path: &str, since: SystemTime) {
let p = Path::new(path);
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
return;
};
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default();
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let Ok(entries) = std::fs::read_dir(dir) else { return };
// file times are coarser than the clock
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
for e in entries.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if is_export_output(name, file, stem, ext) && e.metadata().is_ok_and(|m| m.is_file() && m.modified().is_ok_and(|t| t >= since)) {
let _ = std::fs::remove_file(e.path());
}
}
}
/// Whether `name` is a file an export to `file` (`stem` + `.ext`) writes.
fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
if name == file {
return true;
}
let Some(rest) = name.strip_prefix(stem) else { return false };
if let Some(sidecar) = rest.strip_prefix('.') {
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
}
// image sequence: one or more digits, then the output's own extension
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_exports_own_files_are_removed() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let started = SystemTime::now();
let ours = ["trailer.png", "trailer000.png", "trailer123.png", "trailer.srt", "trailer.vtt"];
// same stem, written during the export, but not the export's
let theirs = ["trailer.aep", "trailer2.psd", "trailer.docx", "trailer000.jpg", "trailers.png", "trailer.png.bak", "other.png"];
for f in ours.iter().chain(theirs.iter()) {
std::fs::write(dir.join(f), b"x").unwrap();
}
remove_partial(&dir.join("trailer.png").to_string_lossy(), started);
for f in ours {
assert!(!dir.join(f).exists(), "{f} should be removed");
}
for f in theirs {
assert!(dir.join(f).exists(), "{f} must not be removed");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn files_older_than_the_export_are_kept() {
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("clip.mp4"), b"x").unwrap();
remove_partial(&dir.join("clip.mp4").to_string_lossy(), SystemTime::now() + Duration::from_secs(60));
assert!(dir.join("clip.mp4").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+546 -28
View File
@@ -1,6 +1,6 @@
//! The MCP server. Tool names are snake_case.
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use base64::Engine as _;
use filmcraft_engine::Session;
@@ -19,6 +19,19 @@ pub enum Backend {
Bridge(Arc<BridgeClient>),
}
/// A panic payload as text.
fn panic_message(p: Box<dyn std::any::Any + Send>) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
/// A failed blocking task: a panic is reported as an internal error and the session stays usable.
fn join_error(e: tokio::task::JoinError) -> AutomationError {
match e.try_into_panic() {
Ok(p) => AutomationError::Other(format!("internal error: {}", panic_message(p))),
Err(e) => AutomationError::Other(e.to_string()),
}
}
#[derive(Clone)]
pub struct FilmcraftMcp {
backend: Arc<Backend>,
@@ -44,6 +57,24 @@ pub struct RunParams {
pub params: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BatchStep {
/// Command id.
pub id: String,
/// Parameters object.
#[serde(default)]
pub params: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BatchParams {
/// Commands to run in order.
pub steps: Vec<BatchStep>,
/// Stop at the first failing step (default true).
#[serde(default)]
pub stop_on_error: Option<bool>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct RenderParams {
/// Timeline time in seconds (default: the playhead).
@@ -148,9 +179,60 @@ impl FilmcraftMcp {
}
pub async fn serve_stdio(self) -> Result<(), AutomationError> {
let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?;
Ok(())
self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
}
/// Serve MCP over any line stream. A line that isn't JSON gets a JSON-RPC parse error
/// (`-32700`, id null) instead of being dropped silently; the rest go to the rmcp service. One
/// writer task owns `output`, so replies never interleave.
pub async fn serve_io(
self,
input: impl tokio::io::AsyncRead + Unpin + Send + 'static,
output: impl tokio::io::AsyncWrite + Unpin + Send + 'static,
) -> Result<(), AutomationError> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (mut to_service, service_in) = tokio::io::duplex(1 << 20);
let (service_out, from_service) = tokio::io::duplex(1 << 20);
let (err_tx, mut err_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(_) => {
if to_service.write_all(format!("{line}\n").as_bytes()).await.is_err() {
break;
}
}
Err(e) => {
let reply = json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("parse error: {e}")}});
let _ = err_tx.send(reply.to_string());
}
}
}
// EOF: closing the pipe ends the service
let _ = to_service.shutdown().await;
});
let writer = tokio::spawn(async move {
let mut output = output;
let mut replies = BufReader::new(from_service).lines();
loop {
let line = tokio::select! {
l = replies.next_line() => match l { Ok(Some(l)) => l, _ => break },
Some(e) = err_rx.recv() => e,
};
if output.write_all(format!("{line}\n").as_bytes()).await.is_err() || output.flush().await.is_err() {
break;
}
}
});
let running = self.serve((service_in, service_out)).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
let r = running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()));
reader.abort();
let _ = writer.await;
r.map(|_| ())
}
/// Run an engine command on whichever backend.
@@ -160,16 +242,24 @@ impl FilmcraftMcp {
let s = s.clone();
let id = id.to_string();
tokio::task::spawn_blocking(move || {
let mut g = s.lock().map_err(|_| AutomationError::Other("session lock poisoned".into()))?;
// A command that panicked earlier poisoned the lock; the session is still usable.
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
g.execute(&id, params).map_err(AutomationError::from)
})
.await
.map_err(|e| AutomationError::Other(e.to_string()))?
.map_err(join_error)?
}
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
}
}
/// The project tree plus the active sequence (null when there is none).
async fn document(&self) -> Result<Value, AutomationError> {
let project = self.run("project.inspect", json!({})).await?;
let sequence = self.run("sequence.inspect", json!({})).await.ok();
Ok(json!({"project": project, "sequence": sequence}))
}
fn bridge_client(&self) -> Option<Arc<BridgeClient>> {
match &*self.backend {
Backend::Bridge(b) => Some(b.clone()),
@@ -190,7 +280,9 @@ impl FilmcraftMcp {
#[tool_router]
impl FilmcraftMcp {
#[tool(
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids."
title = "List commands",
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
let r = self.run("command.list", json!({})).await.map(|v| {
@@ -213,38 +305,99 @@ impl FilmcraftMcp {
}
#[tool(
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History."
title = "Run a command",
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
wrap(self.run(&p.id, p.params.unwrap_or(json!({}))).await)
}
#[tool(description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.")]
#[tool(
title = "Run several commands",
description = "Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}; each edit is its own undo step.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
let stop = p.stop_on_error.unwrap_or(true);
let (mut completed, mut failed, mut results) = (0u32, 0u32, Vec::new());
for st in p.steps {
match self.run(&st.id, st.params.unwrap_or(json!({}))).await {
Ok(v) => {
completed += 1;
results.push(json!({"ok": true, "result": v}));
}
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e.to_string()}));
if stop {
break;
}
}
}
}
let v = json!({"completed": completed, "failed": failed, "results": results});
Ok(if failed > 0 { CallToolResult::error(vec![Content::text(v.to_string())]) } else { ok_json(&v) })
}
#[tool(
title = "Inspect the document",
description = "The project as JSON: the tree (as project_inspect) plus the active sequence (as sequence_inspect; null when there is none).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn doc_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.document().await)
}
#[tool(
title = "Render a preview",
description = "The active sequence's frame at `seconds` (default: the playhead) as PNG, same as render_frame (bridge mode: Program monitor screenshot).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn render_preview(&self, p: Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
self.render_frame(p).await
}
#[tool(
title = "Inspect the project tree",
description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn project_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.run("project.inspect", json!({})).await)
}
#[tool(
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second."
title = "Inspect the active sequence",
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn sequence_inspect(&self) -> Result<CallToolResult, McpError> {
wrap(self.run("sequence.inspect", json!({})).await)
}
#[tool(description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).")]
#[tool(
title = "Import media",
description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).",
annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = false)
)]
async fn media_import(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
let paths: Vec<&str> = p.text.split('\n').map(str::trim).filter(|s| !s.is_empty()).collect();
wrap(self.run("file.import", json!({"paths": paths})).await)
}
#[tool(description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.")]
#[tool(
title = "Render a frame",
description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn render_frame(&self, Parameters(p): Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
let max = p.max_side.unwrap_or(960);
match &*self.backend {
Backend::Headless(s) => {
let s = s.clone();
let r = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AutomationError> {
let mut g = s.lock().map_err(|_| AutomationError::Other("lock".into()))?;
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(sec) = p.seconds {
g.set_playhead(filmcraft_time::Tick::from_seconds_f64(sec));
}
@@ -255,7 +408,7 @@ impl FilmcraftMcp {
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
})
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
.unwrap_or_else(|e| Err(join_error(e)));
Ok(match r {
Ok(png) => png_result(&png, "rendered frame"),
Err(e) => fail(e),
@@ -272,17 +425,29 @@ impl FilmcraftMcp {
}
}
#[tool(description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).")]
#[tool(
title = "Live app: UI state",
description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
self.ui("ui.inspect", json!({})).await
}
#[tool(description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.")]
#[tool(
title = "Live app: on-screen elements",
description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_elements(&self, Parameters(p): Parameters<ElementsParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await
}
#[tool(description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.")]
#[tool(
title = "Live app: click",
description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_click(&self, Parameters(p): Parameters<ClickParams>) -> Result<CallToolResult, McpError> {
let mut v = json!({"button": p.button, "count": p.count, "modifiers": p.modifiers});
if let Some(id) = p.id {
@@ -295,23 +460,37 @@ impl FilmcraftMcp {
}
#[tool(
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks)."
title = "Live app: drag",
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_drag(&self, Parameters(p): Parameters<DragParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.drag", json!({"from": p.from, "to": p.to, "steps": p.steps, "modifiers": p.modifiers})).await
}
#[tool(description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).")]
#[tool(
title = "Live app: press a key",
description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_key(&self, Parameters(p): Parameters<KeyParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.key", json!({"key": p.key})).await
}
#[tool(description = "Live app: type text into the focused field.")]
#[tool(
title = "Live app: type text",
description = "Live app: type text into the focused field.",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_type(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
self.ui("ui.type", json!({"text": p.text})).await
}
#[tool(description = "Live app: screenshot of the window or one panel (PNG).")]
#[tool(
title = "Live app: screenshot",
description = "Live app: screenshot of the window or one panel (PNG).",
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
)]
async fn ui_screenshot(&self, Parameters(p): Parameters<ShotParams>) -> Result<CallToolResult, McpError> {
match self.bridge_client() {
Some(b) => self.screenshot(&b, p.panel, p.max_side.unwrap_or(1600)).await,
@@ -320,7 +499,9 @@ impl FilmcraftMcp {
}
#[tool(
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …)."
title = "Live app: control-channel call",
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …).",
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
)]
async fn ui_control(&self, Parameters(p): Parameters<ControlParams>) -> Result<CallToolResult, McpError> {
self.ui(&p.method, p.params.unwrap_or(json!({}))).await
@@ -350,12 +531,104 @@ impl FilmcraftMcp {
}
}
#[tool_handler(
router = self.tool_router,
name = "filmcraft",
instructions = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable). `project_inspect`/`sequence_inspect` return ids you can pass to commands; `render_frame` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`)."
)]
impl ServerHandler for FilmcraftMcp {}
impl FilmcraftMcp {
/// The first argument `tool` doesn't take, as a message naming the ones it does.
fn unknown_arg(&self, tool: &str, args: Option<&rmcp::model::JsonObject>) -> Option<String> {
let def = self.tool_router.get(tool)?;
let props = def.input_schema.get("properties").and_then(Value::as_object).cloned().unwrap_or_default();
let bad = args?.keys().find(|k| !props.contains_key(*k))?;
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
let accepted = if accepted.is_empty() { "no arguments".to_string() } else { accepted.join(", ") };
Some(format!("unknown argument \"{bad}\" for {tool}; expected: {accepted}"))
}
}
const INSTRUCTIONS: &str = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable; `command_batch` runs several). `doc_inspect` (or `project_inspect`/`sequence_inspect`) returns ids you can pass to commands; `render_preview` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`).";
/// Resources: the project (as `doc_inspect`) and the command catalog (as `command_list`).
const DOCUMENT_URI: &str = "filmcraft://document";
const COMMANDS_URI: &str = "filmcraft://commands";
/// MCP 2026-07-28 clients (current Claude Code) require `ttlMs` and `cacheScope` on list and read
/// results; rmcp fills `resultType` and leaves the cache hints to the server, as the generated
/// `tools/list` does. The resource list never changes; the document does, so reads aren't cached.
fn cache_hints(context: &rmcp::service::RequestContext<rmcp::RoleServer>, ttl_ms: u64) -> Option<(u64, rmcp::model::CacheScope)> {
context.protocol_version().is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28).then_some((ttl_ms, rmcp::model::CacheScope::Private))
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for FilmcraftMcp {
fn get_info(&self) -> rmcp::model::ServerConfig {
rmcp::model::ServerConfig::new(rmcp::model::ServerCapabilities::builder().enable_tools().enable_resources().build())
.with_server_info(rmcp::model::Implementation::new("filmcraft", env!("CARGO_PKG_VERSION")))
.with_instructions(INSTRUCTIONS.to_string())
}
/// Strict arguments and long exports in front of the tool router.
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::CallToolResponse, McpError> {
if let Some(m) = self.unknown_arg(&request.name, request.arguments.as_ref()) {
return Err(McpError::invalid_params(m, None));
}
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
if request.name == "command_run"
&& matches!(&*self.backend, Backend::Headless(_))
&& let Some(a) = &request.arguments
&& let Some(id) = a.get("id").and_then(Value::as_str)
{
let params = a.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
if crate::long_job::is_long(id, &params) {
return Ok(self.run_long(id, params, &context).await.into());
}
}
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.tool_router.call(tcc).await
}
async fn list_resources(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListResourcesResult, McpError> {
use rmcp::model::Resource;
let mut r = rmcp::model::ListResourcesResult::with_all_items(vec![
Resource::new(DOCUMENT_URI, "document")
.with_title("Project")
.with_description("The project tree and the active sequence (same as doc_inspect).")
.with_mime_type("application/json"),
Resource::new(COMMANDS_URI, "commands")
.with_title("Command catalog")
.with_description("Every command with id, label, menu path, shortcut, params and enabled state (same as command_list).")
.with_mime_type("application/json"),
]);
if let Some((ttl, scope)) = cache_hints(&context, 600_000) {
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
}
Ok(r)
}
async fn read_resource(
&self,
request: rmcp::model::ReadResourceRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
let v = match request.uri.as_str() {
DOCUMENT_URI => self.document().await,
COMMANDS_URI => self.run("command.list", json!({})).await,
other => return Err(McpError::resource_not_found(format!("resource not found: {other}"), None)),
}
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let text = serde_json::to_string_pretty(&v).map_err(|e| McpError::internal_error(e.to_string(), None))?;
let mut r = rmcp::model::ReadResourceResult::new(vec![rmcp::model::ResourceContents::text(text, request.uri).with_mime_type("application/json")]);
if let Some((ttl, scope)) = cache_hints(&context, 0) {
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
}
Ok(r.into())
}
}
#[cfg(test)]
mod tests {
@@ -371,4 +644,249 @@ mod tests {
let r = m.render_frame(Parameters(RenderParams { seconds: Some(1.0), max_side: Some(320) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
/// One request/response exchange over `serve_io`, as an MCP client sees it.
struct Client {
tx: tokio::io::DuplexStream,
rx: tokio::io::Lines<tokio::io::BufReader<tokio::io::DuplexStream>>,
}
impl Client {
fn start(s: Session) -> Self {
use tokio::io::AsyncBufReadExt;
let (tx, server_in) = tokio::io::duplex(1 << 22);
let (server_out, rx) = tokio::io::duplex(1 << 22);
tokio::spawn(FilmcraftMcp::headless(s).serve_io(server_in, server_out));
Self { tx, rx: tokio::io::BufReader::new(rx).lines() }
}
async fn send(&mut self, line: &str) {
use tokio::io::AsyncWriteExt;
self.tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
}
async fn next(&mut self) -> Value {
serde_json::from_str(&self.rx.next_line().await.unwrap().unwrap()).unwrap()
}
async fn ask(&mut self, msg: Value) -> Value {
self.send(&msg.to_string()).await;
self.next().await
}
async fn init(&mut self) -> Value {
let r = self.ask(json!({"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}})).await;
self.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string()).await;
r
}
async fn call(&mut self, id: u64, name: &str, arguments: Value) -> Value {
self.ask(json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":arguments}})).await
}
}
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
/// Every listed tool has a title and the four hints, and the core tools are listed.
#[tokio::test(flavor = "multi_thread")]
async fn tools_are_titled_and_annotated() {
let mut c = Client::start(demo());
let init = c.init().await;
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft");
let tools = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})).await["result"]["tools"].as_array().cloned().unwrap();
for t in &tools {
assert!(t["title"].is_string(), "{} has no title", t["name"]);
for h in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][h].is_boolean(), "{} lacks {h}", t["name"]);
}
}
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for want in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "project_inspect", "render_frame", "ui_click"] {
assert!(names.contains(&want), "{want} not listed");
}
let ro = |n: &str| tools.iter().find(|t| t["name"] == n).map(|t| t["annotations"]["readOnlyHint"].clone());
assert_eq!((ro("command_list"), ro("command_run")), (Some(json!(true)), Some(json!(false))));
}
/// Strict arguments, malformed JSON, batch, doc_inspect and render_preview over the raw line
/// protocol.
#[tokio::test(flavor = "multi_thread")]
async fn strict_arguments_and_parse_errors() {
let mut c = Client::start(demo());
c.init().await;
let r = c.call(1, "command_list", json!({"filter": "razor", "enabled_only": true})).await;
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
// an unknown argument names itself and the accepted ones
let r = c.call(2, "command_list", json!({"filtr": "x"})).await;
assert_eq!(r["error"]["code"], -32602, "{r}");
let m = r["error"]["message"].as_str().unwrap();
assert!(m.contains("\"filtr\"") && m.contains("filter") && m.contains("enabled_only"), "{m}");
let r = c.call(3, "doc_inspect", json!({"verbose": true})).await;
assert_eq!(r["error"]["code"], -32602, "{r}");
// command params stay free-form
let r = c.call(4, "command_run", json!({"id": "playhead.set", "params": {"seconds": 0.5}})).await;
assert_eq!(r["result"]["isError"], false, "{r}");
// malformed JSON: a parse error, and the server keeps serving
c.send("{\"jsonrpc\": \"2.0\", \"id\": 5, BROKEN").await;
let r = c.next().await;
assert_eq!((r["error"]["code"].as_i64(), r["id"].is_null()), (Some(-32700), true), "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":6,"method":"ping"})).await;
assert!(r["result"].is_object(), "{r}");
// batch: stops at the first failure and reports both
let r =
c.call(7, "command_batch", json!({"steps": [{"id": "playhead.set", "params": {"seconds": 0.5}}, {"id": "no.such"}, {"id": "playhead.set"}]})).await;
assert_eq!(r["result"]["isError"], true, "{r}");
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!((v["completed"].as_u64(), v["failed"].as_u64(), v["results"].as_array().map(Vec::len)), (Some(1), Some(1), Some(2)), "{v}");
let r = c.call(8, "doc_inspect", json!({})).await;
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
let r = c.call(9, "render_preview", json!({"max_side": 64})).await;
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
}
/// `filmcraft://document` and `filmcraft://commands` are listed and readable as JSON.
#[tokio::test(flavor = "multi_thread")]
async fn resources_document_and_commands() {
let mut c = Client::start(demo());
let init = c.init().await;
assert!(init["result"]["capabilities"]["resources"].is_object(), "{init}");
assert!(init["result"]["instructions"].as_str().unwrap().contains("doc_inspect"), "{init}");
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"resources/list"})).await;
let uris: Vec<&str> = r["result"]["resources"].as_array().unwrap().iter().filter_map(|x| x["uri"].as_str()).collect();
assert_eq!(uris, [DOCUMENT_URI, COMMANDS_URI], "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":DOCUMENT_URI}})).await;
assert_eq!(r["result"]["contents"][0]["mimeType"], "application/json", "{r}");
let v: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
let r = c.ask(json!({"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":COMMANDS_URI}})).await;
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
let r = c.ask(json!({"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"filmcraft://nope"}})).await;
assert!(r["error"]["code"].is_i64(), "{r}");
}
/// MCP 2026-07-28 clients (current Claude Code) negotiate per request and reject list and read
/// results without `resultType`, `ttlMs` and `cacheScope`; sessions that negotiated an older
/// revision through `initialize` get the old shape.
#[tokio::test(flavor = "multi_thread")]
async fn modern_clients_get_result_type_and_cache_hints() {
let mut c = Client::start(Session::default());
let meta = json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {},
});
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":meta}})).await;
assert!(r["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2026-07-28")), "{r}");
for (id, method, params) in [
(2, "tools/list", json!({"_meta": meta})),
(3, "resources/list", json!({"_meta": meta})),
(4, "resources/read", json!({"_meta": meta, "uri": DOCUMENT_URI})),
] {
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
assert_eq!(r["result"]["resultType"], "complete", "{method}: {r}");
assert!(r["result"]["ttlMs"].is_u64(), "{method}: {r}");
assert!(matches!(r["result"]["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
}
let mut c = Client::start(Session::default());
c.init().await;
for (id, method, params) in [(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": DOCUMENT_URI}))] {
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
assert!(r["result"].is_object() && r["result"].get("resultType").is_none() && r["result"].get("ttlMs").is_none(), "{method}: {r}");
}
}
/// docs/agents.md § Long exports: a blocking export reports `notifications/progress` for its
/// token (none without one), answers `ping` meanwhile, and `notifications/cancelled` stops
/// it, deletes the partial file and sends no response.
#[tokio::test(flavor = "multi_thread")]
async fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("filmcraft-mcp-progress-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut c = Client::start(demo());
c.init().await;
let export = |id: u64, name: &str, token: Option<Value>, seconds: f64| {
let path = dir.join(name).to_string_lossy().to_string();
let params = json!({"path": path, "format": "h264", "width": 640, "height": 360, "audio": false, "range": "custom", "startSeconds": 0, "endSeconds": seconds, "wait": true});
let mut call =
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"file.exportMedia","params":params}}});
if let Some(t) = token {
call["params"]["_meta"] = json!({"progressToken": t});
}
call.to_string()
};
// no token: no notifications, the same response as before
c.send(&export(1, "quiet.mp4", None, 0.5)).await;
let m = c.next().await;
assert_eq!((m["id"].as_u64(), &m["result"]["isError"]), (Some(1), &json!(false)), "{m}");
// progress, strictly increasing, with a total; ping answered while the export runs
c.send(&export(2, "a.mp4", Some(json!("tok")), 1.0)).await;
let (mut last, mut notes, mut pinged, mut pong) = (-1.0, 0, false, false);
let done = loop {
let m = c.next().await;
if m["method"] == "notifications/progress" {
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
let p = m["params"]["progress"].as_f64().unwrap();
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
last = p;
notes += 1;
if !pinged {
pinged = true;
c.send(r#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#).await;
}
} else if m["id"] == 3 {
pong = true;
} else if m["id"] == 2 {
break m;
}
};
assert_eq!(done["result"]["isError"], false, "{done}");
assert!(notes >= 2 && pong, "{notes} notifications, ping answered during the export: {pong}");
assert!(dir.join("a.mp4").exists());
// cancel after the first notification: no response, no partial file
c.send(&export(4, "b.mp4", Some(json!(7)), 8.0)).await;
loop {
let m = c.next().await;
assert_ne!(m["id"], 4, "finished before the cancel: {m}");
if m["method"] == "notifications/progress" {
break;
}
}
c.send(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}}"#).await;
let t0 = std::time::Instant::now();
while dir.join("b.mp4").exists() || t0.elapsed() < std::time::Duration::from_millis(500) {
assert!(t0.elapsed().as_secs() < 30, "partial file still there");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
c.send(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"jobs.list"}}}"#).await;
let m = loop {
let m = c.next().await;
assert_ne!(m["id"], 4, "no response for a cancelled request: {m}");
if m["id"] == 5 {
break m;
}
};
let jobs: Value = serde_json::from_str(m["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
let last = jobs.as_array().and_then(|a| a.last()).cloned().unwrap_or_default();
assert_eq!(last["result"]["error"], "cancelled", "{jobs}");
assert!(!dir.join("b.mp4").exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A command that panicked used to poison the session lock, and every later call failed with
/// "session lock poisoned" until the server was restarted.
#[tokio::test(flavor = "multi_thread")]
async fn a_poisoned_session_lock_keeps_serving() {
let m = FilmcraftMcp::headless(demo());
let Backend::Headless(s) = &*m.backend else { panic!("headless backend") };
let s = s.clone();
let _ = std::thread::spawn(move || {
let _g = s.lock().unwrap();
panic!("a command panicked");
})
.join();
let v = m.run("sequence.inspect", json!({})).await.unwrap();
assert!(v.is_object(), "{v}");
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
assert_ne!(r.is_error, Some(true));
}
}
+70 -15
View File
@@ -184,28 +184,45 @@ struct Timing {
tick_rate: i64,
}
fn positive_integer(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<i64>().ok().filter(|v| *v > 0)
}
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
let mut it = s.split_whitespace().map(|x| x.parse::<i64>().ok());
let mut it = s.split_whitespace().map(positive_integer);
let a = it.next()??;
let b = it.next().flatten().unwrap_or(1);
(a > 0 && b > 0).then_some((a, b))
let b = it.next()??;
it.next().is_none().then_some((a, b))
}
fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == local).map(|a| a.value())
}
fn timing(root: roxmltree::Node) -> Timing {
let base = attr(root, "frameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(30);
let (mn, md) = attr(root, "frameRateMultiplier").and_then(parse_ratio).unwrap_or((1, 1));
let rate = FrameRate::new(base * mn, md);
let sub = attr(root, "subFrameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(1);
let tick_rate = attr(root, "tickRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(if attr(root, "frameRate").is_some() {
base * sub
} else {
1
});
Timing { rate, base, sub_frame_rate: sub, tick_rate }
fn timing(root: roxmltree::Node) -> Result<Timing> {
let invalid =
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
let integer = |name: &str, default| match attr(root, name) {
Some(v) => positive_integer(v).ok_or_else(|| invalid(name)),
None => Ok(default),
};
let base = integer("frameRate", 30)?;
let (mn, md) = match attr(root, "frameRateMultiplier") {
Some(v) => parse_ratio(v).ok_or_else(|| invalid("frameRateMultiplier"))?,
None => (1, 1),
};
let rate = FrameRate::new(base.checked_mul(mn).ok_or_else(|| invalid("frameRateMultiplier"))?, md);
let sub = integer("subFrameRate", 1)?;
let tick_rate = match attr(root, "tickRate") {
Some(v) => positive_integer(v).ok_or_else(|| invalid("tickRate"))?,
None if attr(root, "frameRate").is_some() => base.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?,
None => 1,
};
Ok(Timing { rate, base, sub_frame_rate: sub, tick_rate })
}
/// Seconds as an exact decimal string → ticks.
@@ -383,7 +400,7 @@ pub fn parse(text: &str) -> Result<Document> {
if root.tag_name().name() != "tt" {
return Err(Error::NotFormat("TTML"));
}
let tm = timing(root);
let tm = timing(root)?;
let mut styles = std::collections::HashMap::new();
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
@@ -478,6 +495,44 @@ mod tests {
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: 1 }
}
#[test]
fn invalid_timing_parameters_return_an_error() {
for params in [
r#"ttp:frameRate="0""#,
r#"ttp:frameRate="-25""#,
r#"ttp:frameRate="""#,
r#"ttp:subFrameRate="0""#,
r#"ttp:tickRate="-1""#,
r#"ttp:frameRateMultiplier="1 0""#,
r#"ttp:frameRateMultiplier="1""#,
r#"ttp:frameRateMultiplier="1 2 3""#,
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#
);
assert!(crate::parse(xml.as_bytes(), crate::Format::Ttml).is_err(), "{params}");
}
}
#[test]
fn missing_timing_parameters_keep_defaults() {
for (params, begin, end) in [
("", "1s", "2s"),
(r#"ttp:frameRate="25""#, "25f", "50f"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2""#, "60t", "120t"),
(r#"ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10""#, "10t", "20t"),
(r#"ttp:frameRate="25" ttp:frameRateMultiplier="2 1""#, "50f", "100f"),
] {
let xml = format!(
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="{begin}" end="{end}">Hello</p></div></body></tt>"#
);
let doc = crate::parse(xml.as_bytes(), crate::Format::Ttml).unwrap();
assert_eq!(doc.cues.len(), 1, "{params}");
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND), "{params}");
assert_eq!(doc.cues[0].end, Tick(2 * TICKS_PER_SECOND), "{params}");
}
}
#[test]
fn time_expressions() {
let t = tm(FrameRate::FPS_25);
+357 -12
View File
@@ -5,8 +5,11 @@
//! for the next frames therefore hit the cache or continue the running decoder without re-seeking.
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, TryLockError, Weak};
use std::time::Duration;
use web_time::Instant;
use filmcraft_color::ColorInfo;
use filmcraft_frame::VideoFrame;
@@ -68,7 +71,7 @@ fn count_frames<T>(out: &[T]) {
/// Run a decoder call, adding its wall time to the process-wide counter.
fn timed<R>(f: impl FnOnce() -> R) -> R {
let t0 = web_time::Instant::now();
let t0 = Instant::now();
let r = f();
DECODE_NS.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
r
@@ -135,9 +138,35 @@ struct State {
/// The cached frames that were decoded in draft mode: served to draft requests only.
drafts: std::collections::BTreeSet<i64>,
bytes: usize,
/// When a frame was last asked of this cache.
last_used: Instant,
}
impl State {
/// Give up the decoder (and the spare intra decoders): the next request that has to decode
/// makes a new one and restarts at a sync sample. Returned so they are dropped off the lock.
fn release_decoder(&mut self, pool: &Pool) -> Vec<Box<dyn VideoDecoder>> {
let mut out = std::mem::take(&mut self.spare);
if let Some(d) = self.decoder.take() {
pool.decoders.fetch_sub(1, Ordering::Relaxed);
out.push(d);
}
self.next = usize::MAX;
out
}
/// Evict frames, earliest first, while `more` says so.
fn release_frames(&mut self, pool: &Pool, more: impl Fn() -> bool) {
while more() {
let Some((pts, f)) = self.frames.pop_first() else { break };
self.drafts.remove(&pts);
self.bytes = self.bytes.saturating_sub(f.byte_size());
pool.bytes.fetch_sub(f.byte_size(), Ordering::Relaxed);
EVICTED.fetch_add(1, Ordering::Relaxed);
filmcraft_frame::pool::recycle(f);
}
}
/// The cached frame at `pts`, unless it is a draft frame and the request wants the exact one.
fn cached(&self, pts: i64, draft_ok: bool) -> Option<&Arc<VideoFrame>> {
self.frames.get(&pts).filter(|_| draft_ok || !self.drafts.contains(&pts))
@@ -178,9 +207,140 @@ impl Drop for DecodingGuard {
}
}
/// A decoder keeps its reference and in-flight pictures (hundreds of MB for a frame-threaded
/// 4K decoder), so only this many caches keep theirs once idle: a timeline's other clips make a
/// new one when the playhead reaches them, which costs what a seek costs.
const MAX_LIVE_DECODERS: usize = 4;
/// A cache asked for a frame this recently is in use (a layer of the frame being composited,
/// the next clip being prefetched): it keeps its decoder whatever the count.
const IDLE: Duration = Duration::from_secs(3);
/// Decoded frames all caches may hold together before idle ones give theirs up. Each cache also
/// has its own budget, which alone let every clip on a timeline keep hundreds of MB of frames
/// nobody was looking at.
pub const FRAME_BUDGET: usize = 1 << 30;
/// What the caches of a process share: the cap on live decoders and the frame budget.
struct Pool {
caches: Mutex<Vec<Weak<Shared>>>,
/// Caches that hold a decoder.
decoders: AtomicUsize,
max_decoders: usize,
idle: Duration,
/// Bytes of the frames the caches hold.
bytes: AtomicUsize,
budget: usize,
}
/// The part of a [`GopCache`] its pool reaches.
struct Shared {
state: Mutex<State>,
pool: Arc<Pool>,
}
impl Drop for Shared {
fn drop(&mut self) {
let st = self.state.get_mut().unwrap_or_else(PoisonError::into_inner);
drop(st.release_decoder(&self.pool));
self.pool.bytes.fetch_sub(st.bytes, Ordering::Relaxed);
}
}
/// A cache's state unless another thread is using it (it is then decoding: not idle).
fn try_state(c: &Shared) -> Option<MutexGuard<'_, State>> {
match c.state.try_lock() {
Ok(g) => Some(g),
Err(TryLockError::Poisoned(e)) => Some(e.into_inner()),
Err(TryLockError::WouldBlock) => None,
}
}
impl Pool {
fn new(max_decoders: usize, idle: Duration, budget: usize) -> Self {
Self { caches: Mutex::new(Vec::new()), decoders: AtomicUsize::new(0), max_decoders, idle, bytes: AtomicUsize::new(0), budget }
}
/// The pool of every cache in the process.
fn global() -> Arc<Pool> {
static POOL: std::sync::OnceLock<Arc<Pool>> = std::sync::OnceLock::new();
POOL.get_or_init(|| Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET))).clone()
}
fn register(&self, c: &Arc<Shared>) {
let mut g = self.caches.lock().unwrap_or_else(PoisonError::into_inner);
g.retain(|w| w.strong_count() > 0);
g.push(Arc::downgrade(c));
}
/// The idle caches other than `me` that `keep` selects, least recently used first. Other
/// caches are only ever try-locked, so two caches trimming each other cannot deadlock.
fn idle_others(&self, me: &Shared, keep: impl Fn(&State) -> bool) -> Vec<Arc<Shared>> {
let all: Vec<Arc<Shared>> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect();
let mut idle: Vec<(Instant, Arc<Shared>)> = all
.into_iter()
.filter(|c| !std::ptr::eq(Arc::as_ptr(c), me))
.filter_map(|c| {
let used = try_state(&c).filter(|st| keep(st) && st.last_used.elapsed() >= self.idle).map(|st| st.last_used)?;
Some((used, c))
})
.collect();
idle.sort_by_key(|(used, _)| *used);
idle.into_iter().map(|(_, c)| c).collect()
}
/// Take the decoders of idle caches, least recently used first, down to the cap. The caller
/// drops them with no cache lock held: dropping a decoder can block (a hardware session waits
/// for its frames in flight).
#[must_use = "drop the released decoders with no cache lock held"]
fn trim_decoders(&self, me: &Shared) -> Vec<Box<dyn VideoDecoder>> {
let mut released = Vec::new();
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
return released;
}
for c in self.idle_others(me, |st| st.decoder.is_some()) {
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
break;
}
if let Some(mut st) = try_state(&c) {
released.extend(st.release_decoder(self));
}
}
released
}
/// Evict the frames of idle caches, least recently used first, down to the budget. Caches in
/// use keep theirs (each within its own budget): a frame evicted before it is shown costs a
/// re-decode from the keyframe.
fn trim_frames(&self, me: &Shared) {
let over = || self.bytes.load(Ordering::Relaxed) > self.budget;
if !over() {
return;
}
for c in self.idle_others(me, |st| !st.frames.is_empty()) {
if !over() {
break;
}
if let Some(mut st) = try_state(&c) {
st.release_frames(self, over);
}
}
}
}
/// Caches that hold a decoder right now, process-wide (`perf.stats`).
pub fn live_decoders() -> usize {
Pool::global().decoders.load(Ordering::Relaxed)
}
/// Bytes of decoded frames the caches hold right now, process-wide (`perf.stats`).
pub fn cached_bytes() -> usize {
Pool::global().bytes.load(Ordering::Relaxed)
}
/// Decoder + decoded-frame cache for one video track.
pub struct GopCache {
state: Mutex<State>,
shared: Arc<Shared>,
/// Colour signalled by the container, which wins over the bitstream's.
explicit_color: Option<ColorInfo>,
/// Clockwise quarter turns from the container's display matrix, applied to every frame.
@@ -200,7 +360,13 @@ const CONTINUE_THROUGH: usize = 48;
impl GopCache {
pub fn new(explicit_color: Option<ColorInfo>) -> Self {
Self {
// unit tests count decoder restarts: each of their caches gets a pool of its own
let pool = if cfg!(test) { Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET)) } else { Pool::global() };
Self::in_pool(explicit_color, pool)
}
fn in_pool(explicit_color: Option<ColorInfo>, pool: Arc<Pool>) -> Self {
let shared = Arc::new(Shared {
state: Mutex::new(State {
decoder: None,
intra: false,
@@ -211,11 +377,12 @@ impl GopCache {
frames: BTreeMap::new(),
drafts: Default::default(),
bytes: 0,
last_used: Instant::now(),
}),
explicit_color,
rotation: 0,
budget: 384 << 20,
}
pool: pool.clone(),
});
pool.register(&shared);
Self { shared, explicit_color, rotation: 0, budget: 384 << 20 }
}
/// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display
@@ -245,9 +412,11 @@ impl GopCache {
st.drafts.remove(&pts);
}
let budget = self.budget.max(MIN_FRAMES * f.byte_size());
let before = st.bytes;
st.bytes += f.byte_size();
if let Some(old) = st.frames.insert(pts, Arc::new(f)) {
st.bytes -= old.byte_size();
filmcraft_frame::pool::recycle(old);
}
// evict frames far from the most recent (keep a window around the working position)
while st.bytes > budget && st.frames.len() > 2 {
@@ -257,8 +426,17 @@ impl GopCache {
st.drafts.remove(&victim);
st.bytes -= v.byte_size();
EVICTED.fetch_add(1, Ordering::Relaxed);
// its planes serve the next decoded pictures instead of going back to the allocator
filmcraft_frame::pool::recycle(v);
}
}
let pool = &self.shared.pool;
if st.bytes >= before {
pool.bytes.fetch_add(st.bytes - before, Ordering::Relaxed);
} else {
pool.bytes.fetch_sub(before - st.bytes, Ordering::Relaxed);
}
pool.trim_frames(&self.shared);
}
/// Store decoder output (in presentation order) and advance `out_max`.
@@ -297,8 +475,13 @@ impl GopCache {
return self.private_frame(s, i, want_pts, n);
}
let draft = filmcraft_media::cancel::draft();
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
// Idle caches' decoders released below. Declared before the lock guard, so it is dropped
// after the guard on every return path: dropping a decoder can block (a hardware session
// waits for its frames in flight) and must not hold this cache's lock.
let mut released: Vec<Box<dyn VideoDecoder>> = Vec::new();
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let _decoding = DecodingGuard::enter(me);
st.last_used = Instant::now();
if let Some(f) = st.cached(want_pts, draft) {
HITS.fetch_add(1, Ordering::Relaxed);
return Ok(f.clone());
@@ -309,7 +492,9 @@ impl GopCache {
st.intra = d.intra_only();
st.decoder = Some(d);
st.next = usize::MAX;
self.shared.pool.decoders.fetch_add(1, Ordering::Relaxed);
}
released.extend(self.shared.pool.trim_decoders(&self.shared));
// Nothing cached: the work ahead (possibly a seek and a GOP of decoding) is only worth it
// while someone still wants the frame.
if filmcraft_media::cancel::cancelled() {
@@ -462,7 +647,7 @@ impl GopCache {
DECODED.fetch_add(1, Ordering::Relaxed);
timed(|| dec.decode(&data, want_pts))
});
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
if st.spare.len() < 16 {
st.spare.push(dec);
}
@@ -480,7 +665,6 @@ mod tests {
use super::*;
use crate::video::DecodedFrame;
use std::collections::VecDeque;
use std::sync::atomic::AtomicUsize;
/// `n` samples, a sync sample every `gop`, pts = 1000 · index (no reordering).
struct Samples {
@@ -716,4 +900,165 @@ mod tests {
assert_eq!(index_of(&c.frame(&s, 7_000).expect("frame")), 7);
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
}
#[test]
fn idle_caches_give_up_their_decoders_beyond_the_cap() {
let pool = Arc::new(Pool::new(2, Duration::ZERO, FRAME_BUDGET));
let live = || pool.decoders.load(Ordering::Relaxed);
let s: Vec<Samples> = (0..5).map(|_| samples(300, 250, 3, false)).collect();
let c: Vec<GopCache> = (0..5).map(|_| GopCache::in_pool(None, pool.clone())).collect();
// a clip after another, as when clips are dropped on a timeline
for k in 0..5 {
assert_eq!(index_of(&c[k].frame(&s[k], 10_000).expect("frame")), 10);
assert!(live() <= 2, "{} live decoders after clip {k}", live());
}
assert_eq!(live(), 2);
// the most recently used keep theirs and carry on without a seek
assert_eq!(index_of(&c[4].frame(&s[4], 20_000).expect("frame")), 20);
assert_eq!(s[4].resets.load(Ordering::Relaxed), 1);
// a cache that lost its decoder still serves what it cached…
let decodes = s[0].decodes.load(Ordering::Relaxed);
assert_eq!(index_of(&c[0].frame(&s[0], 10_000).expect("cached")), 10);
assert_eq!(s[0].decodes.load(Ordering::Relaxed), decodes);
// …and decodes again with a new decoder, from the sync sample
assert_eq!(index_of(&c[0].frame(&s[0], 20_000).expect("frame")), 20);
assert_eq!(s[0].resets.load(Ordering::Relaxed), 2);
assert_eq!(live(), 2);
// a dropped cache no longer counts
drop(c);
assert_eq!(live(), 0);
}
#[test]
fn caches_in_use_keep_their_decoders_whatever_the_cap() {
// every layer of one composited frame was asked moments ago: none is idle
let pool = Arc::new(Pool::new(2, Duration::from_secs(3600), FRAME_BUDGET));
let s: Vec<Samples> = (0..5).map(|_| samples(300, 250, 3, false)).collect();
let c: Vec<GopCache> = (0..5).map(|_| GopCache::in_pool(None, pool.clone())).collect();
for t in [10_000, 11_000, 12_000] {
for k in 0..5 {
assert_eq!(index_of(&c[k].frame(&s[k], t).expect("frame")) as i64, t / 1000);
}
}
assert_eq!(pool.decoders.load(Ordering::Relaxed), 5);
assert!(s.iter().all(|s| s.resets.load(Ordering::Relaxed) == 1), "no decoder restarted");
}
#[test]
fn intra_caches_give_up_their_spare_decoders_too() {
let pool = Arc::new(Pool::new(1, Duration::ZERO, FRAME_BUDGET));
let (a, b) = (samples(50, 1, 0, true), samples(50, 1, 0, true));
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
assert_eq!(index_of(&ca.frame(&a, 3_000).expect("frame")), 3);
assert_eq!(index_of(&cb.frame(&b, 4_000).expect("frame")), 4);
let st = ca.shared.state.lock().unwrap();
assert!(st.decoder.is_none() && st.spare.is_empty());
drop(st);
assert_eq!(pool.decoders.load(Ordering::Relaxed), 1);
assert_eq!(index_of(&ca.frame(&a, 5_000).expect("frame")), 5);
}
#[test]
fn idle_caches_give_up_their_frames_beyond_the_shared_budget() {
// 1×1 RGBA pictures: 4 bytes each, a shared budget of ten of them
let pool = Arc::new(Pool::new(usize::MAX, Duration::ZERO, 40));
let held = || pool.bytes.load(Ordering::Relaxed);
let (a, b) = (samples(300, 250, 0, false), samples(300, 250, 0, false));
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
// the only cache keeps what its own budget allows
assert_eq!(index_of(&ca.frame(&a, 10_000).expect("frame")), 10);
assert_eq!(held(), 44);
// a second clip takes the room from the idle first one, earliest frames first
assert_eq!(index_of(&cb.frame(&b, 5_000).expect("frame")), 5);
assert_eq!(held(), 40);
let decodes = b.decodes.load(Ordering::Relaxed);
assert!((0..=5).all(|i| index_of(&cb.frame(&b, i * 1000).expect("cached")) as i64 == i));
assert_eq!(b.decodes.load(Ordering::Relaxed), decodes, "the clip in use lost nothing");
let st = ca.shared.state.lock().unwrap();
assert_eq!((st.frames.len(), st.bytes), (4, 16));
assert_eq!(st.frames.keys().next(), Some(&7_000));
drop(st);
// an evicted frame decodes again
assert_eq!(index_of(&ca.frame(&a, 2_000).expect("frame")), 2);
assert!(held() <= 40 + 4 * 3, "{} bytes held", held());
drop((ca, cb));
assert_eq!(held(), 0);
}
#[test]
fn caches_in_use_keep_their_frames_whatever_the_shared_budget() {
let pool = Arc::new(Pool::new(usize::MAX, Duration::from_secs(3600), 40));
let (a, b) = (samples(300, 250, 0, false), samples(300, 250, 0, false));
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
assert_eq!(index_of(&ca.frame(&a, 10_000).expect("frame")), 10);
assert_eq!(index_of(&cb.frame(&b, 10_000).expect("frame")), 10);
assert_eq!(pool.bytes.load(Ordering::Relaxed), 88);
let decodes = a.decodes.load(Ordering::Relaxed);
assert_eq!(index_of(&ca.frame(&a, 0).expect("cached")), 0);
assert_eq!(a.decodes.load(Ordering::Relaxed), decodes);
}
/// Pictures big enough for the plane pool (its own size, so that parallel tests cannot take
/// the buffers), no reordering, one long GOP.
struct Big(usize);
const BIG: (u32, u32) = (200, 101);
impl VideoDecoder for Big {
fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
let mut px = filmcraft_frame::pool::take_u8((BIG.0 * BIG.1 * 4) as usize);
px.resize((BIG.0 * BIG.1 * 4) as usize, (pts / 1000) as u8);
Ok(vec![DecodedFrame { pts, frame: VideoFrame::rgba8(BIG.0, BIG.1, px), draft: false }])
}
fn flush(&mut self) -> Vec<DecodedFrame> {
Vec::new()
}
fn reset(&mut self) {}
fn name(&self) -> &str {
"big"
}
}
impl VideoSamples for Big {
fn count(&self) -> usize {
self.0
}
fn pts(&self, i: usize) -> i64 {
i as i64 * 1000
}
fn sync_before(&self, _i: usize) -> usize {
0
}
fn sample_at(&self, t: i64) -> Option<usize> {
let i = (t / 1000) as usize;
(i < self.0).then_some(i)
}
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
Ok(vec![i as u8])
}
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
Ok(Box::new(Big(self.0)))
}
}
#[test]
fn evicted_frames_are_decoded_into_again() {
let s = Big(200);
// room for MIN_FRAMES pictures only
let mut c = GopCache::new(None);
c.budget = 0;
let reused = || filmcraft_frame::pool::stats().reused;
let before = reused();
// the cache holds MIN_FRAMES pictures: none is evicted yet, every plane is new
for i in 0..MIN_FRAMES as i64 {
assert_eq!(c.frame(&s, i * 1000).expect("frame").width, BIG.0);
}
assert_eq!(reused(), before);
// from here on each picture evicts one, whose buffer the next picture is decoded into
let more = 40;
for i in MIN_FRAMES as i64..MIN_FRAMES as i64 + more {
let f = c.frame(&s, i * 1000).expect("frame");
assert!(matches!(&f.data, filmcraft_frame::PixelData::Rgba8(d) if d.iter().all(|&b| b == i as u8)), "frame {i} has its own pixels");
}
assert!(reused() - before >= more as u64 - 2, "{} of {more} planes recycled", reused() - before);
}
}
+6 -1
View File
@@ -31,7 +31,7 @@ pub mod video;
use std::sync::{Arc, RwLock};
pub use audio::AudioFileSource;
pub use gop::{GopStats, gop_stats};
pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders};
pub use mkv::MkvSource;
pub use mp4::Mp4Source;
pub use mpeg::MpegSource;
@@ -89,6 +89,11 @@ pub fn register_video_decoder(f: VideoDecoderFactory) {
}
}
/// Whether `f` is among the registered video decoder factories (startup diagnostics, tests).
pub fn video_decoder_registered(f: VideoDecoderFactory) -> bool {
factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
}
/// Create a decoder for a sample entry.
pub fn make_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<Box<dyn VideoDecoder>> {
let g = factories().read().unwrap_or_else(|e| e.into_inner());
+5 -3
View File
@@ -359,7 +359,8 @@ impl MediaSource for Mp4Source {
let ch = ainfo.channels.max(1) as usize;
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
let ratio = src_rate as f64 / sample_rate as f64;
let s0 = (start as f64 * ratio).floor() as i64 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
let p0 = (start as f64 * ratio).floor() as i64;
let s0 = p0 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
let need = (frames as f64 * ratio).ceil() as i64 + 2;
// Samples-per-unit: the track timescale is usually the sample rate for audio.
let unit = src_rate as f64 / track.timescale.max(1) as f64;
@@ -393,8 +394,9 @@ impl MediaSource for Mp4Source {
}
return Ok(out);
}
// resample the local window
let frac = s0 as f64 - start as f64 * ratio;
// Resample the local window. Its first sample is presentation sample `p0`; the edit list
// offset is already in `s0` and must not shift the read position a second time.
let frac = p0 as f64 - start as f64 * ratio;
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
for k in 0..frames {
let pos = k as f64 * ratio - frac;
+8
View File
@@ -204,12 +204,14 @@ pub struct H264Decoder {
impl H264Decoder {
pub fn new(avcc: Vec<u8>) -> Result<Self> {
recycle_h264_planes();
let dec = filmcraft_h264::Decoder::from_avcc(&avcc).map_err(|e| CodecError::Decode(e.to_string()))?;
let length_size = avcc_length_size(&avcc);
Ok(Self { avcc, dec, length_size, draft: false })
}
/// A decoder for Annex B byte-stream samples (start codes, in-band parameter sets: MXF, TS).
pub fn annexb() -> Self {
recycle_h264_planes();
Self { avcc: Vec::new(), dec: filmcraft_h264::Decoder::new(), length_size: 0, draft: false }
}
fn convert(p: filmcraft_h264::Picture) -> DecodedFrame {
@@ -269,6 +271,12 @@ impl VideoDecoder for H264Decoder {
}
}
/// The H.264 decoder's output planes come from the frames the caches evicted
/// (`filmcraft_frame::pool`) instead of the allocator.
fn recycle_h264_planes() {
filmcraft_h264::set_plane_allocator(filmcraft_frame::pool::take_u8);
}
/// Worker threads each H.264 decoder uses (frame threading; 1 on wasm).
pub fn h264_threads() -> usize {
filmcraft_h264::default_threads()
+39
View File
@@ -0,0 +1,39 @@
//! MP4 audio against ffmpeg: ffmpeg writes an AAC track (with the edit list that skips the encoder
//! priming), and reads at another sample rate must land on the same source samples as the native
//! decode, however the requests are cut.
mod common;
use common::*;
/// A source whose rate differs from the sequence's is resampled per request. An AAC track in MP4
/// carries an edit list that skips the encoder priming (ffmpeg: 1024 samples), and every request
/// must still land on the same source samples: reading at 44.1 kHz in video-frame-sized pieces (as
/// an export does) equals the 48 kHz decode interpolated at the same times.
#[test]
fn aac_mp4_resampled_reads_follow_the_edit_list() {
let ff = filmcraft_testkit::require_ffmpeg!();
let Some(f) = fixture(&ff, "tone_aac_48k.mp4", &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=3", "-c:a", "aac", "-b:a", "128k"]) else {
panic!("could not write the AAC fixture")
};
let src = filmcraft_codecs::open_bytes("tone_aac_48k.mp4", bytes(&f)).unwrap();
let native = src.audio(0, 2 * 48_000, 48_000).unwrap().channels[0].clone();
let ratio = 48_000.0 / 44_100.0;
// 30 fps at 44.1 kHz, starting mid-file and off the 48 kHz grid
let (mut start, piece) = (22_051i64, 1470usize);
let (mut worst, mut peak) = (0f32, 0f32);
for _ in 0..20 {
let got = src.audio(start, piece, 44_100).unwrap();
for (k, &v) in got.channels[0].iter().enumerate() {
let pos = (start + k as i64) as f64 * ratio;
let i0 = pos.floor() as usize;
let want = native[i0] + (native[i0 + 1] - native[i0]) * (pos - i0 as f64) as f32;
worst = worst.max((v - want).abs());
peak = peak.max(v.abs());
}
start += piece as i64;
}
let level = native.iter().fold(0f32, |m, v| m.max(v.abs()));
assert!(peak <= level * 1.01, "resampled peak {peak} above the source's {level}");
assert!(worst < 1e-4, "resampled read differs from the source by {worst}");
}
+63
View File
@@ -0,0 +1,63 @@
//! Decoded pictures are written into recycled plane buffers (`filmcraft_frame::pool`). A recycled
//! buffer still holds an old picture, so the decoder must overwrite every byte: here the pool is
//! primed with buffers full of 0xAA before decoding, and every frame must still match ffmpeg bit
//! for bit. (Its own process: the pool is global.)
mod common;
use std::sync::Arc;
use common::*;
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use filmcraft_media::FrameRequest;
#[test]
fn dirty_recycled_planes_decode_bit_exact() {
let ff = filmcraft_testkit::require_ffmpeg!();
// 640×480: luma and chroma planes are both large enough to be pooled
let Some(file) = fixture(
&ff,
"pool_h264.mp4",
&[
"-f",
"lavfi",
"-i",
"testsrc2=s=640x480:r=24:d=1,noise=alls=10:allf=t",
"-c:v",
"libx264",
"-preset",
"fast",
"-x264-params",
"bframes=2:keyint=12",
"-pix_fmt",
"yuv420p",
],
) else {
return;
};
let (w, h) = (640usize, 480usize);
let raw = ffmpeg_frames(&ff, &file, "yuv420p");
let fsize = w * h * 3 / 2;
let n = raw.len() / fsize;
assert!(n >= 20, "{n} frames");
let reference = |i: usize| raw_planes(&raw[i * fsize..(i + 1) * fsize], w, h, w / 2, h / 2, 1);
// prime the pool with dirty buffers of exactly the plane sizes the decoder asks for
for _ in 0..8 {
let dirty = |len: usize| Arc::new(vec![0xAAu8; len]);
let f = VideoFrame {
data: PixelData::Yuv8 { planes: [dirty(w * h), dirty(w * h / 4), dirty(w * h / 4)], chroma: Chroma::C420, alpha: None },
..VideoFrame::rgba8(w as u32, h as u32, vec![0; w * h * 4])
};
filmcraft_frame::pool::recycle(Arc::new(f));
}
let reused_before = filmcraft_frame::pool::stats().reused;
let src = filmcraft_codecs::open_bytes("pool_h264.mp4", bytes(&file)).expect("open");
let rate = src.info().frame_rate();
for i in 0..n {
let f = src.video_frame(FrameRequest::full(rate.tick_of(i as i64))).expect("frame");
assert_eq!(max_diff(&planes(&f), &reference(i)), 0, "frame {i} decoded into a recycled buffer");
}
assert!(filmcraft_frame::pool::stats().reused > reused_before, "the decoder took the dirty pooled buffers");
}
+153 -51
View File
@@ -351,21 +351,66 @@ pub fn delete_items(seq: &mut Sequence, items: &[ClipId]) -> usize {
n
}
/// Ripple delete track items: remove them and close the resulting gaps.
pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<()> {
// Collect per-track ranges to close (merged), processed right-to-left.
let mut ranges: Vec<(TrackId, TimeRange)> = Vec::new();
/// The stretches a ripple delete of `items` closes, in time order: the time the deleted items
/// covered, less whatever stays on the tracks that lose an item. Every such track then moves up
/// by the same amount, into time that is free on all of them, so later clips stay in sync and
/// nothing is overwritten. A clip and its linked sound with a split edit have different edges;
/// closing each one's own range would move the tracks twice. Locked tracks lose nothing and do
/// not count.
fn ripple_delete_spans(seq: &Sequence, items: &[ClipId]) -> Vec<TimeRange> {
let (mut deleted, mut kept) = (Vec::new(), Vec::new());
for tr in seq.all_tracks().filter(|t| !t.locked && t.items.iter().any(|i| items.contains(&i.id))) {
for i in &tr.items {
if items.contains(&i.id) { deleted.push(i.range()) } else { kept.push(i.range()) }
}
}
let kept = merged(kept);
let mut spans = Vec::new();
for r in merged(deleted) {
let mut at = r.start;
for k in kept.iter().filter(|k| k.overlaps(&r)) {
if k.start > at {
spans.push(TimeRange::from_bounds(at, k.start));
}
at = at.max(k.end());
}
if at < r.end() {
spans.push(TimeRange::from_bounds(at, r.end()));
}
}
spans
}
/// `ranges` in time order, with the ones that touch or overlap joined.
fn merged(mut ranges: Vec<TimeRange>) -> Vec<TimeRange> {
ranges.sort_by_key(|r| r.start);
let mut out: Vec<TimeRange> = Vec::new();
for r in ranges {
match out.last_mut() {
Some(last) if r.start <= last.end() => *last = TimeRange::from_bounds(last.start, last.end().max(r.end())),
_ => out.push(r),
}
}
out
}
/// Ripple delete track items: remove them and close the resulting gaps. Returns the stretches
/// that were closed, in time order. Refused when no gap can close (what stays on the items'
/// tracks covers all of their time): that would be a plain delete.
pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<Vec<TimeRange>> {
// Collect the tracks that lose an item; the gaps close right-to-left.
let mut affected: Vec<TrackId> = Vec::new();
for c in items {
let (tid, it) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?;
ranges.push((tid, it.range()));
let (tid, _) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?;
affected.push(tid);
}
let spans = ripple_delete_spans(seq, items);
if spans.is_empty() && !items.is_empty() {
let locked = affected.iter().all(|t| seq.track(*t).is_some_and(|tr| tr.locked));
return Err(if locked { EditError::Locked } else { EditError::Other("no gap can close: other clips on these tracks cover the deleted time".into()) });
}
// Group identical ranges across tracks (linked A/V) into one ripple.
let mut spans: Vec<TimeRange> = ranges.iter().map(|r| r.1).collect();
spans.sort_by_key(|r| (r.start, r.duration));
spans.dedup();
let mut work = seq.clone();
delete_items(&mut work, items);
let affected: Vec<TrackId> = ranges.iter().map(|r| r.0).collect();
for span in spans.iter().rev() {
// the gap actually closable: from span.start to the next material on affected tracks
for tr in work.all_tracks_mut() {
@@ -383,7 +428,7 @@ pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<()> {
}
}
*seq = work;
Ok(())
Ok(spans)
}
/// Close the gap containing `t` on a track (Ripple Delete on a gap).
@@ -559,8 +604,9 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
/// Ripple-trim one edge of a group of linked items (e.g. a clip and its audio partners) as a single
/// edit: every member's edge moves by the same delta, later material on the members' tracks and on
/// sync-locked tracks shifts by the change. Only tracks without a group member can block the edit
/// (a sync-locked track whose material would be overwritten).
/// sync-locked tracks shifts by the change, and so does a clip that starts before the cut, reaches
/// past it and is linked to a clip that shifts (a split edit). The edit is refused when it would
/// overwrite material on a sync-locked track, or when such a linked clip has no room to follow.
pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
let Some(&first) = clips.first() else { return Ok(Tick::ZERO) };
let mut d = delta;
@@ -606,27 +652,54 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
origins.iter().find(|(t, _)| *t == tid).map(|o| o.1).unwrap_or_default()
};
let shift = if edge == Edge::In { -d } else { d };
for tr in work.all_tracks_mut() {
if tr.locked {
continue;
}
let member = origins.iter().find(|(t, _)| *t == tr.id).map(|o| o.1);
if member.is_none() && !tr.sync_lock {
continue;
}
let from = member.unwrap_or(main_from);
if member.is_none() && shift < Tick::ZERO {
// where the shift starts on each track that ripples: the members' tracks and the sync-locked ones
let rippling: Vec<(TrackId, Tick, bool)> = work
.all_tracks()
.filter(|tr| !tr.locked)
.filter_map(|tr| match origins.iter().find(|(t, _)| *t == tr.id) {
Some(o) => Some((tr.id, o.1, true)),
None => tr.sync_lock.then_some((tr.id, main_from, false)),
})
.collect();
// A split edit: the linked partner of a later clip can start before the shift does and reach
// past it (its sound leads the cut). It follows the clip it is linked to, or the two would
// drift apart.
// (the trimmed clips' own partners are trimmed, or left alone, by the caller: they never follow)
let own_links: Vec<u64> = clips.iter().filter_map(|c| work.find_item(*c).and_then(|(_, i)| i.link)).collect();
let mut shifting_links: Vec<u64> = Vec::new();
for (tid, from, _) in &rippling {
let Some(tr) = work.track(*tid) else { continue };
let later = tr.items.iter().filter(|i| !clips.contains(&i.id) && i.start >= *from);
shifting_links.extend(later.filter_map(|i| i.link).filter(|l| !own_links.contains(l)));
}
for (tid, from, member) in rippling {
let Some(tr) = work.track_mut(tid) else { continue };
let follows = |i: &TrackItem| i.start < from && i.end() > from && i.link.is_some_and(|l| shifting_links.contains(&l));
if !member && shift < Tick::ZERO {
let at = if edge == Edge::In { from - Tick(1) } else { from };
if !track_range_empty(tr, TimeRange::new(at + shift, -shift)) {
let closing = TimeRange::new(at + shift, -shift);
if tr.items.iter().any(|i| i.range().overlaps(&closing) && !follows(i)) {
return Err(EditError::SyncLockConflict);
}
}
let followers: Vec<ClipId> = tr.items.iter().filter(|i| !clips.contains(&i.id) && follows(i)).map(|i| i.id).collect();
for i in &mut tr.items {
if !clips.contains(&i.id) && i.start >= from {
if !clips.contains(&i.id) && (i.start >= from || followers.contains(&i.id)) {
i.start += shift;
}
}
tr.sort();
// a transition into a follower from a clip that stayed behind no longer sits on a cut
let ends: Vec<(ClipId, Tick)> = tr.items.iter().map(|i| (i.id, i.end())).collect();
let starts: Vec<(ClipId, Tick)> = tr.items.iter().map(|i| (i.id, i.start)).collect();
tr.transitions.retain(|t| match (t.from, t.to) {
(Some(a), Some(b)) if followers.contains(&b) => ends.iter().find(|e| e.0 == a).map(|e| e.1) == starts.iter().find(|x| x.0 == b).map(|x| x.1),
_ => true,
});
// a follower with no room (a clip in its way, or the sequence start) blocks the edit
if tr.items.first().is_some_and(|i| i.start < Tick::ZERO) || tr.items.windows(2).any(|w| w[0].end() > w[1].start) {
return Err(EditError::SyncLockConflict);
}
}
transitions_follow_cuts(seq, &mut work);
work.check().map_err(EditError::Other)?;
@@ -751,41 +824,70 @@ pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, c
/// Set speed/duration (Clip ▸ Speed/Duration…). `ripple` shifts following material.
pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ripple: bool, ctx: &mut EditCtx) -> Result<()> {
if speed <= 0.0 {
set_speed_group(seq, &[clip], speed, reverse, ripple, ctx)
}
/// Speed/Duration on a clip and its linked partners on other tracks (its sound) as a single edit.
/// `clips` holds at most one clip per track. Every member takes the speed, and with `ripple` the
/// later material moves once, on the members' tracks and on sync-locked tracks. Rippling each
/// member on its own would move the later clips once per member. When the members' lengths change
/// by different amounts (a split edit), the later material moves by the largest change, so no
/// member is overlapped and the tracks stay in sync. Nothing changes when the edit fails.
pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse: bool, ripple: bool, ctx: &mut EditCtx) -> Result<()> {
if !(speed.is_finite() && speed > 0.0) {
return Err(EditError::Other("speed must be positive".into()));
}
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
let src_len = it.source_out() - it.source_in;
let mut new_dur = Tick((src_len.0 as f64 / speed).round() as i64).max(ctx.min_duration);
let old_end = it.end();
let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?;
let (_, next_start) = neighbours(tr, clip);
if !ripple {
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))?;
let mut work = seq.clone();
// per member track: where its later material starts, and by how much the member's length changed
let mut origins: Vec<(TrackId, Tick, Tick)> = Vec::new();
for clip in clips {
let (tid, it) = work.find_item(*clip).ok_or(EditError::NoItem(*clip))?;
if origins.iter().any(|o| o.0 == tid) {
return Err(EditError::Other("clips on one track change speed one at a time".into()));
}
let src_len = it.source_out() - it.source_in;
// a tiny speed saturates the cast: keep the clip's end representable
let room = Tick(Tick::MAX.0.saturating_sub(it.start.0.max(0)));
let mut new_dur = Tick((src_len.0 as f64 / speed).round() as i64).min(room).max(ctx.min_duration);
let old_end = it.end();
let tr = work.track(tid).ok_or(EditError::NoTrack(tid))?;
let (_, next_start) = neighbours(tr, *clip);
if !ripple {
new_dur = new_dur.min(next_start - it.start);
}
let delta = new_dur - it.duration;
let (_, it) = work.find_item_mut(*clip).ok_or(EditError::NoItem(*clip))?;
it.speed = speed;
it.reverse = reverse;
it.duration = new_dur;
origins.push((tid, old_end, delta));
}
if ripple && delta != Tick::ZERO {
for tr in seq.all_tracks_mut() {
if !tr.locked && (tr.id == tid || tr.sync_lock) {
for i in &mut tr.items {
if i.id != clip && i.start >= old_end {
i.start += delta;
}
}
tr.sort();
let main_from = origins.first().map(|o| o.1).unwrap_or_default();
let shift = origins.iter().map(|o| o.2).max().unwrap_or_default();
if ripple && shift != Tick::ZERO {
for tr in work.all_tracks_mut() {
if tr.locked {
continue;
}
let member = origins.iter().find(|o| o.0 == tr.id).map(|o| o.1);
if member.is_none() && !tr.sync_lock {
continue;
}
let from = member.unwrap_or(main_from);
for i in &mut tr.items {
if !clips.contains(&i.id) && i.start >= from {
// later clips must still end on the representable timeline
let start = i.start.0.checked_add(shift.0).filter(|s| s.checked_add(i.duration.0).is_some());
i.start = Tick(start.ok_or_else(|| EditError::Other("the speed change would move later clips past the end of the timeline".into()))?);
}
}
tr.sort();
}
}
if let Some(before) = before {
transitions_follow_cuts(&before, seq);
if ripple {
transitions_follow_cuts(seq, &mut work);
}
*seq = work;
Ok(())
}
+204
View File
@@ -148,6 +148,81 @@ fn ripple_delete_and_conflict() {
assert_eq!(fx.spans(v1), vec![(0, 10), (20, 10)], "unchanged on failure");
}
#[test]
fn ripple_delete_closes_the_time_free_on_every_track() {
// four clips on V1 with their sounds on A1; the second sound starts 2 early and ends 2 late
let build = || {
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
let v: Vec<ClipId> = [(0, 10), (10, 10), (20, 10), (30, 10)].iter().map(|(s, d)| fx.put(v1, *s, *d, 0)).collect();
let a: Vec<ClipId> = [(0, 8), (8, 14), (22, 8), (30, 10)].iter().map(|(s, d)| fx.put(a1, *s, *d, 0)).collect();
(fx, v, a)
};
let span = |start, len| TimeRange::new(f(start), f(len));
// one clip with a split edit on both sides: the picture's stretch closes, everything later stays in sync
let (mut fx, v, a) = build();
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[1], a[1]]), Ok(vec![span(10, 10)]));
assert_eq!(fx.spans(fx.v(0)), vec![(0, 10), (10, 10), (20, 10)]);
assert_eq!(fx.spans(fx.a(0)), vec![(0, 8), (12, 8), (20, 10)]);
fx.seq.check().unwrap();
// two neighbours with a split edit between them close as one stretch
let (mut fx, v, a) = build();
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[1], a[1], v[2], a[2]]), Ok(vec![span(10, 20)]));
assert_eq!(fx.spans(fx.v(0)), vec![(0, 10), (10, 10)]);
assert_eq!(fx.spans(fx.a(0)), vec![(0, 8), (10, 10)]);
// clips apart in time each close their own gap
let (mut fx, v, a) = build();
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[0], a[0], v[3], a[3]]), Ok(vec![span(0, 8), span(30, 10)]));
fx.seq.check().unwrap();
// straight cuts with the sounds on alternating tracks: V1 [0,10) [10,20) [20,30), A1 [0,10) [20,30), A2 [10,20)
let mut fx = Fx::new();
let (v1, a1, a2) = (fx.v(0), fx.a(0), fx.a(1));
let v: Vec<ClipId> = [0, 10, 20].iter().map(|s| fx.put(v1, *s, 10, 0)).collect();
let (s0, s1) = (fx.put(a1, 0, 10, 0), fx.put(a2, 10, 10, 0));
fx.put(a1, 20, 10, 0);
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[0], s0, v[1], s1]), Ok(vec![span(0, 20)]));
assert_eq!((fx.spans(v1), fx.spans(a1), fx.spans(a2)), (vec![(0, 10)], vec![(0, 10)], vec![]));
// a sound shorter than its picture with nothing round it: the whole picture closes
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
fx.put(v1, 20, 10, 0);
fx.put(a1, 20, 10, 0);
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(10, 10)]));
// with the neighbours' sound right up against it, only the sound's stretch can close
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
fx.put(a1, 0, 12, 0);
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
fx.put(v1, 20, 10, 0);
fx.put(a1, 18, 12, 0);
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(12, 6)]));
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(14, 10)], vec![(0, 12), (12, 12)]));
// a linked sound on a locked track stays, and does not shrink the gap that closes
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
fx.put(v1, 20, 10, 0);
fx.seq.track_mut(a1).unwrap().locked = true;
fx.seq.track_mut(a1).unwrap().sync_lock = false;
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(10, 10)]));
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(10, 10)], vec![(12, 6)]));
// nothing can close: what stays on each track covers the other track's deleted time. Refused, unchanged.
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
let x = fx.put(v1, 10, 10, 0);
fx.put(v1, 30, 10, 0);
fx.put(a1, 10, 10, 0);
let y = fx.put(a1, 30, 10, 0);
let before = fx.seq.clone();
assert!(matches!(ripple_delete_items(&mut fx.seq, &[x, y]), Err(EditError::Other(_))));
assert_eq!(fx.seq, before);
}
#[test]
fn close_gap_works() {
let mut fx = Fx::new();
@@ -192,6 +267,79 @@ fn ripple_trim_shifts_following() {
assert_eq!(fx.spans(v1), vec![(0, 12), (12, 10)]);
}
#[test]
fn ripple_trim_group_takes_a_split_edit_along() {
// V1: a [0,10) then b [10,20). b's linked sound leads it by 4 on sync-locked A1: [6,20).
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
let a = fx.put(v1, 0, 10, 0);
let b = fx.put(v1, 10, 10, 0);
let sound = fx.put(a1, 6, 14, 0);
for c in [b, sound] {
fx.seq.find_item_mut(c).unwrap().1.link = Some(7);
}
let mut n = fx.next;
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(3), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 13), (13, 10)], vec![(9, 14)]), "longer: the early sound follows its picture");
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(5), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 8), (8, 10)], vec![(4, 14)]), "shorter: it follows too");
// the sound would start before the sequence does: refused, nothing changes
assert_eq!(ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(5), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
// another clip right before the sound leaves it no room either
fx.put(a1, 0, 4, 0);
assert_eq!(ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(2), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 8), (8, 10)], vec![(0, 4), (4, 14)]), "unchanged on failure");
// a linked clip that ends before the cut is not a split edit and stays where it is
let early = fx.put(fx.a(1), 0, 3, 0);
fx.seq.find_item_mut(early).unwrap().1.link = Some(7);
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(2), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(fx.spans(fx.a(1)), vec![(0, 3)]);
fx.seq.check().unwrap();
// In edge: V1 b [10,20), c [20,30); c's sound starts 12 early on A1: [8,30). b loses 3 at its head.
let mut fx = Fx::new();
let (v1, v2, a1, a2) = (fx.v(0), fx.v(1), fx.a(0), fx.a(1));
fx.put(v1, 0, 10, 0);
let b = fx.put(v1, 10, 10, 0);
let c = fx.put(v1, 20, 10, 0);
let c_sound = fx.put(a1, 8, 22, 0);
// two more clips linked to b that the caller did not put in the group
let b_sound = fx.put(a2, 10, 10, 0);
let b_top = fx.put(v2, 12, 8, 0);
for (x, link) in [(c, 7), (c_sound, 7), (b, 8), (b_sound, 8), (b_top, 8)] {
fx.seq.find_item_mut(x).unwrap().1.link = Some(link);
}
let mut n = fx.next;
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 10), (10, 7), (17, 10)], vec![(5, 22)]), "c and its early sound move up together");
assert_eq!((fx.spans(v2), fx.spans(a2)), (vec![(9, 8)], vec![(10, 10)]), "the trimmed clip's own partners never follow");
// a crossfade into the early sound from a clip that stays behind no longer sits on a cut: it goes
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
let a = fx.put(v1, 0, 10, 0);
let b = fx.put(v1, 10, 10, 0);
let stays = fx.put(a1, 0, 6, 0);
let sound = fx.put(a1, 6, 14, 0);
for x in [b, sound] {
fx.seq.find_item_mut(x).unwrap().1.link = Some(7);
}
fx.seq.track_mut(a1).unwrap().transitions = vec![Transition {
id: TransitionId(1),
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
start: f(4),
duration: f(4),
from: Some(stays),
to: Some(sound),
align: Default::default(),
reverse: false,
}];
let mut n = fx.next;
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(3), &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(fx.spans(a1), vec![(0, 6), (9, 14)]);
assert!(fx.seq.track(a1).unwrap().transitions.is_empty());
}
#[test]
fn roll_slip_slide() {
let mut fx = Fx::new();
@@ -269,6 +417,44 @@ fn rate_stretch_and_speed() {
assert_eq!(fx.spans(v1), vec![(0, 5)]);
}
#[test]
fn speed_on_a_linked_group_ripples_once() {
// V1: a [0,10), b [10,20). A1: a's sound [0,10), b's sound [10,20).
let build = |sound_len: i64| {
let mut fx = Fx::new();
let (v1, a1) = (fx.v(0), fx.a(0));
let a = fx.put(v1, 0, 10, 0);
fx.put(v1, 10, 10, 0);
let sound = fx.put(a1, 0, sound_len, 0);
fx.put(a1, sound_len, 10, 0);
(fx, a, sound)
};
for (speed, picture, later) in [(0.5, (0, 20), (20, 10)), (2.0, (0, 5), (5, 10))] {
let (mut fx, a, sound) = build(10);
let mut n = fx.next;
set_speed_group(&mut fx.seq, &[a, sound], speed, false, true, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![picture, later], vec![picture, later]), "{speed}");
}
// a split edit: the sound is 4 longer. Later clips move by the larger change, on both tracks.
let (mut fx, a, sound) = build(14);
let mut n = fx.next;
set_speed_group(&mut fx.seq, &[a, sound], 0.5, false, true, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![(0, 20), (24, 10)], vec![(0, 28), (28, 10)]));
let (mut fx, a, sound) = build(14);
let mut n = fx.next;
set_speed_group(&mut fx.seq, &[a, sound], 2.0, false, true, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![(0, 5), (5, 10)], vec![(0, 7), (9, 10)]));
fx.seq.check().unwrap();
// two clips of one track are not one group; a missing clip changes nothing
let (mut fx, a, _) = build(10);
let b = fx.seq.video_tracks[0].items[1].id;
let before = fx.seq.clone();
let mut n = fx.next;
assert!(set_speed_group(&mut fx.seq, &[a, b], 2.0, false, true, &mut Fx::ctx(&mut n)).is_err());
assert!(set_speed_group(&mut fx.seq, &[a, ClipId(1)], 2.0, false, true, &mut Fx::ctx(&mut n)).is_err());
assert_eq!(fx.seq, before);
}
#[test]
fn move_with_insert_and_overwrite() {
let mut fx = Fx::new();
@@ -367,3 +553,21 @@ fn merge(v: Vec<(i64, i64)>) -> Vec<(i64, i64)> {
fn unused_kind_import() {
let _ = TrackKind::Video;
}
#[test]
fn hostile_speeds_are_refused_or_clamped_never_overflow() {
// a tiny speed used to saturate the new duration to i64::MAX, then the ripple shift overflowed
for speed in [1e-7, 1e-300, f64::MIN_POSITIVE, f64::NAN, f64::INFINITY, -1.0, 0.0] {
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 before = fx.seq.clone();
let mut n = fx.next;
let r = set_speed_group(&mut fx.seq, &[a], speed, false, true, &mut Fx::ctx(&mut n));
match r {
Ok(()) => fx.seq.check().unwrap(),
Err(_) => assert_eq!(format!("{:?}", fx.seq), format!("{before:?}"), "{speed}: a refused edit changes nothing"),
}
}
}
+60 -7
View File
@@ -237,9 +237,15 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
can_replace_from_source,
|s, p| replace_with(s, p, Replace::MatchFrame),
),
spec("clip.replaceFromBin", "From Bin", &["Clip", "Replace With Clip"], None, r#"{"clips":[id]?,"item":id?}"#, can_replace_from_bin, |s, p| {
replace_with(s, p, Replace::Bin)
}),
spec(
"clip.replaceFromBin",
"From Bin",
&["Clip", "Replace With Clip"],
None,
r#"{"clips":[id]?,"item":id?,"keepSourceIn":bool=false}"#,
can_replace_from_bin,
|s, p| replace_with(s, p, Replace::Bin),
),
]
}
@@ -1664,12 +1670,22 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
Replace::Bin => item_range(&s.project, new_item, s.prefs.timeline.still_duration(s.sequence_rate())).map(|r| r.start).unwrap_or_default(),
_ => source_range(s).map(|(_, r)| r.start).unwrap_or_default(),
};
// From Bin starts at the item's In point, as Premiere does; `keepSourceIn` keeps each clip's own
let keep_in = how == Replace::Bin && bool_p(p, "keepSourceIn").unwrap_or(false);
// ...but a subclip that restricts trims has no media outside its range: a kept In stays on
// one of its frames (first frame, last frame)
let keep_within = match &pi.kind {
ItemKind::Subclip { range, restrict_trims: true, .. } => {
Some((range.start, (range.end() - item_rate(&s.project, new_item).frame_duration()).max(range.start)))
}
_ => None,
};
let src_ph = s.state.source_playhead;
let ph = s.playhead();
let targets = with_links(s, &clips_p(s, p));
let src_channels = pi.as_media().and_then(|m| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default();
let replaced = s.edit_sequence("Replace With Clip", |q, _, _| {
let mut out = Vec::new();
let (replaced, clamped) = s.edit_sequence("Replace With Clip", |q, _, _| {
let (mut out, mut clamped) = (Vec::new(), Vec::new());
for c in &targets {
let Some((tid, _)) = q.find_item(*c) else { continue };
let kind = q.track(tid).map(|t| t.kind).unwrap_or_default();
@@ -1680,6 +1696,13 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
let src_in = match how {
// the Source monitor frame lines up with the sequence playhead
Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64),
_ if keep_in => {
let kept = keep_within.map(|(first, last)| it.source_in.clamp(first, last)).unwrap_or(it.source_in);
if kept != it.source_in {
clamped.push(c.0);
}
kept
}
_ => in_point,
};
if src_in < Tick::ZERO {
@@ -1699,7 +1722,37 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
if out.is_empty() {
return Err(EngineError::Other("the replacement has no video or audio for the selected clips".into()));
}
Ok(out)
Ok((out, clamped))
})?;
Ok(json!({"clips": replaced, "item": new_item.0}))
let mut result = json!({"clips": replaced, "item": new_item.0, "short": short_clips(s, &replaced)});
if keep_in {
// the clips whose In was outside a trims-restricted subclip and now sits on its nearest frame
result["sourceInClamped"] = json!(clamped);
}
Ok(result)
}
/// The clips among `clips` that play past the end of their media, with how many sequence frames
/// of the clip have no media (at most the clip's own length). The edit is left as it is (the
/// renderer holds the last frame there); this is so whoever replaced the clip knows.
fn short_clips(s: &Session, clips: &[u64]) -> Vec<Value> {
let Some(q) = s.active_sequence() else { return Vec::new() };
let frame = q.settings.frame_rate.frame_duration().0.max(1);
clips
.iter()
.filter_map(|c| {
let (_, it) = q.find_item(ClipId(*c))?;
// stills, mattes and other sources without an end are never short; nor is a held frame
let media_end = crate::media_duration(&s.project, &s.media, it.item)?;
let over = it.source_out().0.saturating_sub(media_end.0);
if over <= 0 || it.frame_hold.is_some() {
return None;
}
// media ticks past the end, as sequence ticks of this clip
let length = it.duration.0.max(0);
let ticks = ((over as f64 / it.speed.abs().max(1e-9)).round().min(length as f64) as i64).min(length);
let frames = ticks.saturating_add(frame - 1) / frame;
(frames > 0).then(|| json!({"clip": c, "shortByFrames": frames}))
})
.collect()
}
+148
View File
@@ -602,6 +602,154 @@ fn replace_with_clip_from_source_match_frame_and_bin() {
assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err());
}
/// Replace With Clip ▸ From Bin restarts the clip at the new item's In point (as Premiere does);
/// `keepSourceIn` keeps the clip's place in the media instead.
#[test]
fn replace_from_bin_keeps_the_source_in_on_request() {
let mut s = demo();
let rate = s.sequence_rate();
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
let c = v1(&s)[3].clone();
let dunes = item_named(&s, "Desert_Dunes.mp4");
let forest = item_named(&s, "Misty_Forest.mp4");
// disabled without a clip and a replacement, with or without the new parameter
s.execute("timeline.select", json!({"clips": []})).unwrap();
assert!(matches!(s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})), Err(crate::EngineError::Disabled(..))));
// give the clip a source In that is not the start of its media
s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "in", "mode": "regular", "deltaFrames": 7})).unwrap();
let trimmed = clip(&s, c.id.0);
assert_eq!(trimmed.source_in, c.source_in + rate.tick_of(7));
select(&mut s, &[c.id.0]);
s.execute("project.select", json!({"items": [dunes.0]})).unwrap();
let before = s.project.clone();
// default: the replacement starts at the item's In point, and the result is what it was
let r = s.execute("clip.replaceFromBin", json!({})).unwrap();
assert_eq!((clip(&s, c.id.0).item, clip(&s, c.id.0).source_in), (dunes, Tick::ZERO));
assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0, "short": []}));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
// keepSourceIn: the same place in the new media, everything else as before
let r = s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})).unwrap();
let got = clip(&s, c.id.0);
assert_eq!((got.item, got.source_in, got.start, got.duration), (dunes, trimmed.source_in, trimmed.start, trimmed.duration));
assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0, "short": [], "sourceInClamped": []}));
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
s.execute("edit.undo", json!({})).unwrap();
s.execute("clip.replaceFromBin", json!({"keepSourceIn": false})).unwrap();
assert_eq!(clip(&s, c.id.0).source_in, Tick::ZERO);
s.execute("edit.undo", json!({})).unwrap();
// a subclip that restricts trims has no media outside its range: a kept In before or after it
// moves to its first or last frame, and the result names the clip
let sub = |s: &mut Session, from: Tick, to: Tick, restrict: bool| {
let r = s.execute("clip.makeSubclip", json!({"item": forest.0, "start": from.0, "end": to.0, "restrictTrims": restrict})).unwrap();
ItemId(r["item"].as_u64().unwrap())
};
let kept = trimmed.source_in;
let media_frame = s.project.item(forest).unwrap().as_media().unwrap().frame_rate().frame_duration();
assert!(kept > rate.tick_of(10) && media_frame > Tick::ZERO);
let later = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), true);
let earlier = sub(&mut s, Tick::ZERO, kept - rate.tick_of(2), true);
let loose = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), false);
for (item, want, clamped) in [(later, kept + rate.tick_of(3), true), (earlier, kept - rate.tick_of(2) - media_frame, true), (loose, kept, false)] {
let r = s.execute("clip.replaceFromBin", json!({"item": item.0, "keepSourceIn": true})).unwrap();
let got = clip(&s, c.id.0);
assert_eq!((got.item, got.source_in, got.start, got.duration), (item, want, trimmed.start, trimmed.duration));
assert_eq!(r["sourceInClamped"], if clamped { json!([c.id.0]) } else { json!([]) });
s.execute("edit.undo", json!({})).unwrap();
}
// a kept In that is already inside the subclip stays where it is
let around = sub(&mut s, kept - rate.tick_of(5), kept + rate.tick_of(23), true);
let r = s.execute("clip.replaceFromBin", json!({"item": around.0, "keepSourceIn": true})).unwrap();
assert_eq!((clip(&s, c.id.0).source_in, &r["sourceInClamped"]), (kept, &json!([])));
// hostile values of the flag fall back to the default
s.execute("edit.undo", json!({})).unwrap();
for junk in [json!("yes"), json!(1), json!(null), json!([true])] {
let r = s.execute("clip.replaceFromBin", json!({"item": dunes.0, "keepSourceIn": junk})).unwrap();
assert_eq!((clip(&s, c.id.0).source_in, r.get("sourceInClamped")), (Tick::ZERO, None));
s.execute("edit.undo", json!({})).unwrap();
}
}
/// A replacement shorter than the clip's slot left the clip running past the end of its media
/// without a word. The three Replace With Clip commands now say which clips are short, by how
/// many sequence frames.
#[test]
fn replace_with_clip_reports_clips_that_run_past_the_new_media() {
let mut s = demo();
let rate = s.sequence_rate();
let demo_seq = s.state.active_sequence.unwrap();
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
let c = v1(&s)[3].clone();
let slot = rate.frame_at(c.duration);
let forest = item_named(&s, "Misty_Forest.mp4");
let forest_len = s.project.item(forest).unwrap().as_media().unwrap().info.duration;
assert!(slot > 12 && forest_len > c.duration + rate.tick_of(40), "the clip is longer than 12 frames and shorter than the media");
let state = |s: &Session| {
let it = clip(s, c.id.0);
(it.item, it.source_in, it.start, it.duration)
};
select(&mut s, &[c.id.0]);
// From Bin, with 12 frames of media (a subclip that restricts trims): the edit is what it
// always was, and the result says the clip is short
let r = s.execute("clip.makeSubclip", json!({"item": forest.0, "start": rate.tick_of(10).0, "end": rate.tick_of(22).0})).unwrap();
let short = ItemId(r["item"].as_u64().unwrap());
let before = s.project.clone();
let r = s.execute("clip.replaceFromBin", json!({"item": short.0})).unwrap();
assert_eq!(state(&s), (short, rate.tick_of(10), c.start, c.duration), "the edit itself is not changed");
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": slot - 12}]));
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
s.execute("edit.undo", json!({})).unwrap();
// long enough media: nothing is short
assert_eq!(s.execute("clip.replaceFromBin", json!({"item": forest.0})).unwrap()["short"], json!([]));
s.execute("edit.undo", json!({})).unwrap();
// From Source Monitor, with the Source In 20 frames before the end of the media
let frames = |t: Tick| (t.0 + rate.frame_duration().0 - 1) / rate.frame_duration().0;
s.execute("source.open", json!({"item": forest.0})).unwrap();
let mark = forest_len - rate.tick_of(20);
s.execute("project.setMarks", json!({"item": forest.0, "in": mark.0})).unwrap();
let r = s.execute("clip.replaceFromSource", json!({})).unwrap();
let got = clip(&s, c.id.0);
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": frames(got.source_out() - forest_len)}]));
assert_eq!(frames(got.source_out() - forest_len), slot - 20);
s.execute("edit.undo", json!({})).unwrap();
// Match Frame: the last frame of the media on the clip's first frame
s.execute("playhead.set", json!({"time": c.start.0})).unwrap();
s.execute("source.setPlayhead", json!({"time": (forest_len - rate.tick_of(1)).0})).unwrap();
let r = s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap();
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": slot - 1}]));
s.execute("edit.undo", json!({})).unwrap();
// with enough media after the frame, nothing is short
s.execute("source.setPlayhead", json!({"frame": 5})).unwrap();
assert_eq!(s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap()["short"], json!([]));
s.execute("edit.undo", json!({})).unwrap();
// never more than the clip itself: an In that lies past the end of the new media altogether
s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "in", "mode": "regular", "deltaFrames": 7})).unwrap();
let trimmed = clip(&s, c.id.0);
s.execute("file.newSequence", json!({"name": "Brief"})).unwrap();
let brief = s.state.active_sequence.unwrap();
s.execute("timeline.place", json!({"item": forest.0, "time": 0})).unwrap();
let placed = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
s.execute("timeline.trim", json!({"clip": placed.id.0, "edge": "out", "mode": "regular", "delta": (rate.tick_of(3) - placed.duration).0})).unwrap();
s.execute("sequence.open", json!({"item": demo_seq.0})).unwrap();
select(&mut s, &[c.id.0]);
assert!(trimmed.source_in > s.project.sequence(brief).unwrap().duration(), "the kept In is past the end of the nested sequence");
let r = s.execute("clip.replaceFromBin", json!({"clips": [c.id.0], "item": brief.0, "keepSourceIn": true})).unwrap();
assert_eq!(clip(&s, c.id.0).source_in, trimmed.source_in);
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": rate.frame_at(trimmed.duration)}]), "all of the clip, not more");
}
#[test]
fn audio_source_channels_pick_what_a_clip_plays() {
let mut it = v1(&demo())[0].clone();
+169 -44
View File
@@ -110,6 +110,33 @@ fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
if s.state.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
}
/// The ids a caller named under `many` (a list) or `one` (a single id), when the command documents
/// that key as ids (`"clips":[id]`, `"item":id`); `None` when it named none.
fn named_ids(spec: &CommandSpec, p: &Value, many: &str, one: &str) -> Option<Vec<u64>> {
let ids: Vec<u64> = if spec.params.contains(&format!("\"{many}\":[id]"))
&& let Some(a) = p.get(many).and_then(Value::as_array)
{
a.iter().filter_map(Value::as_u64).collect()
} else if spec.params.contains(&format!("\"{one}\":id")) {
u64_p(p, one).into_iter().collect()
} else {
Vec::new()
};
(!ids.is_empty()).then_some(ids)
}
/// Timeline clips named explicitly in `clips` / `clip` (see [`Session::execute`]): only clips of
/// the active sequence count, so an id of nothing does not stand in for a selection.
pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ClipId>> {
let seq = s.active_sequence()?;
let clips: Vec<ClipId> = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect();
(!clips.is_empty()).then_some(clips)
}
/// Project items named explicitly in `items` / `item`, likewise.
pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ItemId>> {
let items: Vec<ItemId> = named_ids(spec, p, "items", "item")?.into_iter().map(ItemId).filter(|i| s.project.item(*i).is_some()).collect();
(!items.is_empty()).then_some(items)
}
// ---------- param helpers ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
@@ -187,23 +214,88 @@ pub(crate) fn clips_p(s: &Session, p: &Value) -> Vec<ClipId> {
None => clip_p(p, "clip").map(|c| vec![c]).unwrap_or_else(|| s.state.selection.clone()),
}
}
pub(crate) fn track_p(s: &Session, p: &Value, k: &str) -> Option<TrackId> {
let v = p.get(k)?;
if let Some(id) = v.as_u64() {
return Some(TrackId(id));
}
// "V1", "A2"
let name = v.as_str()?;
let seq = s.active_sequence()?;
let (kind, idx) = name.split_at(1);
let i: usize = idx.parse().ok()?;
let tracks = if kind.eq_ignore_ascii_case("v") { &seq.video_tracks } else { &seq.audio_tracks };
tracks.get(i.checked_sub(1)?).map(|t| t.id)
/// The track parameter `k` names: a track id, or "V1" / "A2" (kind and 1-based number).
/// `Ok(None)` when the parameter is absent. Naming a track the active sequence does not have is
/// an error (bad parameters of `cmd`): callers fall back to a default track only when no track
/// was asked for.
pub(crate) fn track_p(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<Option<TrackId>> {
let v = match p.get(k) {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let by_name = |name: &str| {
let mut rest = name.chars();
let tracks = match rest.next()? {
'v' | 'V' => &seq.video_tracks,
'a' | 'A' => &seq.audio_tracks,
_ => return None,
};
let i: usize = rest.as_str().parse().ok()?;
tracks.get(i.checked_sub(1)?).map(|t| t.id)
};
let found = match v.as_u64() {
Some(id) => seq.track(TrackId(id)).map(|t| t.id),
None => v.as_str().and_then(by_name),
};
let named = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
found.map(Some).ok_or_else(|| bad(cmd, format!("no track {named} in this sequence (`{k}`)")))
}
pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
u64_p(p, k).map(ItemId)
}
/// `timeline.move`: move the listed clips to (track, time). With Linked Selection on (or
/// `linked: true`) the linked partners of a listed clip that are not listed themselves follow by
/// the same time offset on their own tracks, so picture and sound stay in sync; a partner on a
/// locked track, or one whose position would not change, stays as it is. Nothing moves before the sequence start: when a clip would,
/// every clip of the call lands later by the same amount, so the spacing asked for is kept.
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "timeline.move";
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() {
let track = track_p(s, m, "track", CMD)?;
if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) {
listed.push((clip, track, Tick(time)));
}
}
if listed.is_empty() {
return Err(bad(CMD, "need `moves`"));
}
let follow = bool_p(p, "linked").unwrap_or(s.state.linked_selection);
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
// (clip, destination track, new start, where a partner starts now). `time` is any i64: the
// sums are done in i128, where a few i64 values cannot overflow, and checked on the way back.
let mut plan: Vec<(ClipId, TrackId, i128, Option<i128>)> = Vec::new();
for &(clip, track, time) in &listed {
plan.push((clip, track, i128::from(time.0), None));
let Some((_, it)) = q.find_item(clip) else { continue }; // `edit::move_items` reports it
let Some(link) = it.link.filter(|_| follow) else { continue };
let offset = i128::from(time.0) - i128::from(it.start.0);
for t in q.all_tracks().filter(|t| !t.locked) {
for partner in t.items.iter().filter(|i| i.link == Some(link)) {
if !listed.iter().any(|m| m.0 == partner.id) && !plan.iter().any(|m| m.0 == partner.id) {
plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0))));
}
}
}
}
let late = plan.iter().map(|m| -m.2).max().unwrap_or(0).max(0);
let mut moves: Vec<(ClipId, TrackId, Tick)> = Vec::with_capacity(plan.len());
for (clip, track, start, was) in plan {
// a partner that ends up where it is does not move (its listed clip only changed track):
// re-placing it would drop the transitions at its edges
if was == Some(start + late) {
continue;
}
let start = i64::try_from(start + late).ok().map(Tick).filter(|t| *t <= Tick::MAX).ok_or_else(|| bad(CMD, "`time` is out of range"))?;
moves.push((clip, track, start));
}
let ins = bool_p(p, "insert").unwrap_or(false);
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>()}))
}
/// Expand a clip selection with linked partners (when linked selection is on).
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
@@ -737,9 +829,15 @@ fn build() -> Vec<CommandSpec> {
}
Ok(out)
}),
cmd!("file.exportInterchange", "Export Interchange", [], None, r#"{"format":"edl|xml|fcpxml|otio","path":str,"sequence":id?}"#, has_seq, |s, p| {
crate::interchange::export(s, p)
}),
cmd!(
"file.exportInterchange",
"Export Interchange",
[],
None,
r#"{"format":"edl|xml|fcpxml|otio|aaf|omf"=xml,"path":str,"sequence":id?}"#,
has_seq,
crate::interchange::export
),
cmd!("file.exportEdl", "EDL…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("edl");
@@ -940,10 +1038,7 @@ fn build() -> Vec<CommandSpec> {
}
let sel = with_links(s, &clips_p(s, p));
s.edit_sequence("Ripple Delete", |q, _, st| {
let mut spans: Vec<TimeRange> = sel.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.range())).collect();
spans.sort_by_key(|r| (r.start, r.duration));
spans.dedup();
edit::ripple_delete_items(q, &sel)?;
let spans = edit::ripple_delete_items(q, &sel)?;
if st.ripple_sequence_markers {
for r in spans.iter().rev() {
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
@@ -1034,8 +1129,26 @@ fn build() -> Vec<CommandSpec> {
};
let sel = with_links(s, &clips_p(s, p));
s.edit_sequence("Speed/Duration", |q, ctx, _| {
// a clip and its linked partners on other tracks change as one edit, so a ripple
// moves the later clips once
let place = |q: &filmcraft_project::Sequence, c: ClipId| q.find_item(c).map(|(t, i)| (t, i.link));
let mut groups: Vec<Vec<ClipId>> = Vec::new();
for c in &sel {
// a clip that is not there goes on alone, so the edit fails with "no such item"
let Some((track, link)) = place(q, *c) else {
groups.push(vec![*c]);
continue;
};
let joins = |g: &Vec<ClipId>| link.is_some() && g.iter().all(|o| place(q, *o).is_some_and(|(t, l)| l == link && t != track));
match groups.iter_mut().find(|g| joins(g)) {
Some(g) => g.push(*c),
None => groups.push(vec![*c]),
}
}
for group in &groups {
edit::set_speed_group(q, group, speed, reverse, ripple, ctx)?;
}
for c in &sel {
edit::set_speed(q, *c, speed, reverse, ripple, ctx)?;
if let (Some(m), Some((_, it))) = (interp, q.find_item_mut(*c)) {
it.time_interpolation = m;
}
@@ -1114,7 +1227,7 @@ fn build() -> Vec<CommandSpec> {
"Audio Gain…",
["Clip", "Audio Options"],
Some("G"),
r#"{"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#,
r#"{"clips":[id]?,"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#,
has_selection,
|s, p| crate::mixer::audio_gain(s, p)
),
@@ -1208,7 +1321,7 @@ fn build() -> Vec<CommandSpec> {
set_transition
),
cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| {
let tr = track_p(s, p, "track").ok_or_else(|| bad("sequence.closeGap", "need `track`"))?;
let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?;
let t = time_p(s, p, "").unwrap_or(s.playhead());
s.edit_sequence("Ripple Delete", |q, _, st| {
let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default();
@@ -1252,7 +1365,7 @@ fn build() -> Vec<CommandSpec> {
Ok(Value::Null)
}),
cmd!("sequence.deleteTrack", "Delete Track", [], None, r#"{"track":"V3"|id}"#, has_seq, |s, p| {
let tr = track_p(s, p, "track").ok_or_else(|| bad("sequence.deleteTrack", "need `track`"))?;
let tr = track_p(s, p, "track", "sequence.deleteTrack")?.ok_or_else(|| bad("sequence.deleteTrack", "need `track`"))?;
s.edit_sequence("Delete Track", |q, _, _| {
q.video_tracks.retain(|t| t.id != tr);
q.audio_tracks.retain(|t| t.id != tr);
@@ -1559,15 +1672,30 @@ fn build() -> Vec<CommandSpec> {
"Place Clip",
[],
None,
r#"{"item":id,"track":"V1"|id?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?}"#,
r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?}"#,
has_seq,
|s, p| {
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
let at = time_p(s, p, "").unwrap_or(s.playhead());
let tg = s.targeting();
let v = track_p(s, p, "track").or(tg.video_dest);
let a = track_p(s, p, "audioTrack").or(tg.audio_dest);
let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?;
let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t));
// `track` is where the picture goes and `audioTrack` where the sound goes. A caller that
// names an audio track as `track` asks for the sound alone, on that track.
let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) {
(Some(t), Some(_)) if is_audio(t) => {
return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once"));
}
(_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")),
(Some(t), None) if is_audio(t) && !pi.has_audio() => {
return Err(bad("timeline.place", "`track` names an audio track and this item has no sound"));
}
(Some(t), None) if is_audio(t) => (None, Some(t)),
(Some(_), None) if !pi.has_video() => {
return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track"));
}
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
};
let full = match &pi.kind {
// Settings ▸ Timeline ▸ Still Image Default Duration
ItemKind::Media(m)
@@ -1616,19 +1744,15 @@ fn build() -> Vec<CommandSpec> {
}
Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
}),
cmd!("timeline.move", "Move Clips", [], None, r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool}"#, has_seq, |s, p| {
let moves: Vec<(ClipId, TrackId, Tick)> = p
.get("moves")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|m| Some((clip_p(m, "clip")?, track_p(s, m, "track")?, Tick(m.get("time")?.as_i64()?)))).collect())
.unwrap_or_default();
if moves.is_empty() {
return Err(bad("timeline.move", "need `moves`"));
}
let ins = bool_p(p, "insert").unwrap_or(false);
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
Ok(Value::Null)
}),
cmd!(
"timeline.move",
"Move Clips",
[],
None,
r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?}"#,
has_seq,
move_clips
),
cmd!(
"timeline.trim",
"Trim Edit",
@@ -1830,7 +1954,7 @@ fn build() -> Vec<CommandSpec> {
}),
cmd!("timeline.razor", "Razor", [], None, r#"{"time":ticks,"track":"V1"|id?,"clip":id?}"#, has_seq, |s, p| {
let t = time_p(s, p, "").unwrap_or(s.playhead());
let tr = track_p(s, p, "track");
let tr = track_p(s, p, "track", "timeline.razor")?;
let clip = clip_p(p, "clip");
let n = if let Some(c) = clip {
let clips = with_links(s, &[c]);
@@ -1849,7 +1973,7 @@ fn build() -> Vec<CommandSpec> {
r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?}"#,
has_seq,
|s, p| {
let tr = track_p(s, p, "track").ok_or_else(|| bad("timeline.setTrack", "need `track`"))?;
let tr = track_p(s, p, "track", "timeline.setTrack")?.ok_or_else(|| bad("timeline.setTrack", "need `track`"))?;
let p = p.clone();
s.edit_sequence("Track Settings", |q, _, _| {
let t = q.track_mut(tr).ok_or(filmcraft_edit::EditError::NoTrack(tr))?;
@@ -1883,7 +2007,7 @@ fn build() -> Vec<CommandSpec> {
}
),
cmd!("timeline.setTargeting", "Track Targeting", [], None, r#"{"track":"V1"|id,"targeted":bool?,"sourcePatch":bool?}"#, has_seq, |s, p| {
let tr = track_p(s, p, "track").ok_or_else(|| bad("timeline.setTargeting", "need `track`"))?;
let tr = track_p(s, p, "track", "timeline.setTargeting")?.ok_or_else(|| bad("timeline.setTargeting", "need `track`"))?;
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let mut tg = s.targeting();
let is_video = s.active_sequence().is_some_and(|q| q.video_tracks.iter().any(|t| t.id == tr));
@@ -2714,7 +2838,8 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
"id": t.id.0, "name": t.name, "locked": t.locked, "syncLock": t.sync_lock, "enabled": t.enabled, "muted": t.muted, "solo": t.solo,
"items": t.items.iter().map(|i| json!({
"clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0,
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed,
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse,
"end": i.end().0, "endFrame": rate.frame_at(i.end()), "sourceOut": i.source_out().0, "gainDb": i.gain_db,
"enabled": i.enabled, "link": i.link, "label": i.label.name(),
"effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "masks": e.masks.len(), "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
@@ -2731,7 +2856,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
"in": q.mark_in.map(|t| t.0),
"out": q.mark_out.map(|t| t.0),
"split": q.split,
"markers": q.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "duration": m.duration.0, "kind": format!("{:?}", m.kind), "name": m.name, "color": m.color.name()})).collect::<Vec<_>>(),
"markers": q.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "duration": m.duration.0, "kind": format!("{:?}", m.kind), "name": m.name, "comment": m.comment, "color": m.color.name()})).collect::<Vec<_>>(),
"video": q.video_tracks.iter().map(tr).collect::<Vec<_>>(),
"audio": q.audio_tracks.iter().map(tr).collect::<Vec<_>>(),
"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>(),
+177
View File
@@ -0,0 +1,177 @@
//! A caller that names its targets in the parameters (`clips`, `clip`, `item`, `items`) does not
//! need a selection: enablement is checked against the named targets. Without such parameters
//! (the menus, shortcuts and `engine.commands`) enablement is the selection's, as before.
use filmcraft_project::{ClipId, ItemId, TrackItem};
use serde_json::json;
use crate::{EngineError, Session};
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.state.selection.clear();
s.state.project_selection.clear();
s
}
fn item_named(s: &Session, name: &str) -> ItemId {
s.project.items.values().find(|i| i.name == name).map(|i| i.id).unwrap_or_else(|| panic!("no item {name}"))
}
fn v1(s: &Session) -> Vec<TrackItem> {
s.active_sequence().unwrap().video_tracks[0].items.clone()
}
fn a1(s: &Session) -> Vec<TrackItem> {
s.active_sequence().unwrap().audio_tracks[0].items.clone()
}
fn clip(s: &Session, id: ClipId) -> Option<TrackItem> {
s.active_sequence().unwrap().find_item(id).map(|(_, i)| i.clone())
}
fn disabled(r: crate::Result<serde_json::Value>) -> String {
match r {
Err(EngineError::Disabled(_, why)) => why,
other => panic!("expected a disabled command, got {other:?}"),
}
}
#[test]
fn replace_from_bin_takes_an_explicit_item_and_clips_without_any_selection() {
let mut s = demo();
let c = v1(&s)[0].clone();
let dunes = item_named(&s, "Desert_Dunes.mp4");
assert_ne!(c.item, dunes);
// nothing named, nothing selected: disabled, for the menus and for a caller alike
assert!(!s.is_enabled("clip.replaceFromBin"));
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({}))), "no clips selected");
// the clip is named but the replacement is not: still the Project panel's call
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [c.id.0]}))), "select a clip in the Project panel");
// both named: runs with no timeline and no Project-panel selection
s.execute("clip.replaceFromBin", json!({"clips": [c.id.0], "item": dunes.0})).unwrap();
assert_eq!(clip(&s, c.id).unwrap().item, dunes);
assert!(s.state.selection.is_empty() && s.state.project_selection.is_empty(), "the selections are left as they were");
assert!(!s.is_enabled("clip.replaceFromBin"), "menu enablement still follows the selection");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(clip(&s, c.id).unwrap().item, c.item);
// a timeline selection plus an explicit item (no Project-panel selection)
s.execute("timeline.select", json!({"clips": [c.id.0]})).unwrap();
s.execute("clip.replaceFromBin", json!({"item": dunes.0})).unwrap();
assert_eq!(clip(&s, c.id).unwrap().item, dunes);
}
#[test]
fn clip_commands_take_explicit_clips_without_a_selection() {
let mut s = demo();
let (a, b) = (v1(&s)[0].clone(), v1(&s)[1].clone());
for id in ["edit.clear", "edit.rippleDelete", "clip.audioGain", "edit.pasteAttributes", "clip.enable"] {
assert!(!s.is_enabled(id), "{id} is disabled without a selection");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id} with no parameters stays disabled");
}
// Clear
s.execute("edit.clear", json!({"clips": [a.id.0]})).unwrap();
assert!(clip(&s, a.id).is_none() && clip(&s, b.id).is_some());
s.execute("edit.undo", json!({})).unwrap();
assert!(clip(&s, a.id).is_some());
// Ripple Delete
let a_and_its_audio: Vec<u64> = s
.active_sequence()
.unwrap()
.all_tracks()
.flat_map(|t| &t.items)
.filter(|i| i.id == a.id || (i.link.is_some() && i.link == a.link))
.map(|i| i.id.0)
.collect();
s.execute("timeline.setTrack", json!({"track": "A2", "syncLock": false})).unwrap(); // the music on A2 would block the ripple
s.execute("edit.rippleDelete", json!({"clips": a_and_its_audio})).unwrap();
assert!(clip(&s, a.id).is_none());
assert_eq!(clip(&s, b.id).unwrap().start, a.start, "the next clip closes the gap");
s.execute("edit.undo", json!({})).unwrap();
// Audio Gain
let au = a1(&s)[0].clone();
s.execute("clip.audioGain", json!({"clips": [au.id.0], "mode": "set", "db": -6.0})).unwrap();
assert_eq!(clip(&s, au.id).unwrap().gain_db, -6.0);
// Enable
s.execute("clip.enable", json!({"clips": [a.id.0]})).unwrap();
assert!(!clip(&s, a.id).unwrap().enabled);
s.execute("edit.undo", json!({})).unwrap();
assert!(clip(&s, a.id).unwrap().enabled);
s.execute("edit.redo", json!({})).unwrap();
assert!(!clip(&s, a.id).unwrap().enabled);
// `clip.enable` documents `clips` only: a key it does not document is not a named target
assert_eq!(disabled(s.execute("clip.enable", json!({"clip": a.id.0}))), "no clips selected");
// Paste Attributes: naming the clips lifts only the selection condition, not the clipboard one
assert_eq!(disabled(s.execute("edit.pasteAttributes", json!({"clips": [b.id.0]}))), "copy a clip first");
s.execute("timeline.select", json!({"clips": [a.id.0]})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.state.selection.clear();
s.execute("edit.pasteAttributes", json!({"clips": [b.id.0]})).unwrap();
assert!(s.state.selection.is_empty(), "the selection is left as it was");
for id in ["edit.clear", "edit.rippleDelete", "clip.audioGain", "edit.pasteAttributes"] {
assert!(!s.is_enabled(id), "menu enablement of {id} still follows the selection");
}
}
#[test]
fn naming_nothing_usable_does_not_enable_a_command() {
let mut s = demo();
let before = s.project.clone();
// no ids, ids of no clip, the wrong type, and a key the command does not take
for p in [json!({"clips": []}), json!({"clips": [987_654_321u64]}), json!({"clips": "all"}), json!({"clips": [-1, 1.5, null]}), json!({"items": [1]})] {
assert_eq!(disabled(s.execute("edit.rippleDelete", p.clone())), "nothing selected", "{p}");
}
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel");
// `edit.cut` takes no `clips`: it works on the selection only
assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected");
assert!(std::sync::Arc::ptr_eq(&before, &s.project), "nothing was edited");
}
#[test]
fn link_and_speed_take_explicit_clips_without_a_selection() {
let mut s = demo();
let (v, au) = (v1(&s)[0].clone(), a1(&s)[0].clone());
for id in ["clip.link", "clip.speedDuration"] {
assert!(!s.is_enabled(id));
assert_eq!(disabled(s.execute(id, json!({}))), "no clips selected");
}
// Link toggles the named pair
let was = v.link.is_some() && v.link == au.link;
let r = s.execute("clip.link", json!({"clips": [v.id.0, au.id.0]})).unwrap();
assert_eq!(r["linked"], !was);
assert_eq!(clip(&s, v.id).unwrap().link.is_some(), !was);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(clip(&s, v.id).unwrap().link, v.link);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(clip(&s, v.id).unwrap().link.is_some(), !was);
s.execute("edit.undo", json!({})).unwrap();
// Speed/Duration on the last clip of V1 (nothing after it to run into)
let last = v1(&s).last().unwrap().clone();
s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})).unwrap();
let fast = clip(&s, last.id).unwrap();
assert!(fast.duration < last.duration, "{:?} -> {:?}", last.duration, fast.duration);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(clip(&s, last.id).unwrap().duration, last.duration);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(clip(&s, last.id).unwrap().duration, fast.duration);
assert!(s.state.selection.is_empty() && !s.is_enabled("clip.speedDuration"), "menu enablement still follows the selection");
}
#[test]
fn naming_targets_lifts_only_the_selection_condition() {
// no sequence: named clips cannot stand in for one
let mut empty = Session::default();
assert_eq!(disabled(empty.execute("edit.clear", json!({"clips": [1]}))), "no sequence is open");
assert_eq!(disabled(empty.execute("clip.speedDuration", json!({"clips": [1], "speed": 50}))), "no sequence is open");
let mut s = demo();
let a = v1(&s)[0].clone();
// the wrong kind of target: a video clip is not a graphic
assert_eq!(disabled(s.execute("graphics.setText", json!({"clip": a.id.0, "text": "x"}))), "select a graphic clip");
// a locked track is still the edit's own business: named or selected, the clip stays
s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap();
let _ = s.execute("edit.clear", json!({"clips": [a.id.0]}));
assert!(clip(&s, a.id).is_some(), "a clip on a locked track is not removed");
}
+27
View File
@@ -129,6 +129,33 @@ fn graphic_renders_in_the_program_and_survives_save() {
assert!(fonts.as_array().unwrap().iter().any(|f| f["family"] == "Inter"));
}
/// `file.exportInterchange` wrote a title out of the document without saying so: the command's
/// report names every graphic clip a format cannot carry.
#[test]
fn interchange_export_reports_the_title_it_cannot_carry() {
let d = std::env::temp_dir().join(format!("fc-title-report-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let mut s = demo();
let r = s.execute("graphics.newText", json!({"text": "Opening line", "time": 0, "seconds": 2.0})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
let (track, it) = s.active_sequence().unwrap().find_item(clip).map(|(t, i)| (t, i.clone())).unwrap();
let frame = s.sequence_rate().frame_at(it.start);
let want = format!("graphic clip \"{}\" at frame {frame}", it.name);
for format in ["xml", "fcpxml", "otio", "aaf"] {
let path = d.join(format!("cut.{format}")).to_string_lossy().to_string();
let r = s.execute("file.exportInterchange", json!({"format": format, "path": path})).unwrap();
let named = r["report"].as_array().unwrap().iter().filter(|l| l.as_str().is_some_and(|l| l.starts_with(&want))).count();
assert_eq!(named, 1, "{format}: {}", r["report"]);
}
// an EDL holds one video track: the report names the title when its track is the one written
let q = s.active_sequence().unwrap();
assert_ne!(q.video_tracks[0].id, track, "the title sits above the footage");
let path = d.join("cut.edl").to_string_lossy().to_string();
let r = s.execute("file.exportInterchange", json!({"format": "edl", "path": path})).unwrap();
assert!(r["report"].to_string().contains("one video track"), "{}", r["report"]);
let _ = std::fs::remove_dir_all(&d);
}
fn quad_box(s: &mut Session, clip: u64, layer: usize) -> [f64; 4] {
let l = s.execute("graphics.list", json!({"clip": clip})).unwrap();
let q = l["layers"][layer]["quad"].as_array().unwrap().clone();
+25 -9
View File
@@ -20,6 +20,10 @@ fn report_json(r: &filmcraft_interchange::Report) -> Vec<String> {
r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
}
fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool {
matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. }))
}
/// Import a document: merge its bins, media and sequences into the project (one undo step), then
/// link each media file that exists on disk. Returns the new sequences.
pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Result<Value> {
@@ -46,7 +50,15 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
};
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
let seqs = s.edit(&format!("Import {name}"), |proj, _| Ok(filmcraft_interchange::merge_into(proj, fragment, None)))?;
let seqs = s.edit(&format!("Import {name}"), |proj, _| {
let seqs = filmcraft_interchange::merge_into(proj, fragment, None);
// A document that sets only some Motion parameters leaves the others at their "auto"
// (NaN) defaults. Resolve them as placing a clip does, for every format. A media file's
// picture size is only what the document says so far (an EDL says nothing: the importer
// assumes the sequence size), so its clips' anchors wait for Link Media below.
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));
Ok(seqs)
})?;
// Link media: probe each new file-backed item that exists.
let new_media: Vec<(ItemId, String)> = s
.project
@@ -85,6 +97,8 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
filmcraft_interchange::rebase_source_timecode(proj, id, tc, rate);
}
}
// the picture size is now the file's own: centre the anchors of its clips in it
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| source.id == id);
Ok(())
})?;
s.media.insert_file(id, &mpath, src);
@@ -108,15 +122,17 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
}))
}
/// `file.exportInterchange {format: "edl"|"xml"|"fcpxml"|"otio", path, sequence?}`
/// `file.exportInterchange {format: "edl"|"xml"|"fcpxml"|"otio"|"aaf"|"omf" = "xml", path, sequence?}`
pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
let format = match p.get("format").and_then(Value::as_str).unwrap_or("xml") {
"edl" => Format::Edl,
"fcpxml" => Format::Fcpxml,
"otio" => Format::Otio,
"aaf" => Format::Aaf,
"omf" => Format::Omf,
_ => Format::Fcp7Xml,
// FCP7 XML only when no format is asked for: a format we don't know is an error, not XML.
// The names are the documented ones (each format's extension, in either case) and nothing
// else, so the error can list exactly what is accepted.
let format = match p.get("format") {
None | Some(Value::Null) => Format::Fcp7Xml,
Some(v) => v.as_str().and_then(|name| Format::ALL.into_iter().find(|f| f.extension().eq_ignore_ascii_case(name))).ok_or_else(|| {
let names: Vec<&str> = Format::ALL.iter().map(|f| f.extension()).collect();
crate::commands::bad("file.exportInterchange", format!("unknown format {v}: use one of {}", names.join(", ")))
})?,
};
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::Other("need `path`".into()))?.to_string();
let seq = p.get("sequence").and_then(Value::as_u64).map(ItemId).or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
@@ -0,0 +1,218 @@
//! Interchange imports and the "auto" (NaN) point defaults of the effects they add: the points
//! are resolved as placing a clip resolves them, the anchor once the media's real size is known,
//! and a project saved after an import opens again.
use filmcraft_geom::Vec2;
use filmcraft_media::DemoScene;
use filmcraft_project::{ItemId, ParamValue, Project, TrackItem};
use filmcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::media_test_util::{make_movie, tmp_dir};
const RATE: &str = "<rate><timebase>24</timebase><ntsc>FALSE</ntsc></rate>";
/// A 1920×1080 FCP7 XML sequence with one clip of `media/shot.mov` (declared 3840×2160) whose
/// Basic Motion filter sets only `scale`.
fn xml_with_a_scale_only_motion_filter() -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xmeml>
<xmeml version="4">
<sequence id="sequence-1">
<name>Cut</name>
<duration>24</duration>
{RATE}
<media>
<video>
<format><samplecharacteristics>{RATE}<width>1920</width><height>1080</height></samplecharacteristics></format>
<track>
<clipitem id="clipitem-1">
<name>shot</name>
{RATE}
<start>0</start><end>24</end><in>0</in><out>24</out>
<file id="file-1">
<name>shot.mov</name>
<pathurl>media/shot.mov</pathurl>
{RATE}
<duration>24</duration>
<media><video><samplecharacteristics><width>3840</width><height>2160</height></samplecharacteristics></video></media>
</file>
<filter><effect>
<name>Basic Motion</name><effectid>basic</effectid><effectcategory>motion</effectcategory><effecttype>motion</effecttype><mediatype>video</mediatype>
<parameter><parameterid>scale</parameterid><name>Scale</name><value>50</value></parameter>
</effect></filter>
</clipitem>
</track>
</video>
</media>
</sequence>
</xmeml>
"#
)
}
fn first_clip(s: &Session, seq: ItemId) -> TrackItem {
s.project.sequence(seq).unwrap().video_tracks[0].items[0].clone()
}
fn motion_point(c: &TrackItem, k: &str) -> Vec2 {
c.effect("motion").unwrap().vec2_at(k, Tick::ZERO)
}
/// Every effect point parameter of every clip that is still "auto".
fn auto_points(p: &Project) -> Vec<String> {
let mut out = Vec::new();
for q in p.sequences().filter_map(|i| i.as_sequence()) {
for c in q.all_tracks().flat_map(|t| &t.items) {
for e in &c.effects {
for (k, v) in &e.params {
if matches!(&v.value, ParamValue::Vec2(v) if v.x.is_nan() || v.y.is_nan()) {
out.push(format!("{}.{k}", e.effect));
}
}
}
}
}
out
}
fn import(s: &mut Session, doc: &std::path::Path) -> (serde_json::Value, ItemId) {
let r = s.execute("file.import", json!({"paths": [doc.to_string_lossy()]})).unwrap();
let seq = ItemId(r["sequences"][0].as_u64().unwrap_or_else(|| panic!("no sequence in {r}")));
(r["documents"][0].clone(), seq)
}
/// A project saved after such an import held `null` points and could not be opened again
/// ("project file is damaged: invalid type: null, expected f64").
#[test]
fn a_project_saved_after_an_interchange_import_opens_again() {
let d = tmp_dir("ix-auto-offline");
let doc = d.join("cut.xml");
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
let mut s = Session::default();
let before = s.project.clone();
// the media file is not there: its real size is not known
let (r, seq) = import(&mut s, &doc);
assert_eq!((r["linkedMedia"].as_u64(), r["offlineMedia"].as_array().map(Vec::len)), (Some(0), Some(1)), "{r}");
let clip = first_clip(&s, seq);
assert_eq!(clip.effect("motion").unwrap().f64_at("scale", Tick::ZERO), 50.0);
assert_eq!(motion_point(&clip, "position"), Vec2::new(960.0, 540.0), "the frame centre, as placing a clip resolves it");
assert!(motion_point(&clip, "anchor").x.is_nan(), "the anchor waits for the media's real size");
assert_eq!(auto_points(&s.project), ["motion.anchor"]);
// the saved file opens again and holds the same project (NaN != NaN, so compare what is written)
let path = d.join("p.fcproj").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut t = Session::default();
t.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(filmcraft_format::encode(&t.project, false), filmcraft_format::encode(&s.project, false));
assert_eq!(auto_points(&t.project), ["motion.anchor"]);
assert_eq!(motion_point(&first_clip(&t, seq), "position"), Vec2::new(960.0, 540.0));
// without a link step the import is one undo step
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(auto_points(&s.project), ["motion.anchor"]);
let _ = std::fs::remove_dir_all(&d);
}
/// The anchor is centred in the file's real picture, not in the size the document declares
/// (3840×2160 here) or the sequence's.
#[test]
fn a_linked_clip_gets_its_anchor_from_the_real_size_of_the_media() {
let d = tmp_dir("ix-auto-linked");
std::fs::create_dir_all(d.join("media")).unwrap();
make_movie(&d.join("media/shot.mov"), DemoScene::OceanSunset, 320, 180, 24);
let doc = d.join("cut.xml");
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
let mut s = Session::default();
let before = s.project.clone();
let (r, seq) = import(&mut s, &doc);
assert_eq!(r["linkedMedia"], 1, "{r}");
let clip = first_clip(&s, seq);
assert_eq!(s.project.source_size(clip.item), Some((320, 180)));
assert_eq!((motion_point(&clip, "position"), motion_point(&clip, "anchor")), (Vec2::new(960.0, 540.0), Vec2::new(160.0, 90.0)));
assert_eq!(auto_points(&s.project), Vec::<String>::new());
// nothing is NaN, so the reopened project is equal
let path = d.join("p.fcproj").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut t = Session::default();
t.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(*t.project, *s.project);
// undo: Link Media takes the real size and the anchor back together, then the import itself
let linked = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
let clip = first_clip(&s, seq);
assert_eq!(s.project.source_size(clip.item), Some((3840, 2160)), "the size the document declared");
assert!(motion_point(&clip, "anchor").x.is_nan());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *linked);
let _ = std::fs::remove_dir_all(&d);
}
/// An EDL says nothing about the picture: the importer assumes the sequence size for the media
/// until the file is probed. A clip smaller than the sequence must still be centred.
#[test]
fn an_edl_clip_is_centred_in_its_real_size_not_the_assumed_sequence_size() {
let d = tmp_dir("ix-auto-edl");
make_movie(&d.join("shot.mov"), DemoScene::OceanSunset, 320, 180, 24);
let doc = d.join("cut.edl");
let edl = "TITLE: Cut\nFCM: NON-DROP FRAME\n\n001 AX V C 00:00:00:00 00:00:01:00 01:00:00:00 01:00:01:00\n* FROM CLIP NAME: shot.mov\n* SOURCE FILE: shot.mov\n";
std::fs::write(&doc, edl).unwrap();
let mut s = Session::default();
let (r, seq) = import(&mut s, &doc);
assert_eq!(r["linkedMedia"], 1, "{r}");
let st = s.project.sequence(seq).unwrap().settings.clone();
assert_ne!((st.width, st.height), (320, 180), "the media is not the size of the sequence");
let clip = first_clip(&s, seq);
assert_eq!(motion_point(&clip, "position"), Vec2::new(st.width as f64 / 2.0, st.height as f64 / 2.0));
assert_eq!(motion_point(&clip, "anchor"), Vec2::new(160.0, 90.0), "the centre of the 320×180 picture");
assert_eq!(auto_points(&s.project), Vec::<String>::new());
let _ = std::fs::remove_dir_all(&d);
}
/// Imported with the media missing, saved, reopened, and only then linked: Link Media gives the
/// item its real size, and the anchors that waited for it become numbers.
#[test]
fn relinking_later_resolves_the_anchor_that_waited_for_the_real_size() {
let d = tmp_dir("ix-auto-relink");
let doc = d.join("cut.xml");
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
let mut s = Session::default();
let (_, seq) = import(&mut s, &doc);
let path = d.join("p.fcproj").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut t = Session::default();
t.execute("file.open", json!({"path": path})).unwrap();
let clip = first_clip(&t, seq);
assert!(motion_point(&clip, "anchor").x.is_nan(), "still auto after the reopen");
// the file turns up in another folder, smaller than the document said
std::fs::create_dir_all(d.join("found")).unwrap();
let found = d.join("found/shot.mov");
make_movie(&found, DemoScene::OceanSunset, 320, 180, 24);
let waiting = t.project.clone();
let r = t.execute("media.relink", json!({"item": clip.item.0, "path": found.to_string_lossy(), "force": true})).unwrap();
assert_eq!(r["relinked"].as_array().map(Vec::len), Some(1), "{r}");
let clip = first_clip(&t, seq);
assert_eq!(t.project.source_size(clip.item), Some((320, 180)));
assert_eq!((motion_point(&clip, "position"), motion_point(&clip, "anchor")), (Vec2::new(960.0, 540.0), Vec2::new(160.0, 90.0)));
assert_eq!(auto_points(&t.project), Vec::<String>::new());
// one undo step takes the link and the anchor back together
let linked = t.project.clone();
t.execute("edit.undo", json!({})).unwrap();
assert_eq!(filmcraft_format::encode(&t.project, false), filmcraft_format::encode(&waiting, false));
assert_eq!(auto_points(&t.project), ["motion.anchor"]);
t.execute("edit.redo", json!({})).unwrap();
assert_eq!(*t.project, *linked);
// an anchor that is already a number is not touched by a later relink to another size
std::fs::create_dir_all(d.join("other")).unwrap();
let other = d.join("other/shot.mov");
make_movie(&other, DemoScene::OceanSunset, 160, 90, 24);
t.execute("media.relink", json!({"item": clip.item.0, "path": other.to_string_lossy(), "force": true})).unwrap();
assert_eq!(motion_point(&first_clip(&t, seq), "anchor"), Vec2::new(160.0, 90.0));
let _ = std::fs::remove_dir_all(&d);
}
+49 -1
View File
@@ -163,6 +163,11 @@ impl Services for FsServices {
fn file_loader(&self) -> Option<filmcraft_media::sequence::FrameLoader> {
Some(std::sync::Arc::new(|p: &str| std::fs::read(p)))
}
/// Media are read in place from the open file: a clip costs its index, not its size.
#[cfg(any(unix, windows))]
fn reader(&self, path: &str) -> Option<std::io::Result<filmcraft_media::SharedReader>> {
Some(filmcraft_media::reader::FileReader::open(std::path::Path::new(path)).map(|r| std::sync::Arc::new(r) as filmcraft_media::SharedReader))
}
fn list_entries(&self, dir: &str) -> Option<std::io::Result<Vec<media_browser::DirEntry>>> {
Some(media_browser::std_list_entries(dir))
}
@@ -595,7 +600,7 @@ impl Session {
if self.exec_depth == 0 && spec.journal && self.trim_play.active() {
self.settle_trim_playback(id);
}
if let Err(why) = (spec.enabled)(self) {
if let Err(why) = self.enabled_for(spec, &params) {
let e = EngineError::Disabled(id.to_string(), why);
if self.exec_depth == 0 {
self.log.push(panels::Level::Warning, id, e.to_string());
@@ -645,6 +650,33 @@ impl Session {
}
}
/// Enablement of `spec` for one call. `enabled()` reads the selection, which is right for the
/// menus and for a call without targets. A caller that names its targets in `params`, under a
/// key the command documents (`clips` / `clip`: timeline clips, `items` / `item`: project
/// items), is asked the same question about those targets instead: the check runs again with
/// them standing in for the selection, so every other condition (an open sequence, a filled
/// clipboard, the kind of item…) still applies. The selections are put back before returning.
fn enabled_for(&mut self, spec: &CommandSpec, params: &Value) -> std::result::Result<(), String> {
let by_selection = (spec.enabled)(self);
if by_selection.is_ok() {
return by_selection;
}
let clips = commands::named_clips(self, spec, params);
let items = commands::named_items(self, spec, params);
if clips.is_none() && items.is_none() {
return by_selection;
}
let mut clips = clips.unwrap_or_else(|| self.state.selection.clone());
let mut items = items.unwrap_or_else(|| self.state.project_selection.clone());
std::mem::swap(&mut self.state.selection, &mut clips);
std::mem::swap(&mut self.state.project_selection, &mut items);
let by_params = (spec.enabled)(self);
self.state.selection = clips;
self.state.project_selection = items;
by_params
}
/// Whether the command can run on the current selection (what the menus show).
pub fn is_enabled(&self, id: &str) -> bool {
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
}
@@ -880,14 +912,20 @@ mod color_tests;
#[cfg(test)]
mod essential_sound_tests;
#[cfg(test)]
mod explicit_targets_tests;
#[cfg(test)]
mod export_tests;
#[cfg(test)]
mod file_tests;
#[cfg(test)]
mod image_sequence_tests;
#[cfg(test)]
mod interchange_auto_points_tests;
#[cfg(test)]
mod keyboard_tests;
#[cfg(test)]
mod linked_speed_tests;
#[cfg(test)]
mod masks_tests;
#[cfg(test)]
mod media_browser_tests;
@@ -914,16 +952,26 @@ mod relink_tests;
#[cfg(test)]
mod remix_tests;
#[cfg(test)]
mod ripple_delete_tests;
#[cfg(test)]
mod scopes_tests;
#[cfg(test)]
mod sequence_inspect_tests;
#[cfg(test)]
mod sequence_tools_tests;
#[cfg(test)]
mod settings_tests;
#[cfg(test)]
mod shortcuts_tests;
#[cfg(test)]
mod split_edit_ripple_trim_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod timeline_move_tests;
#[cfg(test)]
mod track_params_tests;
#[cfg(test)]
mod transcript_tests;
#[cfg(test)]
mod trim_tests;
+152
View File
@@ -0,0 +1,152 @@
//! `clip.speedDuration` with `ripple` on a clip that has linked sound: the clip and its sound
//! change as one edit, and the later clips move once.
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
use crate::Session;
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
}
/// Three 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 3].
fn three_clips(s: &mut Session) -> [(ClipId, ClipId); 3] {
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Speed", "video": 1, "audio": 1})).unwrap();
let rate = s.sequence_rate();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
for n in 0..3 {
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
}
let q = s.active_sequence().unwrap();
[0, 1, 2].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
}
/// Slower with ripple moved the later clips twice (once for the picture, once for its sound) and
/// left a double gap.
#[test]
fn slower_with_ripple_moves_later_clips_once() {
let mut s = Session::default();
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f48, f144, f192) = (f(48), f(144), f(192));
let before = s.project.clone();
s.execute("clip.speedDuration", json!({"clips": [picture2.0], "speed": 50, "ripple": true})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f144), (f48, f144)), "the clip and its sound are twice as long");
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f144, f192), (f144, f192)), "the next clip follows, once");
assert_eq!(q.duration(), f192);
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before, "one undo step restores it");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
/// Faster with ripple was refused when more than one clip followed: the second ripple pulled the
/// farther clips onto the nearer ones.
#[test]
fn faster_with_ripple_moves_later_clips_once() {
let mut s = Session::default();
let [(picture1, sound1), (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f0, f24, f72, f120) = (f(0), f(24), f(72), f(120));
s.execute("clip.speedDuration", json!({"clips": [picture1.0], "speed": 200, "ripple": true})).unwrap();
s.active_sequence().unwrap().check().unwrap();
for (picture, sound, at) in [(picture1, sound1, (f0, f24)), (picture2, sound2, (f24, f72)), (picture3, sound3, (f72, f120))] {
assert_eq!((range(&s, picture), range(&s, sound)), (at, at));
}
}
/// A clip that is not in the sequence is an error and nothing changes, also next to one that is.
#[test]
fn speed_on_a_missing_clip_is_an_error() {
let mut s = Session::default();
let [_, (picture2, _), _] = three_clips(&mut s);
let before = s.project.clone();
let undo = s.history.undo.len();
assert!(s.execute("clip.speedDuration", json!({"clips": [999_999], "speed": 50, "ripple": true})).is_err());
assert!(s.execute("clip.speedDuration", json!({"clips": [picture2.0, 999_999], "speed": 50, "ripple": true})).is_err());
assert_eq!(*s.project, *before);
assert_eq!(s.history.undo.len(), undo);
}
/// Several clips in one call each ripple in turn, as before.
#[test]
fn speed_with_ripple_on_two_clips_ripples_each() {
let mut s = Session::default();
let [(picture1, sound1), (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f0, f24, f48, f96) = (f(0), f(24), f(48), f(96));
s.execute("clip.speedDuration", json!({"clips": [picture1.0, picture2.0], "speed": 200, "ripple": true})).unwrap();
s.active_sequence().unwrap().check().unwrap();
for (picture, sound, at) in [(picture1, sound1, (f0, f24)), (picture2, sound2, (f24, f48)), (picture3, sound3, (f48, f96))] {
assert_eq!((range(&s, picture), range(&s, sound)), (at, at));
}
}
/// Without `ripple` nothing after the clip moves.
#[test]
fn speed_without_ripple_leaves_later_clips() {
let mut s = Session::default();
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f48, f72, f96, f144) = (f(48), f(72), f(96), f(144));
s.execute("clip.speedDuration", json!({"clips": [picture2.0], "speed": 200})).unwrap();
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f72), (f48, f72)));
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f96, f144), (f96, f144)));
}
/// Linked Selection off and both the picture and its sound named: still one edit, one ripple.
#[test]
fn speed_with_ripple_on_a_named_picture_and_sound() {
let mut s = Session::default();
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f48, f144, f192) = (f(48), f(144), f(192));
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("clip.speedDuration", json!({"clips": [picture2.0, sound2.0], "speed": 50, "ripple": true})).unwrap();
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f144), (f48, f144)));
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f144, f192), (f144, f192)));
}
/// Two clips of one track that were linked to each other (Clip ▸ Link) ripple one after the
/// other, as unlinked clips do.
#[test]
fn speed_with_ripple_on_linked_clips_of_one_track() {
let mut s = Session::default();
let [(picture1, _), (picture2, _), (picture3, _)] = three_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f0, f96, f192, f240) = (f(0), f(96), f(192), f(240));
s.execute("timeline.setTrack", json!({"track": "A1", "syncLock": false})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.select", json!({"clips": [picture1.0, picture2.0]})).unwrap();
// both are linked to their sounds, so the first call unlinks them and the second links the two
s.execute("clip.link", json!({})).unwrap();
assert_eq!(s.execute("clip.link", json!({})).unwrap()["linked"], true);
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
s.execute("clip.speedDuration", json!({"clips": [picture1.0], "speed": 50, "ripple": true})).unwrap();
s.active_sequence().unwrap().check().unwrap();
assert_eq!((range(&s, picture1), range(&s, picture2), range(&s, picture3)), ((f0, f96), (f96, f192), (f192, f240)));
}
/// A split edit: the sound runs 12 frames longer than its picture. Slower, the later clips move
/// by the sound's change, so nothing lands on the sound and the next clip stays in sync.
#[test]
fn speed_with_ripple_on_a_split_edit() {
let mut s = Session::default();
let [_, (picture2, sound2), (picture3, sound3)] = three_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f48, f144, f156, f168) = (f(48), f(144), f(156), f(168));
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.trim", json!({"clip": sound3.0, "edge": "in", "mode": "regular", "deltaFrames": 12})).unwrap();
s.execute("timeline.trim", json!({"clip": sound2.0, "edge": "out", "mode": "regular", "deltaFrames": 12})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
s.execute("clip.speedDuration", json!({"clips": [picture2.0], "speed": 50, "ripple": true})).unwrap();
s.active_sequence().unwrap().check().unwrap();
assert_eq!((range(&s, picture2), range(&s, sound2)), ((f48, f144), (f48, f168)), "picture 48 to 96 frames, sound 60 to 120");
assert_eq!((range(&s, picture3).0, range(&s, sound3).0), (f156, f168), "the next sound still starts 12 after its picture");
}
+44
View File
@@ -382,3 +382,47 @@ impl MediaSource for ProxySource {
self.proxy.audio(start, frames, sample_rate)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A host whose whole-file read fails: opening must go through the reader.
struct ReaderOnly(Arc<[u8]>);
impl Services for ReaderOnly {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
Err(std::io::Error::other(format!("{path}: read whole")))
}
fn write_file(&self, _path: &str, _data: &[u8]) -> std::io::Result<()> {
Ok(())
}
fn reader(&self, _path: &str) -> Option<std::io::Result<filmcraft_media::SharedReader>> {
Some(Ok(Arc::new(filmcraft_media::reader::MemReader(self.0.clone()))))
}
}
#[test]
fn open_file_prefers_the_hosts_reader() {
let wav = filmcraft_media::wav::write_wav16(&[0.0, 0.5, -0.5, 0.25], 2, 48_000);
let s = MediaPool::default().open_file("a.wav", &ReaderOnly(wav.into())).unwrap();
assert!(s.info().has_audio());
}
/// The desktop host reads media in place instead of loading every clip into memory.
#[cfg(any(unix, windows))]
#[test]
fn fs_services_open_media_through_a_file_reader() {
let dir = std::env::temp_dir().join(format!("filmcraft-pool-reader-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.wav");
let wav = filmcraft_media::wav::write_wav16(&[0.0, 0.5, -0.5, 0.25], 2, 48_000);
std::fs::write(&path, &wav).unwrap();
let p = path.to_string_lossy().to_string();
let r = crate::FsServices.reader(&p).expect("a reader").unwrap();
assert_eq!(r.len(), wav.len() as u64);
assert!(MediaPool::default().open_file(&p, &crate::FsServices).unwrap().info().has_audio());
let missing = dir.join("missing.wav").to_string_lossy().to_string();
assert!(matches!(MediaPool::default().open_file(&missing, &crate::FsServices), Err(MediaError::Offline(_))));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1 -1
View File
@@ -382,7 +382,7 @@ fn synchronize(s: &mut Session, p: &Value) -> Result<Value> {
// reference unit
let reference = if let Some(c) = u64_p(p, "reference").map(ClipId) {
units.iter().position(|u| u.members.contains(&c)).ok_or_else(|| bad(cmd, "`reference` is not among the clips"))?
} else if let Some(t) = track_p(s, p, "track") {
} else if let Some(t) = track_p(s, p, "track", cmd)? {
units
.iter()
.position(|u| u.members.iter().any(|m| q.track(t).is_some_and(|tr| tr.item(*m).is_some())))
+14
View File
@@ -21,6 +21,15 @@ pub fn decode_json() -> Value {
"draftFrames": g.draft,
"h264Threads": filmcraft_codecs::video::h264_threads(),
"evicted": g.evicted,
// sources that hold a decoder right now (idle ones give theirs up beyond a cap)
"liveDecoders": filmcraft_codecs::live_decoders(),
// decoded frames all sources hold, and the budget idle sources are trimmed to
"cacheMB": filmcraft_codecs::cached_bytes() as f64 / 1e6,
"cacheBudgetMB": filmcraft_codecs::FRAME_BUDGET as f64 / 1e6,
// plane buffers of evicted frames waiting for the next decoded pictures, and how many
// planes were decoded into a recycled buffer
"planePoolMB": filmcraft_frame::pool::stats().idle_bytes as f64 / 1e6,
"planesReused": filmcraft_frame::pool::stats().reused,
"decodeMs": g.decode_ns as f64 / 1e6,
"decodeMsPerSample": g.decode_ms_per_sample(),
"framesDecoded": g.frames,
@@ -71,6 +80,11 @@ mod tests {
"decodeMs",
"decodeMsPerSample",
"framesDecoded",
"liveDecoders",
"cacheMB",
"cacheBudgetMB",
"planePoolMB",
"planesReused",
] {
assert!(v["decode"][k].is_number(), "decode.{k} in {v}");
}
+6
View File
@@ -342,6 +342,12 @@ fn apply(s: &mut Session, plans: Vec<Plan>, align_timecode: bool, label: &str) -
m.info = info.clone();
}
proj.shift_media_time(p.item, shift);
if p.cand.info.is_some() {
// The item's size is now the file's own. Clips that came from an interchange import
// while the file was missing still have an "auto" (NaN) anchor waiting for it:
// centre it in the real picture. Points that are numbers are not touched.
proj.resolve_placed_auto_points(|_| true, |source| source.id == p.item);
}
}
Ok(())
})?;
+138
View File
@@ -0,0 +1,138 @@
//! `edit.rippleDelete` on a clip whose sound edge was split (a J or an L cut): the clip and its
//! sound go, and the later clips move up by one amount on every track.
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
use crate::Session;
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
}
/// Four 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 4].
fn four_clips(s: &mut Session) -> [(ClipId, ClipId); 4] {
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Split edits", "video": 1, "audio": 1})).unwrap();
let rate = s.sequence_rate();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
for n in 0..4 {
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
}
let q = s.active_sequence().unwrap();
[0, 1, 2, 3].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
}
/// Move one sound edge alone, as a split edit does (Linked Selection off for the trim).
fn trim_sound(s: &mut Session, sound: ClipId, edge: &str, frames: i64) {
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.trim", json!({"clip": sound.0, "edge": edge, "mode": "regular", "deltaFrames": frames})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
}
/// The second clip's sound starts 12 frames early (a J cut). Ripple delete was refused: the picture
/// and the sound have different ranges, each range was closed on its own, and the clips after the
/// next one were moved up twice, onto it. With a single clip after it the delete went through and
/// a later marker moved twice.
#[test]
fn ripple_delete_of_a_j_cut_clip_closes_one_gap() {
let mut s = Session::default();
let [(_, sound1), (picture2, sound2), (picture3, sound3), (picture4, sound4)] = four_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f36, f48, f72, f96, f120, f144) = (f(36), f(48), f(72), f(96), f(120), f(144));
trim_sound(&mut s, sound1, "out", -12);
trim_sound(&mut s, sound2, "in", -12);
assert_eq!((range(&s, picture2).0, range(&s, sound2).0), (f48, f36), "the sound leads by 12 frames");
s.state.ripple_sequence_markers = true;
let marker = s.execute("markers.add", json!({"time": f120.0, "name": "Later"})).unwrap()["marker"].clone();
let before = s.project.clone();
s.execute("edit.rippleDelete", json!({"clips": [picture2.0]})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert!(q.find_item(picture2).is_none() && q.find_item(sound2).is_none(), "the clip and its sound are gone");
assert_eq!(range(&s, picture3), (f48, f96), "the next picture closes the gap");
assert_eq!(range(&s, sound3), (f48, f96), "and its sound moves up by the same amount");
assert_eq!((range(&s, picture4), range(&s, sound4)), ((f96, f144), (f96, f144)), "the clip after it moves up once");
assert_eq!(range(&s, sound1).1, f36, "the sound before is untouched");
assert_eq!(q.duration(), f144);
let at = q.markers.iter().find(|m| json!(m.id.0) == marker).map(|m| m.start);
assert_eq!(at, Some(f72), "a later marker moves up once, by the same amount");
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before, "one undo step restores it");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
/// The second clip's sound runs 12 frames under the next picture (an L cut).
#[test]
fn ripple_delete_of_an_l_cut_clip_keeps_the_next_clip_in_sync() {
let mut s = Session::default();
let [_, (picture2, sound2), (picture3, sound3), (picture4, sound4)] = four_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f48, f60, f96, f108, f144) = (f(48), f(60), f(96), f(108), f(144));
trim_sound(&mut s, sound3, "in", 12);
trim_sound(&mut s, sound2, "out", 12);
assert_eq!((range(&s, picture2).1, range(&s, sound2).1), (f96, f108), "the sound runs on by 12 frames");
s.execute("edit.rippleDelete", json!({"clips": [picture2.0]})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert!(q.find_item(sound2).is_none());
assert_eq!(range(&s, picture3), (f48, f96));
assert_eq!(range(&s, sound3), (f60, f96), "the next sound still starts 12 frames after its picture");
assert_eq!((range(&s, picture4), range(&s, sound4)), ((f96, f144), (f96, f144)));
}
/// A straight cut is unchanged: the picture and its sound share one range and close one gap.
#[test]
fn ripple_delete_of_a_straight_cut_clip_is_unchanged() {
let mut s = Session::default();
let [_, (picture2, _), (picture3, sound3), _] = four_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f48, f96) = (f(48), f(96));
s.execute("edit.rippleDelete", json!({"clips": [picture2.0]})).unwrap();
assert_eq!((range(&s, picture3), range(&s, sound3)), ((f48, f96), (f48, f96)));
}
/// Two neighbours deleted together, with a split edit between them (the first one's sound runs 12
/// frames under the second picture): one gap closes, the size of both clips.
#[test]
fn ripple_delete_of_two_neighbours_with_a_split_edit_between_them() {
let mut s = Session::default();
let [_, (picture2, sound2), (picture3, sound3), (picture4, sound4)] = four_clips(&mut s);
let f = |n: i64| s.sequence_rate().tick_of(n);
let (f48, f96) = (f(48), f(96));
trim_sound(&mut s, sound3, "in", 12);
trim_sound(&mut s, sound2, "out", 12);
s.execute("edit.rippleDelete", json!({"clips": [picture2.0, picture3.0]})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert!([picture2, sound2, picture3, sound3].iter().all(|c| q.find_item(*c).is_none()));
assert_eq!((range(&s, picture4), range(&s, sound4)), ((f48, f96), (f48, f96)), "the clip after them closes the whole gap");
}
/// Straight cuts with the sounds on alternating audio tracks: deleting two neighbours closes the
/// whole gap, as it did before.
#[test]
fn ripple_delete_of_two_neighbours_with_sounds_on_alternating_tracks() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Alternating", "video": 1, "audio": 2})).unwrap();
let rate = s.sequence_rate();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
let mut pictures = Vec::new();
for n in 0..4 {
let track = if n % 2 == 0 { "A1" } else { "A2" };
let r = s.execute("timeline.place", json!({"item": item.0, "audioTrack": track, "frame": 48 * n, "duration": rate.tick_of(48).0})).unwrap();
pictures.push(ClipId(r["clips"][0].as_u64().unwrap()));
}
s.execute("edit.rippleDelete", json!({"clips": [pictures[1].0, pictures[2].0]})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert_eq!(range(&s, pictures[3]), (rate.tick_of(48), rate.tick_of(96)));
assert_eq!(q.audio_tracks[1].items.iter().map(|i| i.range()).collect::<Vec<_>>(), vec![q.video_tracks[0].items[1].range()], "its sound moved with it");
assert_eq!(q.duration(), rate.tick_of(96));
}
+1 -1
View File
@@ -225,7 +225,7 @@ fn transcribe(s: &mut Session, p: &Value) -> Result<Value> {
let track = match p.get("track") {
None => None,
Some(Value::String(t)) if t.eq_ignore_ascii_case("mix") => None,
Some(_) => Some(crate::commands::track_p(s, p, "track").ok_or_else(|| bad("sequence.transcribe", "unknown `track`"))?),
Some(_) => Some(crate::commands::track_p(s, p, "track", "sequence.transcribe")?.ok_or_else(|| bad("sequence.transcribe", "unknown `track`"))?),
};
let mut items = Vec::new();
for t in q.audio_tracks.iter().filter(|t| track.is_none_or(|x| x == t.id)) {
@@ -0,0 +1,89 @@
//! `sequence.inspect` is how an agent checks its edits: it has to show where a clip ends in the
//! sequence and in its media, the clip's audio gain, whether it plays backward and what a marker says.
use serde_json::json;
use crate::Session;
#[test]
fn sequence_inspect_reports_clip_ends_gain_and_marker_comments() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let rate = s.sequence_rate();
// a clip at 200 %, a clip with gain and a marker with a comment, all made through commands
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
let (fast, loud) = {
let q = s.active_sequence().unwrap();
(q.video_tracks[0].items.last().unwrap().id, q.audio_tracks[0].items[0].id)
};
s.execute("timeline.select", json!({"clips": [fast.0]})).unwrap();
s.execute("clip.speedDuration", json!({"speed": 200})).unwrap();
s.execute("timeline.select", json!({"clips": [loud.0]})).unwrap();
s.execute("clip.audioGain", json!({"mode": "set", "db": -6.5})).unwrap();
let marker = s.execute("markers.add", json!({"time": rate.tick_of(12).0, "name": "Beat", "comment": "cut on the downbeat"})).unwrap()["marker"].clone();
let q = s.active_sequence().unwrap().clone();
let js = s.execute("sequence.inspect", json!({})).unwrap();
let m = js["markers"].as_array().unwrap().iter().find(|m| m["id"] == marker).unwrap();
assert_eq!((m["name"].as_str(), m["comment"].as_str()), (Some("Beat"), Some("cut on the downbeat")));
let mut seen = 0;
for (key, tracks) in [("video", &q.video_tracks), ("audio", &q.audio_tracks)] {
for (ti, t) in tracks.iter().enumerate() {
for (ci, c) in t.items.iter().enumerate() {
let j = &js[key][ti]["items"][ci];
assert_eq!(j["clip"], c.id.0);
assert_eq!(j["end"], c.end().0, "{j}");
assert_eq!(j["end"].as_i64(), j["start"].as_i64().zip(j["duration"].as_i64()).map(|(a, b)| a + b));
assert_eq!(j["endFrame"], rate.frame_at(c.end()));
assert_eq!(j["sourceOut"], c.source_out().0, "{j}");
assert_eq!(j["gainDb"], c.gain_db);
// the keys that were there before stay
for old in ["item", "name", "start", "duration", "startFrame", "durationFrames", "sourceIn", "speed", "enabled", "link", "label", "effects"] {
assert!(j.get(old).is_some(), "{old} is still reported");
}
if c.id == fast {
assert_eq!(j["speed"], 2.0);
assert_eq!(
j["sourceOut"].as_i64().unwrap() - j["sourceIn"].as_i64().unwrap(),
2 * j["duration"].as_i64().unwrap(),
"at 200% a clip uses twice its length of media"
);
seen += 1;
}
if c.id == loud {
assert_eq!(j["gainDb"], -6.5);
seen += 1;
}
}
}
}
assert_eq!(seen, 2, "both edited clips were found in the readback");
}
/// A clip set to play backward read back exactly like a forward one: same `speed`, same source
/// range, and nothing that said it was reversed.
#[test]
fn sequence_inspect_reports_a_reversed_clip() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let backward = s.active_sequence().unwrap().video_tracks[0].items[1].id;
let all = |js: &serde_json::Value| -> Vec<(u64, bool)> {
["video", "audio"]
.iter()
.flat_map(|k| js[*k].as_array().unwrap().iter())
.flat_map(|t| t["items"].as_array().unwrap().iter())
.map(|i| (i["clip"].as_u64().unwrap(), i["reverse"].as_bool().expect("every clip reports `reverse`")))
.collect()
};
let js = s.execute("sequence.inspect", json!({})).unwrap();
assert!(all(&js).iter().all(|(_, reversed)| !reversed), "nothing plays backward yet");
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.select", json!({"clips": [backward.0]})).unwrap();
s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true})).unwrap();
let js = s.execute("sequence.inspect", json!({})).unwrap();
let reversed: Vec<u64> = all(&js).into_iter().filter(|(_, r)| *r).map(|(c, _)| c).collect();
assert_eq!(reversed, vec![backward.0], "only the reversed clip says so");
s.execute("edit.undo", json!({})).unwrap();
let js = s.execute("sequence.inspect", json!({})).unwrap();
assert!(all(&js).iter().all(|(_, reversed)| !reversed));
}
+2 -2
View File
@@ -304,7 +304,7 @@ fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result<V
let t = s.playhead();
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let gaps: Vec<TimeRange> = if in_track {
let tracks: Vec<TrackId> = match track_p(s, p, "track") {
let tracks: Vec<TrackId> = match track_p(s, p, "track", if next { "sequence.goToNextGapInTrack" } else { "sequence.goToPrevGapInTrack" })? {
Some(id) => vec![id],
None => s.targeting().targeted,
};
@@ -460,7 +460,7 @@ fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
tracks.iter().filter(|t| t.items.is_empty()).map(|t| t.id).collect()
}
Some(_) => {
let id = track_p(s, p, key).ok_or_else(|| bad("sequence.deleteTracks", format!("unknown {key} track")))?;
let id = track_p(s, p, key, "sequence.deleteTracks")?.ok_or_else(|| bad("sequence.deleteTracks", format!("unknown {key} track")))?;
if !tracks.iter().any(|t| t.id == id) {
return Err(bad("sequence.deleteTracks", format!("`{key}` must name a {key} track")));
}
+5
View File
@@ -205,6 +205,11 @@ fn list_searches_labels_and_keys() {
let r = s.shortcuts.commands();
assert!(r.iter().any(|c| c.id == "trim.forward"));
assert!(!r.iter().any(|c| c.id == "timeline.move"), "commands with required params are not bindable");
// a parameter doc holds parameters only (each optional or with a default): a result
// description in it would take the command out of this list
for id in ["clip.replaceFromBin", "clip.replaceFromSource", "clip.replaceFromSourceMatchFrame"] {
assert!(r.iter().any(|c| c.id == id), "{id} has no required parameter, so it can be bound");
}
let mut s = s;
let v = s.execute("shortcuts.list", json!({"query": "ripple delete"})).unwrap();
assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "edit.rippleDelete"), "{v}");
@@ -0,0 +1,87 @@
//! A ripple trim ahead of a split edit: the later clip's sound starts before its picture (a J cut)
//! and has to move with it.
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
use crate::Session;
fn start(s: &Session, c: ClipId) -> Tick {
s.active_sequence().unwrap().find_item(c).map(|(_, i)| i.start).unwrap()
}
/// Two clips in a row on V1. The first has its sound on A1, the second on A2, and the second's
/// sound starts 12 frames before its picture. Returns (first picture, second picture, second sound).
fn j_cut(s: &mut Session) -> (ClipId, ClipId, ClipId) {
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "J cut", "video": 1, "audio": 2})).unwrap();
let rate = s.sequence_rate();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
let (source_in, len) = (rate.tick_of(48).0, rate.tick_of(48).0);
s.execute("timeline.place", json!({"item": item.0, "track": "V1", "audioTrack": "A1", "frame": 0, "sourceIn": source_in, "duration": len})).unwrap();
s.execute("timeline.place", json!({"item": item.0, "track": "V1", "audioTrack": "A2", "frame": 48, "sourceIn": source_in, "duration": len})).unwrap();
let q = s.active_sequence().unwrap();
let (first, second, sound) = (q.video_tracks[0].items[0].id, q.video_tracks[0].items[1].id, q.audio_tracks[1].items[0].id);
// the split: only the sound's In edge moves, so Linked Selection is off for this one trim
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.trim", json!({"clip": sound.0, "edge": "in", "mode": "regular", "deltaFrames": -12})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
assert_eq!(start(s, second) - start(s, sound), rate.tick_of(12), "the sound leads its picture by 12 frames");
(first, second, sound)
}
/// Lengthening the clip before a J cut moved the later picture and left its sound behind: a lead
/// of 12 frames became 20, with no error.
#[test]
fn lengthening_before_a_j_cut_keeps_the_sound_with_its_picture() {
ripple_and_check(8);
}
/// Shortening it was refused as a sync-lock conflict, because the early sound sat in the stretch
/// that closes.
#[test]
fn shortening_before_a_j_cut_keeps_the_sound_with_its_picture() {
ripple_and_check(-8);
}
fn ripple_and_check(frames: i64) {
let mut s = Session::default();
let (first, second, sound) = j_cut(&mut s);
let rate = s.sequence_rate();
let (picture_at, sound_at) = (start(&s, second), start(&s, sound));
let before = s.project.clone();
let r = s.execute("timeline.trim", json!({"clip": first.0, "edge": "out", "mode": "ripple", "deltaFrames": frames})).unwrap();
let by = rate.tick_of(frames);
assert_eq!(r["delta"], json!(by.0));
assert_eq!(start(&s, second), picture_at + by, "the later picture ripples");
assert_eq!(start(&s, sound), sound_at + by, "its sound still leads by 12 frames");
s.active_sequence().unwrap().check().unwrap();
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before, "one undo step puts both back");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
/// The early sound has no room to follow: the sequence start, or another clip right before it on
/// its track. The trim is refused and nothing moves.
#[test]
fn ripple_trim_is_refused_when_the_early_sound_has_no_room() {
let mut s = Session::default();
let (first, second, sound) = j_cut(&mut s);
let rate = s.sequence_rate();
let before = s.project.clone();
// the sound starts at frame 36: 40 frames earlier is before the sequence starts
let e = s.execute("timeline.trim", json!({"clip": first.0, "edge": "out", "mode": "ripple", "deltaFrames": -40})).unwrap_err();
assert!(e.to_string().contains("sync"), "{e}");
assert_eq!(*s.project, *before, "a refused trim changes nothing");
let tone = s.project.items.values().find(|i| !i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
s.execute("timeline.place", json!({"item": tone.0, "audioTrack": "A2", "frame": 12, "duration": rate.tick_of(24).0})).unwrap();
let before = s.project.clone();
assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items[0].end(), start(&s, sound));
let e = s.execute("timeline.trim", json!({"clip": first.0, "edge": "out", "mode": "ripple", "deltaFrames": -8})).unwrap_err();
assert!(e.to_string().contains("sync"), "{e}");
assert_eq!(*s.project, *before, "a refused trim changes nothing");
assert_eq!(start(&s, second) - start(&s, sound), rate.tick_of(12));
}
+33
View File
@@ -335,6 +335,39 @@ fn interchange_roundtrip_through_files() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `file.exportInterchange {format: "<unknown>"}` used to write FCP7 XML without a word.
#[test]
fn export_interchange_refuses_an_unknown_format() {
let d = std::env::temp_dir().join(format!("fc-ix-format-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let path = |n: &str| d.join(n).to_string_lossy().to_string();
let mut s = demo();
// unknown names, values that are not names, and spellings that are not the documented ones
for bad in [json!("premiere"), json!("mp4"), json!(""), json!(7), json!(["xml"]), json!(".xml"), json!("fcpxmld"), json!("omfi"), json!("xml ")] {
let e = s.execute("file.exportInterchange", json!({"format": bad, "path": path("bad.out")})).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "file.exportInterchange"), "{bad}: {e:?}");
let e = e.to_string();
assert!(e.contains("unknown format") && e.ends_with("use one of edl, xml, fcpxml, otio, aaf, omf"), "the error lists exactly the accepted names: {e}");
assert!(!d.join("bad.out").exists(), "nothing is written for {bad}");
}
// the documented formats still export (in either case), and no format still means FCP7 XML
let text = |n: &str| String::from_utf8_lossy(&std::fs::read(d.join(n)).unwrap()).into_owned();
s.execute("file.exportInterchange", json!({"path": path("default.xml")})).unwrap();
s.execute("file.exportInterchange", json!({"format": "xml", "path": path("a.xml")})).unwrap();
assert!(text("default.xml").contains("<xmeml") && text("a.xml").contains("<xmeml"));
s.execute("file.exportInterchange", json!({"format": "fcpxml", "path": path("a.fcpxml")})).unwrap();
assert!(text("a.fcpxml").contains("<fcpxml"));
s.execute("file.exportInterchange", json!({"format": "otio", "path": path("a.otio")})).unwrap();
assert!(text("a.otio").contains("OTIO_SCHEMA"));
s.execute("file.exportInterchange", json!({"format": "EDL", "path": path("a.edl")})).unwrap();
assert!(text("a.edl").contains("TITLE:"));
for (format, magic) in [("aaf", "aaf"), ("omf", "omf")] {
let r = s.execute("file.exportInterchange", json!({"format": format, "path": path(&format!("a.{magic}"))})).unwrap();
assert!(r["bytes"].as_u64().unwrap() > 0, "{format}");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn move_items_to_bin_and_back_with_undo() {
let mut s = demo();
+204
View File
@@ -0,0 +1,204 @@
//! `timeline.move`: linked partners follow, locked partners stay, the group stops together at the
//! sequence start, and hostile times are refused.
use filmcraft_project::{ClipId, TrackId, TrackItem};
use filmcraft_time::Tick;
use serde_json::json;
use crate::{EngineError, Session};
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn state(s: &Session, c: ClipId) -> (TrackId, Tick) {
s.active_sequence().unwrap().find_item(c).map(|(t, i)| (t, i.start)).unwrap()
}
/// The last picture clip of V1 and its linked sound: (V1, picture, sound's track, sound).
fn last_pair(s: &Session) -> (TrackId, TrackItem, TrackId, TrackItem) {
let q = s.active_sequence().unwrap();
let v = q.video_tracks[0].items.last().unwrap().clone();
let (a_track, a) = q.audio_tracks.iter().find_map(|t| t.items.iter().find(|i| i.link == v.link && v.link.is_some()).map(|i| (t.id, i.clone()))).unwrap();
(q.video_tracks[0].id, v, a_track, a)
}
/// An empty sequence with one linked picture + sound pair placed at `seconds`: (V1, picture, A1, sound).
fn pair_in_an_empty_sequence(s: &mut Session, seconds: f64) -> (TrackId, TrackItem, TrackId, TrackItem) {
s.execute("file.newSequence", json!({"name": "Moves", "video": 2, "audio": 2})).unwrap();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
s.execute("timeline.place", json!({"item": item.0, "seconds": seconds})).unwrap();
let q = s.active_sequence().unwrap();
let (v, a) = (q.video_tracks[0].items[0].clone(), q.audio_tracks[0].items[0].clone());
assert!(v.link.is_some() && v.link == a.link, "a placed clip's picture and sound are linked");
(q.video_tracks[0].id, v, q.audio_tracks[0].id, a)
}
/// `timeline.move` of one clip left its linked sound behind although Linked Selection was on.
#[test]
fn move_takes_linked_partners_along() {
let mut s = demo();
let rate = s.sequence_rate();
let (v_track, v, a_track, a) = last_pair(&s);
let end = s.active_sequence().unwrap().duration();
assert!(s.state.linked_selection, "linked selection is on by default");
let before = s.project.clone();
// only the picture is listed: the sound follows by the same offset, on its own track
let to = end + rate.tick_of(48);
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}]})).unwrap();
assert_eq!(state(&s, v.id), (v_track, to));
assert_eq!(state(&s, a.id), (a_track, a.start + (to - v.start)), "linked audio stays in sync");
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
s.active_sequence().unwrap().check().unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before, "one undo step restores both clips");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (to, a.start + (to - v.start)));
s.execute("edit.undo", json!({})).unwrap();
// listing both moves each exactly once
let (tv, ta) = (to, a.start + (to - v.start));
let r = s
.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": tv.0}, {"clip": a.id.0, "track": a_track.0, "time": ta.0}]}))
.unwrap();
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, tv), (a_track, ta)));
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
s.execute("edit.undo", json!({})).unwrap();
// `linked: false` moves just the listed clip, whatever the toggle says
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}], "linked": false})).unwrap();
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, to), (a_track, a.start)));
assert_eq!(r["moved"], json!([v.id.0]));
s.execute("edit.undo", json!({})).unwrap();
// linked selection off: unchanged behaviour, only the listed clip moves
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}]})).unwrap();
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, to), (a_track, a.start)));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
// ...unless the command asks for it
s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": ta.0}], "linked": true})).unwrap();
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, tv), (a_track, ta)), "moving the sound takes the picture along");
}
/// A partner on a locked track cannot move; that must not refuse the move of the listed clip.
#[test]
fn a_linked_partner_on_a_locked_track_stays_and_the_listed_clip_still_moves() {
let mut s = demo();
let (v_track, v, a_track, a) = last_pair(&s);
let to = s.active_sequence().unwrap().duration() + s.sequence_rate().tick_of(48);
s.execute("timeline.setTrack", json!({"track": a_track.0, "locked": true})).unwrap();
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V1", "time": to.0}]})).unwrap();
assert_eq!(r["moved"], json!([v.id.0]), "the locked sound is not among the moved clips");
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, to), (a_track, a.start)));
s.active_sequence().unwrap().check().unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(state(&s, v.id), (v_track, v.start));
// a listed clip whose own destination is locked is still refused, and nothing moves
let before = s.project.clone();
assert!(s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": to.0}]})).is_err());
assert_eq!(*s.project, *before);
}
/// A partner that would land where it already is does not move: re-placing it would drop the
/// transitions at its edges and list it in `moved` for nothing. Moving only the picture to
/// another track at the same time leaves the sound as it was.
#[test]
fn a_partner_whose_position_does_not_change_is_left_alone() {
let mut s = demo();
let q = s.active_sequence().unwrap();
let a = q.audio_tracks[0].items[1].clone();
let (v_track, v) = q.video_tracks.iter().find_map(|t| t.items.iter().find(|i| i.link == a.link && a.link.is_some()).map(|i| (t.id, i.clone()))).unwrap();
let (a_track, v2) = (q.audio_tracks[0].id, q.video_tracks[1].id);
assert_ne!(v_track, v2);
s.execute("sequence.applyAudioTransition", json!({"clip": a.id.0})).unwrap();
let sound = |s: &Session| s.active_sequence().unwrap().track(a_track).unwrap().clone();
let before = sound(&s);
assert_eq!(before.transitions.len(), 1, "the sound clip has a crossfade");
// picture to V2 at the same time, Linked Selection on
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v2.0, "time": v.start.0}]})).unwrap();
assert_eq!(state(&s, v.id), (v2, v.start));
assert_eq!(sound(&s), before, "the sound track, its clip and its crossfade are untouched");
assert_eq!(r["moved"], json!([v.id.0]), "the sound did not move, so it is not listed");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((state(&s, v.id), sound(&s)), ((v_track, v.start), before.clone()));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!((state(&s, v.id), sound(&s)), ((v2, v.start), before.clone()));
s.execute("edit.undo", json!({})).unwrap();
// a real offset still takes the sound along
let later = v.start + s.sequence_rate().tick_of(2);
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v2.0, "time": later.0}]})).unwrap();
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
assert_eq!(state(&s, a.id).1 - a.start, later - v.start);
}
/// When a clip would start before the sequence start, every clip of the call lands later by the
/// same amount: a split edit keeps its offset and several listed clips keep their spacing.
#[test]
fn the_group_stops_together_at_the_sequence_start() {
let mut s = demo();
let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0);
let rate = s.sequence_rate();
let f = |n: i64| rate.tick_of(n);
let (lead, gap) = (f(10), f(100));
// make a split edit: the sound starts 10 frames before the picture
s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": (a.start - lead).0}], "linked": false})).unwrap();
assert_eq!(state(&s, a.id).1, v.start - lead);
// ask for the picture at 4 frames: the sound would start 6 frames before zero
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v_track.0, "time": f(4).0}]})).unwrap();
assert_eq!(r["moved"], json!([v.id.0, a.id.0]));
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (lead, Tick::ZERO), "the sound stops at zero and the picture keeps its 10 frames");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (v.start, v.start - lead));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!((state(&s, v.id).1, state(&s, a.id).1), (lead, Tick::ZERO));
s.execute("edit.undo", json!({})).unwrap();
// a second, unlinked clip 100 frames after the picture, on V2; both listed, both asked 20 frames too far left
let item = s.active_sequence().unwrap().video_tracks[0].items[0].item;
let v2 = s.active_sequence().unwrap().video_tracks[1].id;
s.execute("timeline.place", json!({"item": item.0, "time": (v.start + gap).0, "track": v2.0, "audioTrack": "A2"})).unwrap();
let w = s.active_sequence().unwrap().video_tracks[1].items[0].clone();
let back = v.start + f(20);
let moves = json!([{"clip": v.id.0, "track": v_track.0, "time": (v.start - back).0}, {"clip": w.id.0, "track": v2.0, "time": (w.start - back).0}]);
s.execute("timeline.move", json!({"moves": moves, "linked": false})).unwrap();
assert_eq!((state(&s, v.id).1, state(&s, w.id).1), (Tick::ZERO, gap), "the two clips are still 100 frames apart");
s.execute("edit.undo", json!({})).unwrap();
// with the links followed, the earliest clip of all (the split sound) is the one that stops at zero
s.execute("timeline.move", json!({"moves": moves})).unwrap();
assert_eq!((state(&s, a.id).1, state(&s, v.id).1, state(&s, w.id).1), (Tick::ZERO, lead, lead + gap));
s.active_sequence().unwrap().check().unwrap();
}
/// AGENTS.md §0.8: `time` is any i64. Out-of-range values are parameter errors, never a panic
/// (which `execute` would report as an internal error) and never a wrapped position.
#[test]
fn hostile_times_are_refused_or_clamped_never_a_panic() {
let mut s = demo();
let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0);
let before = s.project.clone();
for linked in [true, false] {
for time in [i64::MAX, i64::MAX - 1, Tick::MAX.0 + 1] {
let e = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v_track.0, "time": time}], "linked": linked})).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.move"), "{time} linked={linked}: {e:?}");
assert_eq!(*s.project, *before, "{time}: nothing moved");
}
// far before the start: the clip lands at zero, as a small negative time does
for time in [i64::MIN, i64::MIN + 1, -1] {
s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": v_track.0, "time": time}], "linked": linked})).unwrap();
assert_eq!(state(&s, v.id).1, Tick::ZERO, "{time}");
assert_eq!(state(&s, a.id).1, if linked { Tick::ZERO } else { a.start }, "{time}");
s.execute("edit.undo", json!({})).unwrap();
}
}
// the sound asked far to the left and the picture far to the right in one call: out of range together
let moves = json!([{"clip": v.id.0, "track": v_track.0, "time": Tick::MAX.0}, {"clip": a.id.0, "track": a_track.0, "time": i64::MIN}]);
assert!(matches!(s.execute("timeline.move", json!({"moves": moves})), Err(EngineError::BadParams { .. })));
// junk entries are skipped as before; a call with nothing usable is a parameter error
for moves in
[json!([]), json!([{"clip": v.id.0, "track": v_track.0, "time": "soon"}]), json!([{"clip": v.id.0, "track": v_track.0, "time": 1e300}]), json!("all")]
{
assert!(matches!(s.execute("timeline.move", json!({"moves": moves})), Err(EngineError::BadParams { .. })), "{moves}");
}
assert!(s.execute("timeline.move", json!({"moves": [{"clip": 987_654_321u64, "track": v_track.0, "time": 0}]})).is_err(), "a clip that does not exist");
assert_eq!(*s.project, *before);
}
+116
View File
@@ -0,0 +1,116 @@
//! Track parameters (`track`, `audioTrack`: an id, or "V1" / "A2"): a track the active sequence
//! does not have is a parameter error, never a silent default.
use serde_json::json;
use crate::{EngineError, Session};
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
/// `timeline.place {audioTrack: "A2"}` on a sequence with only A1 used to fall back to the
/// source-patched track and overwrite what was on A1.
#[test]
fn naming_a_missing_track_is_an_error_not_a_default() {
let mut s = demo();
let seq = s.execute("file.newSequence", json!({"name": "One of each", "video": 1, "audio": 1})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (1, 1), "{seq}");
let (v1, a1) = (q.video_tracks[0].id, q.audio_tracks[0].id);
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
// omitted tracks keep their default: the source-patched V1 / A1
s.execute("timeline.place", json!({"item": item.0, "time": 0})).unwrap();
let before = s.project.clone();
let undo = s.history.undo.len();
for (key, name) in [("audioTrack", json!("A2")), ("track", json!("V2")), ("audioTrack", json!(999_999)), ("track", json!("V0")), ("track", json!("X1"))] {
let mut p = json!({"item": item.0, "time": 0});
p[key] = name.clone();
let e = s.execute("timeline.place", p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.place"), "a parameter error of the command: {e:?}");
let e = e.to_string();
let shown = name.as_str().map(str::to_string).unwrap_or_else(|| name.to_string());
assert!(e.contains(&format!("no track {shown} in this sequence")), "{e}");
}
assert!(s.execute("timeline.place", json!({"item": item.0, "time": 0, "audioTrack": "A2"})).unwrap_err().to_string().contains("audioTrack"));
// hostile names are errors too, never a panic
for name in ["", "V", "é1", "1", "V-1", "A1.5", "V99999999999999999999"] {
assert!(s.execute("timeline.place", json!({"item": item.0, "time": 0, "track": name})).is_err(), "{name:?}");
}
// every command that takes a track by name refuses one that is not there
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
let e = s.execute("timeline.move", json!({"moves": [{"clip": clip.0, "track": "V2", "time": 0}]})).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.move"), "{e:?}");
assert!(s.execute("sequence.closeGap", json!({"track": "A2", "time": 0})).is_err());
assert!(s.execute("sequence.deleteTrack", json!({"track": "V2"})).is_err());
assert!(s.execute("sequence.deleteTrack", json!({"track": 999_999})).is_err());
assert!(s.execute("timeline.setTrack", json!({"track": "A2", "muted": true})).is_err());
assert!(s.execute("timeline.setTargeting", json!({"track": "V2", "targeted": true})).is_err());
assert!(s.execute("timeline.razor", json!({"track": "A2", "time": 10})).is_err());
assert!(s.execute("sequence.goToNextGapInTrack", json!({"track": "V2"})).is_err());
assert_eq!(*s.project, *before, "nothing was edited");
assert_eq!(s.history.undo.len(), undo, "and nothing was added to the undo history");
// tracks that exist still resolve by name (either case) and by id
s.execute("timeline.place", json!({"item": item.0, "seconds": 60.0, "track": "v1", "audioTrack": a1.0})).unwrap();
s.execute("timeline.setTrack", json!({"track": v1.0, "locked": true})).unwrap();
s.execute("timeline.setTrack", json!({"track": "a1", "muted": true})).unwrap();
let q = s.active_sequence().unwrap();
assert!(q.video_tracks[0].locked && q.audio_tracks[0].muted);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.active_sequence().unwrap().audio_tracks[0].muted);
}
/// `timeline.place {track: "A3"}` put a picture item on the audio track A3 and a second sound
/// clip on A1; a sound-only item went to A1 whatever audio track `track` named.
#[test]
fn place_on_an_audio_track_places_the_sound_there_and_nothing_else() {
let mut s = demo();
s.execute("file.newSequence", json!({"name": "Sound tracks", "video": 1, "audio": 3})).unwrap();
let a3 = s.active_sequence().unwrap().audio_tracks[2].id;
let with_picture = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
let sound_only = s.project.items.values().find(|i| !i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
let picture_only = s.project.items.values().find(|i| i.has_video() && !i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
// how many clips sit on [V1, A1, A2, A3]
let counts = |s: &Session| {
let q = s.active_sequence().unwrap();
[q.video_tracks[0].items.len(), q.audio_tracks[0].items.len(), q.audio_tracks[1].items.len(), q.audio_tracks[2].items.len()]
};
// a clip with picture and sound, sent to A3 by name: its sound alone, on A3
let r = s.execute("timeline.place", json!({"item": with_picture.0, "track": "A3", "frame": 0})).unwrap();
assert_eq!(counts(&s), [0, 0, 0, 1], "{r}");
let q = s.active_sequence().unwrap();
assert_eq!(r["clips"], json!([q.audio_tracks[2].items[0].id.0]));
assert_eq!(q.audio_tracks[2].items[0].link, None, "no picture was placed, so nothing to link to");
q.check().unwrap();
// a sound-only item goes to the audio track named, by name or by id
s.execute("timeline.place", json!({"item": sound_only.0, "track": "A2", "frame": 0})).unwrap();
assert_eq!(counts(&s), [0, 0, 1, 1]);
s.execute("timeline.place", json!({"item": sound_only.0, "track": a3.0, "seconds": 120.0})).unwrap();
assert_eq!(counts(&s), [0, 0, 1, 2]);
// what cannot be meant is a parameter error that says why, and changes nothing
let before = s.project.clone();
let undo = s.history.undo.len();
for (p, why) in [
(json!({"item": with_picture.0, "track": "A3", "audioTrack": "A1", "seconds": 300.0}), "name the sound's track once"),
(json!({"item": with_picture.0, "audioTrack": "V1", "seconds": 300.0}), "`audioTrack` names a video track"),
(json!({"item": with_picture.0, "track": "V1", "audioTrack": "V1", "seconds": 300.0}), "`audioTrack` names a video track"),
(json!({"item": picture_only.0, "track": "A1", "seconds": 300.0}), "this item has no sound"),
(json!({"item": sound_only.0, "track": "V1", "seconds": 300.0}), "this item has no picture"),
] {
let e = s.execute("timeline.place", p.clone()).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { cmd, .. } if cmd == "timeline.place"), "{p}: {e:?}");
assert!(e.to_string().contains(why), "{p}: {e}");
}
assert_eq!(*s.project, *before, "a refused place changes nothing");
assert_eq!(s.history.undo.len(), undo, "and adds nothing to the undo history");
// a sound-only item dropped on a video row still lands on the audio track sent with it
s.execute("timeline.place", json!({"item": sound_only.0, "track": "V1", "audioTrack": "A1", "seconds": 900.0})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
// the usual forms are unchanged: picture on `track`, sound on `audioTrack` or the default A1
s.execute("timeline.place", json!({"item": with_picture.0, "track": "V1", "audioTrack": "A2", "seconds": 300.0})).unwrap();
assert_eq!(counts(&s), [1, 0, 2, 2]);
s.execute("timeline.place", json!({"item": with_picture.0, "seconds": 600.0})).unwrap();
assert_eq!(counts(&s), [2, 1, 2, 2]);
}
+80
View File
@@ -0,0 +1,80 @@
//! An export depends only on the project and the settings: the same file on every machine, however
//! many cores render it and however the work is cut into steps.
use super::*;
use crate::tests::project;
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("fc-export-det-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
/// Export on a machine with `threads` cores: `export` renders one frame per core of the pool it
/// runs in.
fn export_with_cores(threads: usize, settings: &ExportSettings) -> Vec<u8> {
let (p, seq, m) = project();
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
pool.install(|| export(&p, seq, settings, &m, &Progress::default())).unwrap();
std::fs::read(&settings.path).unwrap()
}
/// Export a step at a time with `batch` frames per step (the web app uses 1).
fn export_stepped(batch: i64, settings: &ExportSettings) -> Vec<u8> {
let (p, seq, m) = project();
let prog = Progress::default();
let mut ex = Exporter::new(p, seq, settings, &prog).unwrap();
ex.set_batch(batch);
while !matches!(ex.step(&m, &prog).unwrap(), Step::Done(_)) {}
std::fs::read(&settings.path).unwrap()
}
#[test]
fn same_file_whatever_the_core_count() {
// 24 frames: one full interleave group and a partial one
for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4"), (Format::Mjpeg, "mov")] {
let settings = |name: &str| ExportSettings { format, path: tmp(&format!("{name}.{ext}")), ..Default::default() };
let reference = export_with_cores(2, &settings(&format!("{}-2", format.id())));
for threads in [3, 5, 16] {
let other = export_with_cores(threads, &settings(&format!("{}-{threads}", format.id())));
assert!(reference == other, "{}: {threads} cores give a different file than 2 cores", format.label());
}
for batch in [1, 7] {
let other = export_stepped(batch, &settings(&format!("{}-b{batch}", format.id())));
assert!(reference == other, "{}: {batch} frame(s) per step give a different file", format.label());
}
}
}
/// Slice NAL units (types 1 and 5) in a length-prefixed (4-byte) H.264 sample.
fn slices_in(sample: &[u8]) -> usize {
let (mut n, mut i) = (0, 0);
while i + 4 <= sample.len() {
let len = u32::from_be_bytes([sample[i], sample[i + 1], sample[i + 2], sample[i + 3]]) as usize;
if let Some(&h) = sample.get(i + 4)
&& matches!(h & 0x1f, 1 | 5)
{
n += 1;
}
i += 4 + len;
}
n
}
#[test]
fn h264_slices_follow_the_frame_size_not_the_cores() {
// 4096 lines = 256 macroblock rows: one slice per four rows on every machine, also on machines
// with fewer than 64 cores (the encoder's default caps the slices at the core count)
let path = tmp("tall.mp4");
let s = ExportSettings { format: Format::H264, path: path.clone(), frame_size: Some((64, 4096)), include_audio: false, ..Default::default() };
let (p, seq, m) = project();
export(&p, seq, &s, &m, &Progress::default()).unwrap();
let bytes = std::fs::read(&path).unwrap();
let file = filmcraft_isobmff::open(bytes.as_slice()).unwrap();
let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap();
assert_eq!(file.tracks[vt].samples.len(), 24);
for i in 0..file.tracks[vt].samples.len() {
let sample = file.read_sample(bytes.as_slice(), vt, i).unwrap();
assert_eq!(slices_in(&sample), 64, "picture {i}");
}
}
+47 -18
View File
@@ -11,6 +11,10 @@
//! When a source's bytes are not available yet (asynchronous web reads mark
//! [`filmcraft_media::pending`]), the batch is rendered again on the next call: nothing is encoded
//! from frames or audio with missing media.
//!
//! The file does not depend on how many frames a step renders (one per core natively, one on the
//! web): packets go to the muxer in fixed groups of [`INTERLEAVE`] output frames, each followed by
//! the audio up to its end, so the same project and settings give the same bytes on any machine.
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -30,6 +34,11 @@ use crate::{
audio_factories, export_range, frame_span, video_factories,
};
/// Output frames per interleaved group: the video packets of these frames, then the audio up to
/// their end, form one chunk of each track. Fixed (not the batch size, which follows the core
/// count) so the file layout is the same on every machine.
pub const INTERLEAVE: i64 = 16;
/// What one [`Exporter::step`] did.
#[derive(Debug)]
pub enum Step {
@@ -58,7 +67,9 @@ pub struct Exporter {
measured: bool,
/// The first pass of a two-pass encode is running.
first_pass: bool,
/// Created after the first batch (encoders may finalise their codec config then).
/// Encoded video packets not written yet (the current [`INTERLEAVE`] group).
queued: Vec<EncodedPacket>,
/// Created after the first group was encoded (encoders may finalise their codec config then).
mux: Option<Mp4Writer<Out>>,
/// MXF exports write through this instead of `mux`.
mxf: Option<MxfMux>,
@@ -140,6 +151,7 @@ impl Exporter {
audio,
measured,
first_pass: two_pass,
queued: Vec::new(),
mux: None,
mxf: None,
seq,
@@ -177,7 +189,7 @@ impl Exporter {
if n == 0 {
return Ok(());
}
let mux = self.mux.as_mut().expect("mux created");
let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?;
match self.aenc.as_mut() {
Some(a) => {
for au in a.encode(&buf)? {
@@ -209,7 +221,7 @@ impl Exporter {
if let Some(m) = self.mxf.as_mut() {
return m.write_video(packets);
}
let mux = self.mux.as_mut().expect("mux created");
let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?;
for p in packets {
mux.write_sample(self.vt, WriteSample { data: &p.data, duration: p.duration, composition_offset: p.composition_offset, is_sync: p.key })
.map_err(|e| ExportError::Io(e.to_string()))?;
@@ -270,7 +282,7 @@ impl Exporter {
if !self.settings.part_of_batch {
progress.set_status("Measuring loudness");
}
let a = self.audio.as_mut().expect("audio");
let a = self.audio.as_mut().ok_or_else(|| ExportError::Encode("internal: the audio pipeline was not created".into()))?;
a.measure(&self.settings, sources, &|| progress.cancel.load(Ordering::Relaxed))?;
if filmcraft_media::pending::take() {
return Ok(Step::Pending);
@@ -295,22 +307,33 @@ impl Exporter {
self.next = end;
return Ok(Step::Progress);
}
let mixed = match self.audio.as_ref().map(|a| a.sr) {
Some(sr) if (self.mux.is_none() && self.mxf.is_none()) || self.at.is_some() || self.mxf.is_some() => {
let until = self.sample_at_frame(end, sr);
self.audio.as_mut().and_then(|a| a.pull(until, sources))
// the audio of every interleave group that ends in this batch (before encoding, so a
// pending source can still make the batch run again)
let cuts: Vec<i64> = (f + 1..=end).filter(|&g| (g - self.f0) % INTERLEAVE == 0).collect();
let mut mixed = Vec::with_capacity(cuts.len());
if let Some(sr) = self.audio.as_ref().map(|a| a.sr) {
for &g in &cuts {
let until = self.sample_at_frame(g, sr);
mixed.push(self.audio.as_mut().and_then(|a| a.pull(until, sources)));
}
_ => None,
};
}
if filmcraft_media::pending::take() {
return Ok(Step::Pending);
}
let packets = self.encode(&frames, f)?;
if self.mux.is_none() && self.mxf.is_none() {
self.open_mux()?;
let mut mixed = mixed.into_iter();
for (k, frame) in frames.iter().enumerate() {
let fi = f + k as i64;
let packets = self.encode(std::slice::from_ref(frame), fi)?;
self.queued.extend(packets);
if cuts.contains(&(fi + 1)) {
if self.mux.is_none() && self.mxf.is_none() {
self.open_mux()?;
}
let packets = std::mem::take(&mut self.queued);
self.write_video(packets)?;
self.write_audio(mixed.next().flatten())?;
}
}
self.write_video(packets)?;
self.write_audio(mixed)?;
progress.done.fetch_add((end - f) as u64, Ordering::Relaxed);
self.next = end;
return Ok(Step::Progress);
@@ -332,12 +355,13 @@ impl Exporter {
if self.mux.is_none() && self.mxf.is_none() {
self.open_mux()?;
}
let tail = self.venc.flush()?;
let mut tail = std::mem::take(&mut self.queued);
tail.extend(self.venc.flush()?);
self.write_video(tail)?;
self.write_audio(mixed)?;
if let (Some(at), Some(a)) = (self.at, self.aenc.as_mut()) {
let fs = a.frame_size();
let mux = self.mux.as_mut().expect("mux created");
let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?;
for au in a.flush()? {
mux.write_sample(at, WriteSample { data: &au, duration: fs, composition_offset: 0, is_sync: true })
.map_err(|e| ExportError::Io(e.to_string()))?;
@@ -346,7 +370,12 @@ impl Exporter {
let (bytes, extra_files) = match self.mxf.take() {
Some(m) => m.finish(&self.settings)?,
None => {
let w = self.mux.take().expect("mux created").finish().map_err(|e| ExportError::Io(e.to_string()))?;
let w = self
.mux
.take()
.ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?
.finish()
.map_err(|e| ExportError::Io(e.to_string()))?;
(w.finish(&self.settings)?, Vec::new())
}
};
+16 -1
View File
@@ -11,6 +11,7 @@
//! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio
//! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the
//! built-in presets.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
use std::io::Write;
@@ -916,6 +917,13 @@ pub fn rgbf_to_yuv420_8(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &m
});
}
/// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core
/// count, which would make the stream (and every decoded picture) depend on the machine; this is
/// what it picks on a machine with enough cores.
pub(crate) fn h264_slices(height: u32) -> usize {
(height.div_ceil(16) as usize).div_ceil(4).max(1)
}
fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
if format != Format::H264 {
return None;
@@ -925,6 +933,7 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
cfg.format = if s.format.is_mxf() { filmcraft_h264enc::PacketFormat::AnnexB } else { filmcraft_h264enc::PacketFormat::LengthPrefixed };
cfg.aud = s.format.is_mxf();
cfg.keyint = s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32);
cfg.slices = h264_slices(h);
let kbps = s.bitrate_kbps.max(100);
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps * 3 / 2);
cfg.rate = match s.bitrate_mode {
@@ -1108,7 +1117,10 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
(total, count)
}
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => unreachable!("stepped export"),
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
// Handled by the stepped exporter above; reaching here would be a dispatch bug.
return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format)));
}
};
let secs = t0.elapsed().as_secs_f64();
if !settings.part_of_batch {
@@ -1129,3 +1141,6 @@ mod surround_tests;
#[cfg(test)]
mod mxf_tests;
#[cfg(test)]
mod determinism_tests;
+32
View File
@@ -303,6 +303,38 @@ mod tests {
}
}
/// A clip with unresolved "auto" points (NaN) is written with `null` coordinates. Such a file
/// used to be refused as damaged; it opens, and the points are still "auto".
#[test]
fn auto_points_written_as_null_open_again() {
use filmcraft_project::{ItemKind, SequenceSettings, TrackKind};
use filmcraft_time::{FrameRate, Tick, TimeRange};
let mut p = Project::new("Imported");
let layer = p.add_item(
"Layer",
filmcraft_project::Label::Iris,
ItemKind::AdjustmentLayer { width: 3840, height: 2160, rate: FrameRate::FPS_24, duration: FrameRate::FPS_24.tick_of(48) },
None,
);
let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None);
let ti = p.make_track_item(layer, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(48)), FrameRate::FPS_24).unwrap();
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti);
for pretty in [true, false] {
let bytes = encode(&p, pretty);
let text = String::from_utf8(bytes.clone()).unwrap();
assert!(text.replace([' ', '\n'], "").contains(r#"{"Vec2":{"x":null,"y":null}}"#), "the file holds NaN points as null");
let l = decode(&bytes).unwrap_or_else(|e| panic!("a file with `null` points is refused: {e}"));
let motion = l.project.sequence(seq).unwrap().video_tracks[0].items[0].effect("motion").unwrap().clone();
let anchor = motion.params["anchor"].value.as_vec2().unwrap();
assert!(anchor.x.is_nan() && anchor.y.is_nan(), "still auto");
assert_eq!(motion.params["scale"].value.as_f64(), Some(100.0));
assert_eq!(encode(&l.project, pretty), bytes, "and the file is written back the same");
// a `null` where no "auto" exists is still a damaged file
let broken = text.replacen("3840", "null", 1);
assert!(matches!(decode(broken.as_bytes()), Err(FormatError::Corrupt(_))));
}
}
#[test]
fn envelope_has_explicit_schema_version() {
let v: Value = serde_json::from_slice(&encode(&Project::new("x"), true)).unwrap();
+2
View File
@@ -7,6 +7,8 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
pub mod pool;
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};
+177
View File
@@ -0,0 +1,177 @@
//! Recycled plane buffers.
//!
//! A decoder allocates three planes per picture and the caches free them a few hundred frames
//! later. At megabytes per plane that traffic leaves the system allocator holding gigabytes of
//! freed pages (measured: 2.5 GB retained next to 2.5 GB live after twelve clips). Decoders take
//! their planes here instead ([`take_u8`], [`take_u16`]) and caches hand evicted frames back
//! ([`recycle`]), so steady-state decoding allocates nothing.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use crate::{PixelData, VideoFrame};
/// Bytes of idle buffers kept per sample type; a buffer that does not fit is freed.
const MAX_BYTES: usize = 192 << 20;
/// Smaller buffers are not worth keeping (the allocator recycles those well).
const MIN_BYTES: usize = 64 << 10;
struct Shelf<T> {
bufs: Vec<Vec<T>>,
bytes: usize,
}
impl<T> Shelf<T> {
const fn new() -> Self {
Self { bufs: Vec::new(), bytes: 0 }
}
fn size(b: &Vec<T>) -> usize {
b.capacity().saturating_mul(std::mem::size_of::<T>())
}
/// The smallest idle buffer that holds `len` samples without wasting over a quarter of it.
fn take(&mut self, len: usize) -> Option<Vec<T>> {
let k = self
.bufs
.iter()
.enumerate()
.filter(|(_, b)| b.capacity() >= len && b.capacity() - len <= len / 4)
.min_by_key(|(_, b)| b.capacity())
.map(|(k, _)| k)?;
let b = self.bufs.swap_remove(k);
self.bytes = self.bytes.saturating_sub(Self::size(&b));
Some(b)
}
fn give(&mut self, mut b: Vec<T>, max_bytes: usize) {
let n = Self::size(&b);
if n < MIN_BYTES || self.bytes.saturating_add(n) > max_bytes {
return;
}
b.clear();
self.bytes += n;
self.bufs.push(b);
}
}
static U8: Mutex<Shelf<u8>> = Mutex::new(Shelf::new());
static U16: Mutex<Shelf<u16>> = Mutex::new(Shelf::new());
static REUSED: AtomicU64 = AtomicU64::new(0);
fn take<T>(shelf: &Mutex<Shelf<T>>, len: usize) -> Vec<T> {
let found = shelf.lock().unwrap_or_else(PoisonError::into_inner).take(len);
match found {
Some(b) => {
REUSED.fetch_add(1, Ordering::Relaxed);
b
}
None => Vec::with_capacity(len),
}
}
fn give<T>(shelf: &Mutex<Shelf<T>>, plane: Arc<Vec<T>>) {
// a plane something else still shows (a monitor, a GPU upload in flight) is theirs
if let Ok(b) = Arc::try_unwrap(plane) {
shelf.lock().unwrap_or_else(PoisonError::into_inner).give(b, MAX_BYTES);
}
}
/// An empty buffer with room for `len` samples: a recycled one when one fits.
pub fn take_u8(len: usize) -> Vec<u8> {
take(&U8, len)
}
/// [`take_u8`] for 16-bit planes.
pub fn take_u16(len: usize) -> Vec<u16> {
take(&U16, len)
}
/// Hand back a frame nobody needs any more: its planes are kept for the next [`take_u8`] /
/// [`take_u16`] unless something else still holds the frame or a plane.
pub fn recycle(frame: Arc<VideoFrame>) {
let Ok(f) = Arc::try_unwrap(frame) else { return };
match f.data {
PixelData::Rgba8(d) => give(&U8, d),
PixelData::RgbaF32(_) => {}
PixelData::Yuv8 { planes, alpha, .. } => planes.into_iter().chain(alpha).for_each(|p| give(&U8, p)),
PixelData::Yuv16 { planes, alpha, .. } => planes.into_iter().chain(alpha).for_each(|p| give(&U16, p)),
}
}
/// Idle buffers held and buffers handed out again so far (`perf.stats`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PoolStats {
pub idle_bytes: usize,
pub reused: u64,
}
pub fn stats() -> PoolStats {
let idle = |n: usize, m: usize| n.saturating_add(m);
let a = U8.lock().unwrap_or_else(PoisonError::into_inner).bytes;
let b = U16.lock().unwrap_or_else(PoisonError::into_inner).bytes;
PoolStats { idle_bytes: idle(a, b), reused: REUSED.load(Ordering::Relaxed) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Chroma;
#[test]
fn shelf_hands_back_a_fitting_buffer_and_stays_within_its_budget() {
let mut s = Shelf::<u8>::new();
s.give(Vec::with_capacity(1 << 20), 3 << 20);
s.give(Vec::with_capacity(2 << 20), 3 << 20);
assert_eq!((s.bufs.len(), s.bytes), (2, 3 << 20));
// over the budget and too small to keep: freed
s.give(Vec::with_capacity(1 << 20), 3 << 20);
s.give(Vec::with_capacity(1024), 3 << 20);
assert_eq!(s.bufs.len(), 2);
// nothing fits a far smaller or a larger request
assert!(s.take(100 << 10).is_none());
assert!(s.take(3 << 20).is_none());
// the smallest buffer that fits, with its contents gone
let b = s.take((1 << 20) - 4096).expect("fits");
assert_eq!((b.len(), b.capacity()), (0, 1 << 20));
assert_eq!((s.bufs.len(), s.bytes), (1, 2 << 20));
assert!(s.take(1 << 20).is_none(), "2 MiB wastes too much for 1 MiB");
}
#[test]
fn recycled_frames_feed_the_next_take_unless_still_shared() {
// sizes nothing else in this test binary asks for
let (n, c) = (777_001usize, 333_003usize);
let yuv = |fill: u8| {
Arc::new(VideoFrame {
width: 1,
height: 1,
data: PixelData::Yuv8 {
planes: [Arc::new(vec![fill; n]), Arc::new(vec![fill; c]), Arc::new(vec![fill; c])],
chroma: Chroma::C420,
alpha: None,
},
color: filmcraft_color::ColorInfo::SRGB_FULL,
par: (1, 1),
pts: filmcraft_time::Tick::ZERO,
})
};
let before = stats().reused;
// a frame someone else still holds is left alone
let shared = yuv(1);
let held = shared.clone();
recycle(shared);
assert!(take_u8(n).capacity() >= n);
assert_eq!(stats().reused, before);
assert_eq!(held.byte_size(), n + 2 * c);
// an unshared frame's planes come back, empty
recycle(yuv(2));
let (y, u, v) = (take_u8(n), take_u8(c), take_u8(c));
assert!(y.is_empty() && y.capacity() >= n && u.capacity() >= c && v.capacity() >= c);
assert_eq!(stats().reused, before + 3);
// and are not handed out twice
take_u8(n);
assert_eq!(stats().reused, before + 3);
}
}
+1
View File
@@ -35,6 +35,7 @@ mod intra;
mod mbtypes;
pub mod params;
mod picture;
pub use picture::set_plane_allocator;
pub mod slice;
mod slicedec;
#[cfg(test)]
+16 -3
View File
@@ -255,14 +255,15 @@ impl Frame {
/// Crop and copy the frame into planar buffers (waits for completion).
pub fn copy_cropped(&self, crop: (usize, usize, usize, usize)) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let (cx, cy, cw, ch) = crop;
let mut y = Vec::with_capacity(cw * ch);
let alloc = plane_allocator();
let mut y = alloc(cw * ch);
for r in cy..cy + ch {
let row = self.row(r >> 4);
y.extend_from_slice(&row.y[(r & 15) * self.width + cx..(r & 15) * self.width + cx + cw]);
}
let (ccx, ccy, ccw, cch) = (cx / 2, cy / 2, cw.div_ceil(2), ch.div_ceil(2));
let mut u = Vec::with_capacity(ccw * cch);
let mut v = Vec::with_capacity(ccw * cch);
let mut u = alloc(ccw * cch);
let mut v = alloc(ccw * cch);
for r in ccy..ccy + cch {
let row = self.row(r >> 3);
let o = (r & 7) * self.cwidth + ccx;
@@ -273,6 +274,18 @@ impl Frame {
}
}
static PLANE_ALLOCATOR: std::sync::OnceLock<fn(usize) -> Vec<u8>> = std::sync::OnceLock::new();
/// Where output planes come from: an empty buffer with room for the given number of samples.
/// The host sets it once to recycle the planes of pictures it is done with; the first call wins.
pub fn set_plane_allocator(alloc: fn(usize) -> Vec<u8>) {
let _ = PLANE_ALLOCATOR.set(alloc);
}
fn plane_allocator() -> fn(usize) -> Vec<u8> {
PLANE_ALLOCATOR.get().copied().unwrap_or(Vec::with_capacity)
}
/// Copy `w` samples of `line` starting at x0 into `out`; when possible a fixed-size block of `N`
/// bytes is copied instead (cheaper than a variable-length copy; `out` must then have room for `N`).
#[inline(always)]
+19
View File
@@ -39,6 +39,22 @@ Not represented: speed changes and frame holds (exported at 100 % with a report
sequences, graphics and synthetic media (gaps), video effects other than transitions, clip
markers (written to the master mob, so they come back as media markers), OMF video and markers.
## Graphics and adjustment layers
No format here has an equivalent of a FilmCraft graphic (title) clip or an adjustment layer, and
none of the exports brings one back. Each such clip is named in the export `Report` as a warning
(`graphic clip "Title" at frame 48 …`, one line per clip), with what was written instead:
| Format | Written as | On import into FilmCraft |
|---|---|---|
| FCP7 XML | a clip item without media (its layers as FilmCraft filter ids) | not read back; the import report names the skipped clip item |
| FCPXML | a clip without media | not read back |
| OTIO | a clip with a `MissingReference` (effects in the `filmcraft` metadata) | offline media |
| EDL | an ordinary event with the reel the reel mode gives | an ordinary clip |
| AAF | a gap | nothing |
OMF carries no video at all.
## Tests
- `tests/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps,
@@ -47,6 +63,9 @@ markers (written to the master mob, so they come back as media markers), OMF vid
(sample data and source offsets); separate consolidated files with a video mixdown; OMF round
trips with embedded audio, separate AIFF files and breakout; empty sequences; truncation and
random corruption never panic.
- `tests/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment
layer once, as a warning; a sequence without them reports nothing; what a re-import gives back
matches the report; the FCP7 XML import names a clip item whose file is not defined.
- `src/aaf/tests.rs`: written files checked against the required properties of every class
written (Header, Identification, Mobs, slots, components, descriptors), weak references
resolving into the dictionary, source clips resolving to mobs and slots, the Edit Protocol
+12
View File
@@ -301,6 +301,18 @@ pub(crate) fn base_item(p: &Project, id: ItemId) -> ItemId {
}
}
/// How an export report names a clip whose source is a graphic (title) or an adjustment layer,
/// which most interchange formats cannot carry: `graphic clip "Title" at frame 48`. `None` for
/// every other clip. A lost title must be named, not folded into a count.
pub(crate) fn uncarried_clip(p: &Project, c: &TrackItem, rate: FrameRate) -> Option<String> {
let what = match &p.item(c.item)?.kind {
ItemKind::Graphic { .. } => "graphic clip",
ItemKind::AdjustmentLayer { .. } => "adjustment layer",
_ => return None,
};
Some(format!("{what} \"{}\" at frame {}", c.name, rate.frame_at(c.start)))
}
pub(crate) fn generator_of(p: &Project, id: ItemId) -> Option<&Generator> {
match &item_media(p, id)?.media {
MediaRef::Generator(g) => Some(g),
+4 -1
View File
@@ -272,7 +272,10 @@ impl Exporter<'_> {
return None;
}
_ => {
self.report.warn("graphics, titles and adjustment layers are not exported (left as gaps)");
match crate::common::uncarried_clip(self.p, c, self.seq_rate) {
Some(clip) => self.report.warn(format!("{clip} is not exported (left as a gap)")),
None => self.report.warn("graphics, titles and adjustment layers are not exported (left as gaps)"),
}
return None;
}
};
+3
View File
@@ -764,6 +764,9 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report:
let tr_in = track.transitions.iter().find(|t| t.to == Some(it.id));
let tr_out = track.transitions.iter().find(|t| t.from == Some(it.id));
let reel = x.reel(it.item, report);
if let Some(clip) = crate::common::uncarried_clip(p, it, rate) {
report.warn(format!("{clip} cannot be represented in an EDL: it is written as an ordinary event (reel {reel})"));
}
let mut rec0 = it.start;
let mut rec1 = it.end();
let mut src0 = it.source_in;
+11 -1
View File
@@ -380,7 +380,13 @@ impl<'a, 'i> Imp<'a, 'i, '_> {
let label = if name.is_empty() { g.label() } else { name.clone() };
self.b.generator_media(&key, &label, g, &spec, None)
} else if let Some(f) = child(c, "file") {
self.file(f, Some(&name), None)?
match self.file(f, Some(&name), None) {
Some(item) => item,
None => {
self.report.warn(format!("clip \"{name}\" refers to a file the document does not define; skipped"));
return None;
}
}
} else if let Some(s) = child(c, "sequence") {
self.sequence(s, None)?
} else {
@@ -908,6 +914,10 @@ impl Exp<'_, '_> {
if !on_frame(rate, c.start) || !on_frame(rate, c.duration) {
self.report.warn("sub-frame clip positions were rounded to frames");
}
if let Some(clip) = crate::common::uncarried_clip(self.p, c, rate) {
self.report
.warn(format!("{clip} has no Final Cut Pro 7 XML equivalent: it is written as a clip item without media and is not read back on import"));
}
let base = base_item(self.p, c.item);
let generator = crate::common::generator_of(self.p, base).cloned();
let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_)));
+7 -1
View File
@@ -972,7 +972,13 @@ impl Exp<'_, '_> {
let origin = self.p.sequence(base).map(|s| s.settings.frame_rate.tick_of(s.start_timecode)).unwrap_or(Tick::ZERO);
("ref-clip", r, origin)
} else {
let r = self.asset(base, rate);
let r = match crate::common::uncarried_clip(self.p, it, rate) {
Some(clip) => {
self.report.warn(format!("{clip} is not supported in FCPXML: it is written as a clip without media and is not read back on import"));
String::new()
}
None => self.asset(base, rate),
};
let m = item_media(self.p, base);
let mrate = m.and_then(|m| m.info.video.as_ref().map(|v| v.frame_rate)).unwrap_or(rate);
let st = m.and_then(|m| m.info.start_timecode).map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO);
+4
View File
@@ -279,6 +279,10 @@ impl Exp<'_, '_> {
return s;
}
let _ = generator_of(self.p, base);
if let Some(clip) = crate::common::uncarried_clip(self.p, it, rate) {
self.report
.warn(format!("{clip} has no OTIO equivalent: it is written as a clip with a missing media reference and is read back as offline media"));
}
let mref = self.media_ref(base, rate);
// OTIO source times are in the reference's time (which starts at its available_range start).
let origin = item_media(self.p, base).and_then(|m| m.info.start_timecode.map(|f| m.frame_rate().tick_of(f))).unwrap_or(Tick::ZERO);
+146
View File
@@ -0,0 +1,146 @@
//! Graphic (title) clips and adjustment layers have no equivalent in the interchange formats. An
//! export used to lose them without a word (FCP7 XML, OTIO, EDL) or with a line that named
//! nothing (FCPXML, AAF): every such clip is now named in the export report.
mod support;
use filmcraft_interchange::{ExportOptions, Format, Report, export, import};
use filmcraft_project::{ItemId, ItemKind, Label, ParamValue, Project, TrackKind, find_effect};
use filmcraft_time::FrameRate;
use support::*;
const RATE: FrameRate = FrameRate::FPS_24;
/// V1: a media clip (frames 0–48) and an adjustment layer (48–72); V2: a title (12–36).
fn project_with_a_title() -> (Project, ItemId) {
let mut p = Project::new("Titles");
let s = sequence(&mut p, "Cut", RATE, false);
let a = media(&mut p, "/m/a.mov", true, true, RATE);
let graphic = p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate: RATE }, None);
let layer =
p.add_item("Adjustment Layer", Label::Iris, ItemKind::AdjustmentLayer { width: 1920, height: 1080, rate: RATE, duration: RATE.tick_of(240) }, None);
clip(&mut p, s, TrackKind::Video, 0, a, 0, 48, 0);
let adj = clip(&mut p, s, TrackKind::Video, 0, layer, 48, 24, 0);
let title = clip(&mut p, s, TrackKind::Video, 1, graphic, 12, 24, 0);
let q = p.sequence_mut(s).unwrap();
let (_, t) = q.find_item_mut(title).unwrap();
t.name = "Opening \"line\"".into();
let mut text = find_effect("graphic_text").unwrap().instance();
text.params.get_mut("text").unwrap().value = ParamValue::Text("Opening line".into());
t.effects.push(text);
q.find_item_mut(adj).unwrap().1.name = "Grade".into();
q.check().unwrap();
(p, s)
}
fn export_report(p: &Project, s: ItemId, format: Format) -> (Vec<u8>, Report) {
export(p, s, format, &ExportOptions::default()).unwrap_or_else(|e| panic!("{format:?}: {e}"))
}
const TITLE: &str = "graphic clip \"Opening \"line\"\" at frame 12";
const LAYER: &str = "adjustment layer \"Grade\" at frame 48";
#[test]
fn every_format_names_the_title_and_the_adjustment_layer_it_cannot_carry() {
let (p, s) = project_with_a_title();
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Aaf] {
let (_, report) = export_report(&p, s, format);
for clip in [TITLE, LAYER] {
let named: Vec<_> = report.warnings().filter(|e| e.message.starts_with(clip)).collect();
assert_eq!(named.len(), 1, "{format:?} names {clip} once, as a warning: {report}");
assert_eq!(named[0].count, 1);
}
}
// an EDL holds one video track: V1 by default (the layer), V2 when asked (the title)
let (_, report) = export_report(&p, s, Format::Edl);
assert!(report.mentions(LAYER) && !report.mentions(TITLE), "{report}");
let mut opts = ExportOptions::default();
opts.edl.video_track = Some(1);
let (_, report) = export(&p, s, Format::Edl, &opts).unwrap();
assert!(report.mentions(TITLE), "{report}");
}
#[test]
fn two_titles_are_two_report_lines() {
let (mut p, s) = project_with_a_title();
let graphic = p.items.values().find(|i| matches!(i.kind, ItemKind::Graphic { .. })).map(|i| i.id).unwrap();
let second = clip(&mut p, s, TrackKind::Video, 1, graphic, 40, 10, 0);
p.sequence_mut(s).unwrap().find_item_mut(second).unwrap().1.name = "Closing".into();
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Aaf] {
let (_, report) = export_report(&p, s, format);
assert!(report.mentions(TITLE) && report.mentions("graphic clip \"Closing\" at frame 40"), "{format:?}: {report}");
}
}
#[test]
fn a_sequence_without_such_clips_reports_nothing_about_them() {
let mut p = Project::new("Plain");
let s = sequence(&mut p, "Cut", RATE, false);
let a = media(&mut p, "/m/a.mov", true, true, RATE);
clip(&mut p, s, TrackKind::Video, 0, a, 0, 48, 0);
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio, Format::Edl, Format::Aaf] {
let (_, report) = export_report(&p, s, format);
assert!(!report.mentions("graphic clip") && !report.mentions("adjustment layer"), "{format:?}: {report}");
}
}
/// What the report says happens is what happens: the written documents do not bring the title
/// back as a graphic.
#[test]
fn the_reports_match_what_an_import_gives_back() {
let (p, s) = project_with_a_title();
let graphics = |q: &Project| {
q.sequences()
.filter_map(|i| i.as_sequence())
.flat_map(|q| q.all_tracks())
.flat_map(|t| &t.items)
.filter(|c| matches!(q.item(c.item).map(|i| &i.kind), Some(ItemKind::Graphic { .. })))
.count()
};
assert_eq!(graphics(&p), 1);
for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] {
let (bytes, _) = export_report(&p, s, format);
let (imported, _) = import(&bytes, format, None).unwrap_or_else(|e| panic!("{format:?}: {e}"));
assert_eq!(graphics(&imported.project), 0, "{format:?}");
let seq = only_seq(&imported);
let names: Vec<String> = imported.project.sequence(seq).unwrap().video_tracks.iter().flat_map(|t| &t.items).map(|c| c.name.clone()).collect();
// "not read back" (the XML formats) / "read back as offline media" (OTIO)
assert_eq!(names.iter().any(|n| n.starts_with("Opening")), format == Format::Otio, "{format:?}: {names:?}");
}
}
/// The FCP7 XML importer dropped a clip item whose `<file>` reference has no definition (which is
/// how the export writes a title) without a word: the import report names it.
#[test]
fn fcp7_import_reports_a_clip_item_whose_file_is_not_defined() {
let (p, s) = project_with_a_title();
let (bytes, _) = export_report(&p, s, Format::Fcp7Xml);
let (imported, report) = import(&bytes, Format::Fcp7Xml, None).unwrap();
for name in ["Opening \"line\"", "Grade"] {
let line = format!("clip \"{name}\" refers to a file the document does not define; skipped");
assert_eq!(report.warnings().filter(|e| e.message == line).count(), 1, "{report}");
}
let seq = imported.project.sequence(only_seq(&imported)).unwrap();
let names: Vec<&str> = seq.video_tracks.iter().flat_map(|t| &t.items).map(|c| c.name.as_str()).collect();
assert_eq!(names, ["a.mov"], "the clip with media is imported as before");
// a hand-written reference to a file id that is never defined
let doc = r#"<?xml version="1.0" encoding="UTF-8"?>
<xmeml version="4"><sequence id="s"><name>Cut</name><duration>48</duration><rate><timebase>24</timebase><ntsc>FALSE</ntsc></rate>
<media><video><track>
<clipitem id="c1"><name>ghost</name><start>0</start><end>24</end><in>0</in><out>24</out><file id="nowhere"/></clipitem>
<clipitem id="c2"><name>real</name><start>24</start><end>48</end><in>0</in><out>24</out><file id="f1"><name>real.mov</name><pathurl>real.mov</pathurl></file></clipitem>
</track></video></media></sequence></xmeml>"#;
let (imported, report) = import(doc.as_bytes(), Format::Fcp7Xml, None).unwrap();
assert!(report.mentions("clip \"ghost\" refers to a file the document does not define; skipped"), "{report}");
assert!(!report.mentions("\"real\""), "{report}");
assert_eq!(imported.project.sequence(only_seq(&imported)).unwrap().video_tracks[0].items.len(), 1);
// a document whose files are all defined says nothing of the kind
let mut plain = Project::new("Plain");
let s = sequence(&mut plain, "Cut", RATE, false);
let a = media(&mut plain, "/m/a.mov", true, true, RATE);
clip(&mut plain, s, TrackKind::Video, 0, a, 0, 48, 0);
clip(&mut plain, s, TrackKind::Video, 0, a, 48, 24, 0);
let (bytes, _) = export_report(&plain, s, Format::Fcp7Xml);
let (_, report) = import(&bytes, Format::Fcp7Xml, None).unwrap();
assert!(!report.mentions("does not define"), "{report}");
}
+73
View File
@@ -43,6 +43,57 @@ impl ByteReader for MemReader {
}
}
/// A file on disk, read in place: the file stays open and only the requested ranges are read, so
/// opening a clip costs its index rather than its size.
#[cfg(any(unix, windows))]
#[derive(Debug)]
pub struct FileReader {
file: std::fs::File,
len: u64,
}
#[cfg(any(unix, windows))]
impl FileReader {
pub fn open(path: &std::path::Path) -> io::Result<Self> {
let file = std::fs::File::open(path)?;
let m = file.metadata()?;
if !m.is_file() {
return Err(io::Error::new(io::ErrorKind::NotFound, format!("{} is not a file", path.display())));
}
Ok(Self { file, len: m.len() })
}
}
#[cfg(any(unix, windows))]
impl ByteReader for FileReader {
fn len(&self) -> u64 {
self.len
}
/// Positional reads: threads decoding different parts of the file do not share a cursor.
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset)
}
#[cfg(windows)]
{
let (mut done, mut at) = (0usize, offset);
while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) {
match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) {
Ok(0) => return Err(eof()),
Ok(n) => {
done = done.saturating_add(n);
at = at.saturating_add(n as u64);
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(())
}
}
}
/// Read `len` bytes at `offset` (fewer at the end of the reader).
pub fn read_range(r: &dyn ByteReader, offset: u64, len: usize) -> io::Result<Vec<u8>> {
let n = (r.len().saturating_sub(offset)).min(len as u64) as usize;
@@ -85,6 +136,28 @@ mod tests {
assert_eq!(read_range(&r, 8, 100).unwrap(), b"89");
}
#[cfg(any(unix, windows))]
#[test]
fn file_reader_reads_ranges_in_place() {
let dir = std::env::temp_dir().join(format!("filmcraft-file-reader-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bytes.bin");
std::fs::write(&path, b"0123456789").unwrap();
let r = FileReader::open(&path).unwrap();
assert_eq!(r.len(), 10);
let mut b = [0u8; 3];
r.read_at(4, &mut b).unwrap();
assert_eq!(&b, b"456");
// reads are positional: an earlier offset after a later one
r.read_at(0, &mut b).unwrap();
assert_eq!(&b, b"012");
assert_eq!(r.read_at(8, &mut b).unwrap_err().kind(), io::ErrorKind::UnexpectedEof);
assert_eq!(read_range(&r, 8, 100).unwrap(), b"89");
assert!(FileReader::open(&dir).is_err(), "a directory is not a media file");
assert_eq!(FileReader::open(&dir.join("missing")).unwrap_err().kind(), io::ErrorKind::NotFound);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn falls_back_to_byte_openers() {
// a WAV is opened through the whole-file path
+12
View File
@@ -53,6 +53,18 @@ pub fn register() -> Availability {
}
}
/// Whether [`register`] has put a hardware decoder factory in front of our decoders.
pub fn registered() -> bool {
#[cfg(target_os = "macos")]
{
filmcraft_codecs::video_decoder_registered(videotoolbox_factory)
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
/// Whether this system's hardware decoder takes the stream of `entry` (whatever the Hardware
/// decoding setting says): diagnostics and tests.
pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool {
+5 -5
View File
@@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex, PoisonError};
use filmcraft_codecs::hw::{NalCodec, NalStreamInfo};
use filmcraft_codecs::{CodecError, DecodedFrame, Result, VideoDecoder};
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use filmcraft_frame::{Chroma, PixelData, VideoFrame, pool};
use objc2_core_foundation::{CFBoolean, CFDictionary, CFNumber, CFRetained, CFString, CFType};
use objc2_core_media::{
CMBlockBuffer, CMFormatDescription, CMSampleBuffer, CMSampleTimingInfo, CMTime, CMTimeFlags, CMVideoFormatDescriptionCreateFromH264ParameterSets,
@@ -216,11 +216,11 @@ fn copy_out(pb: &CVImageBuffer, l: &Layout) -> std::result::Result<VideoFrame, S
return Err("decoded picture planes are smaller than the picture".into());
}
let data = if bps == 1 {
let mut yp = Vec::with_capacity(w * h);
let mut yp = pool::take_u8(w * h);
for y in 0..h {
yp.extend_from_slice(ys.row(oy + y, ox * bps, w * bps)?);
}
let (mut u, mut v) = (Vec::with_capacity(cw * ch), Vec::with_capacity(cw * ch));
let (mut u, mut v) = (pool::take_u8(cw * ch), pool::take_u8(cw * ch));
for y in 0..ch {
for pair in cs.row(coy + y, cox * 2 * bps, cw * 2 * bps)?.as_chunks::<2>().0 {
u.push(pair[0]);
@@ -230,11 +230,11 @@ fn copy_out(pb: &CVImageBuffer, l: &Layout) -> std::result::Result<VideoFrame, S
PixelData::Yuv8 { planes: [Arc::new(yp), Arc::new(u), Arc::new(v)], chroma: l.chroma, alpha: None }
} else {
let shift = 16 - l.bits;
let mut yp = Vec::with_capacity(w * h);
let mut yp = pool::take_u16(w * h);
for y in 0..h {
yp.extend(ys.row(oy + y, ox * bps, w * bps)?.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes([b[0], b[1]]) >> shift));
}
let (mut u, mut v) = (Vec::with_capacity(cw * ch), Vec::with_capacity(cw * ch));
let (mut u, mut v) = (pool::take_u16(cw * ch), pool::take_u16(cw * ch));
for y in 0..ch {
for q in cs.row(coy + y, cox * 2 * bps, cw * 2 * bps)?.as_chunks::<4>().0 {
u.push(u16::from_ne_bytes([q[0], q[1]]) >> shift);
+21 -1
View File
@@ -11,7 +11,11 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ParamValue {
Float(f64),
Vec2(Vec2),
/// A point. A coordinate may be NaN: point parameters use NaN for "auto" (the frame or source
/// centre) until [`crate::resolve_auto_points`] fills it in. JSON has no NaN and writes it as
/// `null`, so `null` reads back as NaN here, and only here: a `null` coordinate anywhere else
/// in a project (a mask vertex, a layer rectangle) is still a damaged file.
Vec2(#[serde(deserialize_with = "point_or_auto")] Vec2),
Color([f32; 4]),
Bool(bool),
Choice(u32),
@@ -22,6 +26,22 @@ pub enum ParamValue {
Path(crate::mask::MaskPath),
}
/// Read a point parameter's value, taking a `null` coordinate as NaN ("auto").
fn point_or_auto<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec2, D::Error> {
fn nan_if_null<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
}
#[derive(Deserialize)]
struct Point {
#[serde(deserialize_with = "nan_if_null")]
x: f64,
#[serde(deserialize_with = "nan_if_null")]
y: f64,
}
let p = Point::deserialize(d)?;
Ok(Vec2::new(p.x, p.y))
}
impl ParamValue {
pub fn as_f64(&self) -> Option<f64> {
match self {
+152 -1
View File
@@ -1335,9 +1335,15 @@ impl Project {
/// Resolve NaN "auto" point defaults (frame centre / source centre) in an effect instance.
pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u32, u32)) {
resolve_auto_points_sized(e, frame, Some(source));
}
/// [`resolve_auto_points`] for a source whose size may not be known yet (`None`): the points
/// measured in the source (`anchor`) then stay "auto".
fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: Option<(u32, u32)>) {
for (k, p) in e.params.iter_mut() {
if let ParamValue::Vec2(v) = &mut p.value {
let (w, h) = if k == "anchor" { source } else { frame };
let Some((w, h)) = (if k == "anchor" { source } else { Some(frame) }) else { continue };
if let Some((fx, fy)) = effect::auto_point(&e.effect, k) {
if v.x.is_nan() {
v.x = w as f64 * fx;
@@ -1357,6 +1363,79 @@ pub fn resolve_auto_points(e: &mut EffectInstance, frame: (u32, u32), source: (u
let _ = Vec2::ZERO;
}
impl Project {
/// Size in pixels of what an item shows: a media clip's picture, a sequence's frame, an
/// adjustment layer or graphic; a subclip has the size of its parent. `None` without
/// picture. This is the size the renderer centres a clip's "auto" anchor in.
pub fn source_size(&self, item: ItemId) -> Option<(u32, u32)> {
let mut id = item;
// subclips of subclips: bounded, a damaged project could point a subclip at itself
for _ in 0..16 {
return match &self.item(id)?.kind {
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
ItemKind::Sequence(q) => Some((q.settings.width, q.settings.height)),
ItemKind::AdjustmentLayer { width, height, .. } | ItemKind::Graphic { width, height, .. } => Some((*width, *height)),
ItemKind::Subclip { parent, .. } => {
id = *parent;
continue;
}
};
}
None
}
/// Resolve the "auto" (NaN) points of the clips in the sequences `sequences` selects, for
/// clips that did not go through a placing command (an interchange import builds them with
/// the effect defaults). Points measured in the frame use the sequence's size. `anchor` is
/// measured in the clip's source ([`Project::source_size`], a subclip through its parent) and
/// is resolved only when `size_is_final` says so for that source item (the media item for a
/// subclip); otherwise it stays "auto", which the renderer centres in whatever size the media
/// turns out to have. Points that are not NaN are never touched, so this can run again when
/// more sizes are known.
pub fn resolve_placed_auto_points(&mut self, sequences: impl Fn(ItemId) -> bool, size_is_final: impl Fn(&ProjectItem) -> bool) {
let seq_ids: Vec<ItemId> = self.items.values().filter(|i| matches!(i.kind, ItemKind::Sequence(_)) && sequences(i.id)).map(|i| i.id).collect();
for seq_id in seq_ids {
let Some(seq) = self.sequence(seq_id) else { continue };
// the source size of each clip that has an unresolved point, looked up before the edit
let sources: BTreeMap<ClipId, Option<(u32, u32)>> = seq
.all_tracks()
.flat_map(|t| t.items.iter())
.filter(|c| c.effects.iter().any(has_auto_point))
.map(|c| (c.id, self.final_source_size(c.item, &size_is_final)))
.collect();
if sources.is_empty() {
continue;
}
let Some(seq) = self.sequence_mut(seq_id) else { continue };
let frame = (seq.settings.width, seq.settings.height);
for clip in seq.all_tracks_mut().flat_map(|t| t.items.iter_mut()) {
let Some(source) = sources.get(&clip.id) else { continue };
for e in &mut clip.effects {
resolve_auto_points_sized(e, frame, *source);
}
}
}
}
/// [`Project::source_size`] of `item` when the item that carries the size (the media item of
/// a subclip) passes `size_is_final`.
fn final_source_size(&self, item: ItemId, size_is_final: &impl Fn(&ProjectItem) -> bool) -> Option<(u32, u32)> {
let mut id = item;
for _ in 0..16 {
let it = self.item(id)?;
match &it.kind {
ItemKind::Subclip { parent, .. } => id = *parent,
_ => return if size_is_final(it) { self.source_size(id) } else { None },
}
}
None
}
}
fn has_auto_point(e: &EffectInstance) -> bool {
e.params.values().any(|p| matches!(&p.value, ParamValue::Vec2(v) if v.x.is_nan() || v.y.is_nan()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1401,6 +1480,78 @@ mod tests {
assert!(q.sequence(seq).unwrap().check().is_ok());
}
#[test]
fn point_params_keep_auto_through_json_and_no_other_point_reads_null() {
// serde_json writes NaN as `null`; a point parameter reads it back as NaN ("auto")
let json = serde_json::to_string(&ParamValue::Vec2(Vec2::new(f64::NAN, 540.0))).unwrap();
assert_eq!(json, r#"{"Vec2":{"x":null,"y":540.0}}"#);
let v = serde_json::from_str::<ParamValue>(&json).unwrap().as_vec2().unwrap();
assert!(v.x.is_nan() && v.y == 540.0);
let both = serde_json::from_str::<ParamValue>(r#"{"Vec2":{"x":null,"y":null}}"#).unwrap().as_vec2().unwrap();
assert!(both.x.is_nan() && both.y.is_nan());
assert_eq!(serde_json::from_str::<ParamValue>(r#"{"Vec2":{"x":1.5,"y":-2}}"#).unwrap(), ParamValue::Vec2(Vec2::new(1.5, -2.0)));
// anything else is still an error, and so is a missing coordinate
for bad in [r#"{"Vec2":{"x":"a","y":0}}"#, r#"{"Vec2":{"x":0}}"#, r#"{"Vec2":null}"#, r#"{"Float":null}"#] {
assert!(serde_json::from_str::<ParamValue>(bad).is_err(), "{bad}");
}
// only point parameters: a `null` coordinate in a mask vertex (or any other point) is damage
assert!(serde_json::from_str::<Vec2>(r#"{"x":null,"y":0}"#).is_err());
let mut path = serde_json::to_value(MaskPath::ellipse(Vec2::new(100.0, 100.0), Vec2::new(50.0, 40.0))).unwrap();
assert!(serde_json::from_value::<MaskPath>(path.clone()).is_ok());
path["vertices"][0]["p"]["x"] = serde_json::Value::Null;
assert!(serde_json::from_value::<MaskPath>(path.clone()).is_err());
assert!(serde_json::from_value::<ParamValue>(serde_json::json!({ "Path": path })).is_err());
}
#[test]
fn placed_auto_points_resolve_once_the_source_size_is_final() {
let (mut p, clip, seq) = demo_project();
let src = p.item(clip).and_then(|i| i.as_media()).and_then(|m| m.info.video.clone()).unwrap();
let rate = p.sequence(seq).unwrap().settings.frame_rate;
let second = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND));
let sub = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent: clip, range: second, restrict_trims: true }, None);
let sub_of_sub = p.add_item("Sub 2", Label::Iris, ItemKind::Subclip { parent: sub, range: second, restrict_trims: true }, None);
assert_eq!(p.source_size(clip), Some((src.width, src.height)));
assert_eq!((p.source_size(sub), p.source_size(sub_of_sub)), (p.source_size(clip), p.source_size(clip)), "a subclip has its parent's size");
assert_eq!(p.source_size(ItemId(987_654_321)), None);
// clips whose Motion position / anchor were never resolved (as an interchange import builds them)
let mut ids = Vec::new();
for (n, item) in [clip, sub, sub_of_sub].into_iter().enumerate() {
let ti = p.make_track_item(item, TrackKind::Video, Tick(n as i64 * TICKS_PER_SECOND), second, rate).unwrap();
assert!(ti.effect("motion").unwrap().vec2_at("position", Tick::ZERO).x.is_nan());
ids.push(ti.id);
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti);
}
let st = p.sequence(seq).unwrap().settings.clone();
let point = |p: &Project, id: ClipId, k: &str| p.sequence(seq).unwrap().find_item(id).unwrap().1.effect("motion").unwrap().vec2_at(k, Tick::ZERO);
let (frame_centre, source_centre) =
(Vec2::new(st.width as f64 / 2.0, st.height as f64 / 2.0), Vec2::new(src.width as f64 / 2.0, src.height as f64 / 2.0));
// sequences that are not selected are left alone
let mut untouched = p.clone();
untouched.resolve_placed_auto_points(|_| false, |_| true);
assert!(point(&untouched, ids[0], "position").x.is_nan());
// the source size is not final yet: the frame's points resolve, the anchor stays auto
p.resolve_placed_auto_points(|_| true, |_| false);
for id in &ids {
assert_eq!(point(&p, *id, "position"), frame_centre);
assert!(point(&p, *id, "anchor").x.is_nan() && point(&p, *id, "anchor").y.is_nan());
}
// ...and survives a save: NaN is written as `null` and read back as NaN
let mut q = Project::from_json(&p.to_json()).unwrap();
assert!(point(&q, ids[0], "anchor").x.is_nan());
assert_eq!(q.to_json(), p.to_json());
// final for the media item only: its clip and the clips of its subclips get the source centre
q.resolve_placed_auto_points(|_| true, |i| i.id == clip);
for id in &ids {
assert_eq!((point(&q, *id, "position"), point(&q, *id, "anchor")), (frame_centre, source_centre));
}
assert!(!q.to_json().contains("null}}"), "no NaN point is left to write");
// running it again changes nothing
let once = q.clone();
q.resolve_placed_auto_points(|_| true, |_| true);
assert_eq!(q, once);
}
#[test]
fn item_at_and_source_time() {
let (mut p, clip, seq) = demo_project();
+16
View File
@@ -850,6 +850,22 @@ mod tests {
assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3);
}
#[test]
fn japanese_vertical_titles_with_an_installed_font() {
// Japanese glyphs come from a system font (none is bundled): skip where none is installed.
crate::fonts::scan_system();
if !crate::fonts::all_faces().iter().any(|f| "日本語縦書き".chars().all(|c| f.has_char(c))) {
eprintln!("SKIPPED: no Japanese font installed");
return;
}
let l = layout("日本語\n縦書き", &st(40.0), &ParagraphStyle { vertical: true, ..Default::default() });
assert!(l.vertical);
assert_eq!(l.glyphs.len(), 6);
assert!(l.glyphs.iter().all(|g| g.id != 0));
assert!(l.glyphs[1].y > l.glyphs[0].y);
assert!(l.glyphs[3].x < l.glyphs[0].x);
}
#[test]
fn vertical_text_stacks_characters_in_columns() {
let p = ParagraphStyle { vertical: true, ..Default::default() };
+20 -2
View File
@@ -3,7 +3,7 @@
//! live in the native menu bar (set up by the app); elsewhere a compact in-window menu bar follows
//! the mode tabs.
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2};
use crate::FilmcraftApp;
use crate::icons::{self, Icon};
@@ -54,6 +54,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
x += gw + 24.0;
}
let mut left_end = x;
// In-window menus when there is no native menu bar.
if app.ui.show_menu_bar {
let menu_rect = Rect::from_min_max(pos2(x + 6.0, rect.min.y + 7.0), pos2(x + 520.0, rect.max.y - 7.0));
@@ -62,10 +63,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
mu.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
mu.style_mut().visuals.override_text_color = Some(t.text_dim);
crate::menus::menu_bar(app, &mut mu);
left_end = mu.min_rect().max.x;
}
// Document title, centred
let title = format!("{}{}", app.session.project.name, if app.session.is_dirty() { " - Edited" } else { "" });
p.text(pos2(rect.center().x, rect.center().y), Align2::CENTER_CENTER, title, Tokens::ui(14.0), t.tab_text_active);
// Right cluster: icons at ~38 pt pitch, then workspace name in caps.
let mut rx = rect.max.x - 14.0;
let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response {
@@ -118,6 +119,23 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent });
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
ui.painter().galley(pos2(r.min.x + 25.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE);
// Localized menus and user titles can be wider than English. Use the actual gap.
let left = left_end + 12.0;
let right = r.min.x - 12.0;
if right > left + 40.0 {
let mut job = egui::text::LayoutJob::simple_singleline(title, Tokens::ui(14.0), t.tab_text_active);
job.wrap.max_width = right - left;
job.wrap.max_rows = 1;
let galley = p.layout_job(job);
let half = galley.size().x / 2.0;
let center = if rect.center().x - half >= left && rect.center().x + half <= right { rect.center().x } else { (left + right) / 2.0 };
p.with_clip_rect(Rect::from_min_max(pos2(left, rect.min.y), pos2(right, rect.max.y))).galley(
pos2(center - half, rect.center().y - galley.size().y / 2.0),
galley,
t.tab_text_active,
);
}
if resp.clicked() {
crate::links::open(ui.ctx(), crate::links::DISCORD);
}
+259
View File
@@ -0,0 +1,259 @@
//! Interface translations. Command ids, document text and file names remain stable.
//! Untranslated labels fall back to English so coverage can grow incrementally.
//!
//! No Japanese font is bundled: Japanese uses a font already installed on the system
//! ([`system_japanese_font`]); without one, switching to Japanese is refused with a message.
use std::sync::{Arc, OnceLock};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
#[default]
En,
Ja,
}
impl Language {
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Ja => "日本語",
}
}
pub fn parse(code: &str) -> Option<Self> {
match code {
"en" => Some(Self::En),
"ja" => Some(Self::Ja),
_ => None,
}
}
pub fn tr(self, text: &str) -> &str {
if self == Self::Ja
&& let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text)
{
return japanese;
}
text
}
}
/// Text every Japanese interface font must cover (menus use kanji, hiragana and katakana).
const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア";
/// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces
/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if
/// none of these is present.
const PREFERRED_JAPANESE: &[&str] = &[
"Hiragino Sans",
"Hiragino Kaku Gothic ProN",
"Hiragino Kaku Gothic Pro",
"Yu Gothic UI",
"Yu Gothic",
"Meiryo UI",
"Meiryo",
"Noto Sans CJK JP",
"Noto Sans JP",
"Source Han Sans JP",
"Source Han Sans",
"IPAexGothic",
"IPAGothic",
"TakaoGothic",
"VL Gothic",
];
const JAPANESE_FONT: &str = "system-japanese";
/// A Japanese font already installed on this system, for the interface (none is bundled). Looked up
/// once per process: the system font folders are scanned on first use (name tables only), then the
/// chosen face's file is read. `None` on the web and on systems without a Japanese font.
pub fn system_japanese_font() -> Option<Arc<egui::FontData>> {
static FONT: OnceLock<Option<Arc<egui::FontData>>> = OnceLock::new();
FONT.get_or_init(|| {
filmcraft_text::fonts::scan_system();
let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect();
let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c));
// within a family, the face closest to regular weight
let by_weight = |f: &&Arc<filmcraft_text::fonts::Face>| f.info.weight.abs_diff(400);
let preferred =
PREFERRED_JAPANESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight));
let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?;
// kept for the life of the process (one font, read once) so installing it again after a
// theme change shares the bytes instead of copying the whole file
let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice());
Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() }))
})
.clone()
}
/// Add the system's Japanese font as the last fallback of every theme font family, from the next
/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again
/// after `theme::install`, which replaces the font definitions.
pub fn install_japanese_font(ctx: &egui::Context) -> bool {
let Some(font) = system_japanese_font() else { return false };
let families = crate::theme::font_families()
.into_iter()
.map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest })
.collect();
// queued for the next pass (works before the first frame); a no-op when already installed
ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families });
true
}
const JAPANESE: &[(&str, &str)] = &[
("Clip", "クリップ"),
("Sequence", "シーケンス"),
("Markers", "マーカー"),
("Graphics and Titles", "グラフィックスとタイトル"),
("Settings", "環境設定"),
("Object", "オブジェクト"),
("Effect", "効果"),
("Settings…", "環境設定…"),
("Image", "画像"),
("Layer", "レイヤー"),
("Type", "書式"),
("Select", "選択"),
("Filter", "フィルター"),
("Window", "ウィンドウ"),
("Language", "表示言語"),
("Save As…", "別名で保存…"),
("Exit", "終了"),
("New…", "新規…"),
("New", "新規"),
("Horizontal", "横書き"),
("Vertical", "縦書き"),
("Orientation", "組み方向"),
("Layers", "レイヤー"),
("History", "履歴"),
("Properties", "プロパティ"),
("Color", "カラー"),
("Brush Settings", "ブラシ設定"),
("Tools", "ツール"),
("Options", "オプション"),
("Zoom In", "ズームイン"),
("Zoom Out", "ズームアウト"),
("Fit on Screen", "画面に合わせる"),
("Copy", "コピー"),
("Cut", "切り取り"),
("Paste", "貼り付け"),
("Select All", "すべて選択"),
("Deselect", "選択を解除"),
("Export", "書き出し"),
("Export As…", "形式を指定して書き出し…"),
("Search…", "検索…"),
("Theme", "テーマ"),
("Menu", "メニュー"),
("File", "ファイル"),
("Edit", "編集"),
("Pages", "ページ"),
("View", "表示"),
("Help", "ヘルプ"),
("Preferences", "環境設定"),
("Preferences…", "環境設定…"),
("Interface language", "表示言語"),
("Open…", "開く…"),
("New blank PDF", "空白の PDF を作成"),
("Create PDF from file…", "ファイルから PDF を作成…"),
("Create PDF from images…", "画像から PDF を作成…"),
("Create PDF from clipboard", "クリップボードから PDF を作成"),
("Combine files…", "ファイルを結合…"),
("Save", "保存"),
("Save as…", "別名で保存…"),
("Close file", "ファイルを閉じる"),
("Close all", "すべて閉じる"),
("Revert", "保存済みの状態に戻す"),
("Print…", "印刷…"),
("Document properties…", "文書のプロパティ…"),
("Undo", "取り消し"),
("Redo", "やり直し"),
("Find…", "検索…"),
("Advanced search…", "高度な検索…"),
("Copy pages", "ページをコピー"),
("Cut pages", "ページを切り取り"),
("Paste pages", "ページを貼り付け"),
("Fit visible", "表示範囲に合わせる"),
("Marquee zoom", "範囲指定ズーム"),
("Take a snapshot", "スナップショットを作成"),
("Full screen mode", "全画面表示"),
("Read mode", "閲覧モード"),
("Switch light / dark theme", "明るい/暗いテーマを切り替え"),
("Comments panel", "コメントパネル"),
("Form fields panel", "フォームフィールドパネル"),
("Clear form", "フォームをクリア"),
("Find tools and commands…", "ツールとコマンドを検索…"),
("Zoom", "ズーム"),
("Actual size", "実際のサイズ"),
("Zoom to page level", "ページ全体を表示"),
("Fit to width", "幅に合わせる"),
("Display theme", "表示テーマ"),
("Side panels", "サイドパネル"),
("OK", "OK"),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translations_are_unique_and_preserve_unknown_text() {
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
assert!(!ja.is_empty());
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
assert_eq!(Language::En.tr(en), *en);
}
assert_eq!(Language::Ja.tr("File"), "ファイル");
assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf");
assert_eq!(Language::parse("xx"), None);
}
#[test]
fn language_commands_switch_and_persist_without_a_document() {
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
let ctx = egui::Context::default();
crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::Ja);
let saved = serde_json::to_string(&app.ui).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::En);
}
#[test]
fn japanese_needs_an_installed_font() {
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
let ctx = egui::Context::default();
crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default()));
let r = crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({}));
if system_japanese_font().is_none() {
// nothing is bundled: refused, and the interface stays English
assert!(r.is_err(), "{r:?}");
assert_eq!(app.ui.language, Language::En);
return;
}
assert!(r.is_ok(), "{r:?}");
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
ctx.fonts_mut(|fonts| {
// every theme family falls back to the system Japanese font
for family in crate::theme::font_families() {
let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default();
assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}");
}
// and the glyphs resolve. (Only families whose replacement-box face is another font:
// egui's `has_glyph` reports false for any character served by the face it also uses
// for the replacement box, which in the Inter-only "medium"/"semibold" stacks is the
// Japanese font itself.)
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
let font = egui::FontId::new(13.0, family);
for ch in JAPANESE_SAMPLE.chars() {
assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}");
}
}
});
}
}
+7 -1
View File
@@ -13,6 +13,7 @@ pub mod crash;
pub mod dock;
pub mod frames;
pub mod header;
pub mod i18n;
pub mod icons;
pub mod links;
pub mod menus;
@@ -854,8 +855,8 @@ impl FilmcraftApp {
if self.bindings_rev != self.session.shortcuts.revision {
self.bindings = menus::bindings(self);
self.bindings_rev = self.session.shortcuts.revision;
let items = menus::menu_items(self);
if let Some(hook) = self.hooks.shortcuts_changed.as_mut() {
let items = menus::menu_items_for(&self.session);
hook(&items);
}
}
@@ -1235,6 +1236,11 @@ impl eframe::App for FilmcraftApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.styled {
theme::install(ctx, &self.tokens);
// theme::install replaces the fonts: add the system Japanese font back (or fall back to
// English when a saved Japanese setting meets a system without one)
if self.ui.language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) {
self.ui.language = i18n::Language::En;
}
self.styled = true;
ctx.request_repaint();
} else {
+25 -2
View File
@@ -22,6 +22,8 @@ macro_rules! uic {
}
pub const UI_COMMANDS: &[UiCommand] = &[
uic!("app.language.english", "English", ["Edit", "Language"], None),
uic!("app.language.japanese", "日本語", ["Edit", "Language"], None),
uic!("playback.toggle", "Play/Stop", [], Some("Space")),
uic!("playback.forward", "Shuttle Right", [], Some("L")),
uic!("playback.stop", "Shuttle Stop", [], Some("K")),
@@ -144,6 +146,19 @@ pub fn panel_command_id(p: PanelKind) -> String {
/// Execute a UI or engine command by id.
pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
if matches!(id, "app.language.english" | "app.language.japanese") {
// no Japanese font is bundled: Japanese needs one installed on the system
if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) {
return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into());
}
app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En };
let items = menu_items(app);
if let Some(hook) = app.hooks.shortcuts_changed.as_mut() {
hook(&items);
}
ctx.request_repaint();
return Ok(json!(app.ui.language));
}
if id == "perf.stats" {
// the engine's counters plus playback, frame workers and UI timings
return Ok(crate::perf::stats(app));
@@ -382,6 +397,12 @@ pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Gr
pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
let mut v = menu_items_for(&app.session);
for it in &mut v {
it.label = app.ui.language.tr(&it.label).to_string();
match it.id.as_str() {
"app.language.english" => it.checked = Some(app.ui.language == crate::i18n::Language::En),
"app.language.japanese" => it.checked = Some(app.ui.language == crate::i18n::Language::Ja),
_ => {}
}
if it.id.starts_with("view.") {
it.checked = crate::panels::monitor_view::checked(app, &it.id).or(crate::panels::menu_dialogs::checked(app, &it.id));
it.enabled &= crate::panels::monitor_view::enabled(app, &it.id);
@@ -518,13 +539,14 @@ pub fn external_commands() -> Vec<filmcraft_engine::shortcuts::CommandInfo> {
/// Draw the in-window menu bar.
pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("interface-language"), app.ui.language));
let items = menu_items(app);
let ctx = ui.ctx().clone();
let mut clicked: Option<String> = None;
egui::MenuBar::new().ui(ui, |ui| {
for top in MENUS {
let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
ui.menu_button(top, |ui| {
ui.menu_button(app.ui.language.tr(top), |ui| {
ui.set_min_width(260.0);
if mine.is_empty() {
ui.add_enabled(false, egui::Button::new("(empty)"));
@@ -549,7 +571,8 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu
}
subs.push(sub);
let inner: Vec<&MenuItem> = items.iter().copied().filter(|x| x.path.get(depth).map(String::as_str) == Some(sub)).collect();
ui.menu_button(sub, |ui| {
let language = ui.ctx().data(|d| d.get_temp::<crate::i18n::Language>(egui::Id::new("interface-language"))).unwrap_or_default();
ui.menu_button(language.tr(sub), |ui| {
ui.set_min_width(220.0);
menu_level(ui, &inner, depth + 1, clicked);
});
+1 -1
View File
@@ -1679,7 +1679,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
.iter()
.filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": shift_track(seq, tid, track_delta).unwrap_or(tid).0, "time": (it.start + offset).max(Tick::ZERO).0})))
.collect();
Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command})))
Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false})))
}
Drag::Trim { clip, edge, mode, delta } if delta != Tick::ZERO => Some(app.session.execute(
"timeline.trim",
+3
View File
@@ -344,6 +344,8 @@ pub enum GuideDialog {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
#[serde(default)]
pub language: crate::i18n::Language,
pub tool: Tool,
pub mode: Mode,
pub workspace: String,
@@ -721,6 +723,7 @@ fn yes() -> bool {
impl Default for UiState {
fn default() -> Self {
Self {
language: crate::i18n::Language::default(),
tool: Tool::Selection,
mode: Mode::Edit,
workspace: "Editing".into(),
+6
View File
@@ -231,6 +231,12 @@ impl Tokens {
}
/// Install fonts (Inter, Inter SemiBold, JetBrains Mono) and egui visuals.
/// Every font family the theme defines (fallback fonts such as the system Japanese font are added to
/// each of them).
pub fn font_families() -> Vec<FontFamily> {
vec![FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("semibold".into()), FontFamily::Name("medium".into())]
}
pub fn install(ctx: &egui::Context, t: &Tokens) {
let mut fonts = FontDefinitions::default();
fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));
+60 -1
View File
@@ -39,8 +39,11 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf
|---|---|---|
| `command_list` | both | every command: id, label, menu, shortcut, params, enabled now (`filter`, `enabled_only`) |
| `command_run` | both | run a command `{id, params}`; edits are undoable |
| `command_batch` | both | run several commands in order `{steps: [{id, params}], stop_on_error}` → `{completed, failed, results}` |
| `doc_inspect` | both | the project tree and the active sequence in one call (`project_inspect` + `sequence_inspect`) |
| `render_preview` | both | same as `render_frame` |
| `project_inspect` | both | bins and items with ids, types, durations; active sequence |
| `sequence_inspect` | both | the active sequence: tracks, clips (ticks and frames), effects, transitions, markers, playhead, selection |
| `sequence_inspect` | both | the active sequence: tracks, clips (`start` / `end` and `sourceIn` / `sourceOut` in ticks, frames, `speed`, `reverse`: the clip plays its `sourceIn` to `sourceOut` stretch backward, `gainDb`), effects, transitions, markers (name and `comment`), playhead, selection |
| `media_import` | both | import files by absolute path (`text`, one path per line) |
| `render_frame` | both | PNG of the program frame at `seconds` (headless renders; bridge screenshots the Program monitor) |
| `ui_inspect` | bridge | UI state: tool, workspace, panels, zoom, playback, fps |
@@ -55,6 +58,47 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf
Typical loop: `project_inspect` / `sequence_inspect` → get ids → `command_run` → `render_frame` or
`ui_screenshot` → look at the result → `edit.undo` if it's wrong.
### Conventions
The server follows a few conventions that make it predictable for agents. They are not
FilmCraft-specific: the sibling craft apps' MCP servers can follow the same ones, so an agent that has
driven one app can drive the others.
- **Core tools.** `command_list`, `command_run`, `command_batch`, `doc_inspect` and `render_preview`
work in both modes. The FilmCraft tools above stay as they are.
- **Titles and annotations.** Every tool has a `title` and the four MCP hints (`readOnlyHint`,
`destructiveHint`, `idempotentHint`, `openWorldHint`), so a client can let the read-only tools
(lists, inspects, renders, screenshots) run without asking and ask before edits. When you add a
tool, set them in its `#[tool(...)]` attribute; a test checks every listed tool.
- **Strict arguments.** An unknown tool argument is a JSON-RPC `-32602` error that names it and the
accepted ones (`unknown argument "filtr" for command_list; expected: enabled_only, filter`), not a
silently ignored key. `command_run.params` goes to the command unchanged.
- **Errors.** A failing command is an `isError` result with the engine's message. A line that is not
JSON gets a `-32700` parse error (id `null`) and the server keeps serving. A command that panics is
reported as `internal error: …`, and the session stays usable.
- **Resources.** `filmcraft://document` (the project tree and active sequence, as `doc_inspect`) and
`filmcraft://commands` (the command catalog, as `command_list`), both `application/json`. For MCP
2026-07-28 clients such as current Claude Code, list and read results carry the `ttlMs` and
`cacheScope` hints that revision requires.
### Long exports: progress and cancellation
`command_run {"id": "file.exportMedia", "params": {…, "wait": true}}` in headless mode blocks until
the file is written. It uses the MCP progress and cancellation utilities, so a client can show
progress and stop it:
- The export runs as the same background job `jobs.list` shows. The session is locked only for short
polls, so other requests (`ping`, `doc_inspect`, `jobs.list`, …) are answered while it encodes.
- With `"_meta": {"progressToken": T}` on the `tools/call`, the server sends
`notifications/progress` `{progressToken: T, progress: <frames done>, total: <frames>, message:
<job status>}` at most every 100 ms while frames advance. Without a token it sends none.
- `notifications/cancelled` for the request stops the encode at the next batch (`jobs.cancel`),
deletes the partial output (the movie, or the frames and caption sidecar written so far) and sends
no response, as the MCP cancellation utility asks.
- Without `wait` the command returns `{job, path}` at once, as before: poll `jobs.list` and stop it
with `jobs.cancel`. Other exports (`file.exportFrame`, interchange formats, …) finish quickly and
ignore the token. Bridge mode forwards the call unchanged; the app shows its own progress.
## 2. Control channel
`filmcraft --control 9876` (or `FILMCRAFT_CONTROL_PORT=9876`) listens on `127.0.0.1` only. Send one
@@ -89,10 +133,25 @@ Notes:
- Time is in ticks (254 016 000 000 per second). Commands also accept `seconds`, `frame` or
`timecode`.
- A track parameter (`track`, `audioTrack`) is a track id or a name such as `"V1"` / `"A2"`.
Leaving it out picks the command's default track; naming a track the sequence does not have is
an "invalid parameters" error, never a different track.
In `timeline.place`, `track` is where the picture goes and `audioTrack` where the sound goes;
an audio track given as `track` places the sound alone, on that track.
- `engine.execute` also runs UI-only commands (`tool.razor`, `playback.toggle`,
`window.workspace.color`, `window.panel.<name>`).
- A command that works on the selection is disabled ("no clips selected", "select a clip in the
Project panel") until something is selected, unless the call names its targets under a key the
command documents (`clips` / `clip` for timeline clips, `items` / `item` for project items):
`clip.replaceFromBin {"clips":[12],"item":4}` runs with nothing selected and leaves the
selection alone. Only the selection condition is answered by the named targets; every other
one (an open sequence, a filled clipboard, the right kind of clip) still applies, and ids of
nothing do not count. `command_list` / `engine.commands` keep reporting the selection's state.
- Element ids come from the previous frame. If an element is missing right after a layout change,
the app retries on later frames before giving up.
- `timeline.move` takes linked partners along while Linked Selection is on: moving a picture
clip moves its sound by the same offset (pass `"linked": false` to move only the listed clips).
It returns `{"moved": [ids]}`, every clip that moved.
- Without a window, `filmcraft-cli run script.jsonl` (lines of `{"id":"…","params":{…}}`) runs the
same commands headlessly. See §2b for the rest of the CLI.
+8 -1
View File
@@ -601,6 +601,11 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
| `png`, `tiff`, `bmp` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
- **Reproducible.** The same project and settings give the same file on every machine. Frames render
in batches of one per core, but the muxer gets them in fixed groups of 16 output frames
(`INTERLEAVE` in `crates/export/src/job.rs`), each followed by its audio, and H.264 uses one slice
per four macroblock rows instead of one per core. MXF files are the exception: their UMIDs and
modification date come from the clock, as SMPTE ST 330 / ST 377 expect.
- **Settings.** `ExportSettings` (serde, camelCase, every field optional) holds Video (frame size or
Match Source, frame rate, Scale to Fit / Fill / Stretch, pixel aspect, field order — progressive
only, profile / level, bitrate encoding, target / maximum / adaptive bitrate, keyframe distance,
@@ -624,7 +629,9 @@ Video encoders implement `export::VideoEncoder`. Codec crates plug in with `regi
Timelines can be exchanged as EDL, FCP7 XML, FCPXML, OTIO, AAF or OMF. `file.import` detects these
formats and merges the result into the project as one undoable step (AAF / OMF embedded audio is
written next to the document first), and `file.exportInterchange`, `file.exportEdl`,
`file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them. For AAF and
`file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them.
`file.exportInterchange {format}` takes `edl`, `xml` (the default), `fcpxml`, `otio`, `aaf` or `omf`;
any other value is a parameter error and nothing is written. For AAF and
OMF the engine (`engine::aaf_omf`) first prepares the audio the document references: it lists the
used ranges (`interchange::essence::audio_needs`), decodes or renders them (clip effects through the
export audio pipeline), embeds them or writes WAV / AIFF files, and optionally renders a video
+3
View File
@@ -38,6 +38,9 @@ cargo xtask web --serve 8765 # the web app on http://12
`FILMCRAFT_CONTROL_PORT=9876` works like `--control 9876`, and `FILMCRAFT_CPU_COMPOSITE=1`
disables the GPU compositor.
`filmcraft --help` lists the app's options. An option it does not know, or a control port that is
not a number (from `--control` or from `FILMCRAFT_CONTROL_PORT`), is an error on stderr with exit
code 2 instead of a window.
Dev builds compile dependencies at `opt-level = 2` and workspace crates at `opt-level = 1`. For
playback and codec speed, use `--release`.
+34 -4
View File
@@ -29,7 +29,13 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `ui.playback` | `{action: play|stop|toggle, speed?}` | |
| `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) |
| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus |
| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) |
| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle plane buffers of evicted frames, and planes decoded into a recycled one), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) |
**Selection or explicit targets.** A command that acts on the selection is "not available right now" when
nothing is selected. When `params` name the targets under a key the command documents (`clips` /
`clip`: timeline clips of the active sequence, `items` / `item`: project items), enablement is
checked against those instead and the selection is left as it is; the command's other conditions
still apply. `engine.commands` and the menus always show the selection's enablement.
**Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens
without activating itself. UI requests and screenshots that need a rendered frame bring the window
@@ -46,7 +52,7 @@ solo, record arm, solo safe, mode Off/Read/Latch/Touch/Write, output, input map,
`mixer.recordStart` / `mixer.recordStop` (playback runs them), `mixer.addSubmix`, `mixer.deleteSubmix`,
`mixer.addInsert` / `removeInsert` / `setInsert`, `mixer.addSend` / `setSend` / `removeSend`,
`mixer.setKeyframe` / `deleteKeyframe` / `moveKeyframe` / `clearLane`, `mixer.writeAutomation`;
`clipMixer.set`; `clip.audioGain {mode: set|adjust|normalizeMax|normalizeAll, db}`, `clip.audioPeak`;
`clipMixer.set`; `clip.audioGain {clips?, mode: set|adjust|normalizeMax|normalizeAll, db}`, `clip.audioPeak`;
`effects.setDefaultTransition`. UI ids: `mixer.<A1|S1|Mix>.<fader|value|pan|panValue|mode|mute|solo|
record|soloSafe|output|input|fx.<n>|send.<n>|meter|name>` (popup entries below them, e.g.
`mixer.A1.mode.Touch`, `mixer.A1.fx.0.studio_reverb`), `mixer.showEffects`, `mixer.transport.<cmd>`,
@@ -74,12 +80,33 @@ management commands and dialog ids: offline media and relinking (`media.findMiss
proxies (`media.createProxies`, `media.attachProxies`, `media.toggleProxies`, `proxies.*`), ingest
(`project.ingestSettings`) and the Project Manager (`file.projectManager`, `pm.*`).
**Moving clips**: `timeline.move {moves: [{clip, track, time}], insert?, linked?}` → `{moved: [clip ids]}`.
With Linked Selection on (the default; `linked` overrides the toggle for one call) the linked
partners of a listed clip that are not listed themselves move by the same time offset and stay on
their own tracks, so picture and sound stay in sync. Like a listed clip, a partner overwrites
what is at its destination (or inserts, with `insert`). A partner on a locked track stays where it
is, and so does one whose position would not change (the listed clip only changed track). `moved`
lists every clip that moved, partners included. No clip is moved before the sequence
start: if one would be, all clips of the call land later by the same amount (their spacing is
kept). A `time` beyond the representable range is an "invalid parameters" error. The timeline
panel's drag passes `linked: false`, because it already lists exactly the clips it moves.
**Trim mode** (`crates/engine/src/trim.rs`): `trim.selectEditPoint` / `trim.selectNearest` enter trim
mode (the Program monitor becomes the Trim Monitor, ids `trimMonitor.*`); `trim.monitor` returns what
it shows. Dynamic trimming takes an explicit clock (seconds) so it is deterministic: `trim.shuttle
{direction, slow?, clock}` (J/L), `trim.tick {clock}`, `trim.shuttleStop {clock?}` (K, one undo step),
`trim.cancelDynamic` (Esc), `trim.playAround {clock, loop?}` (Space / Shift+K).
**Replace With Clip**: `clip.replaceFromSource`, `clip.replaceFromSourceMatchFrame` and
`clip.replaceFromBin {clips?, item?, keepSourceIn?}` swap the media of the clips and return
`{clips: [ids], item, short: [{clip, shortByFrames}]}`. `short` lists the replaced clips that now
run past the end of their media (the renderer holds the last frame there) with the number of
sequence frames that have no media, at most the clip's length; it is empty when the new media
covers every clip. The edit is the same either way. From Bin starts each clip at the new item's In point; with
`keepSourceIn: true` each clip keeps its own source In instead. A subclip that restricts trims has
no media outside its range, so there a kept In is moved to the subclip's first or last frame, and
the result's `sourceInClamped: [ids]` (present with `keepSourceIn`) names the clips it was moved for.
**Colour** (`crates/engine/src/color.rs`): `sequence.colorSettings {workingSpace: rec709|rec2100-pq|
rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace: auto|<id>}`,
`color.spaces`, `media.colorInfo {item}`; LUTs: `lut.import {path, name?}`, `lut.list`,
@@ -103,7 +130,10 @@ Ctrl), `Ctrl` (macOS ⌃), `Alt`, `Shift`. The dialog (Edit ▸ Keyboard Shortcu
## MCP
`filmcraft-cli mcp` serves MCP on stdio: headless (in-process session; `--demo` / `--project p.fcproj`)
or `--bridge 127.0.0.1:9876` to drive the running app. Tools: `command_list`, `command_run`,
`project_inspect`, `sequence_inspect`, `media_import`, `render_frame`, `ui_inspect`, `ui_elements`,
`ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_screenshot`, `ui_control`. `.mcp.json` registers both.
`command_batch`, `doc_inspect`, `render_preview`, `project_inspect`, `sequence_inspect`, `media_import`,
`render_frame`, `ui_inspect`, `ui_elements`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_screenshot`,
`ui_control`; resources `filmcraft://document` and `filmcraft://commands`. `.mcp.json` registers both.
Annotations, argument checking, errors and export progress / cancellation:
[agents.md § Conventions](agents.md#conventions).
Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, `frame` or `timecode`.
+46
View File
@@ -13,6 +13,52 @@ the before/after comparison relies on. Each milestone below alternated base / af
each); the most load-independent figure is the decoder's own cycle count (`proc_pid_rusage`
instructions and cycles of a process, all threads summed).
## Memory of clips on a timeline (MEM1–MEM3)
Measured on the desktop release build with `footprint`, `heap` and `malloc_history`
(`MallocStackLogging=lite`), 2026-10-05: twelve H.264 clips dropped on a timeline took the process
to a 12 GB footprint, 10.8 GB of it live heap in three piles.
1. **Media files read whole (2.9 GB), MEM1.** The desktop host had no `Services::reader`, so
`MediaPool::open_file` fell back to `read_file` and every clip's bytes stayed in memory for as
long as its source was open. `FsServices::reader` now returns a
`filmcraft_media::reader::FileReader`: the file stays open and containers read their index and
then single samples with positional reads, as the web host already did through `BlobReader`
([web.md](web.md)). Formats decoded in one go (stills, WAV, MPEG elementary streams) are still
read whole through the reader.
2. **A decoder per clip, for good (3.5 GB), MEM2.** Every source kept its decoder once it had
decoded a frame, with its reference pictures and the pictures its frame threads have in flight:
about 290 MB per clip here (`h264` `PicState`, `Frame::make_row`). The GOP caches now share a
pool (`crates/codecs/src/gop.rs`): beyond `MAX_LIVE_DECODERS` (4) the least recently used
caches that have been idle for 3 s drop their decoder and make a new one when asked again,
which costs what a seek costs. A cache in use (a layer of the frame being composited, the next
clip being prefetched) always keeps its decoder, so a composite of more than four clips does
not restart decoders on every frame. `perf.stats` reports `decode.liveDecoders`.
3. **A frame budget per clip, none overall (3.8 GB), MEM3.** Each GOP cache keeps up to 384 MB
of decoded frames (at least 64 frames), which every clip on the timeline filled and kept. The
pool now counts the frames of all caches against `FRAME_BUDGET` (1 GiB): over it, caches idle
for 3 s give up frames, least recently used cache first, earliest frames first. Caches in use
keep their own budget, because a frame evicted before it is shown costs a re-decode from the
keyframe. `perf.stats` reports `decode.cacheMB` and `decode.cacheBudgetMB`; before, this
memory appeared in no counter (`frames.cache` covers the frame server's rendered images and
GPU plans only).
4. **Freed planes the allocator keeps (2.5 GB), MEM4.** With MEM1–MEM3 the same twelve clips took
5.1 GB, only 2.5 GB of it live: every decoded picture allocated three planes and every
eviction freed them, and the system allocator kept the freed pages (`footprint`: 1.7 GB
reclaimable). `filmcraft_frame::pool` now keeps the planes of evicted frames (up to 192 MiB per
sample type; a frame something else still holds is left alone) and the H.264 decoder and the
VideoToolbox path decode into them, so steady-state decoding allocates no planes.
`perf.stats` reports `decode.planePoolMB` and `decode.planesReused`.
The same session showed that the desktop app never used hardware decoding: `register()` was an
argument of a `log::info!` that no logger evaluated (fixed under HW1; `decode.hardware.sessions`
was 0 with every frame decoded in software).
What remains per live decoder is unchanged: the H.264 decoder publishes each macroblock row of a
picture as its own allocations (`Frame::make_row`, five per row), which is where the 290 MB per
decoder comes from.
## Results (GPU2: standard effects on the GPU compositor, #30, before → after)
Before = this change with clips that carry standard effects sent back to the CPU layer path in
+7
View File
@@ -19,6 +19,13 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm
- `project` is the serialized `filmcraft_project::Project` (bins, media clips, sequences, tracks,
clips, effects, keyframes, markers). Media is referenced by path, never embedded.
- Files are written compact (no indentation). A 2,000-clip project is about 1.4 MB.
- A coordinate of an effect's point parameter may be `null`. Point parameters use NaN for "auto"
(the frame centre, or the source centre for `anchor`), and JSON writes NaN as `null`; it reads back
as "auto". Placing a clip resolves these points at once. An interchange import resolves them
too, except the `anchor` of a clip whose media file was not found. That one stays "auto", and
the renderer centres it, until Link Media (`media.relink`, `media.autoRelink`) reads the file
and with it the real picture size; a file that merely comes back at its old path does not
change it. `null` anywhere else is a damaged file.
### Schema history
+1
View File
@@ -90,6 +90,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2).
| MPEG TS / PS (via `codecs/tests/mpeg_oracle.rs`) | ffmpeg-written TS (MPEG-2 + MP2, H.264 + AAC, HEVC + LATM AAC), BDAV `.m2ts` / `.mts` (H.264 + Blu-ray LPCM / AC-3), VOB (MPEG-2 + MP2 / DVD LPCM 16 and 24-bit / AC-3), MPEG-1 system streams, `.m2v`; MPEG-2 in MOV (XDCAM `xd5c`), MP4 (`mp4v`), MKV | frame count and every frame's **PTS exact** (ffprobe); H.264 / HEVC **bit-exact**, MPEG-2 within the `mpeg2v` criterion; random seeks = sequential decode; LPCM **sample-exact**; AAC ≤ 2.1·10⁻⁷, MP2 (symphonia) ≤ 3.2·10⁻⁵, AC-3 ≤ 5·10⁻³ (decoder-specific dither; measured 2.3·10⁻³); random-access audio = sequential |
| `ac3` | ffmpeg AC-3 encodes (2/0 with coupling and rematrixing, 1/0 32 kHz, 3/2+LFE 448 kb/s, noise) decoded by ffmpeg | SNR per channel ≥ 40-60 dB on tonal content (measured 66-94 dB), ≥ 18 dB on noise (27.7 dB, the same as between two of our own dither seeds) |
| `ogg` (via `codecs/tests/ogg_oracle.rs`) | ffmpeg (libopus, Vorbis) files decoded by libopus / ffmpeg | length **exact** (pre-skip, end trimming); SNR ≥ 50 dB CELT (measured 77.7), ≥ 10 dB SILK, Vorbis 139 dB; random seeks within 6·10⁻⁶ of the continuous decode |
| MP4 audio at another rate (`codecs/tests/mp4_oracle.rs`) | ffmpeg-written AAC in MP4 (48 kHz, edit list skipping the priming) | reads resampled to 44.1 kHz in frame-sized requests equal the native decode interpolated at the same times (difference < 10⁻⁴, peak ≤ the source level) |
| image sequences / BWF (`codecs/tests/media_oracle.rs`) | ffmpeg-written PNG/TIFF/BMP/JPEG stills read by ffmpeg's image2 demuxer; ffmpeg BWF (`-write_bext`) | PNG/TIFF/BMP **exact**, JPEG ±12 (measured 3); missing frames hold the previous one; BWF TimeReference = ffprobe's, start timecode, samples exact |
| `export` | our own demuxer/decoder reads the file back; ffprobe counts frames when present | expected size, duration, colour, audio level; exact frame count |
| `export` settings and presets (M6.5) | `export/src/settings_tests.rs`, engine `export_tests.rs`: every built-in preset exports a slice of the demo sequence; ffprobe reads codec, profile, size, rate, audio format and data rate; our decoders read pixels back | codec / profile / container / size / `r_frame_rate` / sample format exact; H.264 bitrate ≤ VBR max × 1.1 and ≥ ½ target over 3 s; ProRes ≤ 1.5 × nominal; DNxHR within 25 % of nominal; loudness normalization within **0.5 LU** of the target (WAV and AAC), true peak ≤ ceiling + 0.1 dB; image sequences numbered `<name>000…`; limiter, overlays, metadata (`title`/`copyright` tags), MOV multiplexer, two-pass/CBR progress |
+5 -5
View File
@@ -17,15 +17,15 @@
Scope="perMachine"
Compressed="yes"
Language="1033">
<SummaryInformation Description="FilmCraft image editor" Manufacturer="Learning Machines LLC" />
<SummaryInformation Description="FilmCraft video editor" Manufacturer="Learning Machines LLC" />
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
<MajorUpgrade AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of FilmCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="FilmcraftIcon" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="FilmcraftIcon" />
<Icon Id="FilmcraftIcon.ico" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="FilmcraftIcon.ico" />
<Property Id="ARPURLINFOABOUT" Value="https://getartcraft.com/apps/filmcraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://getartcraft.com/apps/filmcraft" />
<Property Id="ARPHELPLINK" Value="https://discord.gg/artcraft" />
@@ -45,8 +45,8 @@
<Component Id="FilmcraftApp" Guid="15933017-925B-4963-8992-52000EB26AAA">
<File Id="FilmcraftExe" Source="$(var.BinDir)\filmcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="FilmCraft"
Description="FilmCraft image editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="FilmcraftIcon" IconIndex="0" />
Description="FilmCraft video editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="FilmcraftIcon.ico" IconIndex="0" />
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\filmcraft.exe" Value="[#FilmcraftExe]" Type="string" />
+28 -3
View File
@@ -17,9 +17,10 @@
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
#>
param(
[ValidateSet('x64', 'x86')] [string] $Arch = 'x64',
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
[switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
@@ -44,7 +45,7 @@ if (-not $Version) { throw 'could not read [workspace.package] version from Carg
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
$MsiVersion = ($Version -split '-')[0]
$Target = if ($Arch -eq 'x64') { 'x86_64-pc-windows-msvc' } else { 'i686-pc-windows-msvc' }
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
@@ -65,6 +66,23 @@ if (-not $SkipBuild) {
}
$Bin = Join-Path $TargetDir "$Target\release"
# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an
# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console.
# The app must be GUI (no console window opens with it); the CLI must stay console so its output
# reaches the terminal.
function Get-PeHeader([string] $Path) {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
}
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
foreach ($check in @(@('filmcraft.exe', 2), @('filmcraft-cli.exe', 3))) {
$h = Get-PeHeader (Join-Path $Bin $check[0])
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
}
$Stage = Join-Path $TargetDir "windows-package\$Arch"
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
@@ -96,5 +114,12 @@ $Zip = Join-Path $Dist "filmcraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
Invoke-Native 'filmcraft-cli --version' { & (Join-Path $Stage 'filmcraft-cli.exe') --version }
# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run
# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead.
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') {
Invoke-Native 'filmcraft-cli --version' { & (Join-Path $Stage 'filmcraft-cli.exe') --version }
} else {
Write-Output "skipping filmcraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
}
Get-Item $Msi, $Zip | Format-Table Name, Length