Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions crates/driver-mlt-video/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,39 @@ bounded staging, native decoder checks and safe output publication. The runner
receives project/media roots read-only and the output root writable through typed
Host arguments. It never accepts arbitrary executables, environment or shell
commands. Compatibility fixture `Runtime` paths do not select production tools.


## Explicit video-only lossless FFV1 for downstream native A/V mux

The existing `lossless` render profile remains **FFV1 + PCM s16le audio**.
The separate, opt-in `lossless-video-only` profile produces Matroska FFV1
**without any audio stream**, even if the semantic MLT timeline has an audio
track. This supports the separately authorized `driver.mlt-video.av.mux`,
which demands video-only material plus a distinct measured 48 kHz stereo WAV.

This is not a generic encoder override. The profile is selectable only
through the existing typed `render.plan` → `render.start` →
`render.status` → `render.result` Broker/Driver Host path. The pinned
MLT `avformat` consumer receives `an=1` rather than a silent PCM track;
capabilities cannot supply an executable, shell command, arbitrary filter,
encoder flags or external output root.

The confined native render validates that exactly one FFV1 video stream and
**no audio** is present, matches source geometry and bounded duration, and
then **independently decodes and counts every frame** using a pinned FFprobe
`-count_frames` operation inside the existing Host sandbox. A missing
Matroska header `nb_frames` is not treated as success. The exact counted
value must equal the project plan (1–36,000 frames). This check runs
*before* existing exclusive/no-overwrite owner-root publication, preserving
artifact SHA-256, cancellation limits and source revision authority.

The original `lossless`, H.264/AAC and `audio-wav` profiles are unchanged.
A video-only intermediate is not an H.264/AAC master, voice quality verdict
or human acceptance.

Linux Host/MLT E2E exercises both the original lossless and the new
video-only profile on the **same 50-frame semantic timeline** (which includes
an audio source). It also runs independent host FFprobe on the new output,
requiring exactly one FFV1 video stream and 50 decoded frames. Rust unit
tests reject forged profiles, unexpected PCM/audio, wrong frame counts,
codec substitutions, malformed decoder output and out-of-budget metadata.
6 changes: 3 additions & 3 deletions crates/driver-mlt-video/fixtures/SHA256SUMS.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"PROVENANCE.json": "0ad5f992c9cbd885009df4841ccaa617cf885a5b09d72eab87d8008202ab00be",
"expected/capabilities.json": "d002ebd345b81ddcc5d68de848ea43a10c5e898676d20d52f77979597e410ce5",
"expected/catalog-wire.json": "1987d57e2f3ed96c272eae1a59456bf4af7415e755a255650d1c4b77e2494693",
"expected/digests.json": "b74039e30f6fb9e001c188c45e5307384aa5d913491166ea614cc42a2bab5366",
"expected/capabilities.json": "ec96b8856d808c3c9732ef94337a432c519e834a296bedd6ac92d3adc62b74b5",
"expected/catalog-wire.json": "a30653066c11d95f2d632bc6f147d3a8cefd56b88bce7703b614e23b35c0d24d",
"expected/digests.json": "6e324960da6077d2fda6850466b0655aaacc037df84e0bb72d57165b592c6c68",
"kdenlive/file-uri.kdenlive": "9d9017e08a69bab303b336d922e5d241a12922ac7ab9d26402abb1bc8bc8fcbf",
"kdenlive/future.kdenlive": "a8f0491fa66e5b6c20f21f75a1c1737ec674b99a09122e5e701ab113ef74741b",
"kdenlive/multiple.kdenlive": "a2487cea93c77514803dff98a33b3e9967076bd96e1f52ce29aa3b78df56ceb4",
Expand Down
7 changes: 5 additions & 2 deletions crates/driver-mlt-video/fixtures/expected/capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -7806,7 +7806,10 @@
"maxLength": 256
},
"audio_codec": {
"type": "string",
"type": [
"string",
"null"
],
"maxLength": 256
},
"container": {
Expand All @@ -7830,7 +7833,7 @@
],
"additionalProperties": false
},
"maxItems": 4
"maxItems": 5
},
"mlt_version": {
"type": [
Expand Down

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions crates/driver-mlt-video/fixtures/expected/digests.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "1987d57e2f3ed96c272eae1a59456bf4af7415e755a255650d1c4b77e2494693",
"catalog_sha256": "a30653066c11d95f2d632bc6f147d3a8cefd56b88bce7703b614e23b35c0d24d",
"descriptor_sha256": {
"driver.mlt-video.asset.import": "4de33c901da0526cc8fc503defa3e303e05ff12634a1acb2861fffecd8f01f8e",
"driver.mlt-video.asset.inspect": "3e5a56169385238784bfcc2e8a761715ce40bce487c867a4f3fb058c2856cd98",
Expand Down Expand Up @@ -49,7 +49,7 @@
"driver.mlt-video.project.validate": "efbc9338317357bed27320e185922a0a7963309e983f479be5f8d462f5753509",
"driver.mlt-video.render.cancel": "66af788964b880fdfd65541a1a563b0fa2ee42d3de77b7e0397d09a95545c652",
"driver.mlt-video.render.plan": "d4b13726b275ececcf6386d03e769c65a3af2844229a25f2475359a3f669feb6",
"driver.mlt-video.render.profiles": "ebabde9d4ae33a39f216a4de8b7e8566da20aa075a044faf54f7ffd31397ede9",
"driver.mlt-video.render.profiles": "713197d541fc78a1a793b46589520052deffd053a92225146944ccb75c24d4f6",
"driver.mlt-video.render.result": "2ab3cdbcf6e09f12362f83c56e84a8e29327be131cc091de1a88d46de68c2ac0",
"driver.mlt-video.render.start": "b322392546939cc6cd7281f92cd18f638c76e7416e356f33ef4f62d4ae2f5289",
"driver.mlt-video.render.status": "494664293d83b66967bb5b4a24b61e2f55dfb6db9e5e43d08fd09fbbba019356",
Expand Down
2 changes: 1 addition & 1 deletion crates/driver-mlt-video/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1540,7 +1540,7 @@ impl App {
width: Some(width),
height: Some(height),
video_codec: Some("libx264"),
audio_codec: "aac",
audio_codec: Some("aac"),
container: "mp4",
extension: "mp4",
};
Expand Down
57 changes: 39 additions & 18 deletions crates/driver-mlt-video/src/bin/runtime_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,26 +166,39 @@ fn probe(
scratch_root: &Path,
directory: &str,
name: &str,
count_video_frames: bool,
) -> Result<Value, String> {
let (work, target) = scratch_file(scratch_root, directory, name)?;
let args = [
"-v",
"error",
"-show_streams",
"-show_format",
"-of",
"json",
]
.into_iter()
.map(OsString::from)
.chain(std::iter::once(target.into_os_string()))
.collect();
let mut args = vec![OsString::from("-v"), OsString::from("error")];
if count_video_frames {
// Exact opt-in: independently decode *all* video frames while
// retaining the entire stream list, so unexpected audio remains
// visible to the Host and can never be hidden by select_streams.
args.push(OsString::from("-count_frames"));
}
if count_video_frames {
// Keep a small, closed observation document. Importantly do NOT
// select only video streams: any unexpected audio remains visible.
args.extend([
"-show_streams", "-show_format",
"-show_entries",
"stream=codec_name,codec_type,width,height,nb_frames,nb_read_frames,channels,sample_rate:format=duration",
"-of", "json",
].map(OsString::from));
} else {
args.extend(["-show_streams", "-show_format", "-of", "json"].map(OsString::from));
}
args.push(target.into_os_string());
let (stdout, _stderr) = execute(
ffprobe,
args,
&work,
Duration::from_secs(5),
30,
if count_video_frames {
Duration::from_secs(120)
} else {
Duration::from_secs(5)
},
if count_video_frames { 180 } else { 30 },
1_073_741_824,
)?;
let media: Value = serde_json::from_slice(&stdout)
Expand Down Expand Up @@ -464,11 +477,19 @@ fn parse() -> Result<Value, String> {
let scratch_root = PathBuf::from(required_flag(&mut args, "--scratch-root", "probe")?);
let directory = required_flag(&mut args, "--directory", "probe")?;
let name = required_flag(&mut args, "--name", "probe")?;
if args.next().is_some() {
return Err("runtime runner received unexpected arguments".into());
}
let count_video_frames = match args.next() {
None => false,
Some(flag) if flag == "--count-video-frames" && args.next().is_none() => true,
_ => return Err("runtime runner received unexpected arguments".into()),
};
let ffprobe = runtime_entry(&runtime_root, &sealed_ffprobe, "ffprobe")?;
probe(&ffprobe, &scratch_root, &directory, &name)
probe(
&ffprobe,
&scratch_root,
&directory,
&name,
count_video_frames,
)
}
"render" => {
let runtime_root = PathBuf::from(required_flag(&mut args, "--runtime-root", "render")?);
Expand Down
7 changes: 5 additions & 2 deletions crates/driver-mlt-video/src/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -7806,7 +7806,10 @@
"maxLength": 256
},
"audio_codec": {
"type": "string",
"type": [
"string",
"null"
],
"maxLength": 256
},
"container": {
Expand All @@ -7830,7 +7833,7 @@
],
"additionalProperties": false
},
"maxItems": 4
"maxItems": 5
},
"mlt_version": {
"type": [
Expand Down
149 changes: 101 additions & 48 deletions crates/driver-mlt-video/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ impl MltVideoDriver {
name: &str,
context: &DriverExecutionContext,
) -> Result<MediaInfo> {
self.host_probe_staged_observed(directory, name, context)
self.host_probe_staged_observed(directory, name, false, context)
.await
.map(|(media, _)| media)
}
Expand All @@ -474,53 +474,78 @@ impl MltVideoDriver {
&self,
directory: &str,
name: &str,
count_video_frames: bool,
context: &DriverExecutionContext,
) -> Result<(MediaInfo, Vec<u8>)> {
let output = context
.execute_runtime_tool_args(
"mlt-runner",
vec![
RuntimeToolArg::Literal {
value: "probe".into(),
},
RuntimeToolArg::Literal {
value: "--runtime-root".into(),
},
RuntimeToolArg::MountPath {
mount: "mlt-runtime".into(),
relative: String::new(),
},
RuntimeToolArg::Literal {
value: "--ffprobe-sealed".into(),
},
RuntimeToolArg::ToolPath {
tool: "ffprobe".into(),
},
RuntimeToolArg::Literal {
value: "--scratch-root".into(),
},
RuntimeToolArg::MountPath {
mount: "scratch".into(),
relative: String::new(),
},
RuntimeToolArg::Literal {
value: "--directory".into(),
},
RuntimeToolArg::Literal {
value: directory.to_owned(),
},
RuntimeToolArg::Literal {
value: "--name".into(),
},
RuntimeToolArg::Literal {
value: name.to_owned(),
},
],
Vec::new(),
std::time::Duration::from_secs(30),
None,
)
.await?;
let mut args = vec![
RuntimeToolArg::Literal {
value: "probe".into(),
},
RuntimeToolArg::Literal {
value: "--runtime-root".into(),
},
RuntimeToolArg::MountPath {
mount: "mlt-runtime".into(),
relative: String::new(),
},
RuntimeToolArg::Literal {
value: "--ffprobe-sealed".into(),
},
RuntimeToolArg::ToolPath {
tool: "ffprobe".into(),
},
RuntimeToolArg::Literal {
value: "--scratch-root".into(),
},
RuntimeToolArg::MountPath {
mount: "scratch".into(),
relative: String::new(),
},
RuntimeToolArg::Literal {
value: "--directory".into(),
},
RuntimeToolArg::Literal {
value: directory.to_owned(),
},
RuntimeToolArg::Literal {
value: "--name".into(),
},
RuntimeToolArg::Literal {
value: name.to_owned(),
},
];
if count_video_frames {
args.push(RuntimeToolArg::Literal {
value: "--count-video-frames".into(),
});
}
let output = if count_video_frames {
// Full frame decoding is bounded but may exceed 30 seconds for
// 36k FFV1 frames. Protocol v7 synchronous tools have a strict
// 30-second ceiling; use its *existing* Host-owned detached job
// with cancellation and reap-on-error semantics. Never weaken
// the protocol or run a consumer-side ffmpeg process.
let job = context
.start_runtime_tool_job_args(
"mlt-runner",
args,
Vec::new(),
std::time::Duration::from_secs(150),
None,
)
.await?;
context.wait_runtime_tool_job(&job).await?
} else {
context
.execute_runtime_tool_args(
"mlt-runner",
args,
Vec::new(),
std::time::Duration::from_secs(30),
None,
)
.await?
};
if output.exit_code != 0 {
let detail = host_runner_failure(&output.stdout);
return Err(Error::new(
Expand Down Expand Up @@ -827,9 +852,37 @@ impl MltVideoDriver {
context: &DriverExecutionContext,
) -> Result<(semwright_mlt_video::fs::Artifact, MediaInfo)> {
let scratch_path = diagnostic.scratch_path;
let (media, raw_probe) = self
.host_probe_staged_observed(diagnostic.directory, output_file, context)
let (mut media, raw_probe) = self
.host_probe_staged_observed(
diagnostic.directory,
output_file,
profile.id == "lossless-video-only",
context,
)
.await?;
if profile.id == "lossless-video-only" {
// The Host passed the exact --count-video-frames argument to
// FFprobe in the *same* confined runner. Require nb_read_frames:
// ordinary Matroska nb_frames is not proof of a full decode.
// Independently inspect all streams before publishing anything.
let envelope: Value = serde_json::from_slice(&raw_probe).map_err(|_| {
Error::new(
ErrorCode::PluginProtocolError,
"MLT decoded frame envelope is malformed",
)
})?;
let observation = envelope.get("media").ok_or_else(|| {
Error::new(
ErrorCode::PluginProtocolError,
"MLT decoder has no complete stream evidence",
)
})?;
let bytes = serde_json::to_vec(observation)?;
let observed =
semwright_mlt_video::runtime::parse_exact_ffv1_decoded_frame_count(&bytes)
.map_err(map_error)?;
media.frames = Some(observed);
}
if let Err(error) = validate_render_media(&media, profile, expected_profile, frames) {
// Evidence is an additional bounded JSON sibling inside the existing output grant.
// Failure to retain it must not erase or soften the original validation failure.
Expand Down
Loading
Loading