simple_editor\engine/
export.rs

1//! Export: render the timeline with the CPU Compositor + Mixer and pipe it into ffmpeg.exe
2//! (`-f rawvideo -pix_fmt rgba -s WxH -r fps -i pipe:0` + pre-mixed temp WAV), encoder chosen by the
3//! output extension / settings. Plus the lossless `-c copy` fast path for pure cuts of one source.
4//! Runs on a background thread; the UI polls `Progress`.
5
6use crate::engine::compose::Compositor;
7use crate::engine::mixer::Mixer;
8use crate::engine::text::TextRasterizer;
9use crate::media::{ffpipe, Backend, DecoderPool, Frame, SAMPLE_RATE};
10use crate::model::{ClipKind, Project, Track};
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::{Child, Stdio};
14use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
15use std::sync::{Arc, Mutex};
16use std::thread::JoinHandle;
17use std::time::Duration;
18
19/// One frame the export thread wants composited on the GPU. The UI thread owns the GL context, so it
20/// picks these up in `App::update`, renders `layers` and sends the pixels back.
21pub struct GpuFrameRequest {
22    pub layers: crate::engine::gpu::LayerSet,
23    pub t: f64,
24    pub w: u32,
25    pub h: u32,
26    /// The rendered frame, or None when the renderer died (the export falls back to the CPU compositor).
27    pub reply: std::sync::mpsc::SyncSender<Option<Frame>>,
28}
29
30/// Where an export gets its pictures.
31#[derive(Clone)]
32pub enum FrameSource {
33    /// Composite on this thread with `engine::compose` (no GPU-only effects, no node graphs).
34    Cpu,
35    /// Decode here, composite on the UI thread's GL context — identical to what the preview shows.
36    Gpu(std::sync::mpsc::Sender<GpuFrameRequest>),
37}
38
39impl std::fmt::Debug for FrameSource {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.write_str(match self {
42            FrameSource::Cpu => "Cpu",
43            FrameSource::Gpu(_) => "Gpu",
44        })
45    }
46}
47
48#[derive(Clone, Debug)]
49pub struct ExportOptions {
50    pub out_path: PathBuf,
51    /// "auto" or an ffmpeg encoder name (see Settings.encoder).
52    pub encoder: String,
53    pub crf: u32,
54    pub preset: String,
55    pub backend: Backend,
56    /// Output size when it differs from the project (frames are rendered at project size and scaled by
57    /// ffmpeg `-vf scale=W:H:flags=<scaler>`); None = project size.
58    pub out_size: Option<(u32, u32)>,
59    /// ffmpeg scale flags: "neighbor" | "bilinear" | "bicubic" | "lanczos" | "area" | "spline".
60    pub scaler: String,
61    /// CPU compositor, or the UI thread's GPU renderer (so exports match the preview exactly).
62    pub frames: FrameSource,
63    /// Container metadata to write, as `(key, value)`. Empty = strip everything (`-map_metadata -1`),
64    /// which is the default: exports carry no title/encoder/creation-time unless asked for.
65    pub metadata: Vec<(String, String)>,
66}
67
68pub struct Progress {
69    fraction: Mutex<f32>,
70    status: Mutex<String>,
71    error: Mutex<Option<String>>,
72    pub cancel: AtomicBool,
73    done: AtomicBool,
74}
75
76impl Progress {
77    pub fn new() -> Arc<Self> {
78        Arc::new(Self {
79            fraction: Mutex::new(0.0),
80            status: Mutex::new(String::new()),
81            error: Mutex::new(None),
82            cancel: AtomicBool::new(false),
83            done: AtomicBool::new(false),
84        })
85    }
86    pub fn set(&self, fraction: f32, status: impl Into<String>) {
87        *self.fraction.lock().unwrap() = fraction;
88        *self.status.lock().unwrap() = status.into();
89    }
90    pub fn fraction(&self) -> f32 {
91        *self.fraction.lock().unwrap()
92    }
93    pub fn status(&self) -> String {
94        self.status.lock().unwrap().clone()
95    }
96    pub fn finish(&self, error: Option<String>) {
97        *self.error.lock().unwrap() = error;
98        self.done.store(true, Ordering::SeqCst);
99    }
100    pub fn is_done(&self) -> bool {
101        self.done.load(Ordering::SeqCst)
102    }
103    pub fn is_cancelled(&self) -> bool {
104        self.cancel.load(Ordering::SeqCst)
105    }
106    pub fn error(&self) -> Option<String> {
107        self.error.lock().unwrap().clone()
108    }
109}
110
111pub(crate) const CANCELLED: &str = "cancelled";
112/// Audio mix block (frames). Same as `playback::BLOCK` on purpose: `mixer::resample_add` samples clip
113/// gains at the block ends and lerps between them, so a longer block here would smear fades, volume
114/// keyframes and audio crossfades shorter than the block — export must ramp like playback does.
115const MIX_BLOCK: usize = 1024;
116
117/// Start a full (re-encoding) export on a background thread. Frames are rendered at project size
118/// with `Compositor` and audio mixed with `Mixer` into a temp WAV first (fast), then video frames are
119/// piped to ffmpeg's stdin. Progress 0..1; cancel kills ffmpeg and removes the partial file.
120/// Audio-only extensions (mp3, wav, m4a, flac, ogg, aac, opus) skip video (`-vn`); gif skips audio.
121pub fn start_export(project: Project, opts: ExportOptions, text: Arc<Mutex<TextRasterizer>>) -> Arc<Progress> {
122    spawn_job("export", move |prog| run_export(&project, &opts, &text, prog))
123}
124
125/// Run `job` on a named thread; any Err (or panic) lands in `Progress::finish`.
126pub(crate) fn spawn_job(
127    name: &str,
128    job: impl FnOnce(&Progress) -> Result<(), String> + Send + 'static,
129) -> Arc<Progress> {
130    let prog = Progress::new();
131    let p = prog.clone();
132    let spawned = std::thread::Builder::new().name(name.into()).spawn(move || {
133        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job(&p)));
134        p.finish(match r {
135            Ok(Ok(())) => None,
136            Ok(Err(e)) => Some(e),
137            Err(_) => Some(format!("{} thread panicked", std::thread::current().name().unwrap_or("job"))),
138        });
139    });
140    if let Err(e) = spawned {
141        prog.finish(Some(format!("could not start thread: {e}")));
142    }
143    prog
144}
145
146pub(crate) fn ext_of(path: &Path) -> String {
147    path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
148}
149
150fn run_export(
151    project: &Project,
152    opts: &ExportOptions,
153    text: &Arc<Mutex<TextRasterizer>>,
154    prog: &Progress,
155) -> Result<(), String> {
156    let ffmpeg = ffpipe::ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
157    let ext = ext_of(&opts.out_path);
158    let tmp = temp_output(&opts.out_path);
159    let audio_only = AUDIO_EXTS.contains(&ext.as_str());
160    let is_gif = ext == "gif";
161    let dur = project.duration();
162    if dur <= 0.0 {
163        return Err("Project is empty".into());
164    }
165    let fps = project.fps.max(1.0);
166    let (w, h) = (project.width.max(1), project.height.max(1));
167    let mut pool = DecoderPool::new(opts.backend);
168
169    // 1. audio → temp WAV
170    let has_audio = !is_gif
171        && project
172            .audio_tracks()
173            .into_iter()
174            .any(|i| project.active(i) && project.tracks[i].clips.iter().any(|c| c.enabled));
175    let wav = if has_audio { Some(mix_to_wav(project, &mut pool, prog, dur)?) } else { None };
176    if audio_only && wav.is_none() {
177        return Err("Nothing to export: no audio clips".into());
178    }
179
180    // 2. ffmpeg
181    let mut cmd = ffpipe::command(&ffmpeg);
182    cmd.args(["-y", "-hide_banner", "-loglevel", "error"]);
183    if !audio_only {
184        cmd.args(["-f", "rawvideo", "-pix_fmt", "rgba", "-s", &format!("{w}x{h}"), "-r", &format!("{fps}")]);
185        cmd.args(["-i", "pipe:0"]);
186    }
187    if let Some(wav) = &wav {
188        cmd.arg("-i").arg(&wav.0);
189    }
190    if !audio_only {
191        cmd.args(["-map", "0:v"]);
192        if wav.is_some() {
193            cmd.args(["-map", "1:a"]);
194        }
195    }
196    let args = codec_args(&ext, &opts.encoder, opts.crf, &opts.preset, &detect_encoders());
197    if !audio_only {
198        let scale = opts.out_size.filter(|&s| s != (w, h));
199        let mut vf = String::new();
200        if let Some((sw, sh)) = scale {
201            let flags = if opts.scaler.is_empty() { "bicubic" } else { &opts.scaler };
202            vf = format!("scale={sw}:{sh}:flags={flags}");
203        }
204        let (fw, fh) = scale.unwrap_or((w, h));
205        if (fw % 2 == 1 || fh % 2 == 1) && args.iter().any(|a| a == "yuv420p" || a == "nv12") {
206            if !vf.is_empty() {
207                vf.push(',');
208            }
209            vf.push_str("pad=ceil(iw/2)*2:ceil(ih/2)*2");
210        }
211        if !vf.is_empty() {
212            cmd.args(["-vf", &vf]);
213        }
214    }
215    cmd.args(&args);
216    // nothing is inherited from the inputs; only what the user typed is written
217    cmd.args(["-map_metadata", "-1"]);
218    for (k, v) in opts.metadata.iter().filter(|(k, _)| !k.trim().is_empty()) {
219        cmd.args(["-metadata", &format!("{}={}", k.trim(), v)]);
220    }
221    if is_gif {
222        cmd.arg("-an");
223    }
224    // (no `-shortest`: it drops the last video frame when dur isn't a whole number of frames; video and
225    // WAV both end at ≥ dur anyway)
226    cmd.arg(&tmp.0);
227    cmd.stdin(if audio_only { Stdio::null() } else { Stdio::piped() }).stdout(Stdio::null()).stderr(Stdio::piped());
228    let mut child = cmd.spawn().map_err(|e| format!("ffmpeg: {e}"))?;
229    let tail = stderr_tail(&mut child);
230
231    // 3. frames
232    let mut stdin = child.stdin.take();
233    if let Some(pipe) = stdin.as_mut() {
234        let n = (dur * fps).ceil().max(1.0) as u64;
235        let mut frame = Frame::new(w, h);
236        let mut comp = Compositor::new();
237        // GPU path: decode here (off the UI thread), composite there (the GL context lives on the UI
238        // thread), so an export runs the very same shaders the preview does.
239        let mut gpu = match &opts.frames {
240            FrameSource::Gpu(tx) => Some((tx.clone(), GpuScratch::new(opts.backend))),
241            FrameSource::Cpu => None,
242        };
243        let mut gaps = if gpu.is_some() { String::new() } else { cpu_gaps(project) };
244        for i in 0..n {
245            if prog.is_cancelled() {
246                break;
247            }
248            let t = i as f64 / fps;
249            let mut done = false;
250            if let Some((tx, scratch)) = gpu.as_mut() {
251                match scratch.render(project, t, w, h, tx, &text) {
252                    Some(f) => {
253                        frame = f;
254                        done = true;
255                    }
256                    // the renderer died or the UI stopped answering: finish on the CPU and say so
257                    None => {
258                        gpu = None;
259                        gaps = cpu_gaps(project);
260                    }
261                }
262            }
263            if !done {
264                let mut tr = text.lock().unwrap_or_else(|e| e.into_inner());
265                comp.render(project, t, w, h, &mut pool, &mut tr, &mut frame);
266            }
267            if pipe.write_all(&frame.rgba).is_err() {
268                break; // ffmpeg died — its stderr tells why
269            }
270            prog.set(0.1 + 0.9 * (i + 1) as f32 / n as f32, format!("Encoding {t:.1} / {dur:.1} s{gaps}"));
271        }
272    } else {
273        prog.set(0.5, "Encoding audio…");
274    }
275    drop(stdin);
276
277    // 4. wait, then move the finished file into place (decoders released first so an in-place
278    // export over the source can replace it)
279    wait_ffmpeg(&mut child, tail, prog)?;
280    drop(pool);
281    tmp.commit(&opts.out_path)
282}
283
284/// Decoder state for the GPU export path: the same layer decoding the player does, on this thread.
285struct GpuScratch {
286    pool: DecoderPool,
287    spare: Vec<Frame>,
288    shapes: crate::engine::shapes::ShapeRasterizer,
289    comp: Compositor,
290}
291
292impl GpuScratch {
293    fn new(backend: Backend) -> Self {
294        Self {
295            pool: DecoderPool::new(backend),
296            spare: Vec::new(),
297            shapes: crate::engine::shapes::ShapeRasterizer::new(),
298            comp: Compositor::new(),
299        }
300    }
301    /// Decode the layers for `t` and have the UI thread composite them. None = give up on the GPU path
302    /// (renderer gone, UI not answering within 5 s, or the request channel closed).
303    fn render(
304        &mut self,
305        project: &Project,
306        t: f64,
307        w: u32,
308        h: u32,
309        tx: &std::sync::mpsc::Sender<GpuFrameRequest>,
310        text: &Arc<Mutex<TextRasterizer>>,
311    ) -> Option<Frame> {
312        let layers = {
313            let mut tr = text.lock().unwrap_or_else(|e| e.into_inner());
314            crate::playback::decode_layers(
315                project,
316                t,
317                w,
318                h,
319                &mut self.pool,
320                &mut self.spare,
321                &mut tr,
322                &mut self.shapes,
323                &mut self.comp,
324            )
325        };
326        let (reply, rx) = std::sync::mpsc::sync_channel(1);
327        tx.send(GpuFrameRequest { layers, t, w, h, reply }).ok()?;
328        rx.recv_timeout(Duration::from_secs(5)).ok().flatten()
329    }
330}
331
332/// What this export cannot render, as a suffix for the progress line (empty when nothing is dropped).
333///
334/// ponytail: export renders on a background thread with the CPU `Compositor`; the GL context lives on
335/// the UI thread, so GPU-only effect kinds and node graphs are skipped. Naming them in the status beats
336/// dropping them silently — replace this with a real GPU render path (`GpuRenderer::render_frame` fed
337/// from the UI thread) and the note goes away.
338fn cpu_gaps(project: &Project) -> String {
339    let mut kinds: Vec<&str> = Vec::new();
340    let mut graphs = 0usize;
341    let tracks = project.tracks.iter().chain(project.sequences.iter().flat_map(|s| s.tracks.iter()));
342    for clip in tracks.flat_map(|t| t.clips.iter()).filter(|c| c.enabled) {
343        if clip.uses_graph() {
344            graphs += 1;
345        }
346        for e in clip.effects.iter().filter(|e| e.enabled) {
347            let name = e.kind.name();
348            if crate::engine::effects::gpu_only(e.kind) && !kinds.contains(&name) {
349                kinds.push(name);
350            }
351        }
352    }
353    let mut parts: Vec<String> = Vec::new();
354    if !kinds.is_empty() {
355        parts.push(format!("{} (GPU-only)", kinds.join(", ")));
356    }
357    if graphs > 0 {
358        parts.push(format!("{graphs} node graph(s)"));
359    }
360    if parts.is_empty() {
361        String::new()
362    } else {
363        format!(" — not rendered: {}", parts.join(", "))
364    }
365}
366
367/// Mix the whole timeline into a temp WAV (f32 stereo 48 kHz). Progress 0..0.1.
368fn mix_to_wav(project: &Project, pool: &mut DecoderPool, prog: &Progress, dur: f64) -> Result<TempFile, String> {
369    static N: AtomicU32 = AtomicU32::new(0);
370    let tmp = TempFile(std::env::temp_dir().join(format!(
371        "simple-editor-mix-{}-{}.wav",
372        std::process::id(),
373        N.fetch_add(1, Ordering::Relaxed)
374    )));
375    let total = (dur * SAMPLE_RATE as f64).ceil() as u64;
376    let mut f = std::io::BufWriter::new(std::fs::File::create(&tmp.0).map_err(|e| format!("temp wav: {e}"))?);
377    f.write_all(&wav_header(total)).map_err(|e| format!("temp wav: {e}"))?;
378    let mut mixer = Mixer::new();
379    let mut buf = vec![0f32; MIX_BLOCK * 2];
380    let mut bytes = vec![0u8; MIX_BLOCK * 8];
381    let mut done = 0u64;
382    while done < total {
383        if prog.is_cancelled() {
384            return Err(CANCELLED.into());
385        }
386        let n = (total - done).min(MIX_BLOCK as u64) as usize;
387        mixer.mix(project, done as f64 / SAMPLE_RATE as f64, pool, &mut buf[..n * 2]);
388        for (b, s) in bytes.chunks_exact_mut(4).zip(&buf[..n * 2]) {
389            b.copy_from_slice(&s.to_le_bytes());
390        }
391        f.write_all(&bytes[..n * 8]).map_err(|e| format!("temp wav: {e}"))?;
392        done += n as u64;
393        prog.set(0.1 * done as f32 / total.max(1) as f32, "Mixing audio…");
394    }
395    f.flush().map_err(|e| format!("temp wav: {e}"))?;
396    drop(f);
397    Ok(tmp)
398}
399
400/// 44-byte RIFF/WAVE header: IEEE float 32-bit, stereo, 48 kHz, `frames` sample frames.
401fn wav_header(frames: u64) -> [u8; 44] {
402    // ponytail: sizes saturate at 4 GB (~3 h); ffmpeg reads to EOF anyway — RF64 if that matters
403    let data = (frames * 8).min(u32::MAX as u64 - 36) as u32;
404    let mut h = [0u8; 44];
405    h[0..4].copy_from_slice(b"RIFF");
406    h[4..8].copy_from_slice(&(36 + data).to_le_bytes());
407    h[8..16].copy_from_slice(b"WAVEfmt ");
408    h[16..20].copy_from_slice(&16u32.to_le_bytes());
409    h[20..22].copy_from_slice(&3u16.to_le_bytes()); // WAVE_FORMAT_IEEE_FLOAT
410    h[22..24].copy_from_slice(&2u16.to_le_bytes());
411    h[24..28].copy_from_slice(&SAMPLE_RATE.to_le_bytes());
412    h[28..32].copy_from_slice(&(SAMPLE_RATE * 8).to_le_bytes());
413    h[32..34].copy_from_slice(&8u16.to_le_bytes());
414    h[34..36].copy_from_slice(&32u16.to_le_bytes());
415    h[36..40].copy_from_slice(b"data");
416    h[40..44].copy_from_slice(&data.to_le_bytes());
417    h
418}
419
420/// Deleted on drop (a no-op once `commit`ted into place).
421pub(crate) struct TempFile(pub(crate) PathBuf);
422impl TempFile {
423    /// Move the finished file over `dst` (replaces an existing file).
424    pub(crate) fn commit(self, dst: &Path) -> Result<(), String> {
425        std::fs::rename(&self.0, dst).map_err(|e| format!("rename to {}: {e}", dst.display()))
426    }
427}
428impl Drop for TempFile {
429    fn drop(&mut self) {
430        let _ = std::fs::remove_file(&self.0);
431    }
432}
433
434/// Hidden sibling of `out` (same folder, same extension so ffmpeg still picks the muxer by name) that
435/// ffmpeg writes into; it is renamed over `out` only on success, so a cancel or failure never truncates
436/// or deletes an existing destination — or the source, when cutting in place.
437pub(crate) fn temp_output(out: &Path) -> TempFile {
438    let stem = out.file_stem().unwrap_or_default().to_string_lossy();
439    let ext = out.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
440    TempFile(out.with_file_name(format!(".{stem}.simple-editor-tmp{ext}")))
441}
442
443/// Drain ffmpeg's stderr on a helper thread; returns the last ~2000 chars.
444pub(crate) fn stderr_tail(child: &mut Child) -> Option<JoinHandle<String>> {
445    let mut err = child.stderr.take()?;
446    Some(std::thread::spawn(move || {
447        let mut buf = Vec::new();
448        let _ = err.read_to_end(&mut buf);
449        let s = String::from_utf8_lossy(&buf);
450        let s = s.trim();
451        let cut = s.char_indices().rev().nth(1999).map(|(i, _)| i).unwrap_or(0);
452        s[cut..].to_string()
453    }))
454}
455
456/// Wait for ffmpeg, killing it if the job is cancelled. Non-zero exit → Err(stderr tail).
457pub(crate) fn wait_ffmpeg(child: &mut Child, tail: Option<JoinHandle<String>>, prog: &Progress) -> Result<(), String> {
458    let status = loop {
459        if prog.is_cancelled() {
460            let _ = child.kill();
461            let _ = child.wait();
462            if let Some(t) = tail {
463                let _ = t.join();
464            }
465            return Err(CANCELLED.into());
466        }
467        match child.try_wait() {
468            Ok(Some(st)) => break st,
469            Ok(None) => std::thread::sleep(Duration::from_millis(30)),
470            Err(e) => return Err(format!("ffmpeg: {e}")),
471        }
472    };
473    let msg = tail.and_then(|t| t.join().ok()).unwrap_or_default();
474    if status.success() {
475        Ok(())
476    } else if msg.is_empty() {
477        Err(format!("ffmpeg exited with {status}"))
478    } else {
479        Err(msg)
480    }
481}
482
483/// If the project is a pure cut of exactly one video source (every clip from the same asset, audio clips
484/// exactly mirroring their linked video clip's timing on each audio stream (muted tracks may be dropped),
485/// no gaps between clips, no text/image/sequence clips, no effects/retime/pan/fades, volume 1, all clips
486/// enabled, no transitions, no burnt-in subtitles), return the segments as (src_in, duration) in timeline
487/// order. Such a project can be written with `-c copy`.
488pub fn lossless_segments(project: &Project) -> Option<Vec<(f64, f64)>> {
489    const EPS: f64 = 1e-4;
490    if project.show_subtitles && !project.subtitles.is_empty() {
491        return None;
492    }
493    if project.tracks.iter().any(|t| !t.transitions.is_empty()) {
494        return None;
495    }
496    let mut asset = None;
497    for (_, c) in project.all_clips() {
498        if c.kind != ClipKind::Video && c.kind != ClipKind::Audio {
499            return None;
500        }
501        if !c.enabled || c.has_effects() || !c.volume.is_default(1.0) {
502            return None;
503        }
504        if asset.is_some_and(|a| a != c.asset) {
505            return None;
506        }
507        asset = Some(c.asset);
508    }
509    if project.asset(asset?)?.kind != ClipKind::Video {
510        return None;
511    }
512    let mut video: Option<&Track> = None;
513    for ti in project.video_tracks() {
514        let t = &project.tracks[ti];
515        if t.clips.is_empty() {
516            continue;
517        }
518        if !project.active(ti) || video.is_some() {
519            return None;
520        }
521        video = Some(t);
522    }
523    let video = video?;
524    let mut segs = Vec::with_capacity(video.clips.len());
525    let mut pos = 0.0;
526    for c in &video.clips {
527        if (c.start - pos).abs() > EPS {
528            return None;
529        }
530        segs.push((c.src_in, c.duration));
531        pos = c.end();
532    }
533    for ti in project.audio_tracks() {
534        let t = &project.tracks[ti];
535        if t.clips.is_empty() || !project.active(ti) {
536            continue;
537        }
538        if t.clips.len() != video.clips.len() {
539            return None;
540        }
541        for (a, v) in t.clips.iter().zip(&video.clips) {
542            if (a.start - v.start).abs() > EPS
543                || (a.duration - v.duration).abs() > EPS
544                || (a.src_in - v.src_in).abs() > EPS
545            {
546                return None;
547            }
548        }
549    }
550    Some(segs)
551}
552
553/// Lossless cut: `ffmpeg -ss in -t dur -i src -c copy -avoid_negative_ts make_zero` per segment (with
554/// `-map` dropping muted audio streams), then concat demuxer when there is more than one segment.
555/// Cuts land on keyframes (not frame-accurate) — that's the trade for being instant.
556pub fn start_lossless_cut(project: Project, out_path: PathBuf) -> Arc<Progress> {
557    spawn_job("lossless-cut", move |prog| run_lossless(&project, &out_path, prog))
558}
559
560fn run_lossless(project: &Project, out: &Path, prog: &Progress) -> Result<(), String> {
561    let ffmpeg = ffpipe::ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
562    let segs = lossless_segments(project).ok_or("Project is not a plain cut of one video")?;
563    let (_, first) = project.all_clips().next().ok_or("Project is empty")?;
564    let src = project.asset(first.asset).ok_or("Missing asset")?.path.clone();
565    let mut streams: Vec<usize> = project
566        .audio_tracks()
567        .into_iter()
568        .filter(|&i| project.active(i))
569        .flat_map(|i| project.tracks[i].clips.iter().map(|c| c.audio_stream))
570        .collect();
571    streams.sort_unstable();
572    streams.dedup();
573
574    let ext = ext_of(out);
575    let stem = out.file_stem().unwrap_or_default().to_string_lossy().into_owned();
576    let tmp = temp_output(out);
577    let n = segs.len();
578    let mut parts: Vec<TempFile> = Vec::new();
579    let run = |cmd: &mut std::process::Command| -> Result<(), String> {
580        cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
581        let mut child = cmd.spawn().map_err(|e| format!("ffmpeg: {e}"))?;
582        let tail = stderr_tail(&mut child);
583        wait_ffmpeg(&mut child, tail, prog)
584    };
585    for (i, &(src_in, dur)) in segs.iter().enumerate() {
586        prog.set(i as f32 / n as f32, format!("Cutting segment {} / {n}", i + 1));
587        let dst = if n == 1 { tmp.0.clone() } else { out.with_file_name(format!("{stem}.part{i}.{ext}")) };
588        let mut cmd = ffpipe::command(&ffmpeg);
589        cmd.args(["-y", "-hide_banner", "-loglevel", "error"]);
590        cmd.args(["-ss", &format!("{src_in:.6}"), "-t", &format!("{dur:.6}"), "-i", &src]);
591        cmd.args(["-map", "0:v:0"]);
592        for s in &streams {
593            cmd.args(["-map", &format!("0:a:{s}")]);
594        }
595        if streams.is_empty() {
596            cmd.arg("-an");
597        }
598        cmd.args(["-c", "copy", "-avoid_negative_ts", "make_zero"]).arg(&dst);
599        if n > 1 {
600            parts.push(TempFile(dst));
601        }
602        run(&mut cmd)?;
603    }
604    if n > 1 {
605        prog.set(0.95, "Joining segments…");
606        let list = TempFile(out.with_file_name(format!("{stem}.concat.txt")));
607        let mut txt = String::new();
608        for p in &parts {
609            let s = p.0.to_string_lossy().replace('\\', "/").replace('\'', "'\\''");
610            txt.push_str(&format!("file '{s}'\n"));
611        }
612        std::fs::write(&list.0, txt).map_err(|e| format!("concat list: {e}"))?;
613        let mut cmd = ffpipe::command(&ffmpeg);
614        cmd.args(["-y", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"]);
615        cmd.arg(&list.0).args(["-map", "0", "-c", "copy"]).arg(&tmp.0);
616        run(&mut cmd)?;
617    }
618    tmp.commit(out)
619}
620
621static ENCODERS: Mutex<Option<(PathBuf, Vec<String>)>> = Mutex::new(None);
622
623/// Encoder names ffmpeg reports (`ffmpeg -hide_banner -encoders`), cached after the first call.
624/// Empty if ffmpeg is missing.
625pub fn detect_encoders() -> Vec<String> {
626    let Some(exe) = ffpipe::ffmpeg_exe() else {
627        return Vec::new();
628    };
629    let mut cache = ENCODERS.lock().unwrap_or_else(|e| e.into_inner());
630    if let Some((p, v)) = &*cache {
631        if *p == exe {
632            return v.clone();
633        }
634    }
635    let out = ffpipe::command(&exe).args(["-hide_banner", "-encoders"]).stdin(Stdio::null()).output();
636    let names = out.map(|o| parse_encoders(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default();
637    *cache = Some((exe, names.clone()));
638    names
639}
640
641fn parse_encoders(s: &str) -> Vec<String> {
642    s.lines()
643        .filter_map(|l| {
644            let mut it = l.split_whitespace();
645            let flags = it.next()?;
646            let name = it.next()?;
647            (flags.len() == 6 && (flags.starts_with('V') || flags.starts_with('A')) && name != "=")
648                .then(|| name.to_string())
649        })
650        .collect()
651}
652
653/// ffmpeg output arguments (codec/quality) for an extension, given the user's encoder preference and the
654/// available encoders. "auto": mp4/mov/mkv/m4v → libx264 (+aac), webm → libvpx-vp9 (+libopus),
655/// gif → gif, avi → mpeg4 (+mp3), audio-only → sensible codec for the container. The preference is
656/// overridden where the container forbids it (webm: vp9/av1 only; mov: no vp9/av1).
657/// Hardware encoders (nvenc/qsv/amf) use `-cq`/`-global_quality`/`-qp` instead of `-crf`.
658pub fn codec_args(ext: &str, encoder: &str, crf: u32, preset: &str, available: &[String]) -> Vec<String> {
659    let ext = ext.to_ascii_lowercase();
660    let has = |n: &str| available.is_empty() || available.iter().any(|a| a == n);
661    let audio = match ext.as_str() {
662        "webm" | "ogg" | "opus" => {
663            if has("libopus") {
664                "-c:a libopus -b:a 160k"
665            } else {
666                "-c:a libvorbis -q:a 5"
667            }
668        }
669        "avi" | "mp3" => "-c:a libmp3lame -q:a 2",
670        "mpg" | "mpeg" | "vob" => "-c:a mp2 -b:a 192k",
671        "wav" => "-c:a pcm_s16le",
672        "flac" => "-c:a flac",
673        _ => "-c:a aac -b:a 192k",
674    };
675    let mut video = String::new();
676    if ext == "gif" {
677        video.push_str("-c:v gif");
678    } else if !AUDIO_EXTS.contains(&ext.as_str()) {
679        let mut enc = match encoder {
680            "auto" | "" => match ext.as_str() {
681                "webm" => "libvpx-vp9",
682                "avi" if !has("libx264") => "mpeg4",
683                _ => "libx264",
684            },
685            e => e,
686        };
687        let vpx_av1 = matches!(enc, "libvpx-vp9" | "libaom-av1" | "libsvtav1");
688        if ext == "webm" && !vpx_av1 {
689            enc = "libvpx-vp9"; // webm muxer: only vp8/vp9/av1
690        } else if ext == "mov" && vpx_av1 {
691            enc = "libx264"; // mov muxer rejects vp9/av1
692        }
693        if !has(enc) {
694            enc = "libx264";
695        }
696        let preset = if preset.is_empty() { "medium" } else { preset };
697        video = match enc {
698            "libx264" | "libx265" => {
699                format!("-c:v {enc} -preset {preset} -crf {crf} -pix_fmt yuv420p")
700            }
701            "h264_nvenc" | "hevc_nvenc" => {
702                format!("-c:v {enc} -preset p4 -cq {crf} -b:v 0 -pix_fmt yuv420p")
703            }
704            "h264_qsv" | "hevc_qsv" => format!("-c:v {enc} -global_quality {crf} -pix_fmt nv12"),
705            "h264_amf" | "hevc_amf" => format!("-c:v {enc} -rc cqp -qp_i {crf} -qp_p {crf}"),
706            "libvpx-vp9" => {
707                format!("-c:v libvpx-vp9 -crf {crf} -b:v 0 -row-mt 1 -cpu-used 2 -pix_fmt yuv420p")
708            }
709            "libaom-av1" | "libsvtav1" => format!("-c:v {enc} -crf {crf} -pix_fmt yuv420p"),
710            "mpeg4" => "-c:v mpeg4 -q:v 4 -pix_fmt yuv420p".into(),
711            other => format!("-c:v {other}"),
712        };
713        if matches!(ext.as_str(), "mp4" | "mov" | "m4v") {
714            video.push_str(" -movflags +faststart");
715        }
716    }
717    let mut v: Vec<String> = video.split(' ').filter(|s| !s.is_empty()).map(String::from).collect();
718    if ext != "gif" {
719        v.extend(audio.split(' ').map(String::from));
720    }
721    v
722}
723
724pub const AUDIO_EXTS: &[&str] = &["mp3", "wav", "m4a", "flac", "ogg", "aac", "opus"];
725
726#[cfg(test)]
727pub(crate) mod tests {
728    use super::*;
729    use crate::model::{Asset, AudioStreamInfo, TrackKind};
730
731    fn asset(path: &str, streams: usize) -> Asset {
732        Asset {
733            id: 0,
734            path: path.into(),
735            kind: ClipKind::Video,
736            duration: 4.0,
737            width: 320,
738            height: 240,
739            fps: 30.0,
740            audio_streams: (0..streams)
741                .map(|i| AudioStreamInfo { index: i, channels: 2, sample_rate: 48000, ..Default::default() })
742                .collect(),
743            codec: "h264".into(),
744            folder: String::new(),
745            tags: Vec::new(),
746            label: 0,
747            description: String::new(),
748        }
749    }
750
751    /// from_media(4 s, 2 streams), split at 1 and 3, delete the middle → [0,1) + [3,4).
752    fn cut_project(path: &str) -> Project {
753        let mut p = Project::from_media(asset(path, 2));
754        p.split_at(1.0, None);
755        p.split_at(3.0, None);
756        let mid = p.tracks[0].clips[1].id;
757        let ids = p.linked(mid);
758        p.delete_clips(&ids, true);
759        p
760    }
761
762    /// A constant-valued audio source (mirrors the mixer's own test source).
763    struct Const(f32);
764    impl crate::media::AudioSource for Const {
765        fn duration(&self) -> f64 {
766            10.0
767        }
768        fn read_at(&mut self, _t: f64, out: &mut [f32]) {
769            out.fill(self.0);
770        }
771    }
772
773    #[test]
774    fn mix_block_keeps_short_fades_sharp() {
775        // `resample_add` lerps the clip gain across one block, so the block length is the fade's
776        // resolution: a 20 ms fade-in must be done at 20 ms, not still ramping (100 ms blocks put it
777        // at ~0.2). Export has to ramp exactly like playback does.
778        let mut p = Project::from_media(asset("Z:\\nope\\fake.wav", 1));
779        let ai = p.audio_tracks()[0];
780        p.tracks[ai].clips[0].fade_in = 0.02;
781        let mut pool = DecoderPool::new(Backend::Ffmpeg);
782        pool.insert_audio("Z:\\nope\\fake.wav", 0, Box::new(Const(1.0)));
783        let mut buf = vec![0f32; MIX_BLOCK * 2];
784        Mixer::new().mix(&p, 0.0, &mut pool, &mut buf);
785        let at = |ms: f64| buf[(ms / 1000.0 * SAMPLE_RATE as f64) as usize * 2];
786        assert!(at(0.0).abs() < 0.05, "fade should start at silence: {}", at(0.0));
787        assert!((at(10.0) - 0.5).abs() < 0.1, "half way through the fade: {}", at(10.0));
788        assert!(at(20.0) > 0.85, "20 ms fade still ramping at 20 ms: {}", at(20.0));
789    }
790
791    #[test]
792    fn cpu_gaps_names_what_export_drops() {
793        let mut p = Project::from_media(asset("Z:\\nope\\fake.mp4", 1));
794        assert_eq!(cpu_gaps(&p), "");
795        // effects the CPU compositor renders are not reported
796        let id = p.tracks[0].clips[0].id;
797        p.clip_mut(id).unwrap().effects.push(crate::model::Effect::new(crate::model::EffectKind::Blur));
798        assert_eq!(cpu_gaps(&p), "");
799        p.clip_mut(id).unwrap().effects.push(crate::model::Effect::new(crate::model::EffectKind::Vhs));
800        assert!(cpu_gaps(&p).contains("VHS"), "{}", cpu_gaps(&p));
801        // a disabled one is not a gap
802        p.clip_mut(id).unwrap().effects.last_mut().unwrap().enabled = false;
803        assert_eq!(cpu_gaps(&p), "");
804    }
805
806    pub(crate) fn wait_done(prog: &Progress) -> Option<String> {
807        for _ in 0..1200 {
808            if prog.is_done() {
809                return prog.error();
810            }
811            std::thread::sleep(Duration::from_millis(50));
812        }
813        Some("timeout".into())
814    }
815
816    pub(crate) fn temp_dir(name: &str) -> PathBuf {
817        let d = std::env::temp_dir().join(format!("simple-editor-export-test-{}-{name}", std::process::id()));
818        let _ = std::fs::create_dir_all(&d);
819        d
820    }
821
822    /// red 0–2 s, green 2–4 s, two sine streams, keyframe every second. None if ffmpeg is missing.
823    pub(crate) fn gen_media(dir: &Path) -> Option<PathBuf> {
824        let exe = ffpipe::ffmpeg_exe()?;
825        let out = dir.join("test.mp4");
826        let st = ffpipe::command(&exe)
827            .args(["-y", "-hide_banner", "-loglevel", "error"])
828            .args(["-f", "lavfi", "-i", "color=red:s=320x240:d=2", "-f", "lavfi", "-i", "color=lime:s=320x240:d=2"])
829            .args([
830                "-f",
831                "lavfi",
832                "-i",
833                "sine=frequency=440:duration=4",
834                "-f",
835                "lavfi",
836                "-i",
837                "sine=frequency=880:duration=4",
838            ])
839            .args(["-filter_complex", "[0:v][1:v]concat=n=2:v=1[v]", "-map", "[v]", "-map", "2:a", "-map", "3:a"])
840            .args(["-r", "30", "-g", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-c:a", "aac"])
841            .args(["-metadata:s:a:0", "language=eng", "-metadata:s:a:1", "title=Music"])
842            .arg(&out)
843            .status()
844            .expect("run ffmpeg");
845        assert!(st.success(), "ffmpeg could not generate test media");
846        Some(out)
847    }
848
849    pub(crate) fn probe_duration(path: &Path) -> Option<f64> {
850        let o = ffpipe::command(&ffpipe::ffprobe_exe()?)
851            .args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
852            .arg(path)
853            .output()
854            .ok()?;
855        String::from_utf8_lossy(&o.stdout).trim().parse().ok()
856    }
857
858    #[test]
859    fn codec_args_by_ext() {
860        let j = |v: Vec<String>| v.join(" ");
861        let s = j(codec_args("mp4", "auto", 18, "veryfast", &[]));
862        assert!(s.contains("-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p"), "{s}");
863        assert!(s.contains("-movflags +faststart") && s.contains("-c:a aac"), "{s}");
864        let s = j(codec_args("webm", "auto", 30, "veryfast", &[]));
865        assert!(s.contains("libvpx-vp9") && s.contains("libopus") && !s.contains("faststart"), "{s}");
866        let s = j(codec_args("mp3", "auto", 18, "", &[]));
867        assert_eq!(s, "-c:a libmp3lame -q:a 2");
868        assert_eq!(j(codec_args("wav", "auto", 18, "", &[])), "-c:a pcm_s16le");
869        assert_eq!(j(codec_args("gif", "auto", 18, "", &[])), "-c:v gif");
870        let s = j(codec_args("mkv", "hevc_nvenc", 20, "slow", &["hevc_nvenc".into(), "libx264".into()]));
871        assert!(s.contains("-c:v hevc_nvenc -preset p4 -cq 20 -b:v 0"), "{s}");
872        // requested encoder unavailable → libx264
873        let s = j(codec_args("mp4", "h264_nvenc", 20, "slow", &["libx264".into()]));
874        assert!(s.contains("-c:v libx264 -preset slow -crf 20"), "{s}");
875        let s = j(codec_args("mov", "h264_qsv", 22, "", &[]));
876        assert!(s.contains("-global_quality 22 -pix_fmt nv12"), "{s}");
877        let s = j(codec_args("avi", "auto", 22, "", &["mpeg4".into()]));
878        assert!(s.contains("-c:v mpeg4") && s.contains("libmp3lame"), "{s}");
879        let s = j(codec_args("mp4", "h264_amf", 22, "", &[]));
880        assert!(s.contains("-rc cqp -qp_i 22 -qp_p 22"), "{s}");
881        // container forbids the preferred encoder → clamped
882        assert!(j(codec_args("webm", "libx264", 30, "", &[])).contains("-c:v libvpx-vp9"));
883        assert!(j(codec_args("webm", "hevc_nvenc", 30, "", &[])).contains("-c:v libvpx-vp9"));
884        assert!(j(codec_args("mov", "libvpx-vp9", 20, "", &[])).contains("-c:v libx264"));
885        assert!(j(codec_args("mkv", "libvpx-vp9", 20, "", &[])).contains("-c:v libvpx-vp9"));
886        assert!(j(codec_args("mpg", "auto", 20, "", &[])).ends_with("-c:a mp2 -b:a 192k"));
887    }
888
889    #[test]
890    fn parse_encoder_list() {
891        let s = " V..... = Video\n A..... = Audio\n ------\n V....D libx264  H.264\n A....D aac  AAC\n S..... srt  SubRip\n";
892        assert_eq!(parse_encoders(s), vec!["libx264".to_string(), "aac".to_string()]);
893    }
894
895    #[test]
896    fn lossless_segments_rules() {
897        let p = cut_project("C:/fake/v.mp4");
898        let segs = lossless_segments(&p).expect("pure cut");
899        assert_eq!(segs.len(), 2);
900        assert!((segs[0].0).abs() < 1e-9 && (segs[0].1 - 1.0).abs() < 1e-9);
901        assert!((segs[1].0 - 3.0).abs() < 1e-9 && (segs[1].1 - 1.0).abs() < 1e-9);
902
903        // text clip → None
904        let mut q = p.clone();
905        q.add_text_clip(0.5, 1.0);
906        assert!(lossless_segments(&q).is_none());
907        // effect → None
908        let mut q = p.clone();
909        q.tracks[0].clips[0].opacity.value = 0.5;
910        assert!(lossless_segments(&q).is_none());
911        // volume → None
912        let mut q = p.clone();
913        q.tracks[1].clips[0].volume.value = 0.5;
914        assert!(lossless_segments(&q).is_none());
915        // disabled clip → None
916        let mut q = p.clone();
917        q.tracks[0].clips[1].enabled = false;
918        assert!(lossless_segments(&q).is_none());
919        // gap → None
920        let mut q = p.clone();
921        q.tracks[0].clips[1].start += 0.5;
922        assert!(lossless_segments(&q).is_none());
923        // audio not mirroring → None; but muted track with odd clips → Some
924        let mut q = p.clone();
925        q.tracks[2].clips[1].start += 0.2;
926        q.tracks[2].clips[1].duration -= 0.2;
927        assert!(lossless_segments(&q).is_none());
928        q.tracks[2].muted = true;
929        assert_eq!(lossless_segments(&q).map(|s| s.len()), Some(2));
930        // second asset → None
931        let mut q = p.clone();
932        let id = q.add_asset(asset("C:/fake/other.mp4", 0));
933        q.insert_asset_clips(id, 4.0, Some(0));
934        assert!(lossless_segments(&q).is_none());
935        // retimed clip → None
936        let mut q = p.clone();
937        let sp = q.tracks[0].clips[0].id;
938        q.set_speed(&[sp], 2.0, false);
939        assert!(lossless_segments(&q).is_none());
940        // pan / fades → None
941        let mut q = p.clone();
942        q.tracks[1].clips[0].pan.value = 0.5;
943        assert!(lossless_segments(&q).is_none());
944        let mut q = p.clone();
945        q.tracks[1].clips[0].fade_in = 0.5;
946        assert!(lossless_segments(&q).is_none());
947        // transition → None
948        let mut q = p.clone();
949        let right = q.tracks[0].clips[1].id;
950        assert!(q.add_transition(right, crate::model::TransitionKind::CrossFade, 0.5).is_some());
951        assert!(lossless_segments(&q).is_none());
952        // burnt-in subtitles → None; hidden subtitles → Some
953        let mut q = p.clone();
954        q.add_cue(0.0, 1.0, "hi");
955        assert!(lossless_segments(&q).is_none());
956        q.show_subtitles = false;
957        assert!(lossless_segments(&q).is_some());
958        // sequence clip → None
959        let mut q = p.clone();
960        let seq = q.new_sequence("s", 320, 240, 30.0);
961        q.insert_sequence_clip(seq, 4.0, None);
962        assert!(lossless_segments(&q).is_none());
963        // empty project → None
964        assert!(lossless_segments(&Project::new()).is_none());
965        // clip on a second video track → None
966        let mut q = p.clone();
967        q.add_track(TrackKind::Video);
968        let c = q.tracks[0].clips[0].clone();
969        q.tracks[1].clips.push(c);
970        assert!(lossless_segments(&q).is_none());
971    }
972
973    #[test]
974    fn wav_header_is_readable() {
975        if ffpipe::ffprobe_exe().is_none() {
976            eprintln!("ffprobe missing — skipped");
977            return;
978        }
979        let dir = temp_dir("wav");
980        let path = dir.join("hdr.wav");
981        let mut bytes = wav_header(SAMPLE_RATE as u64).to_vec();
982        bytes.resize(44 + SAMPLE_RATE as usize * 8, 0);
983        std::fs::write(&path, bytes).unwrap();
984        let d = probe_duration(&path).expect("ffprobe reads header");
985        assert!((d - 1.0).abs() < 0.01, "{d}");
986        let _ = std::fs::remove_dir_all(&dir);
987    }
988
989    #[test]
990    fn detect_encoders_has_x264() {
991        if ffpipe::ffmpeg_exe().is_none() {
992            eprintln!("ffmpeg missing — skipped");
993            return;
994        }
995        let e = detect_encoders();
996        assert!(e.iter().any(|x| x == "libx264"), "{e:?}");
997        assert!(e.iter().any(|x| x == "aac"));
998        assert_eq!(detect_encoders().len(), e.len()); // cached
999    }
1000
1001    #[test]
1002    fn lossless_cut_real() {
1003        let dir = temp_dir("cut");
1004        let Some(src) = gen_media(&dir) else {
1005            eprintln!("ffmpeg missing — skipped");
1006            return;
1007        };
1008        let p = cut_project(&src.to_string_lossy());
1009        let out = dir.join("cut.mp4");
1010        let prog = start_lossless_cut(p, out.clone());
1011        assert_eq!(wait_done(&prog), None);
1012        assert!(out.exists());
1013        let d = probe_duration(&out).expect("probe");
1014        assert!((d - 2.0).abs() < 0.6, "duration {d}");
1015        assert!(!dir.join("cut.part0.mp4").exists() && !dir.join("cut.concat.txt").exists());
1016        // muted A2 → only one audio stream in the output
1017        let mut p = cut_project(&src.to_string_lossy());
1018        p.tracks[2].muted = true;
1019        let out1 = dir.join("cut1.mp4");
1020        assert_eq!(wait_done(&start_lossless_cut(p, out1.clone())), None);
1021        let o = ffpipe::command(&ffpipe::ffprobe_exe().unwrap())
1022            .args(["-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0"])
1023            .arg(&out1)
1024            .output()
1025            .unwrap();
1026        assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 2);
1027        // a failed cut never removes a pre-existing destination (wrong container → ffmpeg rejects)
1028        let gif = dir.join("keep.gif");
1029        std::fs::write(&gif, b"keep").unwrap();
1030        assert!(wait_done(&start_lossless_cut(cut_project(&src.to_string_lossy()), gif.clone())).is_some());
1031        assert_eq!(std::fs::read(&gif).unwrap(), b"keep");
1032        // cutting in place (destination == source) replaces the source with the cut, never loses it
1033        let p = cut_project(&src.to_string_lossy());
1034        assert_eq!(wait_done(&start_lossless_cut(p, src.clone())), None);
1035        let d = probe_duration(&src).expect("source still readable");
1036        assert!((d - 2.0).abs() < 0.6, "duration {d}");
1037        assert_eq!(
1038            std::fs::read_dir(&dir).unwrap().count(),
1039            4,
1040            "{:?}",
1041            std::fs::read_dir(&dir).unwrap().collect::<Vec<_>>()
1042        );
1043        let _ = std::fs::remove_dir_all(&dir);
1044    }
1045
1046    #[test]
1047    fn scaled_export_real() {
1048        let dir = temp_dir("scaled");
1049        let Some(src) = gen_media(&dir) else {
1050            eprintln!("ffmpeg missing — skipped");
1051            return;
1052        };
1053        let p = Project::from_media(asset(&src.to_string_lossy(), 2));
1054        assert_eq!((p.width, p.height), (320, 240));
1055        let out = dir.join("half.mp4");
1056        let opts = ExportOptions {
1057            out_path: out.clone(),
1058            encoder: "auto".into(),
1059            crf: 23,
1060            preset: "ultrafast".into(),
1061            backend: Backend::Auto,
1062            out_size: Some((160, 120)),
1063            scaler: "bicubic".into(),
1064            frames: FrameSource::Cpu,
1065            metadata: Vec::new(),
1066        };
1067        let text = Arc::new(Mutex::new(TextRasterizer::new()));
1068        assert_eq!(wait_done(&start_export(p, opts, text)), None);
1069        let o = ffpipe::command(&ffpipe::ffprobe_exe().unwrap())
1070            .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0"])
1071            .arg(&out)
1072            .output()
1073            .unwrap();
1074        assert_eq!(String::from_utf8_lossy(&o.stdout).trim(), "160,120");
1075        let d = probe_duration(&out).expect("probe");
1076        assert!((d - 4.0).abs() < 0.2, "duration {d}");
1077        let _ = std::fs::remove_dir_all(&dir);
1078    }
1079
1080    #[test]
1081    fn full_export_real() {
1082        let dir = temp_dir("export");
1083        let Some(src) = gen_media(&dir) else {
1084            eprintln!("ffmpeg missing — skipped");
1085            return;
1086        };
1087        let p = Project::from_media(asset(&src.to_string_lossy(), 2));
1088        let out = dir.join("export.mp4");
1089        let opts = ExportOptions {
1090            out_path: out.clone(),
1091            encoder: "auto".into(),
1092            crf: 23,
1093            preset: "ultrafast".into(),
1094            backend: Backend::Auto,
1095            out_size: None,
1096            scaler: "bicubic".into(),
1097            frames: FrameSource::Cpu,
1098            metadata: Vec::new(),
1099        };
1100        let text = Arc::new(Mutex::new(TextRasterizer::new()));
1101        let prog = start_export(p.clone(), opts.clone(), text.clone());
1102        assert_eq!(wait_done(&prog), None);
1103        let d = probe_duration(&out).expect("probe");
1104        assert!((d - 4.0).abs() < 0.2, "duration {d}");
1105        // audio-only + cancel paths
1106        let mp3 = ExportOptions { out_path: dir.join("export.mp3"), ..opts.clone() };
1107        assert_eq!(wait_done(&start_export(p.clone(), mp3, text.clone())), None);
1108        // cancel: a pre-existing destination is left untouched, no temp file remains
1109        let cancel = dir.join("cancel.mp4");
1110        std::fs::write(&cancel, b"keep").unwrap();
1111        let prog = start_export(p, ExportOptions { out_path: cancel.clone(), ..opts }, text);
1112        prog.cancel.store(true, Ordering::SeqCst);
1113        assert_eq!(wait_done(&prog).as_deref(), Some(CANCELLED));
1114        assert_eq!(std::fs::read(&cancel).unwrap(), b"keep");
1115        assert!(!dir.join(".cancel.simple-editor-tmp.mp4").exists());
1116        let _ = std::fs::remove_dir_all(&dir);
1117    }
1118}