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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>