1use std::ffi::OsString;
13use std::fmt::Write as _;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use std::time::{Duration, Instant};
17
18use std::sync::Arc;
19
20use ffcine_comp::Base;
21use ffcine_render::{
22 Backend, Codec, Decode, Device, EncoderSettings, Grade, Hook, Layer, LayerSource, Lut,
23 LutStage, LutType, Overlay, ParamTrack, Pipeline, Rational, Size, shaders,
24};
25use serde_json::json;
26
27use crate::comp::Finish;
28use crate::plan::{AudioPlan, Plan, Segment, Shot, TextLayer};
29use crate::recipe::{Fingerprinter as _, Key};
30use ffcine_render::Warp;
31use ffcine_stabilize::{Detection, SourceRange};
32
33#[derive(Debug, thiserror::Error)]
35pub enum ExecError {
36 #[error("{0}")]
37 Render(#[from] ffcine_core::Error),
38 #[error("{what}: {source}")]
39 Io {
40 what: String,
41 source: std::io::Error,
42 },
43 #[error("grade of {shot:?}: {msg}")]
44 Grade { shot: String, msg: String },
45 #[error("stabilizing {shot:?}: {msg}")]
46 Stabilize { shot: String, msg: String },
47 #[error("ffmpeg ({what}) failed: {log}")]
48 Ffmpeg { what: String, log: String },
49}
50
51pub(crate) type Result<T> = std::result::Result<T, ExecError>;
52
53fn io(what: impl Into<String>) -> impl FnOnce(std::io::Error) -> ExecError {
54 let what = what.into();
55 move |source| ExecError::Io { what, source }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum Progress {
61 #[default]
63 Human,
64 Json,
66 Quiet,
67}
68
69#[derive(Debug, Clone, Default, PartialEq)]
71pub struct Report {
72 pub segments: usize,
73 pub rendered: usize,
74 pub rendered_frames: i64,
75 pub render: Duration,
77 pub audio: Option<Duration>,
79 pub mux: Option<Duration>,
81 pub total: Duration,
82}
83
84pub struct Renderer<'a> {
86 pub backend: &'a dyn Backend,
87 pub cache: PathBuf,
88 pub ffmpeg: OsString,
89 pub device: Device,
90 pub decode: Decode,
91 pub progress: Progress,
92 pub grader: Option<&'a Grader<'a>>,
95 pub detect: Option<&'a Detector<'a>>,
98}
99
100pub type Grader<'g> = dyn Fn(&Shot) -> std::result::Result<Grade, String> + 'g;
102
103pub type Detector<'d> = dyn Fn(&Path, &SourceRange) -> std::result::Result<Detection, String> + 'd;
105
106#[allow(clippy::cast_possible_truncation)]
108pub fn rational(rate: f64) -> Rational {
109 for den in [1, 1001, 1000, 100] {
110 let num = rate * f64::from(den);
111 if (num - num.round()).abs() < 1e-3 * f64::from(den) / 1000.0_f64.max(1.0) + 1e-6 {
112 return Rational {
113 num: num.round() as i32,
114 den,
115 };
116 }
117 }
118 Rational {
119 num: (rate * 1000.0).round() as i32,
120 den: 1000,
121 }
122}
123
124#[allow(clippy::cast_precision_loss)]
125pub(crate) fn secs(frames: i64, rate: f64) -> f64 {
126 frames as f64 / rate
127}
128
129fn blend_filter(n: u32, hist: i64, fr: &str) -> String {
135 let pad = i64::from(n) - 1 - hist;
136 let mut f = String::new();
137 if pad > 0 {
138 let _ = write!(f, "tpad=start={pad}:start_mode=clone,");
139 }
140 let _ = write!(f, "settb=1/({fr}),setpts=N,hwupload,split={n}");
141 for j in 0..n {
142 let _ = write!(f, "[b{j}]");
143 }
144 f.push(';');
145 for j in 1..n {
146 let _ = write!(f, "[b{j}]trim=start_frame={j},setpts=PTS-STARTPTS[t{j}];");
147 }
148 let mut prev = "b0".to_owned();
149 for j in 1..n {
150 let _ = write!(
151 f,
152 "[t{j}][{prev}]blend_vulkan=all_mode=normal:all_opacity={:.7}",
153 1.0 / f64::from(j + 1)
154 );
155 if j + 1 < n {
156 prev = format!("m{j}");
157 let _ = write!(f, "[{prev}];");
158 }
159 }
160 f.push_str(",hwupload=derive_device=cuda");
161 f
162}
163
164#[derive(Debug, Clone, PartialEq)]
167struct RampCut {
168 seek: f64,
169 duration: f64,
170 filter: String,
171}
172
173#[allow(clippy::cast_precision_loss)]
178fn ramp_cut(ramp: &crate::plan::Ramp, clip_start: f64, from: i64, n: i64, rate: f64) -> RampCut {
179 let r = rational(rate);
180 let seek = (ramp.source_time(secs(from, rate)) - 0.5).max(0.0);
182 RampCut {
183 seek,
184 duration: ramp.source_duration - seek + secs(2, rate),
185 filter: format!(
186 "{},fps={}/{},trim=start_pts={from}:end_pts={},setpts=PTS-STARTPTS",
187 ramp.setpts_of(&format!("(PTS*TB-{clip_start})")),
188 r.num,
189 r.den,
190 from + n
191 ),
192 }
193}
194
195impl Renderer<'_> {
196 fn dir(&self, sub: &str) -> Result<PathBuf> {
197 let d = self.cache.join(sub);
198 std::fs::create_dir_all(&d).map_err(io(format!("creating {}", d.display())))?;
199 Ok(d)
200 }
201
202 pub(crate) fn event(&self, v: &serde_json::Value, human: impl FnOnce() -> String) {
203 match self.progress {
204 Progress::Human => eprintln!("{}", human()),
205 Progress::Json => println!("{v}"),
206 Progress::Quiet => {}
207 }
208 }
209
210 fn ffmpeg(&self, what: &str, args: &[OsString]) -> Result<()> {
211 let out = Command::new(&self.ffmpeg)
212 .args(["-hide_banner", "-nostdin", "-loglevel", "error", "-y"])
213 .args(args)
214 .output()
215 .map_err(io(format!("running {}", self.ffmpeg.to_string_lossy())))?;
216 if out.status.success() {
217 Ok(())
218 } else {
219 let log = String::from_utf8_lossy(&out.stderr);
220 let tail: Vec<&str> = log.lines().rev().take(8).collect();
221 Err(ExecError::Ffmpeg {
222 what: what.into(),
223 log: tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
224 })
225 }
226 }
227
228 fn atomic(path: &Path, make: impl FnOnce(&Path) -> Result<()>) -> Result<bool> {
230 if path.exists() {
231 return Ok(false);
232 }
233 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("mkv");
234 let partial = path.with_extension(format!("partial-{}.{ext}", std::process::id()));
236 if let Err(e) = make(&partial) {
237 let _ = std::fs::remove_file(&partial);
238 return Err(e);
239 }
240 std::fs::rename(&partial, path).map_err(io(format!("renaming {}", partial.display())))?;
241 Ok(true)
242 }
243
244 #[allow(clippy::too_many_lines)]
246 pub fn render(
247 &self,
248 plan: &Plan,
249 mut text: Option<&mut TextLayer>,
250 output: &Path,
251 ) -> Result<Report> {
252 let t0 = Instant::now();
253 if self.cache.is_relative() {
254 let cwd = std::env::current_dir().map_err(io("current directory"))?;
255 return Renderer {
256 cache: cwd.join(&self.cache),
257 ffmpeg: self.ffmpeg.clone(),
258 ..*self
259 }
260 .render(plan, text, output);
261 }
262 let mut report = Report {
263 segments: plan.segments.len(),
264 ..Report::default()
265 };
266 if let Some(t) = text.as_deref_mut() {
267 let a = &plan.profile.aspect;
268 let safe = a.default_safe_area();
269 #[allow(clippy::cast_possible_truncation)]
270 t.set_profile(
271 &a.name,
272 [safe.top, safe.right, safe.bottom, safe.left].map(|v| v as f32),
273 );
274 }
275 let manifest_path = manifest_path(output);
276 let master_key = plan.master_key();
277 if output.exists() && read_manifest_key(&manifest_path).as_ref() == Some(&master_key) {
278 self.event(&json!({"event": "up_to_date"}), || {
279 format!("{}: up to date", output.display())
280 });
281 report.total = t0.elapsed();
282 return Ok(report);
283 }
284
285 let seg_dir = self.dir("seg")?;
286 let mut files = Vec::new();
287 let n = plan.segments.len();
288 for (i, s) in plan.segments.iter().enumerate() {
289 let path = seg_dir.join(format!("{}.mp4", s.key));
290 let t = Instant::now();
291 let made = Self::atomic(&path, |tmp| {
292 #[allow(clippy::cast_precision_loss)]
293 let overlays = match text.as_deref_mut() {
294 Some(t) => t.overlays(
295 s.start as f64 / plan.rate,
296 u64::try_from(s.frames).unwrap_or(0),
297 plan.rate,
298 plan.profile.size,
299 )?,
300 None => Vec::new(),
301 };
302 self.segment(plan, s, overlays, tmp)
303 })?;
304 let el = t.elapsed();
305 if made {
306 report.rendered += 1;
307 report.rendered_frames += s.frames;
308 report.render += el;
309 }
310 self.event(
311 &json!({"event": "segment", "i": i + 1, "n": n, "name": s.name, "key": s.key,
312 "frames": s.frames, "status": if made { "rendered" } else { "cached" },
313 "secs": el.as_secs_f64()}),
314 || {
315 format!(
316 "[{}/{n}] {} {} ({}f){}",
317 i + 1,
318 if made { "rendered" } else { "cached " },
319 s.name,
320 s.frames,
321 if made {
322 format!(" in {:.1} s", el.as_secs_f64())
323 } else {
324 String::new()
325 }
326 )
327 },
328 );
329 files.push(path);
330 }
331
332 let audio = match &plan.audio {
333 Some(a) => {
334 let path = self
335 .dir("audio")?
336 .join(format!("{}-aac{}.m4a", a.key, plan.profile.audio_bitrate));
337 let t = Instant::now();
338 let mut level = None;
339 if Self::atomic(&path, |tmp| {
340 level = Some(self.mix(plan, a, tmp)?);
341 Ok(())
342 })? {
343 report.audio = Some(t.elapsed());
344 }
345 if let (Some((lufs, tp)), Some(el)) = (level, report.audio) {
346 self.event(
347 &json!({"event": "audio", "key": a.key, "clips": a.clips, "lufs": lufs,
348 "true_peak": tp, "secs": el.as_secs_f64()}),
349 || {
350 format!(
351 "mixed {} audio clips in {:.1} s: {lufs:.1} LUFS, {tp:.1} dBTP",
352 a.clips,
353 el.as_secs_f64()
354 )
355 },
356 );
357 }
358 Some(path)
359 }
360 None => None,
361 };
362
363 let t = Instant::now();
364 self.mux(plan, &files, audio.as_deref(), output)?;
365 report.mux = Some(t.elapsed());
366 let manifest = json!({
367 "master": master_key,
368 "program": plan.program_key(),
369 "audio": plan.audio.as_ref().map(|a| &a.key),
370 "mix": plan.audio.as_ref().map(|a| &a.options),
371 "profile": plan.profile.name,
372 "segments": plan.segments.iter().map(|s| &s.key).collect::<Vec<_>>(),
373 });
374 std::fs::write(&manifest_path, format!("{manifest:#}\n"))
375 .map_err(io(format!("writing {}", manifest_path.display())))?;
376 report.total = t0.elapsed();
377 self.event(
378 &json!({"event": "done", "rendered": report.rendered, "segments": n,
379 "secs": report.total.as_secs_f64()}),
380 || {
381 format!(
382 "{}: {} of {n} segments rendered, {:.1} s",
383 output.display(),
384 report.rendered,
385 report.total.as_secs_f64()
386 )
387 },
388 );
389 Ok(report)
390 }
391
392 fn pipeline(&self, input: &Path, out: &Path, start: f64, frames: i64, rate: f64) -> Pipeline {
393 Pipeline::new(input, out)
394 .range(
395 Duration::from_secs_f64(start.max(0.0)),
396 Some(u64::try_from(frames).unwrap_or(0)),
397 )
398 .frame_rate(rational(rate))
399 .device(self.device)
400 .decode(self.decode)
401 }
402
403 fn segment(
406 &self,
407 plan: &Plan,
408 seg: &Segment,
409 mut overlays: Vec<Overlay>,
410 out: &Path,
411 ) -> Result<()> {
412 let mut temps = Vec::new();
413 let r = self.lower(plan, seg, &mut temps).and_then(|(p, ctx)| {
414 let (base, start) = match &p.base {
415 Base::Black => (self.black(plan, seg.frames, &seg.key, &mut temps)?, 0.0),
416 Base::Video { input, start } => (input.clone(), start.as_secs_f64()),
417 };
418 overlays.extend(p.overlays);
419 let pipe = self
420 .pipeline(&base, out, start, seg.frames, plan.rate)
421 .context(
422 u64::try_from(ctx.0).unwrap_or(0),
423 u64::try_from(ctx.1).unwrap_or(0),
424 )
425 .grade(p.grade)
426 .layers(p.layers)
427 .overlays(overlays)
428 .encoder(plan.profile.encoder.clone());
429 self.run_dv_retry(&pipe, &seg.key)
430 });
431 for t in temps {
432 let _ = std::fs::remove_file(t);
433 }
434 r
435 }
436
437 pub(crate) fn filter_preps(&self, layers: &mut [Layer]) -> Result<()> {
441 for l in layers {
442 match &l.source {
443 LayerSource::Filtered(p) => {
444 let p = Arc::clone(p);
445 let out = self.dir("prep")?.join(format!(
446 "{}.filter.mkv",
447 Key::of(&json!({"prep": crate::filter_prep::FILTER_PREP, "node": p.key}))
448 ));
449 let what = format!("filter node `{}`", p.graph);
450 Self::atomic(&out, |partial| {
451 let info = self.backend.probe(&p.input)?;
452 let (_, v) = info.video().ok_or_else(|| ExecError::Ffmpeg {
453 what: what.clone(),
454 log: format!("{}: no video stream", p.input.display()),
455 })?;
456 crate::filter_prep::run(
457 &self.ffmpeg,
458 &p,
459 &v.pixel_format,
460 v.color,
461 partial,
462 )
463 .map_err(|log| ExecError::Ffmpeg {
464 what: what.clone(),
465 log,
466 })?;
467 let out = self.backend.probe(partial)?;
468 let got = out.video().map(|(_, v)| (v.width, v.height));
469 crate::filter_prep::check_dims(got, p.dims).map_err(|log| {
470 ExecError::Ffmpeg {
471 what: what.clone(),
472 log,
473 }
474 })?;
475 if !p.free_timing {
476 self.check_frames(partial, p.store.len(), &what)?;
477 }
478 Ok(())
479 })?;
480 let rate = p.rate;
481 l.source = if p.free_timing {
482 LayerSource::Video {
483 input: out,
484 start: Duration::from_secs_f64(secs(
485 i64::try_from(p.show.start).unwrap_or(0),
486 rate,
487 )),
488 map: None,
489 }
490 } else {
491 #[allow(clippy::cast_precision_loss)]
492 let at = (p.show.start as f64 - 0.5) / rate;
493 LayerSource::Video {
494 input: out,
495 start: Duration::from_secs_f64(at.max(0.0)),
496 map: Some((0..i64::try_from(p.show.len()).unwrap_or(0)).collect()),
497 }
498 };
499 }
500 LayerSource::Scene(s) => {
501 let mut inner = s.layers.clone();
502 self.filter_preps(&mut inner)?;
503 if inner != s.layers {
504 l.source = LayerSource::Scene(Arc::new(ffcine_render::Scene {
505 layers: inner,
506 ..(**s).clone()
507 }));
508 }
509 }
510 _ => {}
511 }
512 }
513 Ok(())
514 }
515
516 fn check_frames(&self, path: &Path, want: usize, what: &str) -> Result<()> {
519 let out = Command::new(&self.ffmpeg)
520 .args(["-hide_banner", "-nostdin", "-i"])
521 .arg(path)
522 .args(["-map", "0:v:0", "-c", "copy", "-f", "null", "-"])
523 .output()
524 .map_err(io(format!("running {}", self.ffmpeg.to_string_lossy())))?;
525 let log = String::from_utf8_lossy(&out.stderr);
526 let got = log
527 .rsplit("frame=")
528 .next()
529 .and_then(|s| s.split_whitespace().next())
530 .and_then(|n| n.parse::<usize>().ok());
531 if got == Some(want) {
532 return Ok(());
533 }
534 Err(ExecError::Ffmpeg {
535 what: what.into(),
536 log: format!(
537 "the graph gave {} frames for {want}: a graph that changes the frame count or \
538 timing needs \"timing\": \"free\" in the layer's filter (the layer then takes \
539 whatever frames it outputs)",
540 got.map_or_else(|| "an unknown number of".into(), |n| n.to_string())
541 ),
542 })
543 }
544
545 pub(crate) fn intra_preps(&self, layers: &mut [Layer]) -> Result<()> {
549 for l in layers {
550 match &mut l.source {
551 LayerSource::Video {
552 input,
553 start,
554 map: Some(map),
555 } if map.windows(2).any(|w| w[1] < w[0]) => {
556 let frames = map.iter().copied().max().unwrap_or(0) + 1;
557 let at = start.as_secs_f64();
558 let id = json!({
559 "prep": INTRA_PREP,
561 "intra": crate::recipe::FsFingerprinter.fingerprint(input),
562 "start": at,
563 "frames": frames,
564 });
565 let prep = self
566 .dir("prep")?
567 .join(format!("{}.intra.mkv", Key::of(&id)));
568 Self::atomic(&prep, |out| {
569 self.ffmpeg("all-intra prep", &intra_prep_args(input, at, frames, out))
570 })?;
571 *input = prep;
572 *start = Duration::ZERO;
573 }
574 LayerSource::Scene(s) => {
575 let mut inner = s.layers.clone();
576 self.intra_preps(&mut inner)?;
577 if inner != s.layers {
578 *s = Arc::new(ffcine_render::Scene {
579 layers: inner,
580 ..(**s).clone()
581 });
582 }
583 }
584 _ => {}
585 }
586 }
587 Ok(())
588 }
589
590 fn run_dv_retry(&self, p: &Pipeline, key: &Key) -> Result<()> {
593 let Err(e) = p.run(self.backend) else {
594 return Ok(());
595 };
596 let tmp = self.dir("tmp")?;
597 let mut stripped = Vec::new();
598 let mut strip = |input: &Path, i: usize| -> Option<PathBuf> {
599 if input.starts_with(&tmp) {
600 return Some(input.to_owned());
601 }
602 let nodv = tmp.join(format!("{key}.{i}.nodv.mov"));
603 let mut v = vec![OsString::from("-i"), input.into()];
604 v.extend(args(&[
605 "-map",
606 "0:v:0",
607 "-c:v",
608 "copy",
609 "-bsf:v",
610 "dovi_rpu=strip=1",
611 ]));
612 v.push(nodv.clone().into());
613 self.ffmpeg("strip Dolby Vision", &v).ok()?;
614 stripped.push(nodv.clone());
615 Some(nodv)
616 };
617 let mut q = p.clone();
618 let mut ok = strip(&p.input, 0).map(|i| q.input = i).is_some();
619 for (i, l) in q.layers.iter_mut().enumerate() {
620 if let LayerSource::Video { input, .. } = &mut l.source {
621 match strip(input, i + 1) {
622 Some(n) => *input = n,
623 None => ok = false,
624 }
625 }
626 }
627 let retry = ok && q.run(self.backend).is_ok();
628 for f in stripped {
629 let _ = std::fs::remove_file(f);
630 }
631 if retry { Ok(()) } else { Err(e.into()) }
632 }
633
634 pub fn render_piece(
637 &self,
638 piece: &ffcine_comp::Piece,
639 rate: f64,
640 out: &Path,
641 encoder: EncoderSettings,
642 output: Option<ffcine_core::ColorSpace>,
643 ) -> Result<()> {
644 let key = Key::of(&piece.recipe);
645 let mut temps = Vec::new();
646 let (base, start) = match &piece.base {
647 Base::Black => (self.black_at(rate, piece.frames, &key, &mut temps)?, 0.0),
648 Base::Video { input, start } => (input.clone(), start.as_secs_f64()),
649 };
650 let mut layers = piece.layers.clone();
651 self.filter_preps(&mut layers)?;
652 self.intra_preps(&mut layers)?;
653 let mut grade = piece.grade.clone();
654 if let Some(c) = output {
655 grade.output = c;
656 }
657 let pipe = self
658 .pipeline(&base, out, start, piece.frames, rate)
659 .grade(grade)
660 .layers(layers)
661 .overlays(piece.overlays.clone())
662 .encoder(encoder);
663 let r = self.run_dv_retry(&pipe, &key);
664 for t in temps {
665 let _ = std::fs::remove_file(t);
666 }
667 r
668 }
669
670 fn black(
672 &self,
673 plan: &Plan,
674 frames: i64,
675 key: &Key,
676 temps: &mut Vec<PathBuf>,
677 ) -> Result<PathBuf> {
678 self.black_at(plan.rate, frames, key, temps)
679 }
680
681 fn black_at(
683 &self,
684 rate: f64,
685 frames: i64,
686 key: &Key,
687 temps: &mut Vec<PathBuf>,
688 ) -> Result<PathBuf> {
689 let black = self.dir("tmp")?.join(format!("{key}.black.mkv"));
690 let r = rational(rate);
691 self.ffmpeg(
692 "black",
693 &args(&[
694 "-f",
695 "lavfi",
696 "-i",
697 &format!("color=c=black:s=64x36:r={}/{}", r.num, r.den),
699 "-frames:v",
700 &frames.to_string(),
701 "-pix_fmt",
702 "yuv420p10le",
703 "-color_primaries",
704 "bt709",
705 "-color_trc",
706 "bt709",
707 "-colorspace",
708 "bt709",
709 "-color_range",
710 "tv",
711 "-c:v",
712 "ffv1",
713 ])
714 .chain([black.clone().into()])
715 .collect::<Vec<_>>(),
716 )?;
717 temps.push(black.clone());
718 Ok(black)
719 }
720
721 pub(crate) fn input(
724 &self,
725 plan: &Plan,
726 s: &Shot,
727 key: &Key,
728 tag: &str,
729 temps: &mut Vec<PathBuf>,
730 ) -> Result<(PathBuf, f64)> {
731 self.input_ctx(plan, s, (0, 0), key, tag, temps)
732 }
733
734 #[allow(clippy::too_many_lines, clippy::many_single_char_names)]
737 pub(crate) fn input_ctx(
738 &self,
739 plan: &Plan,
740 s: &Shot,
741 ctx: (i64, i64),
742 key: &Key,
743 tag: &str,
744 temps: &mut Vec<PathBuf>,
745 ) -> Result<(PathBuf, f64)> {
746 let blend = s.filters.blend.filter(|_| s.speed.abs() > 1e-9);
748 if (s.speed - 1.0).abs() < 1e-9 && s.filters.ramp.is_none() && blend.is_none() {
749 return Ok((s.media.clone(), s.start));
750 }
751 let r = rational(plan.rate);
752 let fr = format!("{}/{}", r.num, r.den);
753 let prep = self
754 .dir("tmp")?
755 .join(format!("{key}.{tag}.{}.retime.mkv", s.frames));
756 let (before, after) = if s.speed.abs() < 1e-9 { (0, 0) } else { ctx };
757 let total = before + s.frames + after;
758 let n = total.to_string();
759 let mut v = Vec::new();
760 if blend.is_some() {
761 v.extend(args(&[
762 "-init_hw_device",
763 "cuda=cu",
764 "-init_hw_device",
765 "vulkan=vk@cu",
766 "-filter_hw_device",
767 "vk",
768 "-hwaccel_device",
769 "cu",
770 ]));
771 }
772 let want = blend.map_or(0, |b| i64::from(b) - 1);
774 let mut hist = 0;
775 let filter = if let Some(ramp) = &s.filters.ramp {
776 let from = s.clip_at.max(0) - before;
777 hist = want.min(from);
778 let cut = ramp_cut(ramp, s.clip_start, from - hist, hist + total, plan.rate);
779 v.extend(args(&[
780 "-hwaccel",
781 "cuda",
782 "-hwaccel_output_format",
783 "cuda",
784 "-copyts",
785 "-start_at_zero",
786 "-ss",
787 &(s.clip_start + cut.seek).to_string(),
788 "-t",
789 &cut.duration.to_string(),
790 "-i",
791 ]));
792 v.push(s.media.clone().into());
793 format!("{},scale_cuda=format=p010le", cut.filter)
794 } else if s.speed.abs() < 1e-9 {
795 v.extend(args(&["-ss", &s.start.to_string(), "-i"]));
796 v.push(s.media.clone().into());
797 format!("trim=end_frame=1,loop=loop=-1:size=1:start=0,setpts=N/({fr})/TB,format=p010le")
798 } else {
799 #[allow(clippy::cast_possible_truncation)]
800 let room =
801 (s.shifted(-before, plan.rate).start * plan.rate / s.speed + 1e-6).floor() as i64;
802 hist = want.min(room.max(0));
803 let src = secs(hist + before + s.frames + after + 2, plan.rate) * s.speed;
804 v.extend(args(&[
807 "-hwaccel",
808 "cuda",
809 "-hwaccel_output_format",
810 "cuda",
811 "-ss",
812 &s.shifted(-before - hist, plan.rate).start.to_string(),
813 "-t",
814 &src.to_string(),
815 "-i",
816 ]));
817 v.push(s.media.clone().into());
818 format!(
819 "setpts=(PTS-STARTPTS)/{},fps={fr},scale_cuda=format=p010le",
820 s.speed
821 )
822 };
823 let filter = match blend {
824 Some(b) => format!("{filter},{}", blend_filter(b, hist, &fr)),
825 None => filter,
826 };
827 v.extend(args(&[
828 "-map",
829 "0:v:0",
830 "-vf",
831 &filter,
832 "-frames:v",
833 &n,
834 "-c:v",
835 "hevc_nvenc",
836 "-tune",
837 "lossless",
838 ]));
839 v.push(prep.clone().into());
840 self.ffmpeg("retime", &v)?;
841 temps.push(prep.clone());
842 Ok((prep, secs(before, plan.rate)))
843 }
844
845 pub(crate) fn shot_grade(
849 &self,
850 s: &Shot,
851 plan: &Plan,
852 size: Size,
853 mix: Option<&Mix>,
854 pic: Pic,
855 ) -> Result<Grade> {
856 let base = match self.grader {
857 Some(g) => g(s).map_err(|msg| ExecError::Grade {
858 shot: s.name.clone(),
859 msg,
860 })?,
861 None => Self::lut_grade(s)?,
862 };
863 let mut g = Grade {
864 size: Some(size),
865 denoise: s.filters.denoise,
866 ..base
867 };
868 let rate = plan.rate;
869 let out = if size == plan.profile.size {
870 plan.profile.aspect.clone()
871 } else {
872 ffcine_reframe::Profile {
873 width: size.width,
874 height: size.height,
875 safe_area: None,
876 ..plan.profile.aspect.clone()
877 }
878 };
879 self.geometry(plan, s, &mut g, &out, pic)?;
880 if let Some(h) = finish_hook(&s.finish, s.clip_at, s.frames, s.clip_frames, rate, mix) {
881 g.hooks.push(h);
882 }
883 Ok(g)
884 }
885
886 pub(crate) fn dims(&self, path: &Path) -> Result<(u32, u32)> {
888 let info = self.backend.probe(path)?;
889 info.video()
890 .map(|(_, v)| (v.width, v.height))
891 .filter(|(w, h)| *w > 0 && *h > 0)
892 .ok_or_else(|| ExecError::Grade {
893 shot: path.display().to_string(),
894 msg: "no video stream".into(),
895 })
896 }
897
898 fn lut_grade(s: &Shot) -> Result<Grade> {
900 Ok(Grade {
901 lut: s
902 .lut
903 .as_ref()
904 .map(|p| {
905 Lut::from_cube_file(p).map(|lut| LutStage {
906 lut,
907 kind: LutType::Normalized,
908 })
909 })
910 .transpose()?,
911 #[allow(clippy::cast_possible_truncation)]
912 sharpen: s.grade.sharpen.unwrap_or(0.0) as f32,
913 ..Grade::default()
914 })
915 }
916
917 #[allow(
928 clippy::many_single_char_names,
929 clippy::cast_possible_truncation,
930 clippy::cast_precision_loss,
931 clippy::cast_sign_loss
932 )]
933 fn geometry(
934 &self,
935 plan: &Plan,
936 s: &Shot,
937 grade: &mut Grade,
938 out: &ffcine_reframe::Profile,
939 pic: Pic,
940 ) -> Result<()> {
941 let stab = s.filters.stabilize.filter(|_| s.speed.abs() > 1e-9);
942 let no_geo = ffcine_reframe::Geo::default();
943 let geo = if pic == Pic::Main {
944 &s.filters.geo
945 } else {
946 &no_geo
947 };
948 let (w, h) = self.dims(&s.media)?;
949 if s.filters.reframe.is_none()
950 && stab.is_none()
951 && geo.is_empty()
952 && (f64::from(w) / f64::from(h) - out.aspect()).abs() < 0.01
953 {
954 return Ok(());
955 }
956 let mut reframe = s.filters.reframe.clone().unwrap_or_default();
957 if pic == Pic::Panel {
958 reframe = reframe.focused();
959 }
960 let shot = ffcine_reframe::Shot {
961 width: w,
962 height: h,
963 frames: s.clip_frames.max(s.clip_at + s.frames).max(1) as u64,
964 rate: plan.rate,
965 start: s.clip_start,
966 };
967 let at = |i: i64| (s.clip_at + i).max(0) as u64;
968 let mapped: Vec<_> = if geo.is_empty() {
969 let windows: Vec<_> = (0..s.frames)
970 .map(|i| reframe.frame(at(i), &shot, out))
971 .collect();
972 ffcine_reframe::pass::apply(grade, &windows, &[], (w, h), out);
973 Vec::new()
974 } else {
975 (0..s.frames)
976 .map(|i| geo.frame(&reframe, at(i), &shot, out).1)
977 .collect()
978 };
979 let Some(params) = stab else {
980 if !mapped.is_empty() {
981 ffcine_reframe::pass::apply_mapped(grade, &mapped, &[], (w, h), out);
982 }
983 return Ok(());
984 };
985 let detect = self.detect.ok_or_else(|| ExecError::Stabilize {
986 shot: s.name.clone(),
987 msg: "needs the GPU motion detect (NVOF)".into(),
988 })?;
989 let src = |k: i64| -> f64 {
991 let t = secs(k, plan.rate);
992 match &s.filters.ramp {
993 Some(r) => r.source_time(t),
994 None => t * s.speed,
995 }
996 };
997 let (lo, hi) = s.span;
998 let first = src(lo.max(0));
999 let last = src(hi) + secs(1, plan.rate);
1000 let range = SourceRange {
1001 start: (s.clip_start + first).max(0.0),
1002 frames: ((last - first) * plan.rate).ceil().max(1.0) as usize,
1003 rate: rational(plan.rate),
1004 };
1005 let det = detect(&s.media, &range).map_err(|msg| ExecError::Stabilize {
1006 shot: s.name.clone(),
1007 msg,
1008 })?;
1009 let st = ffcine_stabilize::stabilize(&det, ¶ms);
1010 let n = st.transforms.len();
1011 let transforms = (0..s.frames)
1012 .filter_map(|i| {
1013 let k = ((src((s.clip_at + i).max(0)) - first) * plan.rate).round();
1014 st.transforms
1015 .get((k.max(0.0) as usize).min(n.saturating_sub(1)))
1016 .copied()
1017 })
1018 .collect();
1019 if mapped.is_empty() {
1020 grade.warp = Some(Warp { transforms });
1021 } else {
1022 ffcine_reframe::pass::apply_mapped(grade, &mapped, &transforms, (w, h), out);
1023 }
1024 Ok(())
1025 }
1026
1027 fn mix(&self, plan: &Plan, a: &AudioPlan, out: &Path) -> Result<(f64, f64)> {
1030 let tmp = self.dir("tmp")?;
1031 let mut options = a.options.clone();
1032 options.scratch = Some(tmp.clone());
1033 let mut mix = ffcine_audio::stream_audio(&a.timeline, &options, false)?;
1034 let wav = tmp.join(format!("{}.wav", a.key));
1035 mix.write_master(&wav)?;
1036 let mut v = vec![OsString::from("-i"), wav.clone().into()];
1037 v.extend(args(&[
1038 "-c:a",
1039 "aac",
1040 "-b:a",
1041 &plan.profile.audio_bitrate.to_string(),
1042 ]));
1043 v.push(out.into());
1044 let r = self.ffmpeg("audio encode", &v);
1045 let _ = std::fs::remove_file(wav);
1046 r.map(|()| (mix.lufs, mix.true_peak))
1047 }
1048
1049 fn mux(
1052 &self,
1053 plan: &Plan,
1054 files: &[PathBuf],
1055 audio: Option<&Path>,
1056 output: &Path,
1057 ) -> Result<()> {
1058 let ext = output.extension().and_then(|e| e.to_str()).unwrap_or("mp4");
1059 let partial = output.with_extension(format!("partial.{ext}"));
1060 let list = output.with_extension("partial.txt");
1061 let body = files.iter().fold(String::new(), |mut s, f| {
1062 let _ = writeln!(
1063 s,
1064 "file '{}'",
1065 f.display().to_string().replace('\'', r"'\''")
1066 );
1067 s
1068 });
1069 std::fs::write(&list, body).map_err(io("writing concat list"))?;
1070 let mut v = args(&["-f", "concat", "-safe", "0", "-i"]).collect::<Vec<_>>();
1071 v.push(list.clone().into());
1072 if let Some(a) = audio {
1073 v.push("-i".into());
1074 v.push(a.into());
1075 }
1076 v.extend(args(&["-map", "0:v", "-c:v", "copy"]));
1077 if matches!(
1078 plan.profile.encoder.codec,
1079 Codec::HevcNvenc | Codec::HevcVulkan | Codec::HevcVaapi | Codec::Libx265
1080 ) {
1081 v.extend(args(&["-tag:v", "hvc1"]));
1082 }
1083 if audio.is_some() {
1084 v.extend(args(&["-map", "1:a", "-c:a", "copy"]));
1085 }
1086 v.extend(args(&["-movflags", "+faststart"]));
1087 v.push(partial.clone().into());
1088 let r = self.ffmpeg("mux", &v);
1089 let _ = std::fs::remove_file(list);
1090 r?;
1091 std::fs::rename(&partial, output).map_err(io(format!("writing {}", output.display())))
1092 }
1093}
1094
1095pub(crate) enum Mix {
1097 Fade(Vec<f32>, [f64; 3]),
1099 Lift(Vec<f32>),
1101 Both((Vec<f32>, Vec<f32>), [f64; 3]),
1103}
1104
1105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1107pub(crate) enum Pic {
1108 Main,
1110 Panel,
1112 Overlay,
1114}
1115
1116pub(crate) fn identity(size: Size) -> Grade {
1118 Grade {
1119 size: Some(size),
1120 ..Grade::default()
1121 }
1122}
1123
1124pub(crate) fn alpha_hook(opacity: Vec<f32>, radius: f64) -> Hook {
1126 Hook {
1127 name: "alpha".into(),
1128 source: shaders::alpha().into(),
1129 params: vec![("radius".into(), radius)],
1130 tracks: vec![ParamTrack {
1131 name: "opacity".into(),
1132 values: opacity,
1133 }],
1134 }
1135}
1136
1137#[allow(clippy::many_single_char_names)]
1140pub(crate) fn finish_hook(
1141 f: &Finish,
1142 at: i64,
1143 n: i64,
1144 len: i64,
1145 rate: f64,
1146 mix: Option<&Mix>,
1147) -> Option<Hook> {
1148 if f.is_identity() && mix.is_none() {
1149 return None;
1150 }
1151 let (mut fade, mut lift) = f.tracks(at, n, len, rate);
1152 let mut colour = f.fade_colour;
1153 let mut fold = |k: &[f32], l: &[f32], c: Option<[f64; 3]>| {
1154 for (i, v) in k.iter().enumerate() {
1155 if let Some(x) = fade.get_mut(i) {
1156 *x = x.min(*v);
1157 }
1158 }
1159 for (i, v) in l.iter().enumerate() {
1160 if let Some(x) = lift.get_mut(i) {
1161 *x = x.max(*v);
1162 }
1163 }
1164 if let Some(c) = c {
1165 colour = c;
1166 }
1167 };
1168 match mix {
1169 Some(Mix::Fade(k, c)) => fold(k, &[], Some(*c)),
1170 Some(Mix::Both((k, l), c)) if k.iter().any(|v| *v < 1.0) => fold(k, l, Some(*c)),
1171 Some(Mix::Lift(l) | Mix::Both((_, l), _)) => fold(&[], l, None),
1172 None => {}
1173 }
1174 Some(Hook {
1175 name: "finish".into(),
1176 source: shaders::finish().into(),
1177 params: vec![
1178 ("darken".into(), f.darken),
1179 ("vignette".into(), f.vignette_angle()),
1180 ("fade_r".into(), colour[0]),
1181 ("fade_g".into(), colour[1]),
1182 ("fade_b".into(), colour[2]),
1183 ],
1184 tracks: vec![
1185 ParamTrack {
1186 name: "fade".into(),
1187 values: fade,
1188 },
1189 ParamTrack {
1190 name: "lift".into(),
1191 values: lift,
1192 },
1193 ],
1194 })
1195}
1196
1197fn args<'a>(a: &'a [&'a str]) -> impl Iterator<Item = OsString> + 'a {
1198 a.iter().map(OsString::from)
1199}
1200
1201pub fn manifest_path(output: &Path) -> PathBuf {
1203 let mut s = output.as_os_str().to_owned();
1204 s.push(".ffcine.json");
1205 PathBuf::from(s)
1206}
1207
1208fn read_manifest_key(path: &Path) -> Option<Key> {
1209 let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
1210 Some(Key(v.get("master")?.as_str()?.to_owned()))
1211}
1212
1213const INTRA_PREP: &str = "ffv1-l3-g1";
1215
1216fn intra_prep_args(input: &Path, at: f64, frames: i64, out: &Path) -> Vec<OsString> {
1221 let mut v: Vec<OsString> = args(&["-ss", &at.to_string(), "-noautorotate", "-i"]).collect();
1222 v.push(input.into());
1223 v.extend(args(&[
1224 "-map",
1225 "0:v:0",
1226 "-frames:v",
1227 &frames.to_string(),
1228 "-fps_mode",
1229 "passthrough",
1230 "-c:v",
1232 "ffv1",
1233 "-level",
1234 "3",
1235 "-g",
1236 "1",
1237 "-slices",
1238 "16",
1239 "-an",
1240 ]));
1241 v.push(out.into());
1242 v
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248 use crate::plan::Ramp;
1249
1250 fn ramp() -> Ramp {
1251 Ramp {
1252 points: vec![(0.0, 1.0), (0.5, 3.0), (1.0, 1.0)],
1253 source_duration: 8.0,
1254 }
1255 }
1256
1257 #[test]
1258 fn ramp_pieces_seek_to_their_own_source() {
1259 let rate = 24000.0 / 1001.0;
1260 let r = ramp();
1261 let first = ramp_cut(&r, 2.5, 0, 30, rate);
1262 assert!(first.seek.abs() < 1e-12);
1263 assert!((first.duration - (8.0 + 2.0 / rate)).abs() < 1e-9);
1264 assert!(first.filter.contains("(PTS*TB-2.5)"));
1265 assert!(
1266 first
1267 .filter
1268 .ends_with("fps=24000/1001,trim=start_pts=0:end_pts=30,setpts=PTS-STARTPTS")
1269 );
1270 let late = ramp_cut(&r, 2.5, 70, 30, rate);
1271 let t = r.source_time(70.0 / rate);
1272 assert!(late.seek > 1.0 && late.seek < t && t - late.seek <= 0.5 + 1e-9);
1273 assert!((late.duration - (8.0 - late.seek + 2.0 / rate)).abs() < 1e-9);
1274 assert!(late.filter.contains("trim=start_pts=70:end_pts=100,"));
1275 }
1276
1277 fn ffmpeg(args: &[&str]) -> Vec<u8> {
1278 let out = Command::new("ffmpeg")
1279 .args(["-v", "error", "-y"])
1280 .args(args)
1281 .output()
1282 .expect("ffmpeg on PATH");
1283 assert!(
1284 out.status.success(),
1285 "{}",
1286 String::from_utf8_lossy(&out.stderr)
1287 );
1288 out.stdout
1289 }
1290
1291 fn ramp_piece(src: &Path, from: i64, n: i64) -> Vec<String> {
1293 let rate = 24000.0 / 1001.0;
1294 let clip_start = 1.0;
1295 let c = ramp_cut(&ramp(), clip_start, from, n, rate);
1296 let out = ffmpeg(&[
1297 "-copyts",
1298 "-start_at_zero",
1299 "-ss",
1300 &(clip_start + c.seek).to_string(),
1301 "-t",
1302 &c.duration.to_string(),
1303 "-i",
1304 src.to_str().expect("utf-8"),
1305 "-vf",
1306 &c.filter,
1307 "-frames:v",
1308 &n.to_string(),
1309 "-f",
1310 "framemd5",
1311 "-",
1312 ]);
1313 String::from_utf8_lossy(&out)
1314 .lines()
1315 .filter(|l| !l.starts_with('#'))
1316 .map(|l| l.rsplit(',').next().unwrap_or("").trim().to_owned())
1317 .collect()
1318 }
1319
1320 #[test]
1321 #[ignore = "needs ffmpeg"]
1322 fn ramp_pieces_match_the_whole_clip() {
1323 let d = std::env::temp_dir().join(format!("ffcine-ramp-{}", std::process::id()));
1324 std::fs::create_dir_all(&d).expect("temp dir");
1325 let src = d.join("src.mp4");
1326 ffmpeg(&[
1327 "-f",
1328 "lavfi",
1329 "-i",
1330 "testsrc2=size=160x90:rate=30000/1001",
1331 "-t",
1332 "10",
1333 "-g",
1334 "15",
1335 "-c:v",
1336 "libx264",
1337 "-qp",
1338 "0",
1339 src.to_str().expect("utf-8"),
1340 ]);
1341 let whole = ramp_piece(&src, 0, 100);
1342 assert_eq!(whole.len(), 100);
1343 let distinct: std::collections::HashSet<_> = whole.iter().collect();
1344 assert!(distinct.len() > 90, "the source's frames all differ");
1345 for (from, n) in [(0, 17), (17, 40), (57, 3), (60, 40)] {
1346 let piece = ramp_piece(&src, from, n);
1347 let (a, b) = (
1348 usize::try_from(from).unwrap(),
1349 usize::try_from(from + n).unwrap(),
1350 );
1351 assert_eq!(piece, whole[a..b], "piece {from}+{n}");
1352 }
1353 let _ = std::fs::remove_dir_all(&d);
1354 }
1355
1356 fn probe(p: &Path) -> String {
1358 let out = Command::new("ffprobe")
1359 .args(["-v", "error", "-select_streams", "v:0", "-show_entries"])
1360 .args([
1361 "stream=width,height,pix_fmt,color_range,color_space,color_transfer,\
1362 color_primaries:stream_side_data=rotation,max_content,max_luminance",
1363 "-of",
1364 "compact",
1365 ])
1366 .arg(p)
1367 .output()
1368 .expect("ffprobe on PATH");
1369 String::from_utf8_lossy(&out.stdout).trim().to_owned()
1370 }
1371
1372 #[test]
1373 #[ignore = "needs ffmpeg"]
1374 fn intra_prep_keeps_pixels_rotation_and_colour() {
1375 let d = std::env::temp_dir().join(format!("ffcine-intra-{}", std::process::id()));
1376 std::fs::create_dir_all(&d).expect("temp dir");
1377 let (hdr, src, prep) = (d.join("hdr.mkv"), d.join("rot.mkv"), d.join("prep.mkv"));
1378 let s = |p: &Path| p.to_str().expect("utf-8").to_owned();
1379 ffmpeg(&[
1381 "-f",
1382 "lavfi",
1383 "-i",
1384 "testsrc2=s=320x180:r=30:d=1",
1385 "-vf",
1386 "format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:\
1387 colorspace=bt2020nc:range=tv",
1388 "-c:v",
1389 "libx265",
1390 "-x265-params",
1391 "log-level=none:keyint=15:hdr10=1:max-cll=1000,400:\
1392 master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)",
1393 &s(&hdr),
1394 ]);
1395 ffmpeg(&[
1396 "-display_rotation",
1397 "90",
1398 "-i",
1399 &s(&hdr),
1400 "-c",
1401 "copy",
1402 &s(&src),
1403 ]);
1404 let at = 10.0 / 30.0;
1405 let mut argv = vec![OsString::from("-v"), "error".into(), "-y".into()];
1406 argv.extend(intra_prep_args(&src, at, 12, &prep));
1407 let st = Command::new("ffmpeg").args(&argv).status().expect("ffmpeg");
1408 assert!(st.success());
1409 let (was, got) = (probe(&src), probe(&prep));
1410 for want in [
1411 "width=320",
1412 "height=180",
1413 "pix_fmt=yuv420p10le",
1414 "color_range=tv",
1415 "color_space=bt2020nc",
1416 "color_transfer=smpte2084",
1417 "color_primaries=bt2020",
1418 "rotation=90",
1419 ] {
1420 assert!(was.contains(want), "source {want}: {was}");
1421 assert!(got.contains(want), "prep {want}: {got}");
1422 }
1423 assert!(
1425 got.contains("max_content=1000"),
1426 "prep HDR10 metadata: {got}"
1427 );
1428 let md5 = |p: &Path, ss: Option<f64>, n: &str| -> Vec<String> {
1430 let mut argv: Vec<String> = Vec::new();
1431 if let Some(secs) = ss {
1432 argv.extend(["-ss".into(), secs.to_string()]);
1433 }
1434 argv.extend(["-noautorotate", "-i"].map(String::from));
1435 argv.push(s(p));
1436 argv.extend(["-frames:v", n, "-f", "framemd5", "-"].map(String::from));
1437 let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
1438 String::from_utf8_lossy(&ffmpeg(&refs))
1439 .lines()
1440 .filter(|l| !l.starts_with('#'))
1441 .map(|l| l.rsplit(',').next().unwrap_or("").trim().to_owned())
1442 .collect()
1443 };
1444 let want = md5(&src, Some(at), "12");
1445 assert_eq!(want.len(), 12);
1446 assert_eq!(md5(&prep, None, "12"), want);
1447 let _ = std::fs::remove_dir_all(&d);
1448 }
1449
1450 #[test]
1451 fn blend_chain() {
1452 assert_eq!(
1453 blend_filter(3, 2, "24000/1001"),
1454 "settb=1/(24000/1001),setpts=N,hwupload,split=3[b0][b1][b2];\
1455 [b1]trim=start_frame=1,setpts=PTS-STARTPTS[t1];\
1456 [b2]trim=start_frame=2,setpts=PTS-STARTPTS[t2];\
1457 [t1][b0]blend_vulkan=all_mode=normal:all_opacity=0.5000000[m1];\
1458 [t2][m1]blend_vulkan=all_mode=normal:all_opacity=0.3333333,\
1459 hwupload=derive_device=cuda"
1460 );
1461 let f = blend_filter(2, 0, "24");
1463 assert!(f.starts_with("tpad=start=1:start_mode=clone,settb=1/(24),"));
1464 assert!(f.contains("[t1][b0]blend_vulkan=all_mode=normal:all_opacity=0.5000000,hwupload"));
1465 }
1466
1467 #[test]
1468 fn rates() {
1469 assert_eq!(
1470 rational(24000.0 / 1001.0),
1471 Rational {
1472 num: 24000,
1473 den: 1001
1474 }
1475 );
1476 assert_eq!(
1477 rational(23.976_023_976_023_978),
1478 Rational {
1479 num: 24000,
1480 den: 1001
1481 }
1482 );
1483 assert_eq!(rational(25.0), Rational { num: 25, den: 1 });
1484 assert_eq!(
1485 rational(60000.0 / 1001.0),
1486 Rational {
1487 num: 60000,
1488 den: 1001
1489 }
1490 );
1491 }
1492}