Skip to main content

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}