From f55d9429e19205e74066d534f595c7109aea28e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orell=20B=C3=BChler?= Date: Wed, 2 Sep 2026 21:11:05 +0200 Subject: [PATCH 1/3] deliver one cut at several frames A clip keeps a crop per delivery shape (Clip.framings) beside the one smart crop bakes into its transform, Timeline::for_delivery renders the cut at another frame wearing those crops (and re-fits generated captions), and render_variants writes one file per delivery, named by shape. A framing pass (framing_inputs / sample_framings / apply_framings) samples each shot once and writes a crop for every requested shape as one revision. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gm2h2GQnUyBRLnVe431o9w --- crates/kerf-core/src/engine/cli.rs | 198 ++++++++++++++++++- crates/kerf-core/src/engine/mod.rs | 14 +- crates/kerf-core/src/lib.rs | 10 +- crates/kerf-core/src/model.rs | 303 ++++++++++++++++++++++++++++- crates/kerf-core/src/project.rs | 240 ++++++++++++++++++++++- 5 files changed, 756 insertions(+), 9 deletions(-) diff --git a/crates/kerf-core/src/engine/cli.rs b/crates/kerf-core/src/engine/cli.rs index bc9ec8b..de4e5cb 100644 --- a/crates/kerf-core/src/engine/cli.rs +++ b/crates/kerf-core/src/engine/cli.rs @@ -11,12 +11,13 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; +use std::time::Instant; use super::cpu; use super::ProbeResult; use crate::error::{Error, Result}; use crate::model::{ - Asset, AudioEffect, Clip, Color, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, SalienceMap, + Asset, AudioEffect, Clip, Color, Delivery, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, SalienceMap, StreamInfo, StreamKind, TextOverlay, TimeRange, Timeline, Transform, VideoEffect, }; @@ -3094,6 +3095,97 @@ pub fn render_with_progress( } } +/// One delivery of a multi-format export: the frame and where its file goes. +#[derive(Debug, Clone, PartialEq)] +pub struct ExportVariant { + pub delivery: Delivery, + pub output: PathBuf, +} + +impl ExportVariant { + /// The variant's file beside `base`, its shape spliced into the name: + /// `cut.mp4` at 9:16 becomes `cut-9x16.mp4`. The `x` rather than a `:` + /// because a colon is not a filename character on Windows. + pub fn beside(base: &Path, delivery: Delivery) -> Self { + let (w, h) = delivery.ratio(); + let stem = base.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = match base.extension() { + Some(ext) => format!("{stem}-{w}x{h}.{}", ext.to_string_lossy()), + None => format!("{stem}-{w}x{h}"), + }; + Self { + delivery, + output: base.with_file_name(name), + } + } +} + +/// Progress across a multi-format export: the overall [`ExportProgress`] plus +/// which variant is rendering, so a bar can say "2 of 3 · 9:16". +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] +pub struct VariantProgress { + /// Zero-based index of the variant being rendered. + pub variant: usize, + pub total: usize, + /// Progress through all of them: each variant owns an equal share. + pub fraction: f64, + pub elapsed_secs: f64, + /// Time left across the remaining variants, estimated from how long the + /// completed share has taken. + pub eta_secs: Option, +} + +/// Render the same cut once per variant — one file per delivery frame, each +/// shot wearing the crop it carries for that shape ([`Timeline::for_delivery`]). +/// +/// Variants render one after another rather than at once: an export already +/// takes every core it is given (`cpu::lease` would serialize them anyway), +/// and a cancelled batch is then clean — the variant in flight is deleted like +/// a cancelled single export, the ones already finished are kept, and the ones +/// not started never existed. `opts` is the encode shared by all of them; its +/// `resolution` and `fit` are replaced per variant by the delivery. Returns the +/// status and how many files were completed. +pub fn render_variants( + timeline: &Timeline, + assets: &[Asset], + variants: &[ExportVariant], + opts: &ExportOptions, + progress: &mut dyn FnMut(VariantProgress), + cancel: &dyn Fn() -> bool, +) -> Result<(RenderStatus, usize)> { + if variants.is_empty() { + return Err(Error::InvalidArgument("no delivery formats to export".to_string())); + } + let total = variants.len(); + let started = Instant::now(); + for (i, variant) in variants.iter().enumerate() { + let framed = timeline.for_delivery(variant.delivery); + let per_variant = ExportOptions { + resolution: Some((variant.delivery.width, variant.delivery.height)), + fit: variant.delivery.fit, + ..opts.clone() + }; + let mut on_progress = |p: ExportProgress| { + let fraction = (i as f64 + p.fraction.clamp(0.0, 1.0)) / total as f64; + let elapsed_secs = started.elapsed().as_secs_f64(); + let eta_secs = (fraction > 0.0).then(|| elapsed_secs / fraction - elapsed_secs); + progress(VariantProgress { + variant: i, + total, + fraction, + elapsed_secs, + eta_secs, + }); + }; + let status = render_with_progress(&framed, assets, &variant.output, &per_variant, &mut on_progress, cancel)?; + if status == RenderStatus::Cancelled { + let _ = std::fs::remove_file(&variant.output); + return Ok((RenderStatus::Cancelled, i)); + } + } + Ok((RenderStatus::Completed, total)) +} + /// One export run with `opts` exactly as given (no fallback). fn render_attempt( timeline: &Timeline, @@ -8211,6 +8303,110 @@ mod tests { assert!(crop.left < 0.3, "the subject is left of centre: {crop:?}"); } + #[test] + fn variant_files_land_beside_the_base_named_by_shape() { + let v = ExportVariant::beside(Path::new("/renders/cut.mp4"), Delivery::new(1080, 1920, Fit::Cover)); + assert_eq!(v.output, PathBuf::from("/renders/cut-9x16.mp4")); + let v = ExportVariant::beside(Path::new("cut"), Delivery::new(1080, 1080, Fit::Cover)); + assert_eq!(v.output, PathBuf::from("cut-1x1")); + let v = ExportVariant::beside(Path::new("/r/my.cut.mov"), Delivery::new(1920, 1080, Fit::Contain)); + assert_eq!(v.output, PathBuf::from("/r/my.cut-16x9.mov")); + } + + #[test] + fn each_variant_renders_its_own_frame_and_its_own_crop() { + let asset = av_asset(Uuid::new_v4(), 30.0); // 1920x1080 + let mut clip = make_clip(asset.id, 0.0, 5.0, 0.0); + // Cut 9:16 with a left-leaning smart crop in the transform, and a + // different framing kept for 1:1. + clip.transform.crop_left = 0.05; + clip.transform.crop_right = 0.6336; + clip.framings.push(crate::model::Framing { + aspect_w: 1, + aspect_h: 1, + crop_left: 0.3, + crop_right: 0.2625, + crop_top: 0.0, + crop_bottom: 0.0, + }); + let mut timeline = timeline_of(vec![video_track(vec![clip])]); + timeline.format = Some(Delivery::new(1080, 1920, Fit::Cover)); + + let graph_for = |d: Delivery| { + let framed = timeline.for_delivery(d); + let opts = ExportOptions { + resolution: Some((d.width, d.height)), + fit: d.fit, + ..ExportOptions::default() + }; + build_export_args(&framed, &[asset.clone()], "out.mp4", &opts) + .unwrap() + .join(" ") + }; + let vertical = graph_for(Delivery::new(1080, 1920, Fit::Cover)); + assert!(vertical.contains("scale=1080:1920"), "{vertical}"); + assert!(vertical.contains("x=iw*0.05"), "the project frame keeps its own crop: {vertical}"); + + let square = graph_for(Delivery::new(1080, 1080, Fit::Cover)); + assert!(square.contains("scale=1080:1080"), "{square}"); + assert!(square.contains("x=iw*0.3"), "the 1:1 delivery wears the 1:1 framing: {square}"); + assert!(!square.contains("x=iw*0.05"), "{square}"); + + // A shape nothing was framed for keeps the crop it has, and the + // delivery's own fit. + let wide = graph_for(Delivery::new(1920, 1080, Fit::Contain)); + assert!(wide.contains("scale=1920:1080"), "{wide}"); + assert!(wide.contains("x=iw*0.05"), "{wide}"); + } + + /// End to end: two files from one cut, each at its delivery frame. Run with + /// `cargo test -p kerf-core --no-default-features -- --ignored renders_every`. + #[test] + #[ignore = "needs the ffmpeg binary"] + fn renders_every_variant_to_its_own_file() { + let dir = std::env::temp_dir().join(format!("kerf-variants-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let media = dir.join("src.mp4"); + let ok = command(&ffmpeg_bin()) + .args(["-hide_banner", "-loglevel", "error", "-y"]) + .args(["-f", "lavfi", "-i", "testsrc=size=640x360:rate=30:duration=2"]) + .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) + .arg(&media) + .status() + .expect("run ffmpeg"); + assert!(ok.success(), "could not synthesize test media"); + + let mut asset = av_asset(Uuid::new_v4(), 2.0); + asset.path = media.to_string_lossy().into_owned(); + asset.streams = vec![video_stream(640, 360, 30.0)]; + let timeline = timeline_of(vec![video_track(vec![make_clip(asset.id, 0.0, 2.0, 0.0)])]); + + let base = dir.join("cut.mp4"); + let variants = vec![ + ExportVariant::beside(&base, Delivery::new(180, 320, Fit::Cover)), + ExportVariant::beside(&base, Delivery::new(200, 200, Fit::Cover)), + ]; + let mut ticks = Vec::new(); + let (status, done) = render_variants( + &timeline, + &[asset], + &variants, + &ExportOptions::default(), + &mut |p| ticks.push(p), + &|| false, + ) + .expect("render"); + assert_eq!((status, done), (RenderStatus::Completed, 2)); + for (v, (w, h)) in variants.iter().zip([(180, 320), (200, 200)]) { + let probed = probe(&v.output).expect("probe the variant"); + let video = probed.streams.iter().find(|s| s.kind == StreamKind::Video).expect("video"); + assert_eq!((video.width, video.height), (Some(w), Some(h)), "{}", v.output.display()); + } + assert!(ticks.iter().any(|p| p.variant == 1), "progress names the second variant"); + assert!(ticks.iter().all(|p| p.total == 2 && (0.0..=1.0).contains(&p.fraction))); + let _ = std::fs::remove_dir_all(&dir); + } + /// End to end against the real `ffmpeg` binary: synthesize a 16:9 shot whose /// only content sits in the left third, and check the sampler finds it and /// the 9:16 crop keeps it — the case a centre crop gets wrong, and the whole diff --git a/crates/kerf-core/src/engine/mod.rs b/crates/kerf-core/src/engine/mod.rs index 3664818..95733c6 100644 --- a/crates/kerf-core/src/engine/mod.rs +++ b/crates/kerf-core/src/engine/mod.rs @@ -47,7 +47,7 @@ pub use cli::{ detect_silence, export_still, frame_at, frame_jpeg, frame_jpeg_region, generate_proxy, hw_encoders, insta360_pair, proxy_path, proxy_width, ready_proxy, salience_map, stitch_insta360, stitched_path, stream_preview, timeline_frame, timeline_frame_region, validate_export, waveform, Container, ExportOptions, ExportProgress, Fit, ImageFormat, PreviewFrame, - RateControl, Region, RenderStatus, + ExportVariant, RateControl, Region, RenderStatus, VariantProgress, }; pub(crate) use cli::insta360_pair_name; @@ -117,3 +117,15 @@ pub fn render_with_progress( ) -> Result { cli::render_with_progress(timeline, assets, output, opts, progress, cancel) } + +/// Render one file per delivery frame — see [`cli::render_variants`]. +pub fn render_variants( + timeline: &crate::model::Timeline, + assets: &[crate::model::Asset], + variants: &[ExportVariant], + opts: &ExportOptions, + progress: &mut dyn FnMut(VariantProgress), + cancel: &dyn Fn() -> bool, +) -> Result<(RenderStatus, usize)> { + cli::render_variants(timeline, assets, variants, opts, progress, cancel) +} diff --git a/crates/kerf-core/src/lib.rs b/crates/kerf-core/src/lib.rs index 7e2b6e8..eb3bbcc 100644 --- a/crates/kerf-core/src/lib.rs +++ b/crates/kerf-core/src/lib.rs @@ -27,15 +27,15 @@ pub use engine::cpu::{ }; pub use engine::{ contact_sheet_times, download_speech_model, export_still, generate_proxy, hw_encoders, insta360_pair, proxy_path, - proxy_width, render_with, render_with_progress, set_speech_model, speech_model_names, stitch_insta360, stitched_path, - stream_preview, validate_export, Container, DownloadProgress, ExportOptions, ExportProgress, Fit, ImageFormat, PreviewFrame, - RateControl, Region, RenderStatus, SpeechModelInfo, DEFAULT_SPEECH_MODEL, + proxy_width, render_variants, render_with, render_with_progress, set_speech_model, speech_model_names, stitch_insta360, stitched_path, + stream_preview, validate_export, Container, DownloadProgress, ExportOptions, ExportProgress, ExportVariant, Fit, ImageFormat, + PreviewFrame, RateControl, Region, RenderStatus, SpeechModelInfo, VariantProgress, DEFAULT_SPEECH_MODEL, }; pub use error::{Error, Result}; pub use fonts::list_system_fonts; pub use model::{ Asset, AssetAnalysis, AudioEffect, CaptionLayout, CaptionOptions, CaptionStyle, Clip, Color, CropFrame, Delivery, DiffEntry, - DiffKind, EditSource, Keyframe, Marker, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, Revision, + DiffKind, EditSource, Framing, Keyframe, Marker, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, Revision, Rhythm, SalienceMap, StagedEdit, StreamInfo, StreamKind, Task, TaskStatus, TextKeyframe, TextOverlay, TimeRange, Timeline, TimelineDiff, Track, TranscriptSegment, Transform, Transition, TransitionKind, VideoEffect, }; @@ -43,4 +43,4 @@ pub use platform::{ check_all as check_platforms, CutSummary, DeliveryCheck, DeliveryIssue, IssueKind, PlatformTarget, Severity, TARGETS as PLATFORM_TARGETS, }; -pub use project::{Project, SmartCropJob, SmartCropPlan}; +pub use project::{FramingPlan, Project, SmartCropJob, SmartCropPlan}; diff --git a/crates/kerf-core/src/model.rs b/crates/kerf-core/src/model.rs index f530972..4f24f09 100644 --- a/crates/kerf-core/src/model.rs +++ b/crates/kerf-core/src/model.rs @@ -1508,6 +1508,13 @@ pub struct Clip { /// track shows through. `None` is the whole frame. #[serde(default, skip_serializing_if = "Option::is_none")] pub mask: Option, + /// Crops for delivery shapes other than the one the project is cut for — + /// what lets one cut render as a 9:16 Reel *and* a 1:1 post with each shot + /// framed for each. Written by the multi-format export's framing pass, read + /// only by [`Timeline::for_delivery`]; the project frame's own crop stays in + /// `transform`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub framings: Vec, /// Whether this clip renders. A disabled clip keeps its place on the /// timeline (and its trims, effects and keyframes) but is dropped before the /// render graph is built — the per-clip counterpart of muting a track. @@ -1548,10 +1555,32 @@ impl Clip { keyframes: Vec::new(), reframe: None, mask: None, + framings: Vec::new(), enabled: true, } } + /// The crop this clip carries for a delivery shape, if it was framed for it. + pub fn framing_for(&self, ratio: (u32, u32)) -> Option<&Framing> { + self.framings.iter().find(|f| f.ratio() == ratio) + } + + /// Record the crop for one delivery shape, replacing an earlier one for the + /// same shape. Returns whether anything changed. + pub fn set_framing(&mut self, framing: Framing) -> bool { + match self.framings.iter_mut().find(|f| f.ratio() == framing.ratio()) { + Some(existing) if *existing == framing => false, + Some(existing) => { + *existing = framing; + true + } + None => { + self.framings.push(framing); + true + } + } + } + /// A new clip that also reframes, when `asset` is 360 footage — the shape /// every clip-creating op should use so a spherical source lands on the /// timeline already looking like ordinary video. @@ -2000,6 +2029,98 @@ impl Delivery { pub fn aspect(&self) -> f64 { self.width as f64 / self.height.max(1) as f64 } + + /// The frame's shape reduced to lowest terms — `(9, 16)` for 1080x1920 — + /// which is what a per-clip [`Framing`] is keyed by, so a 720x1280 render + /// and a 1080x1920 one share the crop. + pub fn ratio(&self) -> (u32, u32) { + reduce_ratio(self.width, self.height) + } + + /// The shape as people write it: `9:16`. + pub fn ratio_label(&self) -> String { + let (w, h) = self.ratio(); + format!("{w}:{h}") + } + + /// The delivery frame a shape name stands for: `"9:16"`, `"1:1"`, `"4:5"`, + /// `"16:9"` — the sizes the app's delivery picker uses, with the fit that + /// picker pairs them with (a vertical or square frame fills and crops, the + /// landscape one keeps the whole picture) — or an explicit `WxH`, which + /// covers when it is not 16:9. `None` for anything else. + pub fn parse(name: &str) -> Option { + let name = name.trim(); + let (w, h) = match name { + "9:16" | "vertical" | "portrait" => (1080, 1920), + "1:1" | "square" => (1080, 1080), + "4:5" => (1080, 1350), + "16:9" | "landscape" => (1920, 1080), + _ => { + let (w, h) = name.split_once(['x', 'X', '×'])?; + (w.trim().parse().ok()?, h.trim().parse().ok()?) + } + }; + if w == 0 || h == 0 { + return None; + } + let fit = if reduce_ratio(w, h) == (16, 9) { Fit::Contain } else { Fit::Cover }; + Some(Delivery::new(w, h, fit)) + } +} + +fn reduce_ratio(w: u32, h: u32) -> (u32, u32) { + fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { a } else { gcd(b, a % b) } + } + let d = gcd(w, h).max(1); + (w / d, h / d) +} + +/// A crop a clip carries for **one** delivery shape it is not being cut in. +/// +/// Smart crop writes its result into `Transform.crop_*`, which is right for the +/// frame the project is cut for and wrong for every other: the crop that keeps +/// the subject in a 9:16 Reel throws the subject away in a 1:1 post, and +/// re-framing for the second shape overwrote the first. A `Framing` is that +/// second answer kept beside the first, keyed by the reduced shape, so one cut +/// can be delivered at several frames with each shot framed for each. It is +/// only read by [`Timeline::for_delivery`] — the ordinary render of the project +/// frame never sees it, so a project with none renders byte-identically. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct Framing { + /// The delivery shape this crop is for, in lowest terms — `(9, 16)`. + pub aspect_w: u32, + pub aspect_h: u32, + /// Fraction of the source cropped from each edge. + pub crop_left: f64, + pub crop_right: f64, + pub crop_top: f64, + pub crop_bottom: f64, +} + +impl Framing { + pub fn new(ratio: (u32, u32), crop: &CropFrame) -> Self { + Self { + aspect_w: ratio.0, + aspect_h: ratio.1, + crop_left: crop.left, + crop_right: crop.right, + crop_top: crop.top, + crop_bottom: crop.bottom, + } + } + + pub fn ratio(&self) -> (u32, u32) { + (self.aspect_w, self.aspect_h) + } + + pub fn ratio_label(&self) -> String { + format!("{}:{}", self.aspect_w, self.aspect_h) + } + + fn crop(&self) -> (f64, f64, f64, f64) { + (self.crop_left, self.crop_right, self.crop_top, self.crop_bottom) + } } /// A coarse map of where a shot's *content* is: `rows`×`cols` non-negative @@ -2036,7 +2157,7 @@ const CROP_SEARCH_STEPS: usize = 240; const ASPECT_TOLERANCE: f64 = 0.01; /// A crop window as the per-edge source fractions [`Transform`] takes. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)] pub struct CropFrame { pub left: f64, pub right: f64, @@ -2331,6 +2452,60 @@ impl Timeline { } } + /// The same cut delivered at another frame: a copy whose format is + /// `delivery` and whose clips wear the crop they carry for *that* shape. + /// + /// This is the multi-format export's whole trick, and it is the same one + /// `for_render` uses — change the timeline, not the graph. A clip framed for + /// the shape ([`Clip::framing_for`]) swaps that crop in for its transform's; + /// one that never was keeps whatever crop it has, since the alternative is + /// throwing away a crop someone made by hand. Generated captions are re-fit + /// to the new aspect the way [`Timeline::captions`] fit them to the old one + /// (a 9:16 frame is half as wide, and `drawtext` draws off the edge rather + /// than wrapping); a typed title is left alone. Delivering the shape the + /// project is already cut for changes only the size. + pub fn for_delivery(&self, delivery: Delivery) -> Timeline { + let ratio = delivery.ratio(); + let same_shape = self.format.is_some_and(|f| f.ratio() == ratio); + let aspect = delivery.aspect(); + Timeline { + tracks: self + .tracks + .iter() + .map(|track| Track { + clips: track + .clips + .iter() + .map(|clip| { + let mut clip = clip.clone(); + if !same_shape { + if let Some(f) = clip.framing_for(ratio).copied() { + let t = &mut clip.transform; + (t.crop_left, t.crop_right, t.crop_top, t.crop_bottom) = f.crop(); + } + } + clip + }) + .collect(), + ..track.clone() + }) + .collect(), + overlays: self + .overlays + .iter() + .map(|o| { + let mut o = o.clone(); + if o.generated && !same_shape { + o.size = fit_size(&o.text, o.size, aspect); + } + o + }) + .collect(), + markers: self.markers.clone(), + format: Some(delivery), + } + } + /// A copy containing only `[start, end)`, shifted so `start` lands at 0 — /// the sub-timeline a range export renders. Clips overlapping the window /// edges are cut down (source window and keyframes adjusted, honoring speed @@ -2823,6 +2998,17 @@ fn clip_changes(before: &Clip, after: &Clip) -> Option { } parts.extend(transform_changes(&before.transform, &after.transform)); parts.extend(color_changes(&before.color, &after.color)); + // A framing pass before a multi-format export is an edit like any other, + // and one an agent proposal has to be able to show. + let framed: Vec = after + .framings + .iter() + .filter(|f| before.framing_for(f.ratio()) != Some(*f)) + .map(Framing::ratio_label) + .collect(); + if !framed.is_empty() { + parts.push(format!("framed for {}", framed.join(", "))); + } if before.transition_in != after.transition_in { parts.push(match &after.transition_in { None => "transition removed".to_string(), @@ -3354,6 +3540,121 @@ mod tests { assert!(tl.for_render().tracks[0].clips.is_empty()); } + fn framed(ratio: (u32, u32), left: f64, right: f64) -> Framing { + Framing { + aspect_w: ratio.0, + aspect_h: ratio.1, + crop_left: left, + crop_right: right, + crop_top: 0.0, + crop_bottom: 0.0, + } + } + + #[test] + fn delivery_shapes_reduce_and_parse() { + assert_eq!(Delivery::new(1080, 1920, Fit::Cover).ratio(), (9, 16)); + assert_eq!(Delivery::new(720, 1280, Fit::Cover).ratio(), (9, 16)); + assert_eq!(Delivery::new(1080, 1350, Fit::Cover).ratio_label(), "4:5"); + let v = Delivery::parse("9:16").unwrap(); + assert_eq!((v.width, v.height, v.fit), (1080, 1920, Fit::Cover)); + let l = Delivery::parse("16:9").unwrap(); + assert_eq!((l.width, l.height, l.fit), (1920, 1080, Fit::Contain)); + let custom = Delivery::parse("1440x1800").unwrap(); + assert_eq!((custom.width, custom.height, custom.fit), (1440, 1800, Fit::Cover)); + assert_eq!(Delivery::parse("3840x2160").unwrap().fit, Fit::Contain); + assert!(Delivery::parse("wide").is_none()); + assert!(Delivery::parse("0x10").is_none()); + } + + #[test] + fn a_framing_replaces_its_own_shape_and_keeps_the_others() { + let mut clip = clip_at(0.0, 2.0); + assert!(clip.set_framing(framed((9, 16), 0.1, 0.5))); + assert!(clip.set_framing(framed((1, 1), 0.2, 0.3))); + assert!(!clip.set_framing(framed((9, 16), 0.1, 0.5)), "unchanged is not a change"); + assert!(clip.set_framing(framed((9, 16), 0.3, 0.3))); + assert_eq!(clip.framings.len(), 2); + assert_eq!(clip.framing_for((9, 16)).unwrap().crop_left, 0.3); + assert_eq!(clip.framing_for((1, 1)).unwrap().crop_left, 0.2); + assert!(clip.framing_for((4, 5)).is_none()); + } + + #[test] + fn for_delivery_swaps_in_the_crop_for_that_shape() { + let mut clip = clip_at(0.0, 2.0); + // The project is cut 9:16 and the clip carries that crop as its transform. + clip.transform.crop_left = 0.1; + clip.transform.crop_right = 0.5836; + clip.set_framing(framed((1, 1), 0.2, 0.3)); + let tl = Timeline { + tracks: vec![track(StreamKind::Video, "V1", vec![clip])], + overlays: Vec::new(), + markers: Vec::new(), + format: Some(Delivery::new(1080, 1920, Fit::Cover)), + }; + + let square = tl.for_delivery(Delivery::new(1080, 1080, Fit::Cover)); + let t = square.tracks[0].clips[0].transform; + assert_eq!((t.crop_left, t.crop_right), (0.2, 0.3)); + assert_eq!(square.format.unwrap().ratio(), (1, 1)); + + // No framing for 4:5: the crop it has is kept rather than thrown away. + let portrait = tl.for_delivery(Delivery::new(1080, 1350, Fit::Cover)); + let t = portrait.tracks[0].clips[0].transform; + assert_eq!((t.crop_left, t.crop_right), (0.1, 0.5836)); + + // The project's own shape at another size changes only the size. + let small = tl.for_delivery(Delivery::new(720, 1280, Fit::Cover)); + assert_eq!(small.tracks[0].clips[0].transform, tl.tracks[0].clips[0].transform); + assert_eq!((small.format.unwrap().width, small.format.unwrap().height), (720, 1280)); + + // Never touches the original. + assert_eq!(tl.tracks[0].clips[0].transform.crop_left, 0.1); + } + + #[test] + fn for_delivery_refits_generated_captions_only() { + let long = "a caption line that is wide"; + let mut caption = TextOverlay::new(long, 0.0, 1.0); + caption.size = 0.05; + caption.generated = true; + let mut title = TextOverlay::new(long, 0.0, 1.0); + title.size = 0.05; + let tl = Timeline { + tracks: vec![track(StreamKind::Video, "V1", vec![clip_at(0.0, 2.0)])], + overlays: vec![caption, title], + markers: Vec::new(), + format: Some(Delivery::new(1920, 1080, Fit::Contain)), + }; + let vertical = tl.for_delivery(Delivery::new(1080, 1920, Fit::Cover)); + let fitted = vertical.overlays[0].size; + assert!(fitted < 0.05, "a 9:16 frame is too narrow for the line at 5%"); + assert!( + long.chars().count() as f64 * CHAR_ADVANCE * fitted <= CAPTION_WIDTH * (1080.0 / 1920.0) + 1e-9, + "fits across the frame" + ); + assert_eq!(vertical.overlays[1].size, 0.05, "a typed title is not resized"); + } + + #[test] + fn a_new_framing_reads_in_the_diff() { + let before = Timeline { + tracks: vec![track(StreamKind::Video, "V1", vec![clip_at(0.0, 2.0)])], + overlays: Vec::new(), + markers: Vec::new(), + format: None, + }; + let mut after = before.clone(); + after.tracks[0].clips[0].set_framing(framed((9, 16), 0.1, 0.5)); + after.tracks[0].clips[0].set_framing(framed((1, 1), 0.2, 0.3)); + let diff = before.diff(&after); + assert_eq!(diff.entries.len(), 1, "{diff:?}"); + let detail = diff.entries[0].detail.clone().unwrap_or_default(); + assert!(detail.contains("framed for 9:16, 1:1"), "{detail}"); + assert!(after.diff(&after).entries.is_empty()); + } + #[test] fn for_render_is_a_no_op_on_an_untouched_timeline() { let tl = Timeline { diff --git a/crates/kerf-core/src/project.rs b/crates/kerf-core/src/project.rs index ad520da..ad5acb9 100644 --- a/crates/kerf-core/src/project.rs +++ b/crates/kerf-core/src/project.rs @@ -13,7 +13,8 @@ use crate::engine::{self, ExportProgress}; use crate::error::{Error, Result}; use crate::model::default_beat_tolerance; use crate::model::{ - Asset, AssetAnalysis, AudioEffect, CaptionOptions, CaptionStyle, Clip, CropFrame, Delivery, EditSource, Keyframe, Marker, + Asset, AssetAnalysis, AudioEffect, CaptionOptions, CaptionStyle, Clip, CropFrame, Delivery, EditSource, Framing, Keyframe, + Marker, Mask, Projection, Reframe, ReframeKeyframe, Revision, StagedEdit, StreamInfo, StreamKind, Task, TaskStatus, Tempo, TextKeyframe, TextOverlay, TimeRange, Timeline, TimelineDiff, Track, TranscriptSegment, Transition, VideoEffect, MAX_FOV, MIN_FOV, @@ -40,6 +41,17 @@ pub struct SmartCropPlan { pub jobs: Vec, } +/// A framing pass for a multi-format export: the delivery shapes the cut is +/// about to be rendered at *besides* the one it is cut for, and the clips to +/// frame for each. Same lock-free shape as [`SmartCropPlan`]. +#[derive(Debug, Clone)] +pub struct FramingPlan { + /// The shapes to frame for, in lowest terms, the project frame's own + /// excluded — its crop is the clip's transform already. + pub ratios: Vec<(u32, u32)>, + pub jobs: Vec, +} + const SCHEMA: &str = r#" PRAGMA foreign_keys = ON; @@ -1905,6 +1917,162 @@ impl Project { }) } + // ---- framing for other deliveries ----------------------------------------- + + /// Plan a framing pass for the shapes a multi-format export is about to + /// render at, **without** decoding anything — the lock-held half, like + /// [`Project::smart_crop_inputs`]. + /// + /// Smart crop bakes its answer into each clip's transform for the *one* + /// frame the project is cut for. Delivering the same cut at a second shape + /// needs a second answer per clip, kept beside the first ([`Framing`]) — + /// this plans it for every requested shape that is not the project's own. + /// A plan with no jobs is not an error: a cut with nothing to frame (every + /// shape requested is the project's, or there is no flat video) is simply + /// exported as it is. + pub fn framing_inputs(&self, deliveries: &[Delivery]) -> Result { + let timeline = self.working_timeline()?; + let assets = self.list_assets()?; + let (fw, fh) = engine::delivery_frame(&timeline, &assets); + let own = Delivery::new(fw, fh, crate::model::Fit::Contain).ratio(); + + let mut ratios: Vec<(u32, u32)> = Vec::new(); + for d in deliveries { + let r = d.ratio(); + if r != own && !ratios.contains(&r) { + ratios.push(r); + } + } + + let mut jobs = Vec::new(); + if !ratios.is_empty() { + for track in timeline.tracks.iter().filter(|t| t.kind == StreamKind::Video && !t.locked) { + for clip in &track.clips { + // A 360 clip's virtual camera is its framing for every shape. + if clip.reframe.is_some() { + continue; + } + let Some(asset) = assets.iter().find(|a| a.id == clip.asset_id) else { + continue; + }; + let Some((w, h)) = asset + .streams + .iter() + .find(|s| s.kind == StreamKind::Video) + .and_then(|s| s.width.zip(s.height)) + else { + continue; + }; + let (start, end) = if asset.is_image() { + (0.0, 0.04) + } else { + (clip.source_in.min(clip.source_out), clip.source_in.max(clip.source_out)) + }; + jobs.push(SmartCropJob { + clip_id: clip.id, + path: PathBuf::from(&asset.path), + start, + end, + width: w, + height: h, + }); + } + } + } + Ok(FramingPlan { ratios, jobs }) + } + + /// Sample every job in `plan` once and pick a crop for each shape. Static + /// and lock-free — one short ffmpeg decode per clip, shared by all the + /// shapes, since the salience map is a property of the shot and the crop + /// is what changes with the frame. + /// + /// A shot already a shape gets an *identity* framing for it (no crop) + /// rather than none: the render looks the framing up by shape, and a miss + /// would leave the shot wearing the project frame's crop — a 16:9 shot cut + /// 9:16 would deliver at 16:9 as the narrow strip that crop keeps. A clip + /// whose media cannot be read is skipped like in smart crop; if nothing + /// could be read at all, the first error is returned. + pub fn sample_framings(plan: &FramingPlan) -> Result> { + let mut out = Vec::new(); + let mut first_error = None; + for job in &plan.jobs { + let needs: Vec<(u32, u32)> = plan + .ratios + .iter() + .copied() + .filter(|&(w, h)| crate::model::needs_crop(job.width, job.height, w as f64 / h as f64)) + .collect(); + let map = if needs.is_empty() { + None + } else { + match engine::salience_map(&job.path, job.start, job.end) { + Ok(map) => Some(map), + Err(e) => { + tracing::warn!(clip = %job.clip_id, path = %job.path.display(), error = %e, "could not sample a shot for framing"); + first_error.get_or_insert(e); + continue; + } + } + }; + for &ratio in &plan.ratios { + let crop = if needs.contains(&ratio) { + let aspect = ratio.0 as f64 / ratio.1 as f64; + match map.as_ref().and_then(|m| m.crop_for(job.width, job.height, aspect)) { + Some(crop) => crop, + None => continue, + } + } else { + CropFrame::default() + }; + out.push((job.clip_id, Framing::new(ratio, &crop))); + } + } + match (out.is_empty(), first_error) { + (true, Some(e)) => Err(e), + _ => Ok(out), + } + } + + /// Write sampled framings onto their clips as one undoable edit, labelled + /// with the shapes it framed for. Returns how many clips changed; framings + /// a clip already carries are dropped first, so a re-run over an unchanged + /// cut reports 0 and leaves the history alone. + pub fn apply_framings(&self, framings: &[(Uuid, Framing)]) -> Result { + let timeline = self.working_timeline()?; + let pending: Vec<_> = framings + .iter() + .filter(|(clip_id, f)| { + timeline + .locate(*clip_id) + .is_some_and(|(ti, ci)| timeline.tracks[ti].clips[ci].framing_for(f.ratio()) != Some(f)) + }) + .collect(); + if pending.is_empty() { + return Ok(0); + } + let mut shapes: Vec = Vec::new(); + for (_, f) in &pending { + let label = f.ratio_label(); + if !shapes.contains(&label) { + shapes.push(label); + } + } + let label = format!("Frame for {}", shapes.join(", ")); + self.edit_timeline(&label, |timeline| { + let mut touched: Vec = Vec::new(); + for (clip_id, f) in &pending { + let Some((ti, ci)) = timeline.locate(*clip_id) else { + continue; + }; + if timeline.tracks[ti].clips[ci].set_framing(*f) && !touched.contains(clip_id) { + touched.push(*clip_id); + } + } + Ok(touched.len()) + }) + } + /// Update a clip's color correction. Each `None` leaves that field unchanged. pub fn set_color( &self, @@ -4437,6 +4605,76 @@ mod tests { assert_eq!(project.history().unwrap().len(), before); } + #[test] + fn framing_plans_every_shape_but_the_projects_own() { + let project = Project::sample().unwrap(); + project + .set_delivery_format(Some(Delivery::new(1080, 1920, Fit::Cover))) + .unwrap(); + let plan = project + .framing_inputs(&[ + Delivery::new(1080, 1920, Fit::Cover), + Delivery::new(1080, 1080, Fit::Cover), + Delivery::new(720, 1280, Fit::Cover), + Delivery::new(1920, 1080, Fit::Contain), + ]) + .unwrap(); + // 9:16 is the project frame (and 720x1280 the same shape); the rest dedupe. + assert_eq!(plan.ratios, vec![(1, 1), (16, 9)]); + let video_clips: usize = project + .timeline() + .unwrap() + .tracks + .iter() + .filter(|t| t.kind == StreamKind::Video) + .map(|t| t.clips.len()) + .sum(); + assert_eq!(plan.jobs.len(), video_clips); + + // Only the project's own shape asked for: nothing to frame, not an error. + let none = project.framing_inputs(&[Delivery::new(1080, 1920, Fit::Cover)]).unwrap(); + assert!(none.ratios.is_empty() && none.jobs.is_empty()); + } + + #[test] + fn framings_land_as_one_labelled_revision_and_only_when_they_change() { + let project = Project::sample().unwrap(); + let clip = first_video_clip(&project); + let crop = CropFrame { + left: 0.3, + right: 0.2625, + top: 0.0, + bottom: 0.0, + offset: 0.2, + }; + let framings = vec![ + (clip, Framing::new((1, 1), &crop)), + (clip, Framing::new((4, 5), &CropFrame::default())), + ]; + let before = project.history().unwrap().len(); + assert_eq!(project.apply_framings(&framings).unwrap(), 1, "one clip changed"); + let history = project.history().unwrap(); + assert_eq!(history.len(), before + 1); + assert_eq!(history.last().unwrap().label, "Frame for 1:1, 4:5"); + let timeline = project.timeline().unwrap(); + let (ti, ci) = timeline.locate(clip).unwrap(); + let framed = &timeline.tracks[ti].clips[ci]; + assert_eq!(framed.framing_for((1, 1)).unwrap().crop_left, 0.3); + assert_eq!(framed.framing_for((4, 5)).unwrap().crop_left, 0.0); + // The project frame's own crop is untouched. + assert!(!framed.transform.has_crop()); + + // Same framings again: no change, no revision. + assert_eq!(project.apply_framings(&framings).unwrap(), 0); + assert_eq!(project.history().unwrap().len(), before + 1); + + // The whole pass undoes in one step. + project.undo().unwrap(); + let timeline = project.timeline().unwrap(); + let (ti, ci) = timeline.locate(clip).unwrap(); + assert!(timeline.tracks[ti].clips[ci].framings.is_empty()); + } + fn first_video_clip(project: &Project) -> Uuid { project .timeline() From 234e3ac017d2bba8df3e4b633ff5a495c7dd69cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orell=20B=C3=BChler?= Date: Wed, 2 Sep 2026 21:18:35 +0200 Subject: [PATCH 2/3] expose the multi-format export to the agent and the dialog export_variants (MCP) and export_variants (Tauri) frame every shot for every requested shape, render one file per delivery beside the chosen path, and stream progress across all of them. The export dialog gets a Deliver to section: shape chips, the file names they will write, a smart crop toggle and a per-file readiness line. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gm2h2GQnUyBRLnVe431o9w --- crates/kerf-app/src/lib.rs | 71 +++++++ crates/kerf-app/src/mcp.rs | 182 +++++++++++++++++- crates/kerf-core/src/engine/cli.rs | 14 +- crates/kerf-core/src/engine/mod.rs | 4 +- crates/kerf-core/src/lib.rs | 12 +- crates/kerf-core/src/model.rs | 12 +- crates/kerf-core/src/project.rs | 3 +- frontend/src/lib/api.ts | 13 ++ .../lib/components/editor/ExportDialog.svelte | 110 ++++++++++- frontend/src/lib/delivery-formats.test.ts | 10 +- frontend/src/lib/delivery-formats.ts | 12 ++ frontend/src/lib/state.svelte.ts | 18 ++ frontend/src/lib/types.ts | 18 ++ 13 files changed, 453 insertions(+), 26 deletions(-) diff --git a/crates/kerf-app/src/lib.rs b/crates/kerf-app/src/lib.rs index 66269d8..b7d159d 100644 --- a/crates/kerf-app/src/lib.rs +++ b/crates/kerf-app/src/lib.rs @@ -1374,6 +1374,76 @@ async fn export_timeline( .await } +/// Render the cut once per delivery frame — one file per shape beside +/// `output_path`, named by shape (`cut-9x16.mp4`). With `smart_crop`, every +/// shot is framed for every shape first (one revision, reused by later +/// exports); the project frame's own crop is never touched. Streams the same +/// `export-progress` event as a single export, with `variant` / `total` added. +#[tauri::command] +async fn export_variants( + app: AppHandle, + state: State<'_, AppState>, + output_path: String, + formats: Vec, + smart_crop: bool, + options: ExportOptions, +) -> CmdResult> { + if formats.is_empty() { + return Err("pick at least one delivery frame".to_string()); + } + let base = std::path::PathBuf::from(&output_path); + let mut deliveries: Vec = Vec::new(); + for d in formats { + let d = Delivery::new(d.width, d.height, d.fit); + if !deliveries.contains(&d) { + deliveries.push(d); + } + } + let variants: Vec = deliveries + .iter() + .map(|d| kerf_core::ExportVariant::beside(&base, *d)) + .collect(); + let shared = state.project.clone(); + let cancel = state.export_cancel.clone(); + cancel.store(false, Ordering::SeqCst); + + blocking(move || { + // Frame first — plan under the lock, sample without it, apply under it + // again — then snapshot and render with the lock released, like a + // single export. + if smart_crop { + let plan = lock_user(&shared).framing_inputs(&deliveries).map_err(|e| e.to_string())?; + if !plan.jobs.is_empty() { + let framings = Project::sample_framings(&plan).map_err(|e| e.to_string())?; + let framed = lock_user(&shared).apply_framings(&framings).map_err(|e| e.to_string())?; + if framed > 0 { + let _ = app.emit("project-changed", ()); + } + } + } + let (timeline, assets) = { + let project = lock_user(&shared); + ( + project.timeline().map_err(|e| e.to_string())?, + project.list_assets().map_err(|e| e.to_string())?, + ) + }; + let mut on_progress = |p: kerf_core::VariantProgress| { + let _ = app.emit("export-progress", p); + }; + let (status, _) = kerf_core::render_variants(&timeline, &assets, &variants, &options, &mut on_progress, &|| { + cancel.load(Ordering::SeqCst) + }) + .map_err(|e| e.to_string())?; + match status { + kerf_core::RenderStatus::Completed => Ok(variants.iter().map(|v| v.output.to_string_lossy().into_owned()).collect()), + // The variant in flight is already gone; the finished ones stay. + kerf_core::RenderStatus::Cancelled => Err("export cancelled".to_string()), + } + }) + .await +} + /// Write the composited frame at `time_secs` to `output_path` as a **cover /// image** — full delivery resolution, decoded from the original media rather /// than a preview proxy. `format` follows the file extension when omitted. @@ -1759,6 +1829,7 @@ pub fn run() { remove_task, hw_encoders, export_timeline, + export_variants, cancel_export, cancel_analysis, export_cover, diff --git a/crates/kerf-app/src/mcp.rs b/crates/kerf-app/src/mcp.rs index 1b9a6f9..0e6a94d 100644 --- a/crates/kerf-app/src/mcp.rs +++ b/crates/kerf-app/src/mcp.rs @@ -663,6 +663,32 @@ struct ExportParams { options: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct ExportVariantsParams { + #[schemars( + description = "Base output path; each delivery lands beside it with its shape in the name — \ + `/renders/cut.mp4` at 9:16 and 1:1 writes `cut-9x16.mp4` and `cut-1x1.mp4`." + )] + output_path: String, + #[schemars( + description = "The delivery frames to render, one file each: \"9:16\" (1080x1920, Reels / Shorts / \ + TikTok), \"1:1\" (1080x1080, feed), \"4:5\" (1080x1350, Instagram portrait), \"16:9\" \ + (1920x1080, YouTube), or an explicit \"WxH\"." + )] + formats: Vec, + #[schemars( + description = "Frame each shot for every shape first (default true): samples where each clip's \ + content sits and keeps a crop per shape on the clip, so a 9:16 and a 1:1 delivery \ + each keep the subject rather than the middle. The project frame's own crop is never \ + touched. false renders whatever crop each clip already has." + )] + smart_crop: Option, + #[schemars(description = "Encode settings shared by every variant — the same fields as `export`. Its \ + resolution and fit are replaced per variant by the delivery frame.")] + #[serde(default)] + options: Option, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] struct AddTaskParams { #[schemars(description = "What the task should accomplish, in plain language")] @@ -1819,6 +1845,156 @@ impl KerfMcp { } } + #[tool( + description = "Export the same cut at several delivery frames in one call — one file per shape, each shot \ + framed for each: a 9:16 Reel, a 1:1 post and a 16:9 upload from one timeline. Shots are \ + smart-cropped per shape first (unless smart_crop is false), which is recorded on the clips as \ + one revision and reused by later exports; generated captions are re-fit to each frame. The \ + project's own frame is untouched. Files land beside output_path named by shape \ + (`cut-9x16.mp4`). Reports each file with the platforms it is ready for and any issue, so \ + there is no need to run platform_check per variant afterwards. Progress and cancellation \ + work as in `export`; cancelling keeps the files already finished and deletes the one in \ + flight." + )] + async fn export_variants( + &self, + Parameters(p): Parameters, + context: RequestContext, + ) -> Result { + if p.formats.is_empty() { + return Err(McpError::invalid_params( + "formats must name at least one delivery frame", + None, + )); + } + let mut deliveries: Vec = Vec::new(); + for name in &p.formats { + let d = Delivery::parse(name).ok_or_else(|| { + McpError::invalid_params( + format!("unknown delivery format {name:?}: expected 9:16, 1:1, 4:5, 16:9 or WxH"), + None, + ) + })?; + if !deliveries.contains(&d) { + deliveries.push(d); + } + } + let opts = p.options.unwrap_or_default(); + let base = std::path::PathBuf::from(&p.output_path); + let variants: Vec = deliveries + .iter() + .map(|d| kerf_core::ExportVariant::beside(&base, *d)) + .collect(); + let project = self.project.clone(); + + // Same protocol plumbing as `export`: progress on the client's token, + // cancellation off the request's token. + let cancel = context.ct.clone(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let forward = { + let peer = context.peer.clone(); + let token = context.meta.get_progress_token(); + let labels: Vec = deliveries.iter().map(Delivery::ratio_label).collect(); + tauri::async_runtime::spawn(async move { + while let Some(progress) = rx.recv().await { + let Some(token) = token.clone() else { continue }; + let param = ProgressNotificationParam::new(token, progress.fraction).with_total(1.0); + let mut message = format!( + "rendering {} of {} ({})", + progress.variant + 1, + progress.total, + labels.get(progress.variant).cloned().unwrap_or_default() + ); + if let Some(eta) = progress.eta_secs { + message.push_str(&format!(", {} to go", fmt_ts(eta))); + } + let _ = peer.notify_progress(param.with_message(message)).await; + } + }) + }; + + let smart_crop = p.smart_crop.unwrap_or(true); + let deliveries_for_plan = deliveries.clone(); + let render_variants = variants.clone(); + let result = blocking(move || { + // Frame first: plan under the lock, sample with it released (one + // short decode per clip, shared by every shape), apply under it again. + let mut framed = 0; + if smart_crop { + let plan = lock_agent(&project).framing_inputs(&deliveries_for_plan).map_err(core_err)?; + if !plan.jobs.is_empty() { + let framings = Project::sample_framings(&plan).map_err(core_err)?; + framed = lock_agent(&project).apply_framings(&framings).map_err(core_err)?; + } + } + let (timeline, assets) = { + let project = lock_agent(&project); + ( + project.working_timeline().map_err(core_err)?, + project.list_assets().map_err(core_err)?, + ) + }; + let mut on_progress = |progress: kerf_core::VariantProgress| { + let _ = tx.send(progress); + }; + let (status, done) = + kerf_core::render_variants(&timeline, &assets, &render_variants, &opts, &mut on_progress, &|| { + cancel.is_cancelled() + }) + .map_err(core_err)?; + // Judged per file, at the frame that file actually is. + let mut outputs = Vec::new(); + let project = lock_agent(&project); + for v in render_variants.iter().take(done) { + let summary = project + .cut_summary(Some((v.delivery.width, v.delivery.height))) + .map_err(core_err)?; + let checks = kerf_core::platform::check_all(&summary); + let is_tip = |i: &kerf_core::platform::DeliveryIssue| i.severity == kerf_core::platform::Severity::Tip; + let ready_for: Vec<&str> = checks + .iter() + .filter(|c| c.issues.iter().all(is_tip)) + .map(|c| c.label.as_str()) + .collect(); + let issues: Vec = checks + .iter() + .flat_map(|c| { + c.issues + .iter() + .filter(|i| !is_tip(i)) + .map(move |i| serde_json::json!({ "target": c.label, "severity": i.severity, "message": i.message })) + }) + .collect(); + outputs.push(serde_json::json!({ + "format": v.delivery.ratio_label(), + "width": v.delivery.width, + "height": v.delivery.height, + "output": v.output.to_string_lossy(), + "ready_for": ready_for, + "issues": issues, + })); + } + Ok((status, framed, outputs)) + }) + .await; + let _ = forward.await; + let (status, framed, outputs) = result?; + if framed > 0 { + self.changed(); + } + match status { + kerf_core::RenderStatus::Completed => json(&serde_json::json!({ "clips_framed": framed, "outputs": outputs })), + kerf_core::RenderStatus::Cancelled => Err(McpError::internal_error( + format!( + "export cancelled after {} of {} files; the one in flight was removed", + outputs.len(), + variants.len() + ), + None, + )), + } + } + #[tool( description = "Check the assembled cut against each publishing target (Instagram Reels / YouTube Shorts / \ TikTok / Instagram feed / YouTube): length limits, frame shape, and the reach limits a platform \ @@ -2291,7 +2467,11 @@ impl ServerHandler for KerfMcp { for it — reshaping 16:9 footage to 9:16 throws away most of the \ width, and without this the middle is what survives, subject or \ not. Look at the result with preview_timeline; the crop is an \ - ordinary transform the user can adjust. \ + ordinary transform the user can adjust. When the same cut is \ + going to several places, export_variants renders it once per \ + shape (9:16, 1:1, 4:5, 16:9) in one call, framing each shot for \ + each shape and reporting what each file is ready for — prefer it \ + to exporting three times by hand. \ Your task edits are STAGED, not applied: claiming a task opens a \ proposal, and every edit you make goes into it instead of changing \ the cut the user is looking at. Your own reads follow the proposal, \ diff --git a/crates/kerf-core/src/engine/cli.rs b/crates/kerf-core/src/engine/cli.rs index de4e5cb..4296e0b 100644 --- a/crates/kerf-core/src/engine/cli.rs +++ b/crates/kerf-core/src/engine/cli.rs @@ -17,8 +17,8 @@ use super::cpu; use super::ProbeResult; use crate::error::{Error, Result}; use crate::model::{ - Asset, AudioEffect, Clip, Color, Delivery, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, SalienceMap, - StreamInfo, StreamKind, TextOverlay, TimeRange, Timeline, Transform, VideoEffect, + Asset, AudioEffect, Clip, Color, Delivery, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, + SalienceMap, StreamInfo, StreamKind, TextOverlay, TimeRange, Timeline, Transform, VideoEffect, }; /// A small process-global LRU of decoded single frames. Decoded frames are a @@ -8345,11 +8345,17 @@ mod tests { }; let vertical = graph_for(Delivery::new(1080, 1920, Fit::Cover)); assert!(vertical.contains("scale=1080:1920"), "{vertical}"); - assert!(vertical.contains("x=iw*0.05"), "the project frame keeps its own crop: {vertical}"); + assert!( + vertical.contains("x=iw*0.05"), + "the project frame keeps its own crop: {vertical}" + ); let square = graph_for(Delivery::new(1080, 1080, Fit::Cover)); assert!(square.contains("scale=1080:1080"), "{square}"); - assert!(square.contains("x=iw*0.3"), "the 1:1 delivery wears the 1:1 framing: {square}"); + assert!( + square.contains("x=iw*0.3"), + "the 1:1 delivery wears the 1:1 framing: {square}" + ); assert!(!square.contains("x=iw*0.05"), "{square}"); // A shape nothing was framed for keeps the crop it has, and the diff --git a/crates/kerf-core/src/engine/mod.rs b/crates/kerf-core/src/engine/mod.rs index 95733c6..ed53183 100644 --- a/crates/kerf-core/src/engine/mod.rs +++ b/crates/kerf-core/src/engine/mod.rs @@ -46,8 +46,8 @@ pub use cli::{ audio_effects_filter, audio_pcm, contact_sheet, contact_sheet_times, decode_hwaccel, delivery_frame, detect_scenes, detect_silence, export_still, frame_at, frame_jpeg, frame_jpeg_region, generate_proxy, hw_encoders, insta360_pair, proxy_path, proxy_width, ready_proxy, salience_map, stitch_insta360, stitched_path, stream_preview, timeline_frame, - timeline_frame_region, validate_export, waveform, Container, ExportOptions, ExportProgress, Fit, ImageFormat, PreviewFrame, - ExportVariant, RateControl, Region, RenderStatus, VariantProgress, + timeline_frame_region, validate_export, waveform, Container, ExportOptions, ExportProgress, ExportVariant, Fit, ImageFormat, + PreviewFrame, RateControl, Region, RenderStatus, VariantProgress, }; pub(crate) use cli::insta360_pair_name; diff --git a/crates/kerf-core/src/lib.rs b/crates/kerf-core/src/lib.rs index eb3bbcc..f3125f8 100644 --- a/crates/kerf-core/src/lib.rs +++ b/crates/kerf-core/src/lib.rs @@ -27,17 +27,17 @@ pub use engine::cpu::{ }; pub use engine::{ contact_sheet_times, download_speech_model, export_still, generate_proxy, hw_encoders, insta360_pair, proxy_path, - proxy_width, render_variants, render_with, render_with_progress, set_speech_model, speech_model_names, stitch_insta360, stitched_path, - stream_preview, validate_export, Container, DownloadProgress, ExportOptions, ExportProgress, ExportVariant, Fit, ImageFormat, - PreviewFrame, RateControl, Region, RenderStatus, SpeechModelInfo, VariantProgress, DEFAULT_SPEECH_MODEL, + proxy_width, render_variants, render_with, render_with_progress, set_speech_model, speech_model_names, stitch_insta360, + stitched_path, stream_preview, validate_export, Container, DownloadProgress, ExportOptions, ExportProgress, ExportVariant, + Fit, ImageFormat, PreviewFrame, RateControl, Region, RenderStatus, SpeechModelInfo, VariantProgress, DEFAULT_SPEECH_MODEL, }; pub use error::{Error, Result}; pub use fonts::list_system_fonts; pub use model::{ Asset, AssetAnalysis, AudioEffect, CaptionLayout, CaptionOptions, CaptionStyle, Clip, Color, CropFrame, Delivery, DiffEntry, - DiffKind, EditSource, Framing, Keyframe, Marker, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, Revision, - Rhythm, SalienceMap, StagedEdit, StreamInfo, StreamKind, Task, TaskStatus, TextKeyframe, TextOverlay, TimeRange, Timeline, - TimelineDiff, Track, TranscriptSegment, Transform, Transition, TransitionKind, VideoEffect, + DiffKind, EditSource, Framing, Keyframe, Marker, Mask, MaskShape, Projection, Reframe, ReframeKeyframe, ResolvedReframe, + Revision, Rhythm, SalienceMap, StagedEdit, StreamInfo, StreamKind, Task, TaskStatus, TextKeyframe, TextOverlay, TimeRange, + Timeline, TimelineDiff, Track, TranscriptSegment, Transform, Transition, TransitionKind, VideoEffect, }; pub use platform::{ check_all as check_platforms, CutSummary, DeliveryCheck, DeliveryIssue, IssueKind, PlatformTarget, Severity, diff --git a/crates/kerf-core/src/model.rs b/crates/kerf-core/src/model.rs index 4f24f09..7102b46 100644 --- a/crates/kerf-core/src/model.rs +++ b/crates/kerf-core/src/model.rs @@ -2063,14 +2063,22 @@ impl Delivery { if w == 0 || h == 0 { return None; } - let fit = if reduce_ratio(w, h) == (16, 9) { Fit::Contain } else { Fit::Cover }; + let fit = if reduce_ratio(w, h) == (16, 9) { + Fit::Contain + } else { + Fit::Cover + }; Some(Delivery::new(w, h, fit)) } } fn reduce_ratio(w: u32, h: u32) -> (u32, u32) { fn gcd(a: u32, b: u32) -> u32 { - if b == 0 { a } else { gcd(b, a % b) } + if b == 0 { + a + } else { + gcd(b, a % b) + } } let d = gcd(w, h).max(1); (w / d, h / d) diff --git a/crates/kerf-core/src/project.rs b/crates/kerf-core/src/project.rs index ad5acb9..3d03a76 100644 --- a/crates/kerf-core/src/project.rs +++ b/crates/kerf-core/src/project.rs @@ -14,8 +14,7 @@ use crate::error::{Error, Result}; use crate::model::default_beat_tolerance; use crate::model::{ Asset, AssetAnalysis, AudioEffect, CaptionOptions, CaptionStyle, Clip, CropFrame, Delivery, EditSource, Framing, Keyframe, - Marker, - Mask, Projection, Reframe, ReframeKeyframe, Revision, StagedEdit, StreamInfo, StreamKind, Task, TaskStatus, Tempo, + Marker, Mask, Projection, Reframe, ReframeKeyframe, Revision, StagedEdit, StreamInfo, StreamKind, Task, TaskStatus, Tempo, TextKeyframe, TextOverlay, TimeRange, Timeline, TimelineDiff, Track, TranscriptSegment, Transition, VideoEffect, MAX_FOV, MIN_FOV, }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fac4321..409ea86 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1678,6 +1678,19 @@ export async function exportTimeline(outputPath: string, options: ExportOptions) return invoke('export_timeline', { outputPath, options }); } +/** Render the cut once per delivery frame — one file per shape beside + * `outputPath`, named by shape (`cut-9x16.mp4`). With `smartCrop` every shot + * is framed for every shape first, recorded on the clips as one revision. */ +export async function exportVariants( + outputPath: string, + formats: Delivery[], + smartCrop: boolean, + options: ExportOptions +): Promise { + if (!inTauri()) throw new Error('export is only available in the desktop app'); + return invoke('export_variants', { outputPath, formats, smartCrop, options }); +} + /** Ask the backend to stop the in-flight export; it then rejects with `export cancelled`. */ export async function cancelExport(): Promise { if (!inTauri()) return; diff --git a/frontend/src/lib/components/editor/ExportDialog.svelte b/frontend/src/lib/components/editor/ExportDialog.svelte index ac1b2d4..e930808 100644 --- a/frontend/src/lib/components/editor/ExportDialog.svelte +++ b/frontend/src/lib/components/editor/ExportDialog.svelte @@ -5,8 +5,8 @@ import { ui } from '$lib/editor-ui.svelte'; import { inTauri, pickExportPath, cancelExport, onExportProgress, hwEncoders, platformCheck, revealPath } from '$lib/api'; import { toast } from '$lib/notifications.svelte'; - import { ratioLabel } from '$lib/delivery-formats'; - import type { Container, DeliveryCheck, ExportOptions, ExportProgress, Fit, RateControl } from '$lib/types'; + import { DELIVERY_PRESETS, ratioLabel, variantPath } from '$lib/delivery-formats'; + import type { Container, Delivery, DeliveryCheck, ExportOptions, ExportProgress, Fit, RateControl } from '$lib/types'; import { PRESETS, CONTAINERS, @@ -61,6 +61,39 @@ let showCommand = $state(false); let useRange = $state(false); + // One cut, every platform: the delivery frames to render as separate files + // beside the chosen path, each named by shape. Empty means the ordinary + // single export at the resolution below. Every shot is framed for every + // shape first unless the toggle is off — reshaping without looking keeps + // whatever was in the middle. + let variantIds = $state([]); + let smartCropVariants = $state(true); + const variantPresets = DELIVERY_PRESETS.filter((p) => p.format !== null); + const variantFormats = $derived( + variantPresets.filter((p) => variantIds.includes(p.id)).map((p) => p.format as Delivery) + ); + function toggleVariant(id: string) { + variantIds = variantIds.includes(id) ? variantIds.filter((v) => v !== id) : [...variantIds, id]; + } + // Each variant judged at its own frame: a 9:16 file is a Reel whatever the + // project is cut in. + let variantChecks = $state>({}); + $effect(() => { + const wanted = variantPresets.filter((p) => variantIds.includes(p.id)); + for (const p of wanted) { + if (variantChecks[p.id]) continue; + const f = p.format as Delivery; + platformCheck([f.width, f.height]) + .then((c) => (variantChecks = { ...variantChecks, [p.id]: c })) + .catch(() => {}); + } + }); + function readyLabels(checks: DeliveryCheck[] | undefined): string { + if (!checks) return ''; + const ready = checks.filter((c) => !c.issues.some((i) => i.severity !== 'tip')).map((c) => c.label); + return ready.length ? `Ready for ${ready.join(' · ')}` : 'Not ready for any target'; + } + // Where this cut is going: the platform limits it meets or misses. Judged at // the resolution *this render* will produce, which is not always the project // frame — a 9:16 project exported at 1920x1080 is a landscape file, and the @@ -236,6 +269,15 @@ }); try { const finalOpts = useRange && marks ? { ...opts, range: marks } : opts; + if (variantFormats.length) { + const outs = await editor.exportVariants(outputPath, variantFormats, smartCropVariants, finalOpts); + const first = outs[0]; + toast.success(`Exported ${outs.length} files → ${outs.map((o) => o.split(/[\\/]/).pop()).join(', ')}`, { + action: { label: 'Show in folder', onClick: () => void revealPath(first).catch(() => {}) } + }); + onClose(); + return; + } const out = await editor.export(outputPath, finalOpts); // A path in a toast is not much use on its own — offer the folder. toast.success(`Exported → ${out}`, { @@ -375,8 +417,8 @@ {summary} - - {#if checks.length} + + {#if checks.length && !variantFormats.length}
@@ -449,6 +491,50 @@ )} {/if} + + {#if showVideo} + {@render secHead('Deliver to')} +
+ {#each variantPresets as p (p.id)} + {@const on = variantIds.includes(p.id)} + + {/each} +
+ {#if variantFormats.length} +
+ One file per shape, each shot framed for each — beside the file above as + {#each variantFormats as f, i (f.width + 'x' + f.height)}{i ? ', ' : ''}{variantPath(outputPath || 'cut.' + info.ext, f).split(/[\\/]/).pop()}{/each}. +
+ {@render toggleRow('Smart crop each shot for every shape', smartCropVariants, (v) => (smartCropVariants = v))} +
+ {#each variantPresets.filter((p) => variantIds.includes(p.id)) as p (p.id)} +
+ {p.label} + {readyLabels(variantChecks[p.id])} +
+ {/each} +
+ {:else} +
+ Pick shapes to write one file each — a Reel, a feed post and a YouTube upload from this one cut. +
+ {/if} + {/if} + {#if showVideo} {@render secHead('Video')} @@ -560,6 +646,11 @@ {/if} {@render secHead('Scaling')} + {#if variantFormats.length} +
+ Resolution and fit come from each delivery frame above. +
+ {:else} {@render selectRow( 'Resolution', resValue(), @@ -611,6 +702,7 @@ {FITS.find((f) => f.id === (opts.fit ?? 'contain'))?.hint}
{/if} + {/if} {@render selectRow( 'Frame rate', opts.fps ? String(opts.fps) : 'source', @@ -716,9 +808,9 @@ - {Math.round((progress?.fraction ?? 0) * 100)}%{progress?.eta_secs != null - ? ` · ${fmtEta(progress.eta_secs)} left` - : ''} + {#if progress?.total != null && progress.total > 1}{(progress.variant ?? 0) + 1}/{progress.total} · {/if}{Math.round( + (progress?.fraction ?? 0) * 100 + )}%{progress?.eta_secs != null ? ` · ${fmtEta(progress.eta_secs)} left` : ''} @@ -727,7 +819,9 @@ {:else}
Cancel - Export + + {variantFormats.length > 1 ? `Export ${variantFormats.length} files` : 'Export'} + {/if} diff --git a/frontend/src/lib/delivery-formats.test.ts b/frontend/src/lib/delivery-formats.test.ts index d3294fb..8a369e0 100644 --- a/frontend/src/lib/delivery-formats.test.ts +++ b/frontend/src/lib/delivery-formats.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from 'bun:test'; -import { DELIVERY_PRESETS, fitLabel, presetFor, ratioLabel } from './delivery-formats'; +import { DELIVERY_PRESETS, fitLabel, presetFor, ratioLabel, variantPath } from './delivery-formats'; + +describe('variantPath', () => { + test('splices the shape into the file name beside the base', () => { + expect(variantPath('/renders/cut.mp4', { width: 1080, height: 1920, fit: 'cover' })).toBe('/renders/cut-9x16.mp4'); + expect(variantPath('C:\\out\\my.cut.mov', { width: 1080, height: 1080, fit: 'cover' })).toBe('C:\\out\\my.cut-1x1.mov'); + expect(variantPath('cut', { width: 1920, height: 1080, fit: 'contain' })).toBe('cut-16x9'); + }); +}); describe('ratioLabel', () => { test('reduces a frame to its aspect', () => { diff --git a/frontend/src/lib/delivery-formats.ts b/frontend/src/lib/delivery-formats.ts index dd0548d..16d498c 100644 --- a/frontend/src/lib/delivery-formats.ts +++ b/frontend/src/lib/delivery-formats.ts @@ -46,6 +46,18 @@ export function ratioLabel(width: number, height: number): string { return `${width / d}:${height / d}`; } +/** Where a multi-format export puts one delivery: beside `base`, its shape in + * the name — `cut.mp4` at 9:16 is `cut-9x16.mp4`. Mirrors + * `ExportVariant::beside`; an `x` because `:` is not a filename character on + * Windows. */ +export function variantPath(base: string, format: Delivery): string { + const shape = ratioLabel(format.width, format.height).replace(':', 'x'); + const m = base.match(/^(.*?)(\.[^./\\]+)?$/); + const stem = m?.[1] ?? base; + const ext = m?.[2] ?? ''; + return `${stem}-${shape}${ext}`; +} + export function fitLabel(fit: Fit): string { return fit === 'cover' ? 'fill & crop' : 'fit & letterbox'; } diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts index 5ba7a45..2c672ed 100644 --- a/frontend/src/lib/state.svelte.ts +++ b/frontend/src/lib/state.svelte.ts @@ -15,6 +15,7 @@ import { cutClip, exportSrt, exportTimeline, + exportVariants, extractAudio, getAssetMetadata, getHistory, @@ -733,6 +734,23 @@ class EditorState { } } + async exportVariants( + outputPath: string, + formats: Delivery[], + smartCrop: boolean, + options: ExportOptions + ): Promise { + this.busy = true; + try { + return await exportVariants(outputPath, formats, smartCrop, options); + } finally { + this.busy = false; + // The framing pass wrote onto the clips; the history has a revision + // the panel has not seen. + await this.refreshTimeline().catch(() => {}); + } + } + #msg(e: unknown): string { return e instanceof Error ? e.message : String(e); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3ee56ab..87efa7c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -271,10 +271,24 @@ export interface Clip { keyframes?: Keyframe[]; /** 360 reprojection; absent for ordinary flat footage. */ reframe?: Reframe | null; + /** Crops kept for delivery shapes other than the project's own, written by + * the multi-format export's framing pass. The project frame's crop is the + * transform's. */ + framings?: Framing[]; /** Whether the clip renders. Absent means enabled (the backend omits it when true). */ enabled?: boolean; } +/** A crop for one delivery shape (`aspect_w:aspect_h` in lowest terms). */ +export interface Framing { + aspect_w: number; + aspect_h: number; + crop_left: number; + crop_right: number; + crop_top: number; + crop_bottom: number; +} + export const DEFAULT_TRANSFORM: Transform = { scale: 1, pos_x: 0, @@ -504,6 +518,10 @@ export interface ExportProgress { fraction: number; elapsed_secs: number; eta_secs?: number | null; + /** Set by a multi-format export: which file is rendering, of how many. + * `fraction` then spans all of them. */ + variant?: number; + total?: number; } /** Payload of the `import-progress` event, emitted while a 360 pair is stitched. */ From 6d079096941d86649b8287aff835355fdcfb537a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orell=20B=C3=BChler?= Date: Wed, 2 Sep 2026 21:19:29 +0200 Subject: [PATCH 3/3] document the multi-format export Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gm2h2GQnUyBRLnVe431o9w --- CLAUDE.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e263fde..4b7dba4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -444,7 +444,37 @@ no editing logic in the adapter. *before* the fit scale, so the preview, the still and the export all follow and the inspector's sliders still have the last word. Clips already the delivery shape and 360-reframed clips are left out (that camera *is* the framing decision), and a pass - that changes nothing writes no revision. The **agent task queue** is a real `tasks` table (one row per `Task`, + that changes nothing writes no revision. + **One cut, every platform**: the same project can be delivered at several + frames in one pass (a 9:16 Reel, a 1:1 post and a 16:9 upload), which is + what exposed the tension in smart crop — its crop is baked into the + transform for *one* shape, and framing for a second overwrote the first. So + a clip carries **`Clip.framings`**, a crop per delivery shape (`Framing`, + keyed by the reduced ratio `Delivery::ratio`, `(9, 16)`) beside the + transform's, and **`Timeline::for_delivery(delivery)`** (pure + + unit-tested) is the render of the cut at another frame: a copy whose format + is that delivery and whose clips wear the crop they carry for its shape — the + same change-the-timeline-not-the-graph pattern as `for_render`, so the graph + builders never learned about it. A clip with no framing for the shape keeps + the crop it has (never throw away a hand-made crop), which is why the framing + pass writes an *identity* framing for a shot already that shape: a lookup + miss would otherwise leave a 16:9 shot cut 9:16 delivering at 16:9 as the + strip its 9:16 crop keeps. Generated captions are re-fit to the new aspect + (`fit_size` again); typed titles are left alone. The framing pass is the + smart-crop trio again for the *other* shapes — `framing_inputs(deliveries)` + under the lock (the project frame's own ratio excluded, duplicates + collapsed), the static `sample_framings` with it released (**one** salience + decode per clip, a crop per shape from it — the map is a property of the + shot, the crop of the frame), `apply_framings` under it as one `Frame for + 9:16, 1:1` revision that a re-run leaves alone — and `engine::render_variants` + renders `ExportVariant`s (a `Delivery` + an output path; `ExportVariant::beside` + names each file by shape, `cut-9x16.mp4`, an `x` because `:` is not a Windows + filename character) **one after another**, each variant's `resolution` / `fit` + taken from its delivery, reporting a `VariantProgress` (which file of how many + plus the overall fraction). Sequential on purpose: an export takes every core + it is given and `cpu::lease` would serialize them anyway, and a cancel is + then clean — the file in flight is deleted, the finished ones kept. + The **agent task queue** is a real `tasks` table (one row per `Task`, columns not JSON): `add_task` / `list_tasks` / `claim_next_task` / `complete_task` / `fail_task` / `resolve_task` / `remove_task` drive the `queued → working → ready → done` (or `failed`) lifecycle in `model.rs`. @@ -564,7 +594,15 @@ both writes the GUI picker makes, though the picker itself only re-reads at launch, so a model an agent selects shows there on the next start. `smart_crop` frames each shot for the delivery frame (the server `instructions` pair it with `set_delivery_format`, since reshaping to 9:16 otherwise keeps -whatever was in the middle). `generate_captions` / `clear_captions` caption the +whatever was in the middle). `export_variants` is the one-call multi-format +delivery: `formats` are shape names (`9:16` / `1:1` / `4:5` / `16:9`, or +`WxH` — `Delivery::parse`), it runs the framing pass first unless +`smart_crop` is false (the one write it makes, `project-changed` only when a +clip actually changed), renders through `render_variants` with progress on +the client's token naming the file in flight, and reports each file with the +platforms it is `ready_for` and its non-tip issues — judged at *that* file's +frame via `cut_summary(Some(frame))`, so the agent does not run +`platform_check` per variant afterwards. `generate_captions` / `clear_captions` caption the cut; its `style` picks `lines` or `word_punch` and the `instructions` say to prefer the latter for a vertical cut, since nothing in a tool list tells an agent that the subtitle shape is not what social captions look like. They also @@ -871,7 +909,15 @@ loudness normalize, and a **Range: In → out** choice when marks are set. It ** the frame the project is cut for** (`initialExport`): the preset whose resolution is that frame when one matches, else the default preset with its resolution cleared so "Project frame" renders — otherwise a 9:16 project opened its export already -landscape and the readiness panel warned about the shape the user had just chosen. `MediaBin`'s +landscape and the readiness panel warned about the shape the user had just chosen. +Its **Deliver to** section is the multi-format export: shape chips (the +`DELIVERY_PRESETS` minus Source) that each add a file beside the chosen path +named by shape (`variantPath`, the bun-tested mirror of +`ExportVariant::beside`), a *Smart crop each shot for every shape* toggle, and a +per-file readiness line judged at that file's frame (`platformCheck([w, h])`), +in place of the single panel — with shapes picked, the Scaling rows hide (each +delivery brings its own resolution and fit) and the button reads `Export N +files`; `export-progress` then carries `variant` / `total`. `MediaBin`'s **Transcript tab is an editing surface**: lines resolve to the clip carrying them, click seeks, the playhead line highlights, and `×` cuts the sentence from the timeline (`cut_clip_range`); cut lines render struck through. When it is *empty* it says which