Files
lightcraft/crates/engine/src/slideshow_export.rs
Jason Baldwin b391307009 LRC-SS-EXPORT: slideshow export to PDF, JPEG sequence and AV1 video
View > Export Slideshow... (and Export PDF / JPEG / Video in Settings >
Slideshow) writes the photos in view as their composed slides, between
the intro and ending screens that are on:

- PDF: a page per slide (1024/1920/3840 px long side, JPEG quality),
  /Dur and /Trans fades per page, optional /PageMode /FullScreen. The
  contact sheet's writer moves to engine/src/pdf.rs and streams to disk.
- JPEG: <name>-001.jpg... in a folder.
- Video: AV1 (rav1e, speed 10, low latency) in MP4 or WebM written by
  our own muxers (codecs/src/video.rs); fades rendered as blended frames,
  held slides one key-frame sample with a long duration; 720p/1080p,
  24/30 fps; no audio; capped at 2 hours.

slideshow.export runs in the background with a row in the activity
stack (activity.cancel stops it); outputs are written as .part and
renamed when complete, so cancel or failure leaves nothing behind. A
photo that can't be rendered is skipped with a warning.
slideshow.exportStatus reports the result.
2026-10-11 10:35:09 +02:00

761 lines
31 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Slideshow export (Lightroom Classic's Export PDF / Export JPEG / Export Video): the photos in
//! view as their composed slides (layout, overlays: [`crate::slide`]), between the intro and
//! ending screens that are on ([`crate::slide_title`]), written as
//!
//! - **PDF**: one page per slide, the page the slide's size in points (1 px = 1 pt), its picture a
//! JPEG; each page carries the slide duration and a fade transition for viewers' presentation
//! mode, and "full screen" asks the viewer to open in it (`/PageMode /FullScreen`);
//! - **JPEG sequence**: `<name>-001.jpg`, `<name>-002.jpg`… in a folder;
//! - **video**: AV1 in MP4 or WebM ([`lightcraft_codecs::video`], our own muxers), the timeline
//! sampled at 24/25/30/60 fps: each slide held for its duration, fades rendered as blended
//! frames (crossfade, or through the fade colour). No audio track.
//!
//! The slides play once in view order (random order and repeat are playback choices, not
//! export ones). The slide aspect comes from Layout's aspect preview; "screen" exports 16:9.
//!
//! [`prepare`] reads the session (cheap: render jobs, no pixels) and [`Prepared::run`] does the
//! work on any thread, with progress and a cancel flag. Files are written as `<file>.part` and
//! renamed when everything is written, so a cancelled or failed export leaves nothing behind. A
//! slide whose photo can't be rendered (file gone, unreadable) is skipped with a warning.
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use lightcraft_catalog::PhotoId;
use lightcraft_codecs::video::{Av1Encoder, Container, VideoConfig, VideoWriter};
use lightcraft_pipeline::{OutputDepth, OutputSpace};
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::media::RenderJob;
use crate::originals::OriginalGuard;
use crate::slide_title::TitleCompose;
use crate::slideshow::{SLIDE_MAX, SLIDE_MIN, Title};
use crate::{Session, export::ExportFormat, export::ExportOptions};
/// The longest video exported (seconds): 2 hours.
pub const MAX_SECONDS: f64 = 2.0 * 3600.0;
/// The most slides an export takes.
pub const MAX_SLIDES: usize = 10_000;
/// Long side of PDF pages and JPEGs, in pixels.
pub const SIZE_MIN: usize = 64;
pub const SIZE_MAX: usize = lightcraft_slideshow::MAX_SIDE;
/// Video frame height, in pixels.
pub const VIDEO_MIN: usize = 64;
pub const VIDEO_MAX: usize = 2160;
/// Frame rates offered.
pub const FPS: [u32; 4] = [24, 25, 30, 60];
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Format {
Pdf,
Jpeg,
Video,
}
impl Format {
pub fn id(self) -> &'static str {
match self {
Format::Pdf => "pdf",
Format::Jpeg => "jpeg",
Format::Video => "video",
}
}
}
/// The export's settings (the dialog's state, and `slideshow.export`'s params without `path`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportSettings {
pub format: Format,
/// PDF / JPEG: the long side in pixels; video: the frame height (720 = 720p).
pub size: usize,
/// JPEG quality (PDF pages, JPEG files) or video quality, 1–100.
pub quality: u8,
/// Video frames per second.
pub fps: u32,
/// Video container: "mp4" or "webm".
pub container: String,
/// PDF: ask the viewer to open in full screen.
pub full_screen: bool,
/// Video: rav1e speed preset, 1 (slow, smaller) – 10 (fast).
pub speed: u8,
}
impl Default for ExportSettings {
fn default() -> Self {
ExportSettings { format: Format::Pdf, size: 1920, quality: 90, fps: 30, container: "mp4".into(), full_screen: true, speed: 10 }
}
}
impl ExportSettings {
/// The defaults for `format` (sizes: PDF/JPEG 1920 px, video 720p; video quality 75).
pub fn for_format(format: Format) -> Self {
match format {
Format::Video => ExportSettings { format, size: 720, quality: 75, ..Default::default() },
_ => ExportSettings { format, ..Default::default() },
}
}
/// The file extension the export's path takes (none: a JPEG sequence goes in a folder).
pub fn extension(&self) -> Option<&'static str> {
match self.format {
Format::Pdf => Some("pdf"),
Format::Jpeg => None,
Format::Video => Some(if self.container == "webm" { "webm" } else { "mp4" }),
}
}
}
/// `slideshow.export`'s params, checked: settings, path, file name prefix and photos.
#[derive(Clone, Debug, PartialEq)]
pub struct Request {
pub settings: ExportSettings,
pub path: String,
/// JPEG sequence: the files' name before `-001` (default: the folder's name).
pub name: Option<String>,
pub ids: Option<Vec<PhotoId>>,
}
const KEYS: &str = "format, path, size, quality, fps, container, fullScreen, speed, name, ids, wait";
fn uint(k: &str, v: &Value, lo: u64, hi: u64) -> Result<u64, String> {
match v.as_u64() {
Some(n) if (lo..=hi).contains(&n) => Ok(n),
_ => Err(format!("`{k}` must be a whole number from {lo} to {hi}, not {v}")),
}
}
impl Request {
pub fn parse(p: &Value) -> Result<Request, String> {
let o: &Map<String, Value> = p.as_object().ok_or("params must be an object")?;
if let Some(k) = o.keys().find(|k| !KEYS.split(", ").any(|x| x == k.as_str())) {
return Err(format!("unknown key `{k}` (known: {KEYS})"));
}
let format = match o.get("format").and_then(Value::as_str).map(str::trim) {
Some("pdf") => Format::Pdf,
Some("jpeg" | "jpg") => Format::Jpeg,
Some("video") => Format::Video,
_ => return Err(format!("`format` must be \"pdf\", \"jpeg\" or \"video\", not {}", o.get("format").unwrap_or(&Value::Null))),
};
let mut s = ExportSettings::for_format(format);
let path = o.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or("missing `path`")?.to_string();
if let Some(v) = o.get("size").filter(|v| !v.is_null()) {
// "720p" / "1080p" for video
let v = match v.as_str().map(|x| x.trim().trim_end_matches(['p', 'P'])) {
Some(x) => x.parse::<u64>().map(Value::from).map_err(|_| format!("`size` must be a number of pixels, not {v}"))?,
None => v.clone(),
};
s.size = match format {
Format::Video => uint("size", &v, VIDEO_MIN as u64, VIDEO_MAX as u64)? as usize,
_ => uint("size", &v, SIZE_MIN as u64, SIZE_MAX as u64)? as usize,
};
}
if let Some(v) = o.get("quality").filter(|v| !v.is_null()) {
s.quality = uint("quality", v, 1, 100)? as u8;
}
if let Some(v) = o.get("fps").filter(|v| !v.is_null()) {
let f =
v.as_u64().filter(|f| FPS.iter().any(|x| u64::from(*x) == *f)).ok_or_else(|| format!("`fps` must be 24, 25, 30 or 60, not {v}"))?;
s.fps = f as u32;
}
if let Some(v) = o.get("container").filter(|v| !v.is_null()) {
let c = v.as_str().and_then(Container::parse).ok_or_else(|| format!("`container` must be \"mp4\" or \"webm\", not {v}"))?;
s.container = c.id().into();
}
if let Some(v) = o.get("fullScreen").filter(|v| !v.is_null()) {
s.full_screen = v.as_bool().ok_or_else(|| format!("`fullScreen` must be true or false, not {v}"))?;
}
if let Some(v) = o.get("speed").filter(|v| !v.is_null()) {
s.speed = uint("speed", v, 1, 10)? as u8;
}
if let Some(v) = o.get("wait").filter(|v| !v.is_null() && !v.is_boolean()) {
return Err(format!("`wait` must be true or false, not {v}"));
}
let name = match o.get("name") {
None | Some(Value::Null) => None,
Some(Value::String(n)) => {
let n = n.trim();
if n.is_empty()
|| n.chars().count() > 120
|| n.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
{
return Err(format!("`name` must be 1–120 characters without path separators or reserved characters, not {n:?}"));
}
Some(n.to_string())
}
Some(v) => return Err(format!("`name` must be text, not {v}")),
};
if name.is_some() && format != Format::Jpeg {
return Err("`name` is for the JPEG sequence only".into());
}
let ids = match o.get("ids") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => {
let ids: Option<Vec<PhotoId>> = a.iter().map(|v| v.as_u64().map(PhotoId)).collect();
Some(ids.ok_or("`ids` must be an array of photo ids")?)
}
Some(v) => return Err(format!("`ids` must be an array of photo ids, not {v}")),
};
Ok(Request { settings: s, path, name, ids })
}
}
/// What a slide shows.
enum Source {
Photo { name: String, job: Box<RenderJob> },
Title { title: Title, compose: Arc<TitleCompose> },
}
struct Slide {
source: Source,
/// Seconds it stays up, its fade in included.
duration: f64,
/// Seconds the transition into it takes (none for the first).
fade: f64,
}
impl Slide {
fn label(&self) -> String {
match &self.source {
Source::Photo { name, .. } => name.clone(),
Source::Title { title, .. } => title.id().to_string(),
}
}
/// The slide's pixels, `w × h`.
fn render(self, w: usize, h: usize) -> Result<Rgba8, String> {
let img = match self.source {
Source::Photo { job, .. } => job.run().rendered?.image,
Source::Title { compose, .. } => compose.compose(),
};
Ok(fit(img, w, h))
}
}
/// `img` at exactly `w × h`: as it is when it already is (the composer's output), else centred
/// on black (a slide bigger than the composer draws, or a surprise from a renderer).
fn fit(img: Rgba8, w: usize, h: usize) -> Rgba8 {
if img.width == w && img.height == h && img.data.len() == w * h {
return img;
}
let mut out = Rgba8::filled(w, h, [0, 0, 0, 255]);
let (cw, ch) = (img.width.min(w), img.height.min(h));
let (dx, dy) = ((w - cw) / 2, (h - ch) / 2);
let (sx, sy) = ((img.width - cw) / 2, (img.height - ch) / 2);
for y in 0..ch {
let (Some(src), Some(dst)) =
(img.data.get((sy + y) * img.width + sx..(sy + y) * img.width + sx + cw), out.data.get_mut((dy + y) * w + dx..(dy + y) * w + dx + cw))
else {
continue;
};
dst.copy_from_slice(src);
}
out
}
/// An export ready to run: everything it needs from the session.
pub struct Prepared {
pub request: Request,
slides: Vec<Slide>,
pub width: usize,
pub height: usize,
fade_color: Option<[u8; 3]>,
guard: OriginalGuard,
/// Problems found while preparing (photos not in the library).
pub warnings: Vec<String>,
}
/// The size of a slide whose long side is `long` at `ratio` (width / height).
fn sized(ratio: f64, long: usize) -> (usize, usize) {
let short = |l: usize, r: f64| ((l as f64 / r).round() as usize).max(1);
if ratio >= 1.0 { (long, short(long, ratio)) } else { (short(long, 1.0 / ratio), long) }
}
/// Read the session: the slides, their render jobs and durations, the output size.
pub fn prepare(s: &mut Session, p: &Value) -> Result<Prepared, String> {
let request = Request::parse(p)?;
let settings = &request.settings;
let ratio = s.slide_layout.aspect.ratio(16.0 / 9.0);
let (width, height) = match settings.format {
Format::Video => {
let h = settings.size & !1;
((((h as f64 * ratio).round() as usize) & !1).max(16), h)
}
_ => sized(ratio, settings.size),
};
let ids = match &request.ids {
Some(ids) => ids.clone(),
None => s.visible_cloned(),
};
if ids.is_empty() {
return Err("no photos in view to export".into());
}
if ids.len() > MAX_SLIDES {
return Err(format!("a slideshow export takes at most {MAX_SLIDES} photos; this view has {}", ids.len()));
}
let play = s.slideshow.clone().sanitized();
let (intro, ending) = s.slide_titles.durations(play.slide_duration);
let clamp = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { play.slide_duration };
let layout = s.slide_layout.clone().sanitized();
let mut warnings = Vec::new();
let mut slides = Vec::with_capacity(ids.len() + 2);
let title = |s: &Session, t: Title, d: f64| Slide {
source: Source::Title { title: t, compose: s.title_compose(t, width, height) },
duration: clamp(d),
fade: 0.0,
};
if let Some(d) = intro {
slides.push(title(s, Title::Intro, d));
}
for id in ids {
let Some(photo) = s.catalog.photo(id).cloned() else {
warnings.push(format!("photo {} is not in the library; skipped", id.0));
continue;
};
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
let (pw, ph) = lightcraft_slideshow::photo_size(&layout, aspect, width, height);
let job = match s.export_job(id, pw.max(8), ph.max(8), OutputSpace::Srgb, OutputDepth::U8) {
Ok(job) => job,
Err(e) => {
warnings.push(format!("{}: {e}; skipped", photo.file_name));
continue;
}
};
let compose = s.slide_compose(&layout, width, height, Some(id));
slides.push(Slide {
source: Source::Photo { name: photo.file_name.clone(), job: Box::new(job.with_slide(compose)) },
duration: play.slide_duration,
fade: 0.0,
});
}
if !slides.iter().any(|x| matches!(x.source, Source::Photo { .. })) {
return Err("none of the photos can be exported".into());
}
if let Some(d) = ending {
slides.push(title(s, Title::Ending, d));
}
// the transition into each slide after the first: the fade, never longer than the slide
for x in slides.iter_mut().skip(1) {
x.fade = play.fade_duration.min(x.duration).max(0.0);
}
if settings.format == Format::Video {
let total: f64 = slides.iter().map(|x| x.duration).sum();
if total > MAX_SECONDS {
return Err(format!(
"the slideshow lasts {} min; video export is limited to {} min (shorten the slides or export fewer photos)",
(total / 60.0).ceil(),
MAX_SECONDS / 60.0
));
}
if !lightcraft_codecs::video::AV1_AVAILABLE {
return Err("video export (AV1) is not available in this build".into());
}
}
s.check_write_target(&request.path)?;
Ok(Prepared { request, slides, width, height, fade_color: play.fade_color, guard: s.original_guard(), warnings })
}
/// What an export wrote.
#[derive(Clone, Debug, PartialEq)]
pub struct Outcome {
pub json: Value,
}
/// Progress: (done, total, the slide being worked on). `total` is slides (PDF, JPEG) or frames
/// at the nominal rate (video).
pub type Progress<'a> = &'a dyn Fn(u64, u64, &str);
const CANCELLED: &str = "Slideshow export cancelled";
/// A file written as `<path>.part` and renamed over `path` when complete; dropped before that,
/// the part file is removed.
struct PartFile {
part: PathBuf,
target: PathBuf,
done: bool,
}
impl PartFile {
fn new(target: &Path) -> Result<(PartFile, std::fs::File), String> {
if let Some(dir) = target.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let mut name = target.file_name().ok_or_else(|| format!("{}: not a file name", target.display()))?.to_os_string();
name.push(".part");
let part = target.with_file_name(name);
let f = std::fs::File::create(&part).map_err(|e| format!("{}: {e}", part.display()))?;
Ok((PartFile { part, target: target.to_path_buf(), done: false }, f))
}
fn commit(mut self, guard: &OriginalGuard) -> Result<(), String> {
guard.check(&self.target)?;
std::fs::rename(&self.part, &self.target).map_err(|e| format!("{}: {e}", self.target.display()))?;
self.done = true;
Ok(())
}
}
impl Drop for PartFile {
fn drop(&mut self) {
if !self.done {
let _ = std::fs::remove_file(&self.part);
}
}
}
/// Blend `a` → `b` at `k` (0 = a, 1 = b), or through `colour` (out to it, then in from it).
fn blend(a: &Rgba8, b: &Rgba8, k: f32, colour: Option<[u8; 3]>) -> Vec<[u8; 4]> {
let mix = |x: u8, y: u8, t: f32| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8;
match colour {
Some(c) => {
let (img, veil) = if k < 0.5 { (a, k * 2.0) } else { (b, 2.0 - k * 2.0) };
img.data.iter().map(|p| [mix(p[0], c[0], veil), mix(p[1], c[1], veil), mix(p[2], c[2], veil), 255]).collect()
}
None => a.data.iter().zip(&b.data).map(|(p, q)| [mix(p[0], q[0], k), mix(p[1], q[1], k), mix(p[2], q[2], k), 255]).collect(),
}
}
impl Prepared {
/// Slides to export (titles included).
pub fn len(&self) -> usize {
self.slides.len()
}
pub fn is_empty(&self) -> bool {
self.slides.is_empty()
}
/// Seconds the slideshow lasts.
pub fn seconds(&self) -> f64 {
self.slides.iter().map(|x| x.duration).sum()
}
/// Units of progress: slides, or frames at the nominal rate for video.
pub fn total(&self) -> u64 {
match self.request.settings.format {
Format::Video => (self.seconds() * f64::from(self.request.settings.fps)).round() as u64,
_ => self.slides.len() as u64,
}
}
/// Write the export. `cancel` is checked between slides and frames.
pub fn run(self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
match self.request.settings.format {
Format::Pdf => self.run_pdf(progress, cancel),
Format::Jpeg => self.run_jpeg(progress, cancel),
Format::Video => self.run_video(progress, cancel),
}
}
fn jpeg(&self, img: &Rgba8) -> Result<Vec<u8>, String> {
crate::export::encode_image(img, &ExportOptions { format: ExportFormat::Jpeg, quality: self.request.settings.quality, ..Default::default() })
}
fn summary(&self, format: Format, slides: usize, skipped: usize, warnings: Vec<String>) -> Value {
json!({
"format": format.id(),
"path": self.request.path,
"width": self.width,
"height": self.height,
"slides": slides,
"skipped": skipped,
"warnings": warnings,
})
}
fn run_pdf(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let target = PathBuf::from(&self.request.path);
let (part, file) = PartFile::new(&target)?;
let mut pdf = crate::pdf::Pdf::new(BufWriter::new(file))?;
let (w, h) = (self.width, self.height);
let total = self.slides.len() as u64;
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let slides = std::mem::take(&mut self.slides);
for (i, slide) in slides.into_iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(i as u64, total, &label);
let (duration, fade) = (slide.duration, slide.fade);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let jpg = self.jpeg(&img)?;
// presentation mode: how long the page stays, and the fade into it
let mut extra = format!(" /Dur {duration:.2}");
if fade > 0.0 {
extra += &format!(" /Trans << /S /Fade /D {fade:.2} >>");
}
pdf.image_page(&jpg, w, h, (w as f64, h as f64), &extra)?;
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let pages = pdf.pages();
if pages == 0 {
return Err("no slide could be rendered".into());
}
let bytes = pdf.len();
let catalog = if self.request.settings.full_screen { " /PageMode /FullScreen" } else { "" };
pdf.finish(catalog)?.into_inner().map_err(|e| format!("PDF: {}", e.error()))?.sync_all().map_err(|e| format!("PDF: {e}"))?;
part.commit(&self.guard)?;
progress(total, total, "");
let mut out = self.summary(Format::Pdf, pages, skipped, warnings);
out["pages"] = json!(pages);
out["bytes"] = json!(bytes);
out["fullScreen"] = json!(self.request.settings.full_screen);
Ok(Outcome { json: out })
}
fn run_jpeg(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let dir = PathBuf::from(&self.request.path);
let prefix = self
.request
.name
.clone()
.or_else(|| dir.file_name().map(|n| n.to_string_lossy().trim().to_string()).filter(|n| !n.is_empty()))
.unwrap_or_else(|| "Slideshow".into());
let digits = self.slides.len().to_string().len().max(3);
let (w, h) = (self.width, self.height);
let total = self.slides.len() as u64;
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let mut parts = Vec::new();
let mut bytes = 0usize;
let slides = std::mem::take(&mut self.slides);
for (i, slide) in slides.into_iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(i as u64, total, &label);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let jpg = self.jpeg(&img)?;
let target = dir.join(format!("{prefix}-{:0digits$}.jpg", parts.len() + 1));
self.guard.check(&target)?;
let (part, mut f) = PartFile::new(&target)?;
f.write_all(&jpg).map_err(|e| format!("{}: {e}", part.part.display()))?;
f.sync_all().map_err(|e| format!("{}: {e}", part.part.display()))?;
bytes = bytes.saturating_add(jpg.len());
parts.push(part);
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
if parts.is_empty() {
return Err("no slide could be rendered".into());
}
let files: Vec<String> = parts.iter().map(|p| p.target.to_string_lossy().to_string()).collect();
// all written: now they take their names (a failure here removes the rest)
for part in parts {
part.commit(&self.guard)?;
}
progress(total, total, "");
let mut out = self.summary(Format::Jpeg, files.len(), skipped, warnings);
out["files"] = json!(files);
out["bytes"] = json!(bytes);
Ok(Outcome { json: out })
}
fn run_video(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let st = self.request.settings.clone();
let container = Container::parse(&st.container).unwrap_or(Container::Mp4);
let (w, h) = (self.width, self.height);
let fps = st.fps;
let mut enc = Av1Encoder::new(&VideoConfig { width: w as u32, height: h as u32, fps, quality: st.quality, speed: st.speed })
.map_err(|e| e.to_string())?;
let target = PathBuf::from(&self.request.path);
let (part, file) = PartFile::new(&target)?;
let mut mux = VideoWriter::new(BufWriter::new(file), container, w as u32, h as u32, fps).map_err(|e| e.to_string())?;
let total = self.total();
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let mut slides_done = 0;
let mut samples = 0u64;
let mut keys = 0u64;
// frames emitted so far, and the slideshow time (seconds) of the slides kept
let mut frames = 0u64;
let mut clock = 0.0f64;
let mut prev: Option<Rgba8> = None;
let mut emit = |mux: &mut VideoWriter<BufWriter<std::fs::File>>,
enc: &mut Av1Encoder,
pixels: &[[u8; 4]],
periods: u64,
key: bool|
-> Result<(), String> {
let out = enc.push(pixels, periods, key).map_err(|e| e.to_string())?;
for s in out {
if let Some(c) = enc.config_record() {
mux.set_config(c);
}
keys += u64::from(s.key);
samples += 1;
mux.add(&s).map_err(|e| e.to_string())?;
}
Ok(())
};
let fade_color = self.fade_color;
let slides = std::mem::take(&mut self.slides);
for slide in slides {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(frames, total, &label);
let (duration, fade) = (slide.duration, slide.fade);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let start = (clock * f64::from(fps)).round() as u64;
clock += duration;
let end = ((clock * f64::from(fps)).round() as u64).max(start + 1);
let n = end - start;
let fade_frames = match &prev {
Some(_) => ((fade * f64::from(fps)).round() as u64).min(n),
None => 0,
};
if let Some(a) = &prev {
for j in 0..fade_frames {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let k = (j + 1) as f32 / (fade_frames + 1) as f32;
emit(&mut mux, &mut enc, &blend(a, &img, k, fade_color), 1, false)?;
frames += 1;
progress(frames, total, &label);
}
}
if n > fade_frames {
// a held slide is a key frame: clean of the slide before it, and a place to seek to
emit(&mut mux, &mut enc, &img.data, n - fade_frames, true)?;
frames += n - fade_frames;
}
slides_done += 1;
prev = Some(img);
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
if slides_done == 0 {
return Err("no slide could be rendered".into());
}
progress(frames, total, "AV1");
for s in enc.finish().map_err(|e| e.to_string())? {
if let Some(c) = enc.config_record() {
mux.set_config(c);
}
keys += u64::from(s.key);
samples += 1;
mux.add(&s).map_err(|e| e.to_string())?;
}
let periods = mux.periods();
let file = mux.finish().map_err(|e| e.to_string())?;
let file = file.into_inner().map_err(|e| format!("video: {}", e.error()))?;
file.sync_all().map_err(|e| format!("video: {e}"))?;
let bytes = file.metadata().map(|m| m.len()).unwrap_or(0);
drop(file);
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
part.commit(&self.guard)?;
progress(total, total, "");
let mut out = self.summary(Format::Video, slides_done, skipped, warnings);
out["container"] = json!(container.id());
out["codec"] = json!("av1");
out["fps"] = json!(fps);
out["frames"] = json!(periods);
out["samples"] = json!(samples);
out["keyFrames"] = json!(keys);
out["seconds"] = json!(periods as f64 / f64::from(fps));
out["bytes"] = json!(bytes);
out["audio"] = json!(false);
Ok(Outcome { json: out })
}
}
/// A slideshow export running in the background (`slideshow.export`; `slideshow.exportStatus`).
#[derive(Debug, Default)]
pub struct ExportRun {
/// Its row in the activity stack (`activity.cancel {id}` stops it).
pub task_id: u64,
pub format: String,
pub path: String,
pub total: std::sync::atomic::AtomicU64,
pub done: std::sync::atomic::AtomicU64,
pub cancel: Arc<AtomicBool>,
pub finished: AtomicBool,
/// The front end has told the user how it ended.
pub reported: AtomicBool,
pub result: std::sync::Mutex<Option<Result<Value, String>>>,
}
impl ExportRun {
pub fn running(&self) -> bool {
!self.finished.load(Ordering::Relaxed)
}
pub fn result(&self) -> Option<Result<Value, String>> {
self.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
pub fn json(&self) -> Value {
let mut out = json!({
"taskId": self.task_id,
"format": self.format,
"path": self.path,
"running": self.running(),
"done": self.done.load(Ordering::Relaxed),
"total": self.total.load(Ordering::Relaxed),
"cancelled": self.cancel.load(Ordering::Relaxed),
});
match self.result() {
Some(Ok(v)) => out["result"] = v,
Some(Err(e)) => out["error"] = json!(e),
None => {}
}
out
}
}
/// Run `prepared` to the end, recording progress and the outcome in `run` (and the row).
pub fn run_to_end(prepared: Prepared, run: &ExportRun, task: &crate::activity::TaskGuard) {
let handle = task.handle();
let progress = |done: u64, total: u64, detail: &str| {
run.done.store(done, Ordering::Relaxed);
run.total.store(total, Ordering::Relaxed);
handle.progress(done, total);
handle.detail(detail);
};
// a panic in a renderer or the encoder is an error, never a crash (the part file goes with it)
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepared.run(&progress, &run.cancel)))
.unwrap_or_else(|_| Err("slideshow export failed unexpectedly".into()))
.map(|o| o.json);
*run.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r);
run.finished.store(true, Ordering::Relaxed);
}