Compare commits

...
18 Commits
Author SHA1 Message Date
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
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
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
17 changed files with 1063 additions and 119 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"
+3 -2
View File
@@ -221,8 +221,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 != ''
+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"
+1 -1
View File
@@ -14,7 +14,7 @@
//! 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;
+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);
+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;
+44
View File
@@ -39,6 +39,9 @@ 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 |
| `media_import` | both | import files by absolute path (`text`, one path per line) |
@@ -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
+5
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,
+5 -2
View File
@@ -103,7 +103,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`.
+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" />