timeline.place kept a sub-frame start and rounded the duration to whole
frames, so the clip ran past time + duration and the sequence ended
between frames. The start and the end now each snap to the nearest frame,
as a timeline drag already does. sequence.inspect's durationFrames now
comes from the same frame_span export uses, so the two always agree.
Fixes#1101
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exports and rendered frames now record which offline items (missing,
made offline, unreadable) they drew the placeholder slate for. The engine
reports them as `offlineMedia` in file.exportMedia (wait), jobs.list
export entries and export.queue items; the bridge and MCP long exports
pass it through. filmcraft-cli export and render name each item on
stderr and exit 1, unless --allow-offline accepts the placeholders.
Fixes#1054
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto main's cli()/out! refactor: `--help` is checked as a parsed flag, so
`filmcraft-cli export --help` prints the reference too (test extended).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`filmcraft-cli commands | head -c 10` panicked with "failed printing to stdout: Broken pipe
(os error 32)". Output now goes through `outln!` / `out!`. When stdout fails, later output is
dropped but the program does not stop: the rest of `exec`, `run` and `import` (later script
lines, `--save`, `--save-as`) still runs and the exit status still reports a failing command.
Exiting at the first failed print would report success while skipping the save, e.g.
`filmcraft-cli run edits.jsonl --project p.fcproj --save | head -1`. A closed pipe is not an
error; any other stdout write error is reported and makes the exit status 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Export mode, the status bar and the Progress panel showed "Encoding 37%" and nothing about how
long the rest would take. They now say "37% · 2:05 left".
- `Progress::eta()` (the new `pace` module) is the time left at the job's speed over the last
15 seconds. Each call is also a reading, so the UI polling on every frame it draws the job
feeds it, and none of the places that advance `done` had to change. A trailing window follows
the real speed changes of an export (an intro, a banner, plain camera footage) where an
average since the start lags; a stall makes the estimate grow instead of freezing it; a long
idle start (the loudness pass) is not counted as the job's speed; nothing is shown until a
second of readings has progress, and nothing once the job is done or finished.
- `format_eta`: `45 s`, `2:05`, `1:02:05`, rounded up.
- Shown in the status bar ("Exporting... 37% · 2:05 left", and the `status.job.progress`
automation label), in the Export queue (on its own line: the column is narrow) and in the
Progress panel. The engine adds `etaSeconds` (null while unknown) to every job of `jobs.list`
and to the encoding items of `export.queue.list`; docs/agents.md says so.
Measured on a whole 9:29 export (193 s, Apple M1), asking every 3 s: the first estimate comes
after 6 s; from 30 s on, the predicted end of the export is on average 1.1 s early and never
more than 14.5 s off (7.5 % of the run). The status bar read "9% · 3:00 left" at 21 s and
"97% · 5 s left" at 184 s.
Tests: `pace` (steady job, speed change, stall, idle start, count going back, bounded history,
extreme values, a whole `Progress`), `format_eta`, the engine (`etaSeconds` of jobs and queue
items), and export_ui (status bar label and queue item).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
file.exportMedia with wait: true was forwarded to the app unchanged. The app then encoded on its UI
thread: it showed no progress and answered no other control request until the encode ended, and the
control server's 60 s reply limit reported "timeout" for any longer export while it went on and
succeeded.
BridgeClient::execute now starts the export without wait and polls jobs.list until the job
finishes, returning the same {job, path, result} (or the export's error). filmcraft-cli --bridge
exec and MCP bridge mode both use it, and MCP command_run in bridge mode now gets the same progress
notifications and cancellation as headless mode.
Tested against a fake app (wait: true never reaches it, one export started, result and error
returned) and end to end against the desktop app: a blocking demo export returned its 642-frame
result with exit 0 while ui.inspect was answered in ~0.05 s throughout.
Fixes#91Fixes#92
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both tools are annotated readOnlyHint, but a call with `seconds` moved the active sequence's
playhead (and, with Selection Follows Playhead on, the selection) to that time, in headless and
bridge mode alike.
- headless: Session::render_program_at renders the frame at `seconds` (frame-snapped, as the
playhead would be) without touching the session.
- bridge: the Program monitor screenshot needs the live playhead there, so the tool records the
playhead and selection, takes the screenshot and puts both back (the selection only if it changed,
since selecting also leaves trim mode).
Regression test: rendering at 5 s and 8 s with Selection Follows Playhead on leaves playhead and
selection unchanged and still renders the requested frame (it moved the playhead to 8 s before).
Fixes#114
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: 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>
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: `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>
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>
ROADMAP now reports two numbers: the feature checklist (~87%, what exists)
and ready for real work (~50-60%, an honest estimate). Adds a by-dimension
assessment with evidence, a prioritised "Where we are lacking" list (hardware
acceleration, a measured parity tool, real-world media corpus, workflow
acceptance tests, Windows/Linux runtime CI, delivery codecs, plugins, AI, web),
and where we're going. Fixes stale rows (M5 effect count, Running now) and logs
2026-10-05.
README: the stale "45%" status replaced with both numbers and the biggest
gaps; the codec note now lists what imports today and what's missing.
docs/agents.md: agents read the honest assessment first, prefer its gaps,
and report progress as measured vs estimated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>
- 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>
Public equivalents of the maintainer-local plan/: docs/architecture.md (layers as enforced by
xtask, Tick time base, data model, command system and undo, render/playback/export pipeline,
automation surfaces), docs/contributing.md (+ root CONTRIBUTING.md), docs/testing.md and
docs/agents.md (MCP/control channel usage, agent work loop, decision rules, Definition of Done).
CLAUDE.md points at the public docs wherever it references plan/; AGENTS.md gains a See also list;
README gains a Documentation section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>