Skip to main content

pito_codecs/media/front/
export.rs

1use std::collections::HashMap;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6
7use super::ffmpeg::{Child, Ffmpeg, pix_fmt};
8use super::mux::{Planner, Premix, Sound, gop_mp4, premixed_m4a};
9use super::picture::Blender;
10use super::probe::keyframes;
11use super::route::{Caps, Picked, Prefs, pick_encode};
12use super::video::{Source, frame_at, open, source};
13use crate::media::{AudioBuffer, Codec, Error, Frame, PixelFormat, Result, Rgba, Route};
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct Piece {
17    pub path: PathBuf,
18    pub from: f64,
19    pub to: f64,
20}
21
22const MAX_SECONDS: f64 = 1e7;
23pub(super) const MAX_FPS: u32 = 1000;
24pub(super) const WRITTEN: f32 = 0.95;
25
26pub type Overlay = Box<dyn FnMut(f64, u32, u32) -> Option<Arc<Rgba>> + Send>;
27
28pub struct ExportOptions {
29    pub video: Codec,
30    pub crf: u8,
31    pub audio_kbps: u32,
32    pub audio_gains: Vec<f32>,
33    pub overlay: Option<Overlay>,
34    pub prefs: Prefs,
35}
36
37impl Default for ExportOptions {
38    fn default() -> Self {
39        Self {
40            video: Codec::H264,
41            crf: 18,
42            audio_kbps: 192,
43            audio_gains: Vec::new(),
44            overlay: None,
45            prefs: Prefs::default(),
46        }
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum Stage {
52    Cutting { piece: usize, pieces: usize },
53    Encoding,
54    Mixing,
55    Joining,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct Progress {
60    pub stage: Stage,
61    pub done: f32,
62    pub encoder: Route,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct Exported {
67    pub path: PathBuf,
68    pub frames: u64,
69    pub copied: u64,
70    pub encoded: u64,
71    pub smart: bool,
72    pub video: Picked,
73    pub reasons: Vec<String>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum Part {
78    Copy { from: i64, frames: i64 },
79    Encode { from: i64, frames: i64 },
80}
81
82impl Part {
83    pub(super) fn frames(self) -> i64 {
84        match self {
85            Part::Copy { frames, .. } | Part::Encode { frames, .. } => frames,
86        }
87    }
88}
89
90pub fn parts(in_f: i64, out_f: i64, keys: &[i64]) -> Vec<Part> {
91    if out_f <= in_f {
92        return Vec::new();
93    }
94    let Some(a) = keys.iter().copied().find(|k| *k >= in_f && *k < out_f) else {
95        return vec![Part::Encode {
96            from: in_f,
97            frames: out_f - in_f,
98        }];
99    };
100    let b = keys
101        .iter()
102        .copied()
103        .rev()
104        .find(|k| *k <= out_f)
105        .unwrap_or(a)
106        .max(a);
107    let mut out = Vec::new();
108    if a > in_f {
109        out.push(Part::Encode {
110            from: in_f,
111            frames: a - in_f,
112        });
113    }
114    if b > a {
115        out.push(Part::Copy {
116            from: a,
117            frames: b - a,
118        });
119    }
120    if out_f > b {
121        out.push(Part::Encode {
122            from: b,
123            frames: out_f - b,
124        });
125    }
126    out
127}
128
129#[derive(Clone)]
130pub(super) struct Job<'a> {
131    pub(super) pieces: &'a [Piece],
132    pub(super) sources: Vec<Source>,
133    pub(super) index: Vec<usize>,
134    pub(super) fps: (u32, u32),
135    pub(super) size: (u32, u32),
136    pub(super) format: PixelFormat,
137    pub(super) total: u64,
138    pub(super) done: u64,
139    pub(super) work: PathBuf,
140    pub(super) route: Route,
141}
142
143pub(super) type Tell<'a> = &'a mut dyn FnMut(&Progress) -> bool;
144
145pub(super) type Plan = (Vec<(usize, Part)>, Vec<usize>);
146
147impl Job<'_> {
148    pub(super) fn frame_of(&self, seconds: f64) -> i64 {
149        frame_at(seconds, self.fps)
150    }
151
152    pub(super) fn span(&self, piece: &Piece) -> (i64, i64) {
153        (self.frame_of(piece.from), self.frame_of(piece.to))
154    }
155
156    pub(super) fn quiet(&self) -> Self {
157        Job {
158            total: 0,
159            done: 0,
160            ..self.clone()
161        }
162    }
163
164    pub(super) fn plan(
165        &self,
166        prefs: &Prefs,
167        mut split: impl FnMut(usize, Part, &[i64]) -> Result<Vec<Part>>,
168    ) -> Result<Plan> {
169        let mut keys: HashMap<usize, Vec<i64>> = HashMap::new();
170        let mut plan: Vec<(usize, Part)> = Vec::new();
171        let mut order = Vec::new();
172        for (n, piece) in self.pieces.iter().enumerate() {
173            let si = self.index[n];
174            if let std::collections::hash_map::Entry::Vacant(slot) = keys.entry(si) {
175                slot.insert(
176                    keyframes(&self.sources[si].path, prefs)?
177                        .into_iter()
178                        .map(|t| self.frame_of(t))
179                        .collect(),
180                );
181            }
182            let (a, b) = self.span(piece);
183            for part in parts(a, b, &keys[&si]) {
184                for p in split(si, part, &keys[&si])? {
185                    let i = match plan.iter().position(|q| *q == (si, p)) {
186                        Some(i) => i,
187                        None => {
188                            plan.push((si, p));
189                            plan.len() - 1
190                        }
191                    };
192                    order.push(i);
193                }
194            }
195        }
196        Ok((plan, order))
197    }
198
199    pub(super) fn finish(&self, progress: Tell) -> Result<()> {
200        if progress(&Progress {
201            stage: Stage::Joining,
202            done: 1.0,
203            encoder: self.route,
204        }) {
205            Ok(())
206        } else {
207            Err(Error::Cancelled)
208        }
209    }
210
211    pub(super) fn seconds(&self, frames: i64) -> f64 {
212        frames as f64 * self.fps.1 as f64 / self.fps.0 as f64
213    }
214
215    pub(super) fn tell(&self, progress: Tell, stage: Stage) -> Result<()> {
216        let done = if self.total == 0 {
217            0.0
218        } else {
219            (self.done as f32 / self.total as f32).min(1.0) * WRITTEN
220        };
221        let done = match stage {
222            Stage::Mixing | Stage::Joining => WRITTEN,
223            _ => done,
224        };
225        if progress(&Progress {
226            stage,
227            done,
228            encoder: self.route,
229        }) {
230            Ok(())
231        } else {
232            Err(Error::Cancelled)
233        }
234    }
235}
236
237pub fn export(
238    out: &Path,
239    pieces: &[Piece],
240    opts: ExportOptions,
241    progress: impl FnMut(&Progress) -> bool,
242) -> Result<Exported> {
243    export_to(out, pieces, opts, None, progress)
244}
245
246pub fn export_with_audio(
247    out: &Path,
248    pieces: &[Piece],
249    opts: ExportOptions,
250    mut audio: impl FnMut(usize, usize) -> Result<AudioBuffer> + Send,
251    progress: impl FnMut(&Progress) -> bool,
252) -> Result<Exported> {
253    export_to(out, pieces, opts, Some(&mut audio), progress)
254}
255
256fn export_to(
257    out: &Path,
258    pieces: &[Piece],
259    mut opts: ExportOptions,
260    premix: Option<Premix>,
261    mut progress: impl FnMut(&Progress) -> bool,
262) -> Result<Exported> {
263    if pieces.is_empty() {
264        return Err(Error::Invalid("nothing to export".to_string()));
265    }
266    let mut sources: Vec<Source> = Vec::new();
267    let mut by_path: HashMap<PathBuf, usize> = HashMap::new();
268    let mut index = Vec::new();
269    for p in pieces {
270        if p.to.partial_cmp(&p.from) != Some(std::cmp::Ordering::Greater)
271            || !(-MAX_SECONDS..=MAX_SECONDS).contains(&p.from)
272            || !(-MAX_SECONDS..=MAX_SECONDS).contains(&p.to)
273        {
274            return Err(Error::Invalid(format!(
275                "a piece from {} to {}",
276                p.from, p.to
277            )));
278        }
279        let i = match by_path.get(&p.path) {
280            Some(&i) => i,
281            None => {
282                sources.push(source(&p.path, &opts.prefs)?);
283                by_path.insert(p.path.clone(), sources.len() - 1);
284                sources.len() - 1
285            }
286        };
287        index.push(i);
288    }
289    let caps = Caps::detect(&opts.prefs);
290    let mut video = pick_encode(opts.video, &opts.prefs, &caps)?;
291    let first = sources[index[0]].probe.clone();
292    let fps = first.fps;
293    if fps.0 == 0 || fps.1 == 0 || fps.0 / fps.1 > MAX_FPS {
294        return Err(Error::Invalid(format!(
295            "a frame rate of {}/{}",
296            fps.0, fps.1
297        )));
298    }
299    let size = (first.width & !1, first.height & !1);
300    if size.0 == 0 || size.1 == 0 {
301        return Err(Error::Invalid(format!(
302            "a picture of {}x{}",
303            first.width, first.height
304        )));
305    }
306    let format = if first.bit_depth > 8 && video.codec == Codec::Hevc {
307        PixelFormat::Yuv420p10
308    } else {
309        PixelFormat::Yuv420p8
310    };
311    let format = match video.route {
312        Route::Gpu => match super::mux::gpu_check(video.codec, size, fps, format) {
313            Ok(()) => format,
314            Err(e) => {
315                video.reasons.push(format!(
316                    "the GPU encoder failed ({e}); encoding H.264 on the CPU instead"
317                ));
318                video.route = Route::Cpu;
319                video.codec = Codec::H264;
320                PixelFormat::Yuv420p8
321            }
322        },
323        Route::Cpu => PixelFormat::Yuv420p8,
324        Route::Ffmpeg => format,
325    };
326    let same = sources.iter().all(|s| {
327        s.probe.video_codec == Some(video.codec)
328            && s.probe.fps == fps
329            && (s.probe.width, s.probe.height) == (first.width, first.height)
330            && s.probe.bit_depth == first.bit_depth
331            && s.probe.rotation == first.rotation
332    });
333    let readable = sources.iter().all(|s| s.demux.is_some());
334    let ffmpeg = match (&caps.ffmpeg, video.route) {
335        (Some(f), Route::Ffmpeg) => Some(f.clone()),
336        (_, Route::Cpu | Route::Gpu) => None,
337        (None, Route::Ffmpeg) => {
338            return Err(Error::Unsupported(
339                "ffmpeg was picked but not found".to_string(),
340            ));
341        }
342    };
343    let why = if opts.overlay.is_some() {
344        Some("an overlay is drawn on every frame, so the whole export is re-encoded".to_string())
345    } else if !same {
346        Some(match sources.iter().find(|s| s.probe.video_codec != Some(video.codec)) {
347            Some(s) => format!(
348                "the source video is {}, the export {}, so the whole export is re-encoded",
349                s.probe.video_codec_name, video.codec
350            ),
351            None => "the sources differ in frame rate, size, depth or rotation, so the whole export is re-encoded".to_string(),
352        })
353    } else if video.codec == Codec::Vp9 && (first.profile != "Profile 0" || first.bit_depth > 8) {
354        Some(format!(
355            "the source VP9 is {} at {} bits, our VP9 encoder writes profile 0 at 8 bits, so the whole export is re-encoded",
356            first.profile, first.bit_depth
357        ))
358    } else if size != (first.width, first.height) {
359        Some(format!(
360            "the picture's odd size {}x{} is evened, so the whole export is re-encoded",
361            first.width, first.height
362        ))
363    } else if ffmpeg.is_none() && !readable {
364        Some("our MP4 and MKV readers can't index a source to copy it, so the whole export is re-encoded".to_string())
365    } else {
366        None
367    };
368    let smart = why.is_none();
369    let mut reasons: Vec<String> = video.reasons.clone();
370    for s in &sources {
371        for r in &s.reasons {
372            if !reasons.contains(r) {
373                reasons.push(r.clone());
374            }
375        }
376    }
377    reasons.extend(why);
378    let work = std::path::absolute(out.with_extension("parts"))?;
379    if work.exists() {
380        std::fs::remove_dir_all(&work).map_err(|e| Error::at(&work, e))?;
381    }
382    std::fs::create_dir_all(&work).map_err(|e| Error::at(&work, e))?;
383    let mut job = Job {
384        pieces,
385        sources,
386        index,
387        fps,
388        size,
389        format,
390        total: 0,
391        done: 0,
392        work: work.clone(),
393        route: video.route,
394    };
395    job.total = pieces
396        .iter()
397        .map(|p| (job.frame_of(p.to) - job.frame_of(p.from)).max(0) as u64)
398        .sum();
399    let result = match &ffmpeg {
400        Some(f) => run(
401            &mut job,
402            f,
403            &video,
404            &mut opts,
405            smart,
406            out,
407            &mut progress,
408            premix,
409        ),
410        None => super::mux::run(
411            &mut job,
412            &video,
413            &mut opts,
414            smart,
415            out,
416            &mut progress,
417            match premix {
418                Some(p) => Sound::Premixed(p),
419                None => Sound::Tracks(caps.ffmpeg.as_ref()),
420            },
421        ),
422    };
423    let _ = std::fs::remove_dir_all(&work);
424    let (copied, encoded, planned) = result?;
425    for r in planned {
426        if !reasons.contains(&r) {
427            reasons.push(r);
428        }
429    }
430    Ok(Exported {
431        path: out.to_path_buf(),
432        frames: copied + encoded,
433        copied,
434        encoded,
435        smart,
436        video,
437        reasons,
438    })
439}
440
441#[allow(clippy::too_many_arguments)]
442fn run(
443    job: &mut Job,
444    f: &Ffmpeg,
445    video: &Picked,
446    opts: &mut ExportOptions,
447    smart: bool,
448    out: &Path,
449    progress: Tell,
450    mut premix: Option<Premix>,
451) -> Result<(u64, u64, Vec<String>)> {
452    let (mut copied, mut encoded) = (0u64, 0u64);
453    let mut list = String::new();
454    let exact = premix.is_some();
455    let kbps = opts.audio_kbps;
456    let mut planner = Planner::default();
457    if smart {
458        let jobref = &*job;
459        let (plan, order) = jobref.plan(&opts.prefs, |si, p, keys| {
460            planner.split(jobref, (si, p), keys, false)
461        })?;
462        let mut at = 0i64;
463        for &i in &order {
464            let file = job.work.join(format!("part-{i:05}.ts"));
465            let frames = plan[i].1.frames();
466            list.push_str(&listed(job, &file, at, frames));
467            at += frames;
468            match plan[i].1 {
469                Part::Copy { frames, .. } => copied += frames as u64,
470                Part::Encode { frames, .. } => encoded += frames as u64,
471            }
472        }
473        let tail = tail(f, job, video, opts.crf, true)?;
474        let mix = job.work.join("mix.m4a");
475        let mix_args = if exact {
476            Vec::new()
477        } else {
478            mix_args(job, opts, &mix)
479        };
480        let total: i64 = plan.iter().map(|(_, p)| p.frames()).sum::<i64>().max(1);
481        let next = std::sync::atomic::AtomicUsize::new(0);
482        let stop = std::sync::atomic::AtomicBool::new(false);
483        let (tx, rx) = std::sync::mpsc::channel::<Result<i64>>();
484        let workers = plan.len().clamp(1, 6);
485        let jobref = &*job;
486        let outcome = std::thread::scope(|s| {
487            {
488                let tx = tx.clone();
489                let (mix_args, mix, stop) = (&mix_args, &mix, &stop);
490                s.spawn(move || {
491                    let r = match premix.take() {
492                        Some(p) => premixed_m4a(jobref, p, kbps, stop, mix),
493                        None => f.run_args(mix_args).map(|_| ()),
494                    };
495                    let _ = tx.send(r.map(|_| 0));
496                });
497            }
498            for _ in 0..workers {
499                let tx = tx.clone();
500                let (plan, next, stop, tail) = (&plan, &next, &stop, &tail);
501                s.spawn(move || {
502                    loop {
503                        if stop.load(std::sync::atomic::Ordering::Relaxed) {
504                            break;
505                        }
506                        let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
507                        let Some(&(si, part)) = plan.get(i) else {
508                            break;
509                        };
510                        let file = jobref.work.join(format!("part-{i:05}.ts"));
511                        let r = match part {
512                            Part::Copy { from, frames } => {
513                                copy(f, jobref, si, from, frames, &file).map(|_| frames)
514                            }
515                            Part::Encode { from, frames } => {
516                                edge(f, jobref, si, from, frames, tail, &file).map(|_| frames)
517                            }
518                        };
519                        let _ = tx.send(r);
520                    }
521                });
522            }
523            drop(tx);
524            let mut done = 0i64;
525            let mut first_err = None;
526            for (k, r) in rx.iter().enumerate() {
527                match r {
528                    Ok(frames) => done += frames,
529                    Err(e) => {
530                        stop.store(true, std::sync::atomic::Ordering::Relaxed);
531                        first_err.get_or_insert(e);
532                        continue;
533                    }
534                }
535                let keep = progress(&Progress {
536                    stage: Stage::Cutting {
537                        piece: k + 1,
538                        pieces: plan.len() + 1,
539                    },
540                    done: done as f32 / total as f32 * WRITTEN,
541                    encoder: Route::Ffmpeg,
542                });
543                if !keep {
544                    stop.store(true, std::sync::atomic::Ordering::Relaxed);
545                    first_err.get_or_insert(Error::Cancelled);
546                }
547            }
548            match first_err {
549                Some(e) => Err(e),
550                None => Ok(()),
551            }
552        });
553        outcome?;
554        return join(job, f, video, &list, (&mix, exact), out, progress)
555            .map(|_| (copied, encoded, planner.reasons));
556    } else {
557        job.tell(progress, Stage::Encoding)?;
558        let file = job.work.join("whole.ts");
559        let mut enc = Encoder::open(f, job, video, opts.crf, &file)?;
560        let mut at = 0i64;
561        for (n, piece) in job.pieces.iter().enumerate() {
562            let (a, b) = job.span(piece);
563            reencode(
564                job,
565                job.index[n],
566                (a, b - a),
567                at,
568                &mut |f| enc.push(f),
569                &mut opts.overlay,
570                progress,
571            )?;
572            at += b - a;
573        }
574        enc.finish()?;
575        encoded = at as u64;
576        list.push_str(&listed(job, &file, 0, at));
577    }
578    job.tell(progress, Stage::Mixing)?;
579    let mix = job.work.join("mix.m4a");
580    match premix {
581        Some(p) => premixed_m4a(job, p, kbps, &AtomicBool::new(false), &mix)?,
582        None => {
583            f.run_args(&mix_args(job, opts, &mix))?;
584        }
585    }
586    join(job, f, video, &list, (&mix, exact), out, progress)?;
587    Ok((copied, encoded, planner.reasons))
588}
589
590fn micros(job: &Job, frames: i64) -> i128 {
591    let num = frames as i128 * job.fps.1 as i128 * 1_000_000;
592    let den = (job.fps.0 as i128).max(1);
593    (2 * num + den).div_euclid(2 * den)
594}
595
596fn listed(job: &Job, file: &Path, at: i64, frames: i64) -> String {
597    let us = micros(job, at + frames) - micros(job, at);
598    format!(
599        "file '{}'\nduration {}.{:06}\n",
600        file.display(),
601        us.div_euclid(1_000_000),
602        us.rem_euclid(1_000_000)
603    )
604}
605
606fn join(
607    job: &Job,
608    f: &Ffmpeg,
609    video: &Picked,
610    list: &str,
611    (mix, exact): (&Path, bool),
612    out: &Path,
613    progress: Tell,
614) -> Result<()> {
615    job.tell(progress, Stage::Joining)?;
616    let list_path = job.work.join("pieces.txt");
617    std::fs::write(&list_path, list).map_err(|e| Error::at(&list_path, e))?;
618    let part = job.work.join("joined.mp4");
619    let mut join: Vec<String> = vec![
620        "-f".into(),
621        "concat".into(),
622        "-safe".into(),
623        "0".into(),
624        "-i".into(),
625        list_path.display().to_string(),
626        "-i".into(),
627        mix.display().to_string(),
628        "-map".into(),
629        "0:v:0".into(),
630        "-map".into(),
631        "1:a:0".into(),
632        "-c".into(),
633        "copy".into(),
634    ];
635    if video.codec == Codec::Hevc {
636        join.extend(["-tag:v".into(), "hvc1".into()]);
637    }
638    let rotation = job.sources[job.index[0]].probe.rotation;
639    if rotation != 0 {
640        join.extend([
641            "-display_rotation:v:0".into(),
642            format!("{}", -(rotation as i32)),
643        ]);
644    }
645    if exact {
646        join.extend(["-movie_timescale".into(), "48000".into()]);
647    }
648    join.extend([
649        "-movflags".into(),
650        "+faststart".into(),
651        "-f".into(),
652        "mp4".into(),
653        part.display().to_string(),
654    ]);
655    f.run_args(&join)?;
656    std::fs::rename(&part, out).map_err(|e| Error::at(out, e))?;
657    job.finish(progress)
658}
659
660fn copy(f: &Ffmpeg, job: &Job, si: usize, from: i64, frames: i64, file: &Path) -> Result<()> {
661    if job.sources[si].demux.is_some() {
662        let gop = file.with_extension("mp4");
663        gop_mp4(job, si, from, frames, &gop)?;
664        let args: Vec<String> = vec![
665            "-i".into(),
666            gop.display().to_string(),
667            "-map".into(),
668            "0:v:0".into(),
669            "-c:v".into(),
670            "copy".into(),
671            "-f".into(),
672            "mpegts".into(),
673            file.display().to_string(),
674        ];
675        let done = f.run_args(&args);
676        let _ = std::fs::remove_file(&gop);
677        done?;
678        return padded(file);
679    }
680    let seek = job.seconds(from) + job.seconds(1) / 2.0;
681    let args: Vec<String> = vec![
682        "-ss".into(),
683        format!("{seek:.6}"),
684        "-i".into(),
685        job.sources[si].path.display().to_string(),
686        "-map".into(),
687        "0:v:0".into(),
688        "-an".into(),
689        "-sn".into(),
690        "-dn".into(),
691        "-frames:v".into(),
692        frames.to_string(),
693        "-c:v".into(),
694        "copy".into(),
695        "-f".into(),
696        "mpegts".into(),
697        file.display().to_string(),
698    ];
699    f.run_args(&args)?;
700    padded(file)
701}
702
703const TS_PACKET: usize = 188;
704const TS_FLOOR: u64 = 64 * TS_PACKET as u64;
705
706fn padded(file: &Path) -> Result<()> {
707    let len = std::fs::metadata(file)
708        .map_err(|e| Error::at(file, e))?
709        .len();
710    if len >= TS_FLOOR {
711        return Ok(());
712    }
713    let mut null = [0xffu8; TS_PACKET];
714    null[..4].copy_from_slice(&[0x47, 0x1f, 0xff, 0x10]);
715    let count = (TS_FLOOR - len).div_ceil(TS_PACKET as u64) as usize;
716    let mut out = std::fs::OpenOptions::new()
717        .append(true)
718        .open(file)
719        .map_err(|e| Error::at(file, e))?;
720    out.write_all(&null.repeat(count))
721        .map_err(|e| Error::at(file, e))
722}
723
724pub(super) fn reencode(
725    job: &mut Job,
726    si: usize,
727    (from, frames): (i64, i64),
728    at: i64,
729    push: &mut dyn FnMut(&Frame) -> Result<()>,
730    overlay: &mut Option<Overlay>,
731    progress: Tell,
732) -> Result<()> {
733    if frames <= 0 {
734        return Ok(());
735    }
736    let src = &job.sources[si];
737    let size = (src.probe.width, src.probe.height) != job.size;
738    let mut frames_in = open(
739        src,
740        job.seconds(from),
741        Some(frames as u64),
742        size.then_some(job.size),
743        Some(job.fps),
744    )?;
745    let mut n = 0i64;
746    let mut last: Option<Frame> = None;
747    let mut blender = Blender::new();
748    while n < frames {
749        let mut frame = match frames_in.next() {
750            Some(f) => f?,
751            None => match last.clone() {
752                Some(f) => f,
753                None => return Err(Error::Invalid("a piece with no frames".to_string())),
754            },
755        };
756        if frame.format != job.format {
757            frame = convert_depth(&frame, job.format);
758        }
759        frame.pts = at + n;
760        let layer = overlay
761            .as_mut()
762            .and_then(|o| o(job.seconds(at + n), frame.width, frame.height));
763        let keep = match layer {
764            Some(layer) => {
765                let clean = frame.clone();
766                blender.draw(&mut frame, &layer);
767                push(&frame)?;
768                frames_in.recycle(frame);
769                clean
770            }
771            None => {
772                push(&frame)?;
773                frame
774            }
775        };
776        if let Some(old) = last.replace(keep) {
777            frames_in.recycle(old);
778        }
779        n += 1;
780        job.done += 1;
781        if n % 15 == 0 {
782            job.tell(progress, Stage::Encoding)?;
783        }
784    }
785    Ok(())
786}
787
788pub(super) fn convert_depth(frame: &Frame, format: PixelFormat) -> Frame {
789    let mut out = Frame::new(
790        format,
791        frame.width,
792        frame.height,
793        frame.pts,
794        frame.time_base,
795    );
796    for (o, i) in out.planes.iter_mut().zip(&frame.planes) {
797        let rows = o
798            .data
799            .chunks_mut(o.stride.max(1))
800            .zip(i.data.chunks(i.stride.max(1)));
801        for (dst, src) in rows.take(i.height) {
802            match format {
803                PixelFormat::Yuv420p8 => {
804                    for (d, s) in dst.iter_mut().zip(src.chunks_exact(2)).take(i.width) {
805                        *d = ((u16::from_le_bytes([s[0], s[1]]) + 2) / 4).min(255) as u8;
806                    }
807                }
808                PixelFormat::Yuv420p10 => {
809                    for (d, &s) in dst.chunks_exact_mut(2).zip(src).take(i.width) {
810                        d.copy_from_slice(&(s as u16 * 4).to_le_bytes());
811                    }
812                }
813            }
814        }
815    }
816    out
817}
818
819struct Encoder {
820    child: Child,
821}
822
823struct Tail {
824    pre: Vec<String>,
825    post: Vec<String>,
826}
827
828fn tail(f: &Ffmpeg, job: &Job, video: &Picked, crf: u8, edge: bool) -> Result<Tail> {
829    let name = f
830        .encoder_for(video.codec, video.ffmpeg_codec.as_deref())
831        .ok_or_else(|| {
832            Error::Unsupported(format!(
833                "{} has no {} encoder",
834                f.path.display(),
835                video.codec
836            ))
837        })?;
838    let mut pre: Vec<String> = Vec::new();
839    let mut post = encoder_args(&name, video.codec, crf, job, edge);
840    if name.ends_with("_vaapi") {
841        pre.extend([
842            "-init_hw_device".into(),
843            "vaapi=va:/dev/dri/renderD128".into(),
844            "-filter_hw_device".into(),
845            "va".into(),
846        ]);
847        let up = if job.format == PixelFormat::Yuv420p10 {
848            "format=p010,hwupload"
849        } else {
850            "format=nv12,hwupload"
851        };
852        post.extend(["-vf".into(), up.into()]);
853    }
854    if edge {
855        post.extend(["-bf".into(), "0".into(), "-g".into(), "100000".into()]);
856    }
857    Ok(Tail { pre, post })
858}
859
860fn edge(
861    f: &Ffmpeg,
862    job: &Job,
863    si: usize,
864    from: i64,
865    frames: i64,
866    tail: &Tail,
867    file: &Path,
868) -> Result<()> {
869    let src = &job.sources[si];
870    let mut args = tail.pre.clone();
871    args.extend(f.decoder_args(src.ffmpeg_codec.as_deref()));
872    args.extend([
873        "-ss".into(),
874        format!("{:.6}", (job.seconds(from) - job.seconds(1) / 2.0).max(0.0)),
875        "-i".into(),
876        src.path.display().to_string(),
877        "-map".into(),
878        "0:v:0".into(),
879        "-an".into(),
880        "-sn".into(),
881        "-dn".into(),
882        "-frames:v".into(),
883        frames.to_string(),
884    ]);
885    args.extend(tail.post.iter().cloned());
886    args.extend(["-f".into(), "mpegts".into(), file.display().to_string()]);
887    f.run_args(&args)?;
888    padded(file)
889}
890
891impl Encoder {
892    fn open(f: &Ffmpeg, job: &Job, video: &Picked, crf: u8, file: &Path) -> Result<Encoder> {
893        let t = tail(f, job, video, crf, false)?;
894        let pix = pix_fmt(job.format);
895        let mut args = t.pre;
896        args.extend([
897            "-f".into(),
898            "rawvideo".into(),
899            "-pix_fmt".into(),
900            pix.into(),
901            "-s".into(),
902            format!("{}x{}", job.size.0, job.size.1),
903            "-r".into(),
904            format!("{}/{}", job.fps.0, job.fps.1),
905            "-i".into(),
906            "-".into(),
907        ]);
908        args.extend(t.post);
909        args.extend(["-f".into(), "mpegts".into(), file.display().to_string()]);
910        Ok(Encoder {
911            child: f.spawn(&args, true, false)?,
912        })
913    }
914
915    fn push(&mut self, frame: &Frame) -> Result<()> {
916        let Some(input) = self.child.stdin.as_mut() else {
917            return Err(Error::Backend("ffmpeg closed its input".to_string()));
918        };
919        for p in &frame.planes {
920            let row = p.width * frame.format.bytes_per_sample();
921            let ok = if p.stride == row {
922                input.write_all(&p.data)
923            } else {
924                p.data
925                    .chunks(p.stride)
926                    .take(p.height)
927                    .try_for_each(|r| input.write_all(&r[..row.min(r.len())]))
928            };
929            if ok.is_err() {
930                let err = self.child.stderr_now();
931                return Err(Error::Backend(format!("ffmpeg: {err}")));
932            }
933        }
934        Ok(())
935    }
936
937    fn finish(self) -> Result<()> {
938        self.child.finish()
939    }
940}
941
942fn encoder_args(name: &str, codec: Codec, crf: u8, job: &Job, edge: bool) -> Vec<String> {
943    let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
944    let crf_edge = if edge { crf.saturating_sub(2) } else { crf };
945    let q = crf_edge.to_string();
946    let mut a = s(&["-c:v", name]);
947    match name {
948        "libx264" => a.extend(s(&[
949            "-preset",
950            if edge { "veryfast" } else { "medium" },
951            "-crf",
952            &q,
953            "-pix_fmt",
954            "yuv420p",
955        ])),
956        "libx265" => a.extend(s(&[
957            "-preset",
958            if edge { "veryfast" } else { "medium" },
959            "-crf",
960            &q,
961            "-pix_fmt",
962            pix_fmt(job.format),
963            "-x265-params",
964            "log-level=error",
965        ])),
966        n if n.ends_with("_vaapi") => a.extend(s(&[
967            "-rc_mode",
968            "CQP",
969            "-qp",
970            &(crf_edge as u32 + 1).to_string(),
971        ])),
972        n if n.ends_with("_nvenc") => {
973            a.extend(s(&["-preset", "p5", "-rc", "vbr", "-cq", &q, "-b:v", "0"]))
974        }
975        n if n.ends_with("_qsv") => a.extend(s(&["-global_quality", &q])),
976        n if n.ends_with("_videotoolbox") => a.extend(s(&[
977            "-q:v",
978            &(100u32.saturating_sub(crf_edge as u32 * 2)).to_string(),
979        ])),
980        _ => {
981            let pixels = job.size.0 as f64 * job.size.1 as f64;
982            let rate = pixels * job.fps.0 as f64 / job.fps.1 as f64;
983            let bits = rate * 0.15 * 2f64.powf((18.0 - crf_edge as f64) / 6.0);
984            a.extend(s(&["-b:v", &format!("{}", bits.round() as u64)]));
985        }
986    }
987    if codec == Codec::Hevc && !name.ends_with("_vaapi") && !name.starts_with("lib") {
988        a.extend(s(&["-tag:v", "hvc1"]));
989    }
990    a
991}
992
993fn mix_args(job: &Job, opts: &ExportOptions, out: &Path) -> Vec<String> {
994    let mut args: Vec<String> = Vec::new();
995    let mut graph = String::new();
996    let mut joined = String::new();
997    let (mut at, mut first) = (0i64, 0i64);
998    for (i, piece) in job.pieces.iter().enumerate() {
999        let src = &job.sources[job.index[i]];
1000        let (a, b) = job.span(piece);
1001        let start = job.seconds(a);
1002        let dur = job.seconds(b - a);
1003        at += b - a;
1004        let end = (job.seconds(at) * 48_000.0).round() as i64;
1005        let n = (end - first).max(0);
1006        first = end;
1007        args.extend([
1008            "-ss".into(),
1009            format!("{start:.6}"),
1010            "-t".into(),
1011            format!("{:.6}", dur + 0.5),
1012            "-i".into(),
1013            src.path.display().to_string(),
1014        ]);
1015        let tracks: Vec<(usize, f32)> = src
1016            .probe
1017            .audio
1018            .iter()
1019            .map(|t| {
1020                (
1021                    t.index,
1022                    opts.audio_gains.get(t.index).copied().unwrap_or(1.0),
1023                )
1024            })
1025            .filter(|(_, g)| *g > 0.0)
1026            .collect();
1027        if tracks.is_empty() {
1028            graph.push_str(&format!(
1029                "anullsrc=r=48000:cl=stereo,atrim=end_sample={n}[p{i}];"
1030            ));
1031        } else {
1032            let mut labels = String::new();
1033            for (t, g) in &tracks {
1034                graph.push_str(&format!(
1035                    "[{i}:a:{t}]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,volume={g:.4}[p{i}t{t}];"
1036                ));
1037                labels.push_str(&format!("[p{i}t{t}]"));
1038            }
1039            graph.push_str(&format!(
1040                "{labels}amix=inputs={}:normalize=0:duration=longest,apad=whole_len={n},atrim=end_sample={n},asetpts=N/SR/TB[p{i}];",
1041                tracks.len()
1042            ));
1043        }
1044        joined.push_str(&format!("[p{i}]"));
1045    }
1046    graph.push_str(&format!(
1047        "{joined}concat=n={}:v=0:a=1[mix]",
1048        job.pieces.len()
1049    ));
1050    let script = job.work.join("mix.graph");
1051    let _ = std::fs::write(&script, &graph);
1052    args.extend([
1053        "-/filter_complex".into(),
1054        script.display().to_string(),
1055        "-map".into(),
1056        "[mix]".into(),
1057        "-c:a".into(),
1058        "aac".into(),
1059        "-b:a".into(),
1060        format!("{}k", opts.audio_kbps.max(32)),
1061        "-ar".into(),
1062        "48000".into(),
1063        "-ac".into(),
1064        "2".into(),
1065        out.display().to_string(),
1066    ]);
1067    args
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073
1074    #[test]
1075    fn a_piece_between_keyframes_copies_its_middle_and_encodes_its_edges() {
1076        let keys = [0, 120, 240, 360];
1077        assert_eq!(
1078            parts(100, 300, &keys),
1079            [
1080                Part::Encode {
1081                    from: 100,
1082                    frames: 20
1083                },
1084                Part::Copy {
1085                    from: 120,
1086                    frames: 120
1087                },
1088                Part::Encode {
1089                    from: 240,
1090                    frames: 60
1091                },
1092            ]
1093        );
1094        assert_eq!(
1095            parts(130, 200, &keys),
1096            [Part::Encode {
1097                from: 130,
1098                frames: 70
1099            }]
1100        );
1101        assert_eq!(
1102            parts(100, 200, &keys),
1103            [
1104                Part::Encode {
1105                    from: 100,
1106                    frames: 20
1107                },
1108                Part::Encode {
1109                    from: 120,
1110                    frames: 80
1111                },
1112            ]
1113        );
1114    }
1115}