1use 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
19pub struct GpuFrameRequest {
22 pub layers: crate::engine::gpu::LayerSet,
23 pub t: f64,
24 pub w: u32,
25 pub h: u32,
26 pub reply: std::sync::mpsc::SyncSender<Option<Frame>>,
28}
29
30#[derive(Clone)]
32pub enum FrameSource {
33 Cpu,
35 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 pub encoder: String,
53 pub crf: u32,
54 pub preset: String,
55 pub backend: Backend,
56 pub out_size: Option<(u32, u32)>,
59 pub scaler: String,
61 pub frames: FrameSource,
63 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";
112const MIX_BLOCK: usize = 1024;
116
117pub 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
125pub(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 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 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 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 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 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 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 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; }
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 wait_ffmpeg(&mut child, tail, prog)?;
280 drop(pool);
281 tmp.commit(&opts.out_path)
282}
283
284struct 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 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
332fn 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
367fn 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
400fn wav_header(frames: u64) -> [u8; 44] {
402 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()); 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
420pub(crate) struct TempFile(pub(crate) PathBuf);
422impl TempFile {
423 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
434pub(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
443pub(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
456pub(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
483pub 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
553pub 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
623pub 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
653pub 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"; } else if ext == "mov" && vpx_av1 {
691 enc = "libx264"; }
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 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 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 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 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 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 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 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 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 let mut q = p.clone();
905 q.add_text_clip(0.5, 1.0);
906 assert!(lossless_segments(&q).is_none());
907 let mut q = p.clone();
909 q.tracks[0].clips[0].opacity.value = 0.5;
910 assert!(lossless_segments(&q).is_none());
911 let mut q = p.clone();
913 q.tracks[1].clips[0].volume.value = 0.5;
914 assert!(lossless_segments(&q).is_none());
915 let mut q = p.clone();
917 q.tracks[0].clips[1].enabled = false;
918 assert!(lossless_segments(&q).is_none());
919 let mut q = p.clone();
921 q.tracks[0].clips[1].start += 0.5;
922 assert!(lossless_segments(&q).is_none());
923 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 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 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 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 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 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 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 assert!(lossless_segments(&Project::new()).is_none());
965 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()); }
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 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 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 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 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 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}