screen_app/editor_export.rs
1//! Deferred export frame generator (ED.20 / M-EDIT).
2//!
3//! The optical printer was the lab's export stage: it re-photographed the
4//! cut negative one frame at a time onto fresh stock, honoring every edit
5//! decision as it went. This is that printer in software — given an
6//! [`EditProject`], it walks the *project* frames `0..project_duration` and,
7//! for each, maps to the source frame via [`EditProject::source_time`] (so
8//! trim, split, and speed are all baked in), decodes it from a seekable
9//! [`EditorVideoStream`], and composes it through the **same**
10//! [`EditorPreview`] path the live preview uses. The result is a
11//! deterministic frame stream the exporter (ED.21) feeds to the encoder, so
12//! the file you export matches the cut you scrubbed.
13//!
14//! It is **forward-only**: project frames are visited in order, and our edit
15//! ops never reorder the timeline, so the source frames it requests are
16//! monotonic non-decreasing — the decode stream never re-spawns (cheap,
17//! and asserted by the golden test via [`ExportFrameGenerator::spawn_count`]).
18//!
19//! The cinematic *visual* edits apply as a transform on the composed screen
20//! sprite: the zoom punch-in (ED.16) + the crop / aspect reframe (ED.15) are
21//! written into the screen sprite each frame via
22//! [`EditorPreview::render_framed`](crate::editor_preview::EditorPreview::render_framed),
23//! and the background framing (ED.18) — the backdrop fill, padding inset, and
24//! rounded-corner frame window — is applied once at construction via
25//! [`EditorPreview::set_background`](crate::editor_preview::EditorPreview::set_background).
26//! This chunk is the frame-accurate timeline walk the whole export rests on.
27//!
28//! Audio (ED.21) rides the same timeline: [`export_edited_project`] decodes
29//! the source's audio once ([`media::encode::decode_source_audio_f32`]),
30//! retimes it in pure Rust per the segment list ([`retime_audio`] — trim +
31//! per-segment speed via linear-interpolation resample), and feeds it to the
32//! encoder's audio scratch so the finalize remux muxes it onto the retimed
33//! video. `GStreamer` owns the intake; Rust owns the edit arithmetic.
34
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::time::Duration;
38
39use decode::EditorVideoStream;
40use edit::style::CropRect;
41use edit::zoom_anim::active_zoom_at;
42use edit::{EditProject, TimelineSegment};
43use media::encode::{EncoderConfig, LiveGstreamerEncoder, OutputFormat, VideoEncoder};
44
45use crate::editor_preview::EditorPreview;
46use crate::recording_compose::ComposedFrame;
47
48/// One generated export frame.
49pub struct ExportFrame {
50 /// The composed BGRA frame.
51 pub frame: ComposedFrame,
52 /// Presentation timestamp in project time (matches the live encoder's
53 /// `feed_real_capture` formula so timestamps line up).
54 pub pts: Duration,
55 /// The source frame this project frame mapped to (for verification).
56 pub source_frame: u64,
57}
58
59/// Walks an [`EditProject`] into a deterministic composed-frame stream.
60pub struct ExportFrameGenerator {
61 project: EditProject,
62 stream: EditorVideoStream,
63 preview: EditorPreview,
64 next: u64,
65 total: u64,
66}
67
68impl ExportFrameGenerator {
69 /// Open the source clip + compose pipeline for `project`, composing at the
70 /// project's aspect-ratio canvas ([`EditProject::canvas_dims`], AUT-513).
71 ///
72 /// # Errors
73 ///
74 /// Returns a message if the source can't be opened or the wgpu compose
75 /// pipeline can't be created.
76 pub fn new(project: EditProject, source: &Path) -> Result<Self, String> {
77 let (cw, ch) = project.canvas_dims();
78 Self::with_canvas(project, source, cw, ch)
79 }
80
81 /// Like [`Self::new`] but composes into an explicit `canvas_w × canvas_h`
82 /// output — the export path passes the aspect canvas after clamping it to
83 /// the HW-encoder edge cap (`fit_within_encoder_limits`) so the generator
84 /// and the encoder agree on dimensions. The source is aspect-fit
85 /// (letterbox / pillarbox) into the canvas.
86 ///
87 /// # Errors
88 ///
89 /// Returns a message if the source can't be opened or the wgpu compose
90 /// pipeline can't be created.
91 pub fn with_canvas(
92 project: EditProject,
93 source: &Path,
94 canvas_w: u32,
95 canvas_h: u32,
96 ) -> Result<Self, String> {
97 // Export is a strictly-forward, monotonic walk (`source_time` is
98 // non-decreasing across project frames), so a single-frame cache is
99 // sufficient — it still serves the repeated source frame a slow-
100 // motion segment requests, while the default 300-frame LRU would
101 // pin ~2.5 GB of decoded BGRA at 1080p for no benefit.
102 let stream = EditorVideoStream::open_with_cache(source, 1)
103 .map_err(|e| format!("open source: {e}"))?;
104 let mut preview = EditorPreview::with_canvas(
105 project.source.width,
106 project.source.height,
107 canvas_w,
108 canvas_h,
109 )
110 .map_err(|e| format!("init compose: {e}"))?;
111 // Background framing (ED.18) is constant across the export — set the
112 // backdrop + rounded-corner clip + padding once, here, so the
113 // per-frame `render_framed` only updates the zoom/crop transform.
114 preview.set_background(&project.background);
115 let total = project.project_duration();
116 Ok(Self {
117 project,
118 stream,
119 preview,
120 next: 0,
121 total,
122 })
123 }
124
125 /// Total project frames the generator will emit.
126 #[must_use]
127 pub fn frame_count(&self) -> u64 {
128 self.total
129 }
130
131 /// Decode pipelines spawned so far — stays `1` for a full forward walk.
132 #[must_use]
133 pub fn spawn_count(&self) -> u64 {
134 self.stream.spawn_count()
135 }
136
137 /// Generate the next project frame, or `None` at the end of the project.
138 pub fn next_frame(&mut self) -> Option<ExportFrame> {
139 if self.next >= self.total {
140 return None;
141 }
142 let f = self.next;
143 // Kept for the returned `source_frame` (verification); the compose
144 // itself re-derives it inside `compose_project_frame`.
145 let source_frame = self.project.source_time(f)?;
146 let frame = compose_project_frame(&mut self.preview, &mut self.stream, &self.project, f)?;
147 let fps = u64::from(self.project.project_fps.max(1));
148 let pts = Duration::from_micros(f * (1_000_000 / fps));
149 self.next += 1;
150 Some(ExportFrame {
151 frame,
152 pts,
153 source_frame,
154 })
155 }
156}
157
158/// Compose project frame `frame` into a [`ComposedFrame`] — the single
159/// per-frame compose shared by the deferred **export** generator and the live
160/// editor **preview** (AUT-510), so what you scrub is what you ship.
161///
162/// Maps the project frame to its source frame (trim / split / speed via
163/// [`EditProject::source_time`]), decodes it from `stream`, then applies the
164/// cinematic framing: the zoom punch-in ([`active_zoom_at`], ED.16), the crop /
165/// aspect reframe (ED.15), and — when the project carries a cursor track — the
166/// cursor overlay + click ripples ([`cursor_for_frame`] + `ripples_at`, ED.19).
167/// Returns `None` past the end of the timeline or on a decode miss.
168#[must_use]
169pub fn compose_project_frame(
170 preview: &mut EditorPreview,
171 stream: &mut EditorVideoStream,
172 project: &EditProject,
173 frame: u64,
174) -> Option<ComposedFrame> {
175 let source_frame = project.source_time(frame)?;
176 let decoded = stream.frame(source_frame)?;
177 let zoom = active_zoom_at(project, frame);
178 let crop = project.crop.unwrap_or_else(CropRect::full);
179 if let Some(track) = project.cursor_track.as_deref() {
180 let cfg = project.cursor;
181 // Ripple window ≈ 0.4 s.
182 let ripple_frames = (project.project_fps * 2 / 5).max(1);
183 let clicks = project.clicks.as_deref().unwrap_or(&[]);
184 let ripples = edit::telemetry::ripples_at(clicks, frame, ripple_frames);
185 // `hide_static` fades the pointer out while parked, but a live click
186 // ripple keeps it visible (see `cursor_for_frame`).
187 let cursor = cursor_for_frame(track, &cfg, project.project_fps, frame, !ripples.is_empty());
188 preview.render_framed_with_cursor(decoded.bgra, zoom, crop, cursor, &ripples, &cfg)
189 } else {
190 preview.render_framed(decoded.bgra, zoom, crop)
191 }
192}
193
194/// The pointer position to draw at project `frame`, applying
195/// [`CursorConfig::hide_static`](edit::style::CursorConfig::hide_static).
196///
197/// Returns `None` (pointer hidden) only when the cursor is parked **and** no
198/// click ripple is live — a click is an action worth showing even if the
199/// cursor hasn't moved (the Screen Studio rule). Otherwise the smoothed
200/// position from [`cursor_at`](edit::telemetry::cursor_at). Pure, so the
201/// hide-while-static decision is unit-testable without a renderer.
202#[must_use]
203fn cursor_for_frame(
204 track: &[edit::telemetry::CursorSample],
205 cfg: &edit::style::CursorConfig,
206 fps: u32,
207 frame: edit::segment::Frame,
208 ripple_active: bool,
209) -> Option<(f32, f32)> {
210 if cfg.hide_static && !ripple_active && edit::telemetry::cursor_is_static(track, frame, fps) {
211 None
212 } else {
213 edit::telemetry::cursor_at(track, frame, cfg.smoothing)
214 }
215}
216
217/// Retime the source audio to the edited timeline (ED.21).
218///
219/// `full` is the source's interleaved F32LE audio (`channels` samples per
220/// sample-frame). For each timeline segment, the source sample-frame range
221/// `[source_start, source_end)` — mapped from source *video* frames via
222/// `source_fps` and `sample_rate` — is resampled to the segment's **project**
223/// duration: a `timescale` of `2.0` emits half as many sample-frames (sped
224/// up), `0.5` twice as many (slowed). Linear interpolation between adjacent
225/// sample-frames keeps it click-free; the segments are then concatenated, so
226/// the result matches the retimed video frame-for-frame.
227///
228/// **v1 ships speed-with-pitch by design**: a sped-up segment rises in pitch,
229/// a slowed one drops — the industry default for editor speed ramps (Premiere
230/// / FCP / Resolve / Loom all default to it). Pitch-*preserving* retime
231/// (time-stretch) is a tracked enhancement, **ISS-18**: it slots in behind
232/// this exact pure `(samples, segments, fps, rate, channels) -> Vec<f32>`
233/// contract, so callers and tests don't change when it lands. Pure — no gst,
234/// exhaustively testable.
235#[must_use]
236#[allow(
237 clippy::cast_precision_loss,
238 clippy::cast_possible_truncation,
239 clippy::cast_sign_loss,
240 reason = "audio sample counts are well under 2^52 so the u64/usize→f64 conversions are lossless; positions are clamped to a valid range before the f64→usize index cast, and the interpolation fraction is in [0, 1)"
241)]
242fn retime_audio(
243 full: &[f32],
244 segments: &[TimelineSegment],
245 source_fps: u32,
246 sample_rate: u32,
247 channels: usize,
248) -> Vec<f32> {
249 if full.is_empty() || channels == 0 {
250 return Vec::new();
251 }
252 let total_frames = full.len() / channels;
253 if total_frames == 0 {
254 return Vec::new();
255 }
256 let fps = f64::from(source_fps.max(1));
257 let rate = f64::from(sample_rate.max(1));
258 // Source video frame → source audio sample-frame position.
259 let frame_to_sample = |frame: u64| (frame as f64) * rate / fps;
260 let last = (total_frames - 1) as f64;
261 // Linearly interpolate channel `ch` at sample-frame position `pos`,
262 // clamped to the valid range.
263 let sample_at = |pos: f64, ch: usize| -> f32 {
264 let clamped = pos.clamp(0.0, last);
265 let i0 = clamped.floor() as usize;
266 let i1 = (i0 + 1).min(total_frames - 1);
267 let frac = (clamped - i0 as f64) as f32;
268 let a = full[i0 * channels + ch];
269 let b = full[i1 * channels + ch];
270 a + (b - a) * frac
271 };
272
273 let mut out = Vec::new();
274 for seg in segments {
275 let ts = if seg.timescale.is_finite() && seg.timescale > 0.0 {
276 seg.timescale
277 } else {
278 1.0
279 };
280 let start = frame_to_sample(seg.source_start);
281 let end = frame_to_sample(seg.source_end);
282 let src_span = (end - start).max(0.0);
283 if src_span <= 0.0 {
284 continue;
285 }
286 // Project sample-frames = source span / timescale (2× → half as many).
287 let out_frames = (src_span / ts).round().max(0.0) as usize;
288 out.reserve(out_frames * channels);
289 for j in 0..out_frames {
290 let pos = start + (j as f64) * ts;
291 for ch in 0..channels {
292 out.push(sample_at(pos, ch));
293 }
294 }
295 }
296 out
297}
298
299/// Export an entire edited project to a single video file (ED.21).
300///
301/// Drives the [`ExportFrameGenerator`] frame by frame into a fresh
302/// [`LiveGstreamerEncoder`] (reused unchanged from the live recorder), then
303/// finalizes the container. Synchronous and long-running — call it from a
304/// blocking task. `cancel` is polled once per frame (for the export UI,
305/// ED.22) and `on_progress(done, total)` reports after each frame.
306///
307/// The output is composed at the project's **aspect-ratio canvas**
308/// ([`EditProject::canvas_dims`], AUT-513) — the source reframed to 16:9 /
309/// 9:16 / 1:1 / 4:3, letterboxed/pillarboxed without stretch — clamped to the
310/// HW-encoder edge cap. The zoom punch-ins (ED.16), crop (ED.15), and
311/// background framing (ED.18 — backdrop, padding, rounded corners, shadow,
312/// inset border) are all baked into each frame by the generator; the
313/// per-segment audio retime (ED.21) muxes on at finalize.
314///
315/// # Errors
316///
317/// Returns a message if the source can't be opened, the encoder can't start,
318/// a frame fails to encode, or `cancel` fired mid-export.
319pub fn export_edited_project(
320 project: EditProject,
321 source: &Path,
322 output_path: PathBuf,
323 format: OutputFormat,
324 cancel: &AtomicBool,
325 mut on_progress: impl FnMut(u64, u64),
326) -> Result<PathBuf, String> {
327 let total = project.project_duration();
328 // Output canvas = the project's aspect-ratio reframe (AUT-513), clamped to
329 // the HW-encoder per-axis edge cap (AUT-334). The generator composes at the
330 // same dims so the encoder caps match the composed frames.
331 let (canvas_w, canvas_h) = media::encode::fit_within_encoder_limits(
332 project.canvas_dims().0,
333 project.canvas_dims().1,
334 format,
335 );
336 let mut config = EncoderConfig::for_output(output_path, format);
337 config.width = canvas_w;
338 config.height = canvas_h;
339 config.framerate = project.project_fps;
340
341 // Capture the audio-retime inputs before `project` moves into the
342 // generator (ED.21): the segment list + the source recording's fps map
343 // source frames → audio sample positions.
344 let segments = project.segments.clone();
345 let source_fps = project.source.source_fps;
346 let (sample_rate, channels) = (config.sample_rate, config.channels);
347
348 let mut generator = ExportFrameGenerator::with_canvas(project, source, canvas_w, canvas_h)?;
349 let mut encoder =
350 LiveGstreamerEncoder::new(config).map_err(|e| format!("start encoder: {e}"))?;
351
352 let mut done = 0u64;
353 while let Some(ef) = generator.next_frame() {
354 if cancel.load(Ordering::Relaxed) {
355 return Err("export cancelled".to_owned());
356 }
357 encoder
358 .push_video_frame(&ef.frame.bytes, ef.pts)
359 .map_err(|e| format!("encode frame {done}: {e}"))?;
360 done += 1;
361 on_progress(done, total);
362 }
363 // The generator stops early (without a per-frame error) only if a source
364 // decode failed mid-walk — don't finalize a truncated file as success.
365 if done < total {
366 return Err(format!(
367 "export ended early at {done}/{total} frames (source decode failed?)"
368 ));
369 }
370
371 // ED.21 audio: decode the source's audio once, retime it to the edited
372 // timeline in pure Rust, and feed it to the encoder's audio scratch so the
373 // finalize remux muxes it. The video already succeeded — an audio decode
374 // failure falls back to a video-only export rather than sinking it.
375 match media::encode::decode_source_audio_f32(source, sample_rate, channels) {
376 Ok(samples) if !samples.is_empty() => {
377 let retimed = retime_audio(
378 &samples,
379 &segments,
380 source_fps,
381 sample_rate,
382 usize::from(channels),
383 );
384 if !retimed.is_empty() {
385 encoder
386 .push_audio_chunk(&retimed, Duration::ZERO)
387 .map_err(|e| format!("push edited audio: {e}"))?;
388 }
389 }
390 Ok(_) => { /* source has no audio track — video-only export */ }
391 Err(e) => {
392 tracing::warn!(error = %e, "edited audio decode failed — exporting video-only");
393 }
394 }
395
396 Box::new(encoder)
397 .finalize()
398 .map_err(|e| format!("finalize export: {e}"))
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 // 10 audio sample-frames per video frame (rate 300 / fps 30) keeps the
406 // arithmetic exact and the fixtures small.
407 const FPS: u32 = 30;
408 const RATE: u32 = 300;
409
410 /// Mono ramp: sample-frame `i` holds the value `i` (built without an
411 /// integer→float cast so it stays clippy-clean).
412 fn mono_ramp(frames: usize) -> Vec<f32> {
413 (0..frames)
414 .scan(0.0f32, |acc, _| {
415 let v = *acc;
416 *acc += 1.0;
417 Some(v)
418 })
419 .collect()
420 }
421
422 #[test]
423 fn retime_audio_empty_or_degenerate_is_empty() {
424 let seg = TimelineSegment::new(0, 30);
425 assert!(retime_audio(&[], &[seg], FPS, RATE, 1).is_empty());
426 assert!(retime_audio(&mono_ramp(300), &[seg], FPS, RATE, 0).is_empty());
427 // No segments → nothing to emit.
428 assert!(retime_audio(&mono_ramp(300), &[], FPS, RATE, 1).is_empty());
429 }
430
431 #[test]
432 fn retime_audio_realtime_preserves_length_and_content() {
433 // Source: 30 frames → 300 sample-frames. A single real-time segment
434 // over the whole clip reproduces it exactly.
435 let full = mono_ramp(300);
436 let out = retime_audio(&full, &[TimelineSegment::new(0, 30)], FPS, RATE, 1);
437 assert_eq!(out.len(), 300);
438 assert!((out[0] - 0.0).abs() < 1e-3);
439 assert!((out[100] - 100.0).abs() < 1e-3);
440 assert!((out[299] - 299.0).abs() < 1e-3);
441 }
442
443 /// The **speed-with-pitch contract** (ED.21, ISS-18): a 2× segment emits
444 /// half the sample-frames *and* strides the source 2× — which is exactly
445 /// what raises the pitch with the tempo. This is the deliberate v1
446 /// behavior; a pitch-preserving time-stretch would keep `out[1]` near the
447 /// *unstrided* source while still halving the length. Asserting the stride
448 /// pins the contract so a future ISS-18 change is a conscious one.
449 #[test]
450 fn retime_audio_double_speed_shifts_pitch_with_tempo() {
451 let full = mono_ramp(300);
452 let out = retime_audio(
453 &[full.as_slice()].concat(),
454 &[TimelineSegment::with_speed(0, 30, 2.0)],
455 FPS,
456 RATE,
457 1,
458 );
459 // 300 source sample-frames / 2.0 → 150 (tempo doubled).
460 assert_eq!(out.len(), 150);
461 // Output strides the source 2× (pitch rises with tempo): out[1] samples
462 // source position 2, out[10] position 20 — NOT 1 and 10.
463 assert!((out[1] - 2.0).abs() < 1e-3, "2× stride → pitch shift");
464 assert!((out[10] - 20.0).abs() < 1e-3);
465 }
466
467 #[test]
468 fn retime_audio_trim_selects_the_subrange() {
469 // Trim to source frames [10, 20) → sample-frames [100, 200).
470 let full = mono_ramp(300);
471 let out = retime_audio(&full, &[TimelineSegment::new(10, 20)], FPS, RATE, 1);
472 assert_eq!(out.len(), 100);
473 assert!(
474 (out[0] - 100.0).abs() < 1e-3,
475 "starts at the trimmed in-point"
476 );
477 assert!((out[99] - 199.0).abs() < 1e-3);
478 }
479
480 #[test]
481 fn retime_audio_concatenates_segments() {
482 // Two real-time slices [0,10) + [20,30) → 100 + 100 sample-frames.
483 let full = mono_ramp(300);
484 let out = retime_audio(
485 &full,
486 &[TimelineSegment::new(0, 10), TimelineSegment::new(20, 30)],
487 FPS,
488 RATE,
489 1,
490 );
491 assert_eq!(out.len(), 200);
492 assert!((out[0] - 0.0).abs() < 1e-3, "first slice starts at 0");
493 assert!(
494 (out[100] - 200.0).abs() < 1e-3,
495 "second slice jumps to sample-frame 200"
496 );
497 }
498
499 #[test]
500 fn retime_audio_preserves_stereo_interleave() {
501 // Stereo: left = i, right = 1000 + i, interleaved (no int→float cast).
502 let frames = 300usize;
503 let mut full = Vec::with_capacity(frames * 2);
504 let mut v = 0.0f32;
505 for _ in 0..frames {
506 full.push(v);
507 full.push(1000.0 + v);
508 v += 1.0;
509 }
510 let out = retime_audio(&full, &[TimelineSegment::new(0, 30)], FPS, RATE, 2);
511 assert_eq!(out.len(), 600);
512 assert!((out[0] - 0.0).abs() < 1e-3, "L0");
513 assert!((out[1] - 1000.0).abs() < 1e-3, "R0");
514 assert!((out[2] - 1.0).abs() < 1e-3, "L1");
515 assert!((out[3] - 1001.0).abs() < 1e-3, "R1");
516 }
517
518 #[test]
519 fn cursor_for_frame_hides_a_parked_pointer_unless_clicked() {
520 use edit::style::CursorConfig;
521 use edit::telemetry::CursorSample;
522
523 // A pointer that hasn't emitted a new sample for the whole ~0.4 s
524 // window is parked.
525 let parked = [CursorSample::new(0, 0.5, 0.5)];
526 let cfg = CursorConfig::default(); // hide_static = true
527 assert!(
528 cursor_for_frame(&parked, &cfg, 30, 100, false).is_none(),
529 "parked + no ripple ⇒ hidden"
530 );
531 // A live click ripple keeps the parked pointer visible.
532 assert!(
533 cursor_for_frame(&parked, &cfg, 30, 100, true).is_some(),
534 "parked + active ripple ⇒ shown"
535 );
536 // With hide_static off, a parked pointer always shows.
537 let mut on = cfg;
538 on.hide_static = false;
539 assert!(cursor_for_frame(&parked, &on, 30, 100, false).is_some());
540 // A moving pointer always shows, ripple or not.
541 let moving = [
542 CursorSample::new(88, 0.0, 0.0),
543 CursorSample::new(100, 1.0, 1.0),
544 ];
545 assert!(cursor_for_frame(&moving, &cfg, 30, 100, false).is_some());
546 }
547}