diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 657987189..8076e0c6c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -11,6 +11,7 @@ //! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode). //! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding //! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export. +//! - [`video`] encodes AV1 video (native, feature `avif`) and writes it as MP4 or WebM. //! //! No decoder panics on malformed input (property-tested). #![forbid(unsafe_code)] @@ -33,6 +34,7 @@ mod psd; mod sniff; pub mod space; mod tiff_codec; +pub mod video; mod webp; pub use encode::{ diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs new file mode 100644 index 000000000..3991269d8 --- /dev/null +++ b/crates/codecs/src/video.rs @@ -0,0 +1,1158 @@ +//! Video: AV1 encoding of RGBA frames (`rav1e`, native targets with the `avif` feature) and two +//! small muxers of our own, written from the public specifications only: +//! +//! - **MP4** (ISO/IEC 14496-12 ISO base media file format + the AOM "AV1 Codec ISO Media File +//! Format Binding"): `ftyp`, one `mdat` (64-bit size, patched at the end) holding the samples, +//! then `moov` with one video track whose sample entry is `av01` + `av1C` + `colr` (`nclx`). +//! - **WebM** (Matroska / EBML as restricted by WebM, codec mapping `V_AV1`): EBML header, +//! Segment (size patched at the end) with SeekHead, Info, Tracks, Clusters of SimpleBlocks (the +//! last frame a BlockGroup with its duration) and Cues. +//! +//! Frames may last several frame periods: a picture held on screen is one sample with a long +//! duration (variable frame rate), so a still slide costs one encoded frame however long it +//! stays up. Durations are counted in frame periods of the nominal rate. +//! +//! Samples are AV1 temporal units without temporal delimiter OBUs (both bindings ask for that); +//! the configuration record carries the sequence header OBU. No audio track. + +use std::io::{Seek, SeekFrom, Write}; + +use crate::{Error, Result}; + +/// The container a video is written in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Container { + Mp4, + WebM, +} + +impl Container { + pub fn id(self) -> &'static str { + match self { + Container::Mp4 => "mp4", + Container::WebM => "webm", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "mp4" => Some(Container::Mp4), + "webm" => Some(Container::WebM), + _ => None, + } + } +} + +/// One encoded picture: an AV1 temporal unit (no temporal delimiter), whether it is a key frame +/// (a sync sample), and how many frame periods it stays on screen (≥ 1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sample { + pub data: Vec, + pub key: bool, + pub duration: u64, +} + +/// The longest video the muxers write, in frame periods (keeps every timestamp in range). +pub const MAX_PERIODS: u64 = 60 * 60 * 24 * 120; + +fn io(e: std::io::Error) -> Error { + Error::Encode(format!("video: {e}")) +} + +fn bad(msg: impl Into) -> Error { + Error::Encode(format!("video: {}", msg.into())) +} + +// ---------------------------------------------------------------- AV1 OBUs + +/// OBU types (AV1 specification §6.2.2). +pub const OBU_SEQUENCE_HEADER: u8 = 1; +pub const OBU_TEMPORAL_DELIMITER: u8 = 2; +pub const OBU_PADDING: u8 = 15; + +/// Read a LEB128 value at `pos` (at most 8 bytes); returns (value, bytes read). +fn leb128(data: &[u8], pos: usize) -> Result<(u64, usize)> { + let mut v = 0u64; + for i in 0..8 { + let b = *data.get(pos + i).ok_or_else(|| bad("truncated OBU size"))?; + v |= u64::from(b & 0x7f) << (7 * i); + if b & 0x80 == 0 { + return Ok((v, i + 1)); + } + } + Err(bad("OBU size longer than 8 bytes")) +} + +/// The OBUs of a temporal unit: (type, the whole OBU's byte range). Every OBU but the last must +/// carry its size (as the low-overhead bitstream format requires). +pub fn obus(data: &[u8]) -> Result)>> { + let mut out = Vec::new(); + let mut pos = 0usize; + while let Some(&header) = data.get(pos) { + if header & 0x80 != 0 { + return Err(bad("OBU forbidden bit set")); + } + let kind = (header >> 3) & 0x0f; + let ext = header & 0x04 != 0; + let has_size = header & 0x02 != 0; + let mut p = pos + 1 + usize::from(ext); + let end = if has_size { + let (size, n) = leb128(data, p)?; + p += n; + let size = usize::try_from(size).map_err(|_| bad("OBU too large"))?; + p.checked_add(size).filter(|e| *e <= data.len()).ok_or_else(|| bad("OBU runs past the end of the packet"))? + } else { + data.len() + }; + if p > end { + return Err(bad("truncated OBU header")); + } + out.push((kind, pos..end)); + pos = end; + } + Ok(out) +} + +/// A temporal unit as a sample: temporal delimiters and padding dropped. Also returns its +/// sequence header OBU, if it has one. +pub fn to_sample_data(tu: &[u8]) -> Result<(Vec, Option>)> { + let mut data = Vec::with_capacity(tu.len()); + let mut seq = None; + for (kind, r) in obus(tu)? { + let Some(bytes) = tu.get(r) else { continue }; + match kind { + OBU_TEMPORAL_DELIMITER | OBU_PADDING => {} + _ => { + if kind == OBU_SEQUENCE_HEADER && seq.is_none() { + seq = Some(bytes.to_vec()); + } + data.extend_from_slice(bytes); + } + } + } + Ok((data, seq)) +} + +/// The `av1C` configuration record (AV1-ISOBMFF §2.3) for 8-bit 4:2:0 main profile, followed by +/// `seq_header` (the sequence header OBU, may be empty). Matroska's `CodecPrivate` is the same. +pub fn av1_config(seq_header: &[u8]) -> Vec { + // marker 1, version 1 | profile 0, level 31 (unconstrained) | tier 0, 8-bit, not mono, + // subsampling x and y, chroma position 0 | no initial presentation delay + let mut v = vec![0x81, 31, 0b0000_1100, 0]; + v.extend_from_slice(seq_header); + v +} + +// ---------------------------------------------------------------- MP4 + +fn mp4_box(kind: &[u8; 4], body: &[u8]) -> Result> { + let size = u32::try_from(body.len().checked_add(8).ok_or_else(|| bad("box too large"))?).map_err(|_| bad("box too large"))?; + let mut v = Vec::with_capacity(body.len() + 8); + v.extend_from_slice(&size.to_be_bytes()); + v.extend_from_slice(kind); + v.extend_from_slice(body); + Ok(v) +} + +fn full_box(kind: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Result> { + let mut b = Vec::with_capacity(body.len() + 4); + b.push(version); + b.extend_from_slice(&flags.to_be_bytes()[1..]); + b.extend_from_slice(body); + mp4_box(kind, &b) +} + +/// The unity transformation matrix of `mvhd` / `tkhd`. +fn matrix() -> Vec { + let mut m = Vec::with_capacity(36); + for v in [0x0001_0000u32, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000] { + m.extend_from_slice(&v.to_be_bytes()); + } + m +} + +/// An MP4 writer: [`Mp4Writer::add`] streams each sample into `mdat`; [`Mp4Writer::finish`] +/// patches its size and appends `moov`. +pub struct Mp4Writer { + w: W, + width: u16, + height: u16, + fps: u32, + /// Where `mdat`'s 64-bit size is, and where its data starts. + mdat_size_at: u64, + data_start: u64, + written: u64, + sizes: Vec, + durations: Vec, + keys: Vec, + config: Option>, +} + +/// Track ticks per frame period (ffmpeg's habit: 512 per frame). +const TICKS: u64 = 512; + +impl Mp4Writer { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result { + let (width, height) = (u16::try_from(width).map_err(|_| bad("too wide"))?, u16::try_from(height).map_err(|_| bad("too tall"))?); + if width == 0 || height == 0 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut ftyp = Vec::new(); + ftyp.extend_from_slice(b"isom"); + ftyp.extend_from_slice(&0x200u32.to_be_bytes()); + for brand in [b"isom", b"iso6", b"av01", b"mp41"] { + ftyp.extend_from_slice(brand); + } + w.write_all(&mp4_box(b"ftyp", &ftyp)?).map_err(io)?; + let start = w.stream_position().map_err(io)?; + // size 1 = a 64-bit size follows (patched in finish) + w.write_all(&1u32.to_be_bytes()).map_err(io)?; + w.write_all(b"mdat").map_err(io)?; + w.write_all(&0u64.to_be_bytes()).map_err(io)?; + Ok(Mp4Writer { + w, + width, + height, + fps, + mdat_size_at: start + 8, + data_start: start + 16, + written: 0, + sizes: Vec::new(), + durations: Vec::new(), + keys: Vec::new(), + config: None, + }) + } + + /// The `av1C` record ([`av1_config`]); needed before [`Self::finish`]. + pub fn set_config(&mut self, config: Vec) { + self.config = Some(config); + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + let size = u32::try_from(s.data.len()).map_err(|_| bad("sample too large"))?; + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.sizes.is_empty() && !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.w.write_all(&s.data).map_err(io)?; + self.written = self.written.saturating_add(u64::from(size)); + self.sizes.push(size); + self.durations.push(s.duration); + if s.key { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + self.keys.push(n); + } + Ok(()) + } + + /// Frame periods written so far. + pub fn periods(&self) -> u64 { + self.durations.iter().sum() + } + + pub fn finish(mut self) -> Result { + if self.sizes.is_empty() { + return Err(bad("no frames")); + } + let config = self.config.take().ok_or_else(|| bad("no AV1 configuration"))?; + let periods = self.periods(); + if periods > MAX_PERIODS { + return Err(bad("video too long")); + } + let end = self.w.stream_position().map_err(io)?; + self.w.seek(SeekFrom::Start(self.mdat_size_at)).map_err(io)?; + self.w.write_all(&(end - (self.mdat_size_at - 8)).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + let moov = self.moov(&config, periods)?; + self.w.write_all(&moov).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } + + fn moov(&self, config: &[u8], periods: u64) -> Result> { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + let timescale = u32::try_from(u64::from(self.fps) * TICKS).map_err(|_| bad("bad frame rate"))?; + let ticks = periods.checked_mul(TICKS).ok_or_else(|| bad("video too long"))?; + // the movie's own timescale: milliseconds + let movie_ms = periods.saturating_mul(1000) / u64::from(self.fps); + let u32_of = |v: u64| u32::try_from(v).map_err(|_| bad("video too long")); + let (track_ticks, movie_ms) = (u32_of(ticks)?, u32_of(movie_ms)?); + + let mut mvhd = Vec::new(); + mvhd.extend_from_slice(&[0; 8]); // creation, modification + mvhd.extend_from_slice(&1000u32.to_be_bytes()); + mvhd.extend_from_slice(&movie_ms.to_be_bytes()); + mvhd.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0 + mvhd.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0 + mvhd.extend_from_slice(&[0; 10]); + mvhd.extend_from_slice(&matrix()); + mvhd.extend_from_slice(&[0; 24]); // pre_defined + mvhd.extend_from_slice(&2u32.to_be_bytes()); // next track id + + let mut tkhd = Vec::new(); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&1u32.to_be_bytes()); // track id + tkhd.extend_from_slice(&[0; 4]); + tkhd.extend_from_slice(&movie_ms.to_be_bytes()); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&[0; 2]); // layer + tkhd.extend_from_slice(&[0; 2]); // alternate group + tkhd.extend_from_slice(&[0; 2]); // volume (video: 0) + tkhd.extend_from_slice(&[0; 2]); + tkhd.extend_from_slice(&matrix()); + tkhd.extend_from_slice(&(u32::from(self.width) << 16).to_be_bytes()); + tkhd.extend_from_slice(&(u32::from(self.height) << 16).to_be_bytes()); + + let mut mdhd = Vec::new(); + mdhd.extend_from_slice(&[0; 8]); + mdhd.extend_from_slice(×cale.to_be_bytes()); + mdhd.extend_from_slice(&track_ticks.to_be_bytes()); + mdhd.extend_from_slice(&0x55c4u16.to_be_bytes()); // language "und" + mdhd.extend_from_slice(&[0; 2]); + + let mut hdlr = Vec::new(); + hdlr.extend_from_slice(&[0; 4]); + hdlr.extend_from_slice(b"vide"); + hdlr.extend_from_slice(&[0; 12]); + hdlr.extend_from_slice(b"LightCraft video\0"); + + let mut vmhd = Vec::new(); + vmhd.extend_from_slice(&[0; 8]); // graphics mode, opcolor + let url = full_box(b"url ", 0, 1, &[])?; + let mut dref = 1u32.to_be_bytes().to_vec(); + dref.extend_from_slice(&url); + let dinf = mp4_box(b"dinf", &full_box(b"dref", 0, 0, &dref)?)?; + + // av01 visual sample entry + let mut av01 = Vec::new(); + av01.extend_from_slice(&[0; 6]); + av01.extend_from_slice(&1u16.to_be_bytes()); // data reference index + av01.extend_from_slice(&[0; 16]); // pre_defined, reserved + av01.extend_from_slice(&self.width.to_be_bytes()); + av01.extend_from_slice(&self.height.to_be_bytes()); + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // 72 dpi + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); + av01.extend_from_slice(&[0; 4]); + av01.extend_from_slice(&1u16.to_be_bytes()); // frame count + let mut name = [0u8; 32]; + let label = b"AOM Coding"; + name[0] = label.len() as u8; + name[1..=label.len()].copy_from_slice(label); + av01.extend_from_slice(&name); + av01.extend_from_slice(&0x0018u16.to_be_bytes()); // depth + av01.extend_from_slice(&(-1i16).to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"av1C", config)?); + // BT.709 primaries, transfer and matrix, limited range + let mut colr = b"nclx".to_vec(); + colr.extend_from_slice(&[0, 1, 0, 1, 0, 1, 0]); + av01.extend_from_slice(&mp4_box(b"colr", &colr)?); + let mut pasp = 1u32.to_be_bytes().to_vec(); + pasp.extend_from_slice(&1u32.to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"pasp", &pasp)?); + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend_from_slice(&mp4_box(b"av01", &av01)?); + + // decoding times: runs of equal durations + let mut runs: Vec<(u32, u64)> = Vec::new(); + for d in &self.durations { + match runs.last_mut() { + Some((count, delta)) if *delta == *d => *count += 1, + _ => runs.push((1, *d)), + } + } + let mut stts = u32::try_from(runs.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for (count, delta) in runs { + stts.extend_from_slice(&count.to_be_bytes()); + stts.extend_from_slice(&u32_of(delta.saturating_mul(TICKS))?.to_be_bytes()); + } + let mut stsc = 1u32.to_be_bytes().to_vec(); + for v in [1u32, n, 1] { + stsc.extend_from_slice(&v.to_be_bytes()); + } + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend_from_slice(&n.to_be_bytes()); + for s in &self.sizes { + stsz.extend_from_slice(&s.to_be_bytes()); + } + // one chunk holding every sample, at the start of mdat's data + let chunk_offset = match u32::try_from(self.data_start) { + Ok(o) => full_box(b"stco", 0, 0, &[1u32.to_be_bytes(), o.to_be_bytes()].concat())?, + Err(_) => { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&self.data_start.to_be_bytes()); + full_box(b"co64", 0, 0, &b)? + } + }; + let mut stbl = Vec::new(); + stbl.extend_from_slice(&full_box(b"stsd", 0, 0, &stsd)?); + stbl.extend_from_slice(&full_box(b"stts", 0, 0, &stts)?); + if self.keys.len() < self.sizes.len() { + let mut stss = u32::try_from(self.keys.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for k in &self.keys { + stss.extend_from_slice(&k.to_be_bytes()); + } + stbl.extend_from_slice(&full_box(b"stss", 0, 0, &stss)?); + } + stbl.extend_from_slice(&full_box(b"stsc", 0, 0, &stsc)?); + stbl.extend_from_slice(&full_box(b"stsz", 0, 0, &stsz)?); + stbl.extend_from_slice(&chunk_offset); + + let mut minf = full_box(b"vmhd", 0, 1, &vmhd)?; + minf.extend_from_slice(&dinf); + minf.extend_from_slice(&mp4_box(b"stbl", &stbl)?); + let mut mdia = full_box(b"mdhd", 0, 0, &mdhd)?; + mdia.extend_from_slice(&full_box(b"hdlr", 0, 0, &hdlr)?); + mdia.extend_from_slice(&mp4_box(b"minf", &minf)?); + let mut trak = full_box(b"tkhd", 0, 3, &tkhd)?; + trak.extend_from_slice(&mp4_box(b"mdia", &mdia)?); + let mut moov = full_box(b"mvhd", 0, 0, &mvhd)?; + moov.extend_from_slice(&mp4_box(b"trak", &trak)?); + mp4_box(b"moov", &moov) + } +} + +// ---------------------------------------------------------------- WebM + +/// An EBML element id with its length marker, as written (e.g. `0x1A45DFA3`). +fn ebml_id(id: u32) -> Vec { + let b = id.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(3); + b[skip..].to_vec() +} + +/// An EBML size, in the fewest bytes (all-ones values are reserved for "unknown"). +fn ebml_size(n: u64) -> Vec { + for len in 1..=8u32 { + if n < (1u64 << (7 * len)) - 1 { + let mut v = Vec::with_capacity(len as usize); + let marked = n | (1u64 << (7 * len)); + let bytes = marked.to_be_bytes(); + v.extend_from_slice(&bytes[(8 - len as usize)..]); + return v; + } + } + vec![0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe] +} + +/// An 8-byte EBML size (patchable in place). +fn ebml_size8(n: u64) -> [u8; 8] { + let mut b = (n & 0x00ff_ffff_ffff_ffff).to_be_bytes(); + b[0] = 0x01; + b +} + +fn el(id: u32, body: &[u8]) -> Vec { + let mut v = ebml_id(id); + v.extend_from_slice(&ebml_size(body.len() as u64)); + v.extend_from_slice(body); + v +} + +fn el_uint(id: u32, n: u64) -> Vec { + let b = n.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(7); + el(id, &b[skip..]) +} + +fn el_float(id: u32, f: f64) -> Vec { + el(id, &f.to_be_bytes()) +} + +const EBML: u32 = 0x1A45_DFA3; +const SEGMENT: u32 = 0x1853_8067; +const SEEK_HEAD: u32 = 0x114D_9B74; +const SEEK: u32 = 0x4DBB; +const SEEK_ID: u32 = 0x53AB; +const SEEK_POSITION: u32 = 0x53AC; +const VOID: u32 = 0xEC; +const INFO: u32 = 0x1549_A966; +const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; +const DURATION: u32 = 0x4489; +const TRACKS: u32 = 0x1654_AE6B; +const TRACK_ENTRY: u32 = 0xAE; +const CLUSTER: u32 = 0x1F43_B675; +const TIMESTAMP: u32 = 0xE7; +const SIMPLE_BLOCK: u32 = 0xA3; +const BLOCK_GROUP: u32 = 0xA0; +const BLOCK: u32 = 0xA1; +const BLOCK_DURATION: u32 = 0x9B; +const REFERENCE_BLOCK: u32 = 0xFB; +const CUES: u32 = 0x1C53_BB6B; + +/// Room kept after the Segment header for the SeekHead written at the end. +const SEEK_ROOM: usize = 96; + +/// A WebM writer (see the module docs). Timestamps are in milliseconds. +pub struct WebmWriter { + w: W, + width: u32, + height: u32, + fps: u32, + segment_size_at: u64, + segment_start: u64, + duration_at: u64, + info_at: u64, + tracks_at: Option, + config: Option>, + /// Frame periods before the pending sample. + periods: u64, + /// The sample not written yet (the last one becomes a BlockGroup with its duration). + pending: Option<(Sample, u64)>, + cluster: Vec, + cluster_ts: u64, + last_block_ts: u64, + cues: Vec<(u64, u64)>, +} + +impl WebmWriter { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result { + if width == 0 || height == 0 || width > 65_535 || height > 65_535 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut header = Vec::new(); + header.extend_from_slice(&el_uint(0x4286, 1)); // EBMLVersion + header.extend_from_slice(&el_uint(0x42F7, 1)); // EBMLReadVersion + header.extend_from_slice(&el_uint(0x42F2, 4)); // EBMLMaxIDLength + header.extend_from_slice(&el_uint(0x42F3, 8)); // EBMLMaxSizeLength + header.extend_from_slice(&el(0x4282, b"webm")); // DocType + header.extend_from_slice(&el_uint(0x4287, 4)); // DocTypeVersion + header.extend_from_slice(&el_uint(0x4285, 2)); // DocTypeReadVersion + w.write_all(&el(EBML, &header)).map_err(io)?; + w.write_all(&ebml_id(SEGMENT)).map_err(io)?; + let segment_size_at = w.stream_position().map_err(io)?; + w.write_all(&ebml_size8(0)).map_err(io)?; + let segment_start = w.stream_position().map_err(io)?; + // a Void the SeekHead replaces at the end + w.write_all(&el(VOID, &[0; SEEK_ROOM - 2])).map_err(io)?; + let info_at = w.stream_position().map_err(io)? - segment_start; + let mut info = el_uint(TIMESTAMP_SCALE, 1_000_000); + let duration_offset = info.len() + 3; // after Duration's id (2 bytes) and size (1 byte) + info.extend_from_slice(&el_float(DURATION, 0.0)); + info.extend_from_slice(&el(0x4D80, b"LightCraft")); // MuxingApp + info.extend_from_slice(&el(0x5741, b"LightCraft")); // WritingApp + let info_el = el(INFO, &info); + let duration_at = segment_start + info_at + (info_el.len() - info.len()) as u64 + duration_offset as u64; + w.write_all(&info_el).map_err(io)?; + Ok(WebmWriter { + w, + width, + height, + fps, + segment_size_at, + segment_start, + duration_at, + info_at, + tracks_at: None, + config: None, + periods: 0, + pending: None, + cluster: Vec::new(), + cluster_ts: 0, + last_block_ts: 0, + cues: Vec::new(), + }) + } + + /// The `CodecPrivate` (the `av1C` record, [`av1_config`]); needed before the first sample. + pub fn set_config(&mut self, config: Vec) { + self.config = Some(config); + } + + fn ms(&self, periods: u64) -> u64 { + periods.saturating_mul(1000) / u64::from(self.fps) + } + + fn write_tracks(&mut self) -> Result<()> { + let config = self.config.clone().ok_or_else(|| bad("no AV1 configuration"))?; + let mut video = el_uint(0xB0, u64::from(self.width)); // PixelWidth + video.extend_from_slice(&el_uint(0xBA, u64::from(self.height))); // PixelHeight + let mut entry = el_uint(0xD7, 1); // TrackNumber + entry.extend_from_slice(&el_uint(0x73C5, 1)); // TrackUID + entry.extend_from_slice(&el_uint(0x83, 1)); // TrackType video + entry.extend_from_slice(&el_uint(0x9C, 0)); // FlagLacing + entry.extend_from_slice(&el(0x86, b"V_AV1")); // CodecID + entry.extend_from_slice(&el(0x63A2, &config)); // CodecPrivate + entry.extend_from_slice(&el_uint(0x23E383, 1_000_000_000 / u64::from(self.fps))); // DefaultDuration (ns) + entry.extend_from_slice(&el(0xE0, &video)); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(TRACKS, &el(TRACK_ENTRY, &entry))).map_err(io)?; + self.tracks_at = Some(at); + Ok(()) + } + + fn flush_cluster(&mut self) -> Result<()> { + if self.cluster.is_empty() { + return Ok(()); + } + let mut body = el_uint(TIMESTAMP, self.cluster_ts); + body.extend_from_slice(&self.cluster); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(CLUSTER, &body)).map_err(io)?; + self.cues.push((self.cluster_ts, at)); + self.cluster.clear(); + Ok(()) + } + + /// The block header: track 1, timestamp relative to the cluster, flags. + fn block(&self, data: &[u8], ts: u64, flags: u8) -> Result> { + let rel = i16::try_from(ts.saturating_sub(self.cluster_ts)).map_err(|_| bad("block timestamp out of range"))?; + let mut b = Vec::with_capacity(data.len() + 4); + b.push(0x81); + b.extend_from_slice(&rel.to_be_bytes()); + b.push(flags); + b.extend_from_slice(data); + Ok(b) + } + + /// Start a new cluster at a key frame or before the relative timestamp could overflow. + fn place(&mut self, key: bool, ts: u64) -> Result<()> { + if !self.cluster.is_empty() && (key || ts.saturating_sub(self.cluster_ts) > 30_000) { + self.flush_cluster()?; + } + if self.cluster.is_empty() { + self.cluster_ts = ts; + } + Ok(()) + } + + fn write_simple(&mut self, s: &Sample, ts: u64) -> Result<()> { + self.place(s.key, ts)?; + let b = self.block(&s.data, ts, if s.key { 0x80 } else { 0 })?; + self.cluster.extend_from_slice(&el(SIMPLE_BLOCK, &b)); + self.last_block_ts = ts; + Ok(()) + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.tracks_at.is_none() { + if !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.write_tracks()?; + } + if let Some((p, start)) = self.pending.take() { + let ts = self.ms(start); + self.write_simple(&p, ts)?; + } + let start = self.periods; + self.periods = self.periods.saturating_add(s.duration); + if self.periods > MAX_PERIODS { + return Err(bad("video too long")); + } + self.pending = Some((s.clone(), start)); + Ok(()) + } + + pub fn periods(&self) -> u64 { + self.periods + } + + pub fn finish(mut self) -> Result { + let (last, start) = self.pending.take().ok_or_else(|| bad("no frames"))?; + let ts = self.ms(start); + self.place(last.key, ts)?; + let mut group = el(BLOCK, &self.block(&last.data, ts, 0)?); + group.extend_from_slice(&el_uint(BLOCK_DURATION, self.ms(self.periods).saturating_sub(ts).max(1))); + if !last.key { + // the previous block, as a (negative) offset from this one + let back = i64::try_from(ts.saturating_sub(self.last_block_ts)).unwrap_or(i64::MAX).max(1); + el_int_into(&mut group, REFERENCE_BLOCK, -back); + } + self.cluster.extend_from_slice(&el(BLOCK_GROUP, &group)); + self.flush_cluster()?; + // cues: one per cluster (each starts with a key frame or carries on from one) + let cues_at = self.w.stream_position().map_err(io)? - self.segment_start; + let mut cues = Vec::new(); + for (t, pos) in &self.cues { + let mut positions = el_uint(0xF7, 1); // CueTrack + positions.extend_from_slice(&el_uint(0xF1, *pos)); // CueClusterPosition + let mut point = el_uint(0xB3, *t); // CueTime + point.extend_from_slice(&el(0xB7, &positions)); // CueTrackPositions + cues.extend_from_slice(&el(0xBB, &point)); // CuePoint + } + self.w.write_all(&el(CUES, &cues)).map_err(io)?; + let end = self.w.stream_position().map_err(io)?; + // SeekHead in the room kept for it, the rest stays a Void + let tracks_at = self.tracks_at.ok_or_else(|| bad("no tracks"))?; + let mut seeks = Vec::new(); + for (id, at) in [(INFO, self.info_at), (TRACKS, tracks_at), (CUES, cues_at)] { + let mut seek = el(SEEK_ID, &ebml_id(id)); + seek.extend_from_slice(&el(SEEK_POSITION, &at.to_be_bytes())); + seeks.extend_from_slice(&el(SEEK, &seek)); + } + let head = el(SEEK_HEAD, &seeks); + let rest = SEEK_ROOM.checked_sub(head.len()).filter(|r| *r >= 2).ok_or_else(|| bad("seek head too large"))?; + let mut room = head; + room.extend_from_slice(&el(VOID, &vec![0; rest - 2])); + self.w.seek(SeekFrom::Start(self.segment_start)).map_err(io)?; + self.w.write_all(&room).map_err(io)?; + self.w.seek(SeekFrom::Start(self.duration_at)).map_err(io)?; + self.w.write_all(&(self.ms(self.periods) as f64).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(self.segment_size_at)).map_err(io)?; + self.w.write_all(&ebml_size8(end - self.segment_start)).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} + +fn el_int_into(out: &mut Vec, id: u32, v: i64) { + let b = v.to_be_bytes(); + // the fewest bytes that keep the sign + let mut skip = 0; + while skip < 7 && ((b[skip] == 0 && b[skip + 1] & 0x80 == 0) || (b[skip] == 0xff && b[skip + 1] & 0x80 != 0)) { + skip += 1; + } + out.extend_from_slice(&el(id, &b[skip..])); +} + +// ---------------------------------------------------------------- either container + +/// An MP4 or WebM writer. +pub enum VideoWriter { + Mp4(Mp4Writer), + WebM(WebmWriter), +} + +impl VideoWriter { + pub fn new(w: W, container: Container, width: u32, height: u32, fps: u32) -> Result { + Ok(match container { + Container::Mp4 => VideoWriter::Mp4(Mp4Writer::new(w, width, height, fps)?), + Container::WebM => VideoWriter::WebM(WebmWriter::new(w, width, height, fps)?), + }) + } + + pub fn set_config(&mut self, config: Vec) { + match self { + VideoWriter::Mp4(m) => m.set_config(config), + VideoWriter::WebM(m) => m.set_config(config), + } + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + match self { + VideoWriter::Mp4(m) => m.add(s), + VideoWriter::WebM(m) => m.add(s), + } + } + + pub fn periods(&self) -> u64 { + match self { + VideoWriter::Mp4(m) => m.periods(), + VideoWriter::WebM(m) => m.periods(), + } + } + + pub fn finish(self) -> Result { + match self { + VideoWriter::Mp4(m) => m.finish(), + VideoWriter::WebM(m) => m.finish(), + } + } +} + +// ---------------------------------------------------------------- AV1 encoder + +/// How to encode: frame size (even numbers; odd ones are rounded down), nominal frame rate, +/// quality 1–100 and rav1e speed 1 (slow) – 10 (fast). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VideoConfig { + pub width: u32, + pub height: u32, + pub fps: u32, + pub quality: u8, + pub speed: u8, +} + +/// Is AV1 video encoding in this build (native, `avif` feature)? +pub const AV1_AVAILABLE: bool = cfg!(all(feature = "avif", not(target_arch = "wasm32"))); + +/// BT.709 limited-range 4:2:0 planes of an sRGB RGBA image (alpha ignored), `w × h` (even). +pub fn yuv420(rgba: &[[u8; 4]], w: usize, h: usize) -> Result<(Vec, Vec, Vec)> { + if w == 0 || h == 0 || !w.is_multiple_of(2) || !h.is_multiple_of(2) || w.checked_mul(h).is_none_or(|n| rgba.len() < n) { + return Err(bad("frame size")); + } + const KR: f32 = 0.2126; + const KB: f32 = 0.0722; + const KG: f32 = 1.0 - KR - KB; + let mut y = vec![0u8; w * h]; + let (cw, ch) = (w / 2, h / 2); + let mut u = vec![0u8; cw * ch]; + let mut v = vec![0u8; cw * ch]; + let luma = |p: [u8; 4]| KR * f32::from(p[0]) + KG * f32::from(p[1]) + KB * f32::from(p[2]); + for (i, out) in y.iter_mut().enumerate() { + let p = rgba.get(i).copied().unwrap_or([0; 4]); + *out = (16.0 + luma(p) * (219.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + for cy in 0..ch { + for cx in 0..cw { + let mut acc = [0f32; 3]; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let p = rgba.get((cy * 2 + dy) * w + cx * 2 + dx).copied().unwrap_or([0; 4]); + for (a, c) in acc.iter_mut().zip(p) { + *a += f32::from(c) / 4.0; + } + } + let l = KR * acc[0] + KG * acc[1] + KB * acc[2]; + let cb = (acc[2] - l) / (2.0 * (1.0 - KB)); + let cr = (acc[0] - l) / (2.0 * (1.0 - KR)); + let i = cy * cw + cx; + if let Some(o) = u.get_mut(i) { + *o = (128.0 + cb * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + if let Some(o) = v.get_mut(i) { + *o = (128.0 + cr * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + } + } + Ok((y, u, v)) +} + +/// The quantizer for `quality` (ravif's curve, so qualities match the AVIF export). +pub fn quantizer(quality: u8) -> usize { + let q = f32::from(quality.clamp(1, 100)) / 100.0; + let x = if q >= 0.82 { + (1.0 - q) * 2.6 + } else if q > 0.25 { + 1.0 - 0.125 - q * 0.5 + } else { + 1.0 - q + }; + (x * 255.0).round().clamp(0.0, 255.0) as usize +} + +/// An AV1 encoder fed RGBA frames, each with how many frame periods it stays up; it hands back +/// the samples as they come out (in order: no frame reordering). +pub struct Av1Encoder { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + ctx: rav1e::Context, + /// Each frame sent so far: its duration (by input frame number). + #[cfg_attr(not(all(feature = "avif", not(target_arch = "wasm32"))), allow(dead_code))] + durations: Vec, + width: usize, + height: usize, + seq_header: Option>, +} + +impl Av1Encoder { + pub fn new(c: &VideoConfig) -> Result { + let (w, h) = ((c.width & !1) as usize, (c.height & !1) as usize); + if w < 16 || h < 16 || w > 8192 || h > 8192 || !(1..=240).contains(&c.fps) { + return Err(bad("unsupported video size or frame rate")); + } + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + use rav1e::prelude::*; + let mut ec = EncoderConfig::with_speed_preset(c.speed.clamp(1, 10)); + ec.width = w; + ec.height = h; + ec.time_base = Rational::new(1, u64::from(c.fps)); + ec.bit_depth = 8; + ec.chroma_sampling = ChromaSampling::Cs420; + ec.pixel_range = PixelRange::Limited; + ec.color_description = Some(ColorDescription { + color_primaries: ColorPrimaries::BT709, + transfer_characteristics: TransferCharacteristics::BT709, + matrix_coefficients: MatrixCoefficients::BT709, + }); + // in order, one shown frame per packet: samples map 1:1 to the frames sent + ec.low_latency = true; + let q = quantizer(c.quality); + ec.quantizer = q; + ec.min_quantizer = q.min(255) as u8; + ec.min_key_frame_interval = 1; + ec.max_key_frame_interval = 120; + // tiles are what rav1e encodes in parallel (16 measured best for 1080p on 32 threads) + ec.tiles = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(2, 16); + let ctx: Context = Config::new().with_encoder_config(ec).new_context().map_err(|e| bad(format!("AV1: {e}")))?; + Ok(Av1Encoder { ctx, durations: Vec::new(), width: w, height: h, seq_header: None }) + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (w, h); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// The frame size actually encoded (even numbers). + pub fn size(&self) -> (usize, usize) { + (self.width, self.height) + } + + /// The configuration record for the container ([`av1_config`]), once the first sample (with + /// the sequence header) has come out. + pub fn config_record(&self) -> Option> { + self.seq_header.as_ref().map(|s| av1_config(s)) + } + + /// Encode `rgba` (`width × height` of [`Self::size`]) shown for `periods` frame periods; + /// `key` forces a key frame (a picture that doesn't lean on earlier ones: a held slide is + /// then clean of the slide before it, and seeking lands on it). + pub fn push(&mut self, rgba: &[[u8; 4]], periods: u64, key: bool) -> Result> { + let (y, u, v) = yuv420(rgba, self.width, self.height)?; + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + let mut frame = self.ctx.new_frame(); + for (plane, (data, w)) in frame.planes.iter_mut().zip([(&y, self.width), (&u, self.width / 2), (&v, self.width / 2)]) { + plane.copy_from_raw_u8(data, w, 1); + } + let params = rav1e::prelude::FrameParameters { + frame_type_override: if key { rav1e::prelude::FrameTypeOverride::Key } else { rav1e::prelude::FrameTypeOverride::No }, + ..Default::default() + }; + self.ctx.send_frame((frame, params)).map_err(|e| bad(format!("AV1: {e}")))?; + self.durations.push(periods.max(1)); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (y, u, v, periods, key); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// Flush the encoder: the samples still inside. + pub fn finish(&mut self) -> Result> { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + self.ctx.flush(); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + Err(bad("AV1 video encoding is not available in this build")) + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + fn drain(&mut self) -> Result> { + use rav1e::prelude::*; + let mut out = Vec::new(); + loop { + match self.ctx.receive_packet() { + Ok(p) => { + let (data, seq) = to_sample_data(&p.data)?; + if self.seq_header.is_none() { + self.seq_header = seq; + } + let duration = usize::try_from(p.input_frameno).ok().and_then(|i| self.durations.get(i)).copied().unwrap_or(1); + out.push(Sample { data, key: p.frame_type == FrameType::KEY, duration }); + } + Err(EncoderStatus::Encoded) => {} + Err(EncoderStatus::NeedMoreData | EncoderStatus::LimitReached) => break, + Err(e) => return Err(bad(format!("AV1: {e}"))), + } + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// The boxes of an MP4 byte range: (type, body range). + pub fn boxes(d: &[u8], range: std::ops::Range) -> Vec<([u8; 4], std::ops::Range)> { + let mut out = Vec::new(); + let mut p = range.start; + while p + 8 <= range.end { + let size32 = u32::from_be_bytes(d[p..p + 4].try_into().unwrap()) as usize; + let kind: [u8; 4] = d[p + 4..p + 8].try_into().unwrap(); + let (hdr, size) = if size32 == 1 { (16, u64::from_be_bytes(d[p + 8..p + 16].try_into().unwrap()) as usize) } else { (8, size32) }; + assert!(size >= hdr && p + size <= range.end, "box {} overruns", String::from_utf8_lossy(&kind)); + out.push((kind, p + hdr..p + size)); + p += size; + } + assert_eq!(p, range.end, "trailing bytes"); + out + } + + fn find(d: &[u8], r: std::ops::Range, path: &[&[u8; 4]]) -> std::ops::Range { + let mut r = r; + for k in path { + r = boxes(d, r).into_iter().find(|(t, _)| t == *k).unwrap_or_else(|| panic!("no {}", String::from_utf8_lossy(*k))).1; + } + r + } + + fn samples() -> Vec { + // fake AV1 data: an OBU_FRAME (type 6) with a size field + let frame = |b: u8| vec![0x32, 2, b, b]; + vec![ + Sample { data: frame(1), key: true, duration: 30 }, + Sample { data: frame(2), key: false, duration: 1 }, + Sample { data: frame(3), key: false, duration: 1 }, + Sample { data: frame(4), key: true, duration: 58 }, + ] + } + + #[test] + fn obus_split_and_temporal_delimiters_go() { + // TD, sequence header (3 bytes), frame (2 bytes) + let tu = [0x12, 0, 0x0a, 3, 1, 2, 3, 0x32, 2, 9, 9]; + let o = obus(&tu).unwrap(); + assert_eq!(o.iter().map(|(k, _)| *k).collect::>(), vec![2, 1, 6]); + let (data, seq) = to_sample_data(&tu).unwrap(); + assert_eq!(data, &tu[2..]); + assert_eq!(seq.unwrap(), &tu[2..7]); + // hostile: sizes past the end, forbidden bit, endless LEB128 + assert!(obus(&[0x32, 9, 1]).is_err()); + assert!(obus(&[0x80]).is_err()); + assert!(obus(&[0x32, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).is_err()); + assert!(obus(&[0x36]).is_err(), "extension byte missing"); + assert_eq!(av1_config(&[1, 2])[..], [0x81, 31, 0x0c, 0, 1, 2]); + } + + #[test] + fn mp4_box_tree_samples_and_durations() { + let mut m = Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![1], key: false, duration: 1 }).is_err(), "must start on a key frame"); + for s in samples() { + m.add(&s).unwrap(); + } + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap().finish().is_err(), "no frames"); + m.set_config(av1_config(&[0x0a, 1, 0])); + let d = m.finish().unwrap().into_inner(); + let top = boxes(&d, 0..d.len()); + assert_eq!(top.iter().map(|(k, _)| *k).collect::>(), vec![*b"ftyp", *b"mdat", *b"moov"]); + assert_eq!(&d[top[1].1.clone()], &[0x32, 2, 1, 1, 0x32, 2, 2, 2, 0x32, 2, 3, 3, 0x32, 2, 4, 4]); + let stbl = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"minf", b"stbl"]); + let kinds: Vec<_> = boxes(&d, stbl.clone()).into_iter().map(|(k, _)| k).collect(); + assert_eq!(kinds, vec![*b"stsd", *b"stts", *b"stss", *b"stsc", *b"stsz", *b"stco"]); + let be = |r: usize| u32::from_be_bytes(d[r..r + 4].try_into().unwrap()); + let stsz = find(&d, stbl.clone(), &[b"stsz"]); + assert_eq!(be(stsz.start + 8), 4, "sample count"); + let stts = find(&d, stbl.clone(), &[b"stts"]); + assert_eq!(be(stts.start + 4), 3, "runs: 30, 1×2, 58"); + let total: u32 = (0..3).map(|i| be(stts.start + 8 + i * 8) * be(stts.start + 12 + i * 8)).sum(); + assert_eq!(total, 90 * 512); + let stss = find(&d, stbl.clone(), &[b"stss"]); + assert_eq!((be(stss.start + 4), be(stss.start + 8), be(stss.start + 12)), (2, 1, 4)); + let stco = find(&d, stbl.clone(), &[b"stco"]); + assert_eq!(be(stco.start + 8) as usize, top[1].1.start, "chunk offset = mdat data"); + let stsd = find(&d, stbl, &[b"stsd"]); + let av01 = boxes(&d, stsd.start + 8..stsd.end); + assert_eq!(av01[0].0, *b"av01"); + let entry = av01[0].1.clone(); + assert_eq!(u16::from_be_bytes([d[entry.start + 24], d[entry.start + 25]]), 64); + let inner: Vec<_> = boxes(&d, entry.start + 78..entry.end).into_iter().map(|(k, _)| k).collect(); + assert_eq!(inner, vec![*b"av1C", *b"colr", *b"pasp"]); + let mdhd = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"mdhd"]); + assert_eq!((be(mdhd.start + 12), be(mdhd.start + 16)), (30 * 512, 90 * 512)); + let mvhd = find(&d, 0..d.len(), &[b"moov", b"mvhd"]); + assert_eq!(be(mvhd.start + 16), 3000, "3 s in ms"); + } + + /// The EBML elements of a byte range: (id, body range). + fn elements(d: &[u8], range: std::ops::Range) -> Vec<(u32, std::ops::Range)> { + let vint = |p: usize| -> (u64, usize) { + let len = d[p].leading_zeros() as usize + 1; + let mut v = u64::from(d[p]) & (0xff >> len); + for i in 1..len { + v = (v << 8) | u64::from(d[p + i]); + } + (v, len) + }; + let mut out = Vec::new(); + let mut p = range.start; + while p < range.end { + let idlen = d[p].leading_zeros() as usize + 1; + let id = d[p..p + idlen].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + let (size, n) = vint(p + idlen); + let start = p + idlen + n; + let end = start + size as usize; + assert!(end <= range.end, "element {id:x} overruns"); + out.push((id, start..end)); + p = end; + } + out + } + + #[test] + fn webm_element_tree_blocks_and_duration() { + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![0x32, 0], key: true, duration: 1 }).is_err(), "configuration first"); + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + m.set_config(av1_config(&[0x0a, 1, 0])); + for s in samples() { + m.add(&s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + let top = elements(&d, 0..d.len()); + assert_eq!(top.iter().map(|(i, _)| *i).collect::>(), vec![EBML, SEGMENT]); + let seg = elements(&d, top[1].1.clone()); + let ids: Vec<_> = seg.iter().map(|(i, _)| *i).collect(); + assert_eq!(ids, vec![SEEK_HEAD, VOID, INFO, TRACKS, CLUSTER, CLUSTER, CUES]); + let info = elements(&d, seg[2].1.clone()); + let dur = info.iter().find(|(i, _)| *i == DURATION).unwrap().1.clone(); + assert_eq!(f64::from_be_bytes(d[dur].try_into().unwrap()), 3000.0); + let track = elements(&d, elements(&d, seg[3].1.clone())[0].1.clone()); + let codec = track.iter().find(|(i, _)| *i == 0x86).unwrap().1.clone(); + assert_eq!(&d[codec], b"V_AV1"); + let first: Vec<_> = elements(&d, seg[4].1.clone()).into_iter().map(|(i, _)| i).collect(); + assert_eq!(first, vec![TIMESTAMP, SIMPLE_BLOCK, SIMPLE_BLOCK, SIMPLE_BLOCK]); + let second = elements(&d, seg[5].1.clone()); + assert_eq!(d[second[0].1.clone()], [0x04, 0x2a], "the second cluster starts at 1066 ms (32 periods)"); + let group = elements(&d, second[1].1.clone()); + assert_eq!(group.iter().map(|(i, _)| *i).collect::>(), vec![BLOCK, BLOCK_DURATION]); + // the SeekHead points at Info, Tracks and Cues + let seg_start = top[1].1.start; + for seek in elements(&d, seg[0].1.clone()) { + let e = elements(&d, seek.1); + let pos = d[e[1].1.clone()].iter().fold(0usize, |a, b| (a << 8) | usize::from(*b)); + let id = d[e[0].1.clone()].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + assert!(seg.iter().any(|(i, r)| *i == id && r.start > seg_start + pos), "{id:x}"); + } + let mut neg = Vec::new(); + el_int_into(&mut neg, REFERENCE_BLOCK, -33); + assert_eq!(neg, vec![0xfb, 0x81, 0xdf]); + assert_eq!(ebml_size(126), vec![0xfe]); + assert_eq!(ebml_size(127), vec![0x40, 0x7f]); + } + + #[test] + fn hostile_sizes_are_refused() { + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 0, 10, 30).is_err()); + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 70_000, 10, 30).is_err()); + assert!(WebmWriter::new(Cursor::new(Vec::new()), 10, 10, 0).is_err()); + assert!(yuv420(&[[0; 4]; 3], 2, 2).is_err()); + assert!(yuv420(&[[0; 4]; 9], 3, 3).is_err()); + let (y, u, v) = yuv420(&[[255; 4]; 4], 2, 2).unwrap(); + assert_eq!((y, u, v), (vec![235; 4], vec![128], vec![128])); + assert!(quantizer(100) < quantizer(50) && quantizer(50) < quantizer(1)); + assert!(Av1Encoder::new(&VideoConfig { width: 8, height: 8, fps: 30, quality: 50, speed: 10 }).is_err()); + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + #[test] + fn encodes_av1_into_a_playable_mp4() { + let c = VideoConfig { width: 64, height: 48, fps: 24, quality: 60, speed: 10 }; + let mut e = Av1Encoder::new(&c).unwrap(); + let mut out = Vec::new(); + for (i, periods) in [(0u8, 24u64), (80, 1), (160, 1), (240, 30)] { + let img = vec![[i, 255 - i, 128, 255]; 64 * 48]; + out.extend(e.push(&img, periods, periods > 1).unwrap()); + } + out.extend(e.finish().unwrap()); + assert_eq!(out.len(), 4, "one sample per frame sent"); + assert!(out[0].key); + assert_eq!(out.iter().map(|s| s.duration).sum::(), 56); + for s in &out { + assert!(obus(&s.data).unwrap().iter().all(|(k, _)| *k != OBU_TEMPORAL_DELIMITER)); + } + let config = e.config_record().unwrap(); + assert_eq!(obus(&config[4..]).unwrap()[0].0, OBU_SEQUENCE_HEADER); + let mut m = VideoWriter::new(Cursor::new(Vec::new()), Container::Mp4, 64, 48, 24).unwrap(); + m.set_config(config); + for s in &out { + m.add(s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + assert_eq!(boxes(&d, 0..d.len()).len(), 3); + } +} diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 92c1a325c..33ff76ec0 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -8,7 +8,9 @@ //! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes //! one slide, or a title screen, as a PNG (with the library's settings, or a template's). //! `slideshow.templates.*`: the Template Browser (built-in and user templates; see -//! [`crate::slide_template`]). +//! [`crate::slide_template`]). `slideshow.export`: the slideshow as a PDF, a JPEG sequence or an +//! AV1 video, in the background ([`crate::slideshow_export`]); `slideshow.exportStatus` reads how +//! it is going. use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; @@ -307,6 +309,69 @@ fn tpl_export(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": t.id, "name": t.name, "path": path})) } +/// `slideshow.export`: prepare on this thread, run on a worker (or here with `wait`). +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.export"; + if cfg!(target_arch = "wasm32") { + return Err(bad(C, "slideshow export needs the desktop app or lightcraft-cli")); + } + if s.slideshow_export.as_ref().is_some_and(|r| r.running()) { + return Err(bad(C, "a slideshow export is already running (activity.cancel stops it)")); + } + let prepared = crate::slideshow_export::prepare(s, p).map_err(|e| bad(C, e))?; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let task = s.activity.start("export", "Exporting slideshow", crate::activity::Cancel::Flag(cancel.clone())); + let total = prepared.total(); + task.progress(0, total); + task.detail(&prepared.request.path); + let settings = &prepared.request.settings; + let run = std::sync::Arc::new(crate::slideshow_export::ExportRun { + task_id: task.id(), + format: settings.format.id().into(), + path: prepared.request.path.clone(), + total: total.into(), + cancel, + ..Default::default() + }); + s.slideshow_export = Some(run.clone()); + let started = json!({ + "taskId": task.id(), + "background": !wait, + "format": settings.format.id(), + "path": prepared.request.path, + "slides": prepared.len(), + "seconds": prepared.seconds(), + "total": total, + "width": prepared.width, + "height": prepared.height, + "warnings": prepared.warnings, + }); + if wait { + crate::slideshow_export::run_to_end(prepared, &run, &task); + drop(task); + return match run.result() { + Some(Ok(v)) => Ok(v), + Some(Err(e)) => Err(bad(C, e)), + None => Err(bad(C, "the export did not finish")), + }; + } + #[cfg(not(target_arch = "wasm32"))] + { + // the row goes when the worker ends, however it ends + let r = run.clone(); + std::thread::Builder::new() + .name("lc-slideshow-export".into()) + .spawn(move || crate::slideshow_export::run_to_end(prepared, &r, &task)) + .map_err(|e| bad(C, format!("could not start: {e}")))?; + } + Ok(started) +} + +fn export_status(s: &mut Session, _: &Value) -> Result { + Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json())) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -426,5 +491,23 @@ pub fn specs() -> Vec { always, preview ), + cmd!( + query "slideshow.export", + "Export Slideshow", + [], + None, + "{format: \"pdf\"|\"jpeg\"|\"video\", path: the .pdf / .mp4 / .webm file, or for jpeg the folder (created if missing), size?: pdf/jpeg the long side in pixels (64–8192, default 1920), video the frame height (64–2160 or \"720p\"/\"1080p\", default 720), quality?: 1–100 (JPEG quality, default 90; video default 75), fps?: 24|25|30|60 (video, default 30), container?: \"mp4\"|\"webm\" (video, default mp4), fullScreen?: bool (pdf: open in the viewer's full screen mode, default true), speed?: 1–10 (video encoder speed, default 10 = fastest), name?: jpeg file name prefix (default the folder's name: -001.jpg…), ids?: photo ids (default the photos in view, in view order), wait?: bool (run here and reply with the result)} — the photos' slides with the slideshow layout, overlays and the intro/ending screens that are on; video is AV1 (no audio), each slide held for its duration and fades rendered as frames, at most 2 hours. Runs in the background with a row in the activity stack (activity.cancel {id: taskId} stops it; nothing is left behind: files are written as .part and renamed when complete); a photo that can't be rendered is skipped with a warning → {taskId, background, format, path, slides, seconds, total, width, height, warnings}; with wait: the result (slideshow.exportStatus)", + always, + export + ), + cmd!( + query "slideshow.exportStatus", + "Slideshow Export Status", + [], + None, + "{} → the last slideshow export: {taskId, format, path, running, done, total (slides, or frames for video), cancelled, result?: {format, path, width, height, slides, skipped, warnings, pages? | files? | frames, samples, keyFrames, seconds, fps, container, codec: \"av1\", audio: false; bytes}, error?} | {running: false} when there was none", + always, + export_status + ), ] } diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index 663d6f826..d6dbf158a 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -1,4 +1,5 @@ -//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF. +//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF +//! ([`crate::pdf`]). use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image}; use crate::{Session, media::RenderJob}; use lightcraft_pipeline::{OutputDepth, OutputSpace}; @@ -114,7 +115,7 @@ impl Prepared { let capacity = self.options.columns * self.options.rows; let photos = self.items.len(); let pages = photos.div_ceil(capacity); - let mut pdf = Pdf::new(pages, l.points); + let mut pdf = crate::pdf::Pdf::new(Vec::new())?; let mut page = Rgba8::filled(l.width, l.height, [255; 4]); for (i, (name, job)) in self.items.into_iter().enumerate() { if !progress(i, &name) { @@ -148,17 +149,17 @@ impl Prepared { } if slot + 1 == capacity || i + 1 == photos { let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?; - if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { + if pdf.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { return Err("contact sheet exceeds 256 MiB; select fewer photos".into()); } - pdf.page(i / capacity, &jpg, l.width, l.height); + pdf.image_page(&jpg, l.width, l.height, l.points, "")?; page.data.fill([255; 4]); } } if !progress(photos, "PDF") { return Err("Contact sheet cancelled".into()); } - Ok(Document { bytes: pdf.finish(), pages, photos }) + Ok(Document { bytes: pdf.finish("")?, pages, photos }) } } @@ -195,67 +196,6 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> Ok(()) } -// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -struct Pdf { - bytes: Vec, - offsets: Vec, - points: (f64, f64), -} - -impl Pdf { - fn new(pages: usize, points: (f64, f64)) -> Self { - let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points }; - p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>"); - let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); - p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes()); - p - } - - fn object(&mut self, id: usize, body: &[u8]) { - self.offsets.push(self.bytes.len()); - self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); - self.bytes.extend_from_slice(body); - self.bytes.extend_from_slice(b"\nendobj\n"); - } - - fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) { - let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes(); - body.extend_from_slice(bytes); - body.extend_from_slice(b"\nendstream"); - self.object(id, &body); - } - - fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { - let id = 3 + index * 3; - let (w, h) = self.points; - self.object( - id, - format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>", - id + 2, - id + 1 - ) - .as_bytes(), - ); - self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes()); - self.stream( - id + 2, - &format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), - jpg, - ); - } - - fn finish(mut self) -> Vec { - let start = self.bytes.len(); - self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); - for offset in self.offsets.iter().skip(1) { - self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); - self.bytes - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5cb09fd87..74b6f5dbb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,6 +49,7 @@ pub mod memory; pub mod merge; mod model_download; pub mod originals; +mod pdf; pub mod preset_import; pub mod preset_luminar; pub mod presets; @@ -60,6 +61,7 @@ pub mod slide_overlay; pub mod slide_template; pub mod slide_title; pub mod slideshow; +pub mod slideshow_export; pub mod smart; pub mod sync; pub mod text; @@ -271,6 +273,8 @@ pub struct Session { /// Background tasks in flight (imports, exports, preview builds…), for the activity stack and /// `activity.list` / `activity.cancel`. pub activity: activity::Activity, + /// The last (or running) slideshow export (`slideshow.export`). + pub slideshow_export: Option>, /// The last (or running) Build Previews. pub preview_build: Option>, /// Develop defaults applied on import (persisted in prefs.json). @@ -376,6 +380,7 @@ impl Session { folder_changes: None, activity: Default::default(), preview_build: None, + slideshow_export: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, slideshow: slideshow::PlaybackSettings::default(), @@ -941,6 +946,8 @@ mod tests_settings_files; #[cfg(test)] mod tests_slide; #[cfg(test)] +mod tests_slideshow_export; +#[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_sync; diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs new file mode 100644 index 000000000..963535f5a --- /dev/null +++ b/crates/engine/src/pdf.rs @@ -0,0 +1,110 @@ +//! A minimal PDF 1.4 writer for image pages: one JPEG XObject per page, explicit byte lengths and +//! a classic cross-reference table. Used by the contact sheet ([`crate::contact_sheet`]) and the +//! slideshow PDF export ([`crate::slideshow_export`]). It streams into any writer, so a long +//! slideshow is never held in memory; the page tree and catalog are written last, once the +//! number of pages is known. + +use std::io::Write; + +/// Objects 1 and 2 are the catalog and the page tree; pages take three objects each from 3. +const FIRST_PAGE: usize = 3; + +pub(crate) struct Pdf { + w: W, + /// Bytes written so far. + pos: usize, + /// Each object's offset, by object number (index 0 unused). + offsets: Vec, + pages: Vec, +} + +fn io(e: std::io::Error) -> String { + format!("PDF: {e}") +} + +impl Pdf { + pub(crate) fn new(w: W) -> Result { + let mut p = Pdf { w, pos: 0, offsets: vec![0; FIRST_PAGE], pages: Vec::new() }; + p.put(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")?; + Ok(p) + } + + /// Bytes written so far. + pub(crate) fn len(&self) -> usize { + self.pos + } + + /// Pages added so far. + pub(crate) fn pages(&self) -> usize { + self.pages.len() + } + + fn put(&mut self, b: &[u8]) -> Result<(), String> { + self.w.write_all(b).map_err(io)?; + self.pos = self.pos.saturating_add(b.len()); + Ok(()) + } + + fn object(&mut self, id: usize, body: &[u8]) -> Result<(), String> { + if self.offsets.len() <= id { + self.offsets.resize(id + 1, 0); + } + if let Some(o) = self.offsets.get_mut(id) { + *o = self.pos; + } + self.put(format!("{id} 0 obj\n").as_bytes())?; + self.put(body)?; + self.put(b"\nendobj\n") + } + + fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) -> Result<(), String> { + let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes(); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\nendstream"); + self.object(id, &body) + } + + /// A page of `points` (width, height) filled by `jpg` (`width × height` pixels, RGB). + /// `extra` goes into the page dictionary (e.g. a display duration and transition). + pub(crate) fn image_page(&mut self, jpg: &[u8], width: usize, height: usize, points: (f64, f64), extra: &str) -> Result<(), String> { + let id = FIRST_PAGE + self.pages.len() * 3; + let (w, h) = points; + self.object( + id, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R{extra} >>", + id + 2, + id + 1 + ) + .as_bytes(), + )?; + self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes())?; + self.stream( + id + 2, + &format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), + jpg, + )?; + self.pages.push(id); + Ok(()) + } + + /// Write the page tree, the catalog (with `catalog_extra` in its dictionary, e.g. a page + /// mode), the cross-reference table and the trailer. + pub(crate) fn finish(mut self, catalog_extra: &str) -> Result { + if self.pages.is_empty() { + return Err("PDF: no pages".into()); + } + let kids = self.pages.iter().map(|i| format!("{i} 0 R")).collect::>().join(" "); + self.object(2, format!("<< /Type /Pages /Count {} /Kids [{kids}] >>", self.pages.len()).as_bytes())?; + self.object(1, format!("<< /Type /Catalog /Pages 2 0 R{catalog_extra} >>").as_bytes())?; + let start = self.pos; + let mut xref = format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).into_bytes(); + for offset in self.offsets.iter().skip(1) { + xref.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + xref.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); + self.put(&xref)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} diff --git a/crates/engine/src/slideshow_export.rs b/crates/engine/src/slideshow_export.rs new file mode 100644 index 000000000..e91c1ed34 --- /dev/null +++ b/crates/engine/src/slideshow_export.rs @@ -0,0 +1,760 @@ +//! 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**: `-001.jpg`, `-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 `.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, + pub ids: Option>, +} + +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 { + 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 { + let o: &Map = 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::().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> = 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 }, + Title { title: Title, compose: Arc }, +} + +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 { + 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, + 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, +} + +/// 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 { + 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 `.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 { + 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, 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) -> 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 { + 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 { + 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 = 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 { + 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 = None; + let mut emit = |mux: &mut VideoWriter>, + 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, + pub finished: AtomicBool, + /// The front end has told the user how it ended. + pub reported: AtomicBool, + pub result: std::sync::Mutex>>, +} + +impl ExportRun { + pub fn running(&self) -> bool { + !self.finished.load(Ordering::Relaxed) + } + + pub fn result(&self) -> Option> { + 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); +} diff --git a/crates/engine/src/tests_slideshow_export.rs b/crates/engine/src/tests_slideshow_export.rs new file mode 100644 index 000000000..766ae66f5 --- /dev/null +++ b/crates/engine/src/tests_slideshow_export.rs @@ -0,0 +1,334 @@ +//! `slideshow.export`: PDF, JPEG sequence and AV1 video of the slideshow, validation, the activity +//! row, cancel and photos that can't be rendered. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, Source}; +use serde_json::{Value, json}; + +use crate::Session; + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-ssexp-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } + fn files(&self, rel: &str) -> Vec { + let mut v: Vec = std::fs::read_dir(self.0.join(rel)) + .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect()) + .unwrap_or_default(); + v.sort(); + v + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A demo session playing 1 s slides with 0.5 s fades, its first `n` photos as ids. +fn session(n: usize) -> (Session, Vec) { + let mut s = Session::with_demo(); + s.execute("slideshow.playback", &json!({"slideDuration": 1.0, "fadeDuration": 0.5})).unwrap(); + let ids = s.catalog.photos().take(n).map(|p| p.id.0).collect(); + (s, ids) +} + +fn export(s: &mut Session, p: Value) -> Value { + let mut p = p; + p["wait"] = json!(true); + s.execute("slideshow.export", &p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +#[test] +fn every_parameter_is_checked() { + let (mut s, ids) = session(2); + for p in [ + json!({"path": "x.pdf"}), + json!({"format": "gif", "path": "x.gif"}), + json!({"format": "pdf"}), + json!({"format": "pdf", "path": " "}), + json!({"format": "pdf", "path": "x.pdf", "size": 10}), + json!({"format": "pdf", "path": "x.pdf", "size": 100_000}), + json!({"format": "pdf", "path": "x.pdf", "size": "big"}), + json!({"format": "pdf", "path": "x.pdf", "size": -5}), + json!({"format": "video", "path": "x.mp4", "size": 4320}), + json!({"format": "video", "path": "x.mp4", "size": "8kp"}), + json!({"format": "pdf", "path": "x.pdf", "quality": 0}), + json!({"format": "pdf", "path": "x.pdf", "quality": 101}), + json!({"format": "video", "path": "x.mp4", "fps": 29}), + json!({"format": "video", "path": "x.mp4", "fps": 30.5}), + json!({"format": "video", "path": "x.avi", "container": "avi"}), + json!({"format": "video", "path": "x.mp4", "speed": 11}), + json!({"format": "pdf", "path": "x.pdf", "fullScreen": "yes"}), + json!({"format": "pdf", "path": "x.pdf", "name": "a"}), + json!({"format": "jpeg", "path": "out", "name": "../up"}), + json!({"format": "jpeg", "path": "out", "name": ""}), + json!({"format": "pdf", "path": "x.pdf", "ids": "all"}), + json!({"format": "pdf", "path": "x.pdf", "ids": ["one"]}), + json!({"format": "pdf", "path": "x.pdf", "ids": []}), + json!({"format": "pdf", "path": "x.pdf", "ids": [u64::MAX]}), + json!({"format": "pdf", "path": "x.pdf", "typo": 1}), + json!({"format": "pdf", "path": "x.pdf", "wait": "now"}), + json!("pdf"), + ] { + let e = s.execute("slideshow.export", &p).err().unwrap_or_else(|| panic!("accepted {p}")); + assert!(!e.to_string().is_empty()); + } + assert!(s.activity.list().is_empty(), "nothing started"); + assert!(s.slideshow_export.is_none()); + // the photos in view: none + s.execute("library.filter", &json!({"text": "zzz no photo is called this"})).unwrap(); + assert!(s.visible_cloned().is_empty()); + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": "x.pdf"})).unwrap_err(); + assert!(e.to_string().contains("no photos in view"), "{e}"); + // in view: exported without ids + s.execute("library.filter", &json!({"text": ""})).unwrap(); + assert!(!s.visible_cloned().is_empty() && !ids.is_empty()); +} + +/// A minimal PDF reader: the cross-reference table points at every object, and the pages. +fn pdf_pages(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes); + let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::().unwrap(); + assert!(bytes[xref..].starts_with(b"xref\n0 ")); + let table = std::str::from_utf8(&bytes[xref..]).unwrap(); + let count: usize = table.lines().nth(1).unwrap().split_whitespace().nth(1).unwrap().parse().unwrap(); + for (id, line) in table.lines().skip(3).take(count - 1).enumerate() { + let offset: usize = line.split_whitespace().next().unwrap().parse().unwrap(); + assert!(bytes[offset..].starts_with(format!("{} 0 obj\n", id + 1).as_bytes()), "object {} misplaced", id + 1); + } + text.split(" 0 obj\n").filter(|o| o.starts_with("<< /Type /Page ")).map(|o| o.split("\nendobj").next().unwrap().to_string()).collect() +} + +/// The JPEGs embedded in a PDF, decoded. +fn pdf_images(bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut at = 0; + while let Some(start) = bytes[at..].windows(3).position(|v| v == [0xff, 0xd8, 0xff]).map(|p| p + at) { + let end = bytes[start..].windows(10).position(|v| v == b"\nendstream").unwrap() + start; + out.push(lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()); + at = end; + } + out +} + +#[test] +fn pdf_has_a_page_per_slide_titles_included() { + let dir = Scratch::new("pdf"); + let (mut s, ids) = session(3); + s.execute("slideshow.titles", &json!({"intro": {"enabled": true, "content": "text", "text": "Hello"}, "ending": {"enabled": true}})).unwrap(); + let path = dir.path("Show.pdf"); + let r = export(&mut s, json!({"format": "pdf", "path": path, "size": 160, "quality": 80, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["slides"].as_u64(), r["width"].as_u64(), r["height"].as_u64()), (Some(5), Some(5), Some(160), Some(90))); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.4")); + let pages = pdf_pages(&bytes); + assert_eq!(pages.len(), 5); + assert!(pages.iter().all(|p| p.contains("/MediaBox [0 0 160.00 90.00]") && p.contains("/Dur 1.00")), "{pages:?}"); + assert!(!pages[0].contains("/Trans"), "nothing fades into the first slide"); + assert!(pages[1].contains("/Trans << /S /Fade /D 0.50 >>")); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Count 5 ") && text.contains("/PageMode /FullScreen")); + let images = pdf_images(&bytes); + assert_eq!(images.len(), 5); + assert!(images.iter().all(|i| (i.width, i.height) == (160, 90))); + // the intro (black with white text) and a photo slide differ + let mean = |i: &lightcraft_raster::Rgba8| i.data.iter().map(|p| u64::from(p[1])).sum::() / i.data.len() as u64; + assert!(mean(&images[0]) < mean(&images[1]), "the intro screen is the first page"); + assert!(dir.files("").iter().all(|f| !f.ends_with(".part"))); + // no full screen; a 4:3 aspect makes 4:3 pages + s.execute("slideshow.layout", &json!({"aspect": "4:3"})).unwrap(); + let r = export(&mut s, json!({"format": "pdf", "path": path, "size": 120, "fullScreen": false, "ids": ids})); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(120), Some(90))); + assert!(!String::from_utf8_lossy(&std::fs::read(&path).unwrap()).contains("/FullScreen")); +} + +#[test] +fn jpeg_sequence_is_numbered_in_a_folder() { + let dir = Scratch::new("jpeg"); + let (mut s, ids) = session(3); + let out = dir.path("Trip"); + let r = export(&mut s, json!({"format": "jpeg", "path": out, "size": 128, "ids": ids})); + assert_eq!(dir.files("Trip"), vec!["Trip-001.jpg", "Trip-002.jpg", "Trip-003.jpg"]); + assert_eq!(r["files"].as_array().unwrap().len(), 3); + let img = lightcraft_codecs::decode(&std::fs::read(Path::new(&out).join("Trip-002.jpg")).unwrap(), Default::default()).unwrap().to_srgb8(); + assert_eq!((img.width, img.height), (128, 72)); + // a name of its own, and a portrait-free square check: the long side is the size + let r = export(&mut s, json!({"format": "jpeg", "path": out, "size": 64, "name": "Slide", "ids": [ids[0]]})); + assert_eq!(r["files"][0].as_str().unwrap(), Path::new(&out).join("Slide-001.jpg").to_string_lossy()); +} + +/// MP4 boxes of a byte range: (type, body range). +fn boxes(d: &[u8], r: std::ops::Range) -> Vec<([u8; 4], std::ops::Range)> { + let mut out = Vec::new(); + let mut p = r.start; + while p + 8 <= r.end { + let size32 = u32::from_be_bytes(d[p..p + 4].try_into().unwrap()) as usize; + let kind: [u8; 4] = d[p + 4..p + 8].try_into().unwrap(); + let (hdr, size) = if size32 == 1 { (16, u64::from_be_bytes(d[p + 8..p + 16].try_into().unwrap()) as usize) } else { (8, size32) }; + assert!(size >= hdr && p + size <= r.end); + out.push((kind, p + hdr..p + size)); + p += size; + } + assert_eq!(p, r.end); + out +} + +fn child(d: &[u8], r: std::ops::Range, path: &[&[u8; 4]]) -> std::ops::Range { + path.iter().fold(r, |r, k| boxes(d, r).into_iter().find(|(t, _)| t == *k).unwrap().1) +} + +#[test] +fn video_is_av1_in_mp4_with_fades_as_frames() { + let dir = Scratch::new("mp4"); + let (mut s, ids) = session(2); + let path = dir.path("show.mp4"); + let r = export(&mut s, json!({"format": "video", "path": path, "size": 64, "fps": 24, "quality": 40, "ids": ids})); + // 2 slides × 1 s at 24 fps; the second fades in over 12 frames + assert_eq!((r["frames"].as_u64(), r["seconds"].as_f64(), r["width"].as_u64(), r["height"].as_u64()), (Some(48), Some(2.0), Some(114), Some(64))); + assert_eq!(r["samples"].as_u64(), Some(1 + 12 + 1), "held slides are one sample each, fades a frame each"); + assert_eq!((r["codec"].as_str(), r["audio"].as_bool(), r["container"].as_str()), (Some("av1"), Some(false), Some("mp4"))); + let d = std::fs::read(&path).unwrap(); + let top: Vec<_> = boxes(&d, 0..d.len()).into_iter().map(|(k, _)| k).collect(); + assert_eq!(top, vec![*b"ftyp", *b"mdat", *b"moov"]); + let stbl = child(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"minf", b"stbl"]); + let be = |at: usize| u32::from_be_bytes(d[at..at + 4].try_into().unwrap()); + let stsz = child(&d, stbl.clone(), &[b"stsz"]); + assert_eq!(be(stsz.start + 8), 14, "sample count"); + let stts = child(&d, stbl.clone(), &[b"stts"]); + let runs = be(stts.start + 4) as usize; + let ticks: u32 = (0..runs).map(|i| be(stts.start + 8 + i * 8) * be(stts.start + 12 + i * 8)).sum(); + let mdhd = child(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"mdhd"]); + assert_eq!(ticks / be(mdhd.start + 12), 2, "duration: 2 s"); + let stsd = child(&d, stbl, &[b"stsd"]); + let entry = &boxes(&d, stsd.start + 8..stsd.end)[0]; + assert_eq!(&entry.0, b"av01"); + let inner = boxes(&d, entry.1.start + 78..entry.1.end); + let av1c = &inner.iter().find(|(k, _)| k == b"av1C").unwrap().1; + assert_eq!(d[av1c.start], 0x81, "av1C marker and version"); + assert_eq!((d[av1c.start + 4] >> 3) & 0x0f, 1, "the sequence header OBU follows"); + assert!(dir.files("").iter().all(|f| !f.ends_with(".part"))); +} + +#[test] +fn video_in_webm_with_a_fade_colour() { + let dir = Scratch::new("webm"); + let (mut s, ids) = session(2); + s.execute("slideshow.playback", &json!({"fadeColor": "#ffffff"})).unwrap(); + let path = dir.path("show.webm"); + let r = export(&mut s, json!({"format": "video", "path": path, "size": "64p", "fps": 25, "container": "webm", "ids": ids})); + assert_eq!((r["frames"].as_u64(), r["container"].as_str()), (Some(50), Some("webm"))); + let d = std::fs::read(&path).unwrap(); + assert!(d.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]), "EBML header"); + assert!(d.windows(4).any(|w| w == b"webm") && d.windows(5).any(|w| w == b"V_AV1")); + // Segment: the 8-byte size spans the rest of the file + let seg = d.windows(4).position(|w| w == [0x18, 0x53, 0x80, 0x67]).unwrap(); + let size = u64::from_be_bytes(d[seg + 4..seg + 12].try_into().unwrap()) & 0x00ff_ffff_ffff_ffff; + assert_eq!(seg + 12 + size as usize, d.len()); +} + +fn wait_for(s: &Session) -> Value { + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let run = s.slideshow_export.clone().unwrap(); + if !run.running() { + return run.json(); + } + assert!(Instant::now() < deadline, "export did not finish"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn background_export_has_an_activity_row_and_reports_its_result() { + let dir = Scratch::new("bg"); + let (mut s, ids) = session(4); + let path = dir.path("bg.pdf"); + let r = s.execute("slideshow.export", &json!({"format": "pdf", "path": path, "size": 96, "ids": ids})).unwrap(); + assert_eq!((r["background"].as_bool(), r["slides"].as_u64(), r["total"].as_u64()), (Some(true), Some(4), Some(4))); + let rows = s.activity.list(); + let row = rows.iter().find(|t| t.id == r["taskId"].as_u64().unwrap()).expect("a row in the activity stack"); + assert_eq!((row.kind, row.label.as_str(), row.total), ("export", "Exporting slideshow", 4)); + assert!(row.cancellable); + assert!(s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("other.pdf"), "ids": ids})).is_err(), "one at a time"); + let status = wait_for(&s); + assert_eq!(status["result"]["pages"], 4, "{status}"); + assert_eq!(status["done"], 4); + assert!(s.activity.list().is_empty(), "the row goes when the export ends"); + assert_eq!(s.execute("slideshow.exportStatus", &json!({})).unwrap()["result"]["path"], json!(path)); + assert!(Path::new(&path).exists()); +} + +#[test] +fn cancel_leaves_no_file_behind() { + let dir = Scratch::new("cancel"); + for (format, name, extra) in [("pdf", "c.pdf", json!({})), ("jpeg", "seq", json!({})), ("video", "c.mp4", json!({"size": 64}))] { + let (mut s, ids) = session(12); + let path = dir.path(name); + let mut p = json!({"format": format, "path": path, "ids": ids}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let r = s.execute("slideshow.export", &p).unwrap(); + s.execute("activity.cancel", &json!({"id": r["taskId"]})).unwrap(); + let status = wait_for(&s); + assert!(status["error"].as_str().is_some_and(|e| e.contains("cancelled")), "{format}: {status}"); + assert!(status["cancelled"].as_bool().unwrap()); + assert!(!Path::new(&path).is_file(), "{format}: no output"); + let left: Vec<_> = dir.files("").into_iter().chain(dir.files("seq")).filter(|f| f.ends_with(".part") || f.ends_with(".jpg")).collect(); + assert!(left.is_empty(), "{format}: {left:?}"); + assert!(s.activity.list().is_empty()); + } +} + +#[test] +fn a_photo_that_cannot_render_is_skipped_with_a_warning() { + let dir = Scratch::new("skip"); + let (mut s, mut ids) = session(2); + let id = s.catalog.alloc_photo_id(); + let gone = dir.path("gone.jpg"); + s.catalog + .apply(Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: gone }, "gone.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00")) }) + .unwrap(); + ids.insert(1, id.0); + let r = export(&mut s, json!({"format": "pdf", "path": dir.path("s.pdf"), "size": 64, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["skipped"].as_u64()), (Some(2), Some(1))); + assert!(r["warnings"][0].as_str().unwrap().contains("gone.jpg"), "{r}"); + // video: the skipped slide takes no time + let r = export(&mut s, json!({"format": "video", "path": dir.path("s.mp4"), "size": 64, "fps": 24, "ids": ids})); + assert_eq!((r["frames"].as_u64(), r["skipped"].as_u64()), (Some(48), Some(1))); + // only the broken photo: nothing to write, and nothing written + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("none.pdf"), "ids": [id.0], "wait": true})).unwrap_err(); + assert!(e.to_string().contains("no slide"), "{e}"); + assert!(dir.files("").iter().all(|f| f != "none.pdf" && !f.ends_with(".part"))); +} + +#[test] +fn unwritable_targets_and_long_videos_are_errors() { + let dir = Scratch::new("bad"); + let (mut s, ids) = session(2); + // a folder where the file should go + std::fs::create_dir_all(dir.path("taken.pdf")).unwrap(); + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("taken.pdf"), "size": 64, "ids": ids, "wait": true})); + assert!(e.is_err()); + // a file where the folder should go + std::fs::write(dir.path("file"), b"x").unwrap(); + let e = s.execute("slideshow.export", &json!({"format": "jpeg", "path": dir.path("file"), "size": 64, "ids": ids, "wait": true})); + assert!(e.is_err()); + assert!(s.activity.list().is_empty()); + // more than 2 hours of video: 120 s slides × 61 + s.execute("slideshow.playback", &json!({"slideDuration": 120})).unwrap(); + let many: Vec = std::iter::repeat_n(ids[0], 61).collect(); + let e = s.execute("slideshow.export", &json!({"format": "video", "path": dir.path("long.mp4"), "ids": many})).unwrap_err(); + assert!(e.to_string().contains("limited to 120 min"), "{e}"); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index ed7549c32..eb57dafd0 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} importiert", "Skipped {problem}": "Übersprungen: {problem}", "…and {more} more": "…und {more} weitere", - "Template preview: {name}": "Vorlagenvorschau: {name}" + "Template preview: {name}": "Vorlagenvorschau: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Folien · {time}", + "{n} pages": "{n} Seiten", + "{n} JPEG files": "{n} JPEG-Dateien", + "{s} s of AV1 video": "{s} s AV1-Video", + "Slideshow exported: {what}": "Diashow exportiert: {what}", + " · {n} skipped (could not be rendered)": " · {n} übersprungen (nicht darstellbar)" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 78b215d7e..22cde84e3 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2117,5 +2117,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Eine hellgraue Wand mit weißem Passepartout und weichem Schatten; Bildunterschrift und Bewertung unter dem Foto.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm beschnitten, mit langsamen Überblendungen.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Ein 16:9-Bild mit schwarzen Balken oben und unten, dem Titel in der Ecke, Blenden über Schwarz und einer Abschlusstafel.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto.", + "Exporting slideshow": "Diashow wird exportiert", + "Export Slideshow…": "Diashow exportieren…", + "Export Slideshow": "Diashow exportieren", + "Export Slideshow: folder for the JPEG files": "Diashow exportieren: Ordner für die JPEG-Dateien", + "Frame rate": "Bildrate", + "Container": "Container", + "Export PDF…": "PDF exportieren…", + "Export JPEG…": "JPEG exportieren…", + "Export Video…": "Video exportieren…", + "Automatically show full screen": "Automatisch im Vollbild anzeigen", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Folie (lange Seite in Pixeln = Punkte), mit Foliendauer und Überblendung für den Präsentationsmodus des Betrachters.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Nummerierte JPEG-Dateien (name-001.jpg…) in einem neuen Ordner, der im nächsten Schritt benannt wird.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jede Folie für ihre Dauer mit ihren Überblendungen. Kein Ton: Musik wird noch nicht exportiert.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Folien verwenden Layout, Überlagerungen sowie Intro- und Abspannbild der Diashow, in der Reihenfolge der Ansicht.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 167288981..62fde3ad1 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "Se importó {name}", "Skipped {problem}": "Omitido: {problem}", "…and {more} more": "…y {more} más", - "Template preview: {name}": "Vista previa de la plantilla: {name}" + "Template preview: {name}": "Vista previa de la plantilla: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} fotos en la vista · {slides} diapositivas · {time}", + "{n} pages": "{n} páginas", + "{n} JPEG files": "{n} archivos JPEG", + "{s} s of AV1 video": "{s} s de vídeo AV1", + "Slideshow exported: {what}": "Presentación exportada: {what}", + " · {n} skipped (could not be rendered)": " · {n} omitidas (no se pudieron representar)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e826a8393..439348347 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Una pared gris claro con paspartú blanco y sombra suave; el pie de foto y la valoración debajo de la foto.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para llenar toda la pantalla, con fundidos cruzados lentos.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un encuadre 16:9 con franjas negras arriba y abajo, el título en la esquina, fundidos a negro y un cartel final.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fondo pizarra oscuro con la exposición, el ISO y la distancia focal, y luego la cámara y el objetivo, bajo cada foto." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fondo pizarra oscuro con la exposición, el ISO y la distancia focal, y luego la cámara y el objetivo, bajo cada foto.", + "Exporting slideshow": "Exportando presentación", + "Export Slideshow…": "Exportar presentación…", + "Export Slideshow": "Exportar presentación", + "Export Slideshow: folder for the JPEG files": "Exportar presentación: carpeta para los archivos JPEG", + "Frame rate": "Fotogramas por segundo", + "Container": "Contenedor", + "Export PDF…": "Exportar PDF…", + "Export JPEG…": "Exportar JPEG…", + "Export Video…": "Exportar vídeo…", + "Automatically show full screen": "Mostrar automáticamente a pantalla completa", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Una página por diapositiva (el lado largo en píxeles = puntos), con la duración y el fundido de la diapositiva para el modo de presentación del visor.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Archivos JPEG numerados (nombre-001.jpg…) en una carpeta nueva cuyo nombre se elige en el paso siguiente.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada diapositiva durante su duración con sus fundidos. Sin sonido: la música aún no se exporta.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Las diapositivas usan el diseño, las superposiciones y las pantallas de inicio y final de la presentación, en el orden de la vista.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Las fotos de la vista como diapositivas, con los ajustes de arriba. El progreso aparece en la pila de actividad." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index f5c7823fa..9f268e446 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} importé", "Skipped {problem}": "Ignoré : {problem}", "…and {more} more": "…et {more} de plus", - "Template preview: {name}": "Aperçu du modèle : {name}" + "Template preview: {name}": "Aperçu du modèle : {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos affichées · {slides} diapositives · {time}", + "{n} pages": "{n} pages", + "{n} JPEG files": "{n} fichiers JPEG", + "{s} s of AV1 video": "{s} s de vidéo AV1", + "Slideshow exported: {what}": "Diaporama exporté : {what}", + " · {n} skipped (could not be rendered)": " · {n} ignorées (rendu impossible)" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index dece0397c..08bd10e8f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Un mur gris clair avec un passe-partout blanc et une ombre douce ; la légende et la note sous la photo.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Chaque photo recadrée pour remplir tout l'écran, avec des fondus enchaînés lents.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un cadre 16:9 avec des bandes noires en haut et en bas, le titre dans le coin, des fondus au noir et un carton de fin.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fond ardoise sombre avec l'exposition, l'ISO et la focale, puis le boîtier et l'objectif, sous chaque photo." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fond ardoise sombre avec l'exposition, l'ISO et la focale, puis le boîtier et l'objectif, sous chaque photo.", + "Exporting slideshow": "Exportation du diaporama", + "Export Slideshow…": "Exporter le diaporama…", + "Export Slideshow": "Exporter le diaporama", + "Export Slideshow: folder for the JPEG files": "Exporter le diaporama : dossier des fichiers JPEG", + "Frame rate": "Images par seconde", + "Container": "Conteneur", + "Export PDF…": "Exporter en PDF…", + "Export JPEG…": "Exporter en JPEG…", + "Export Video…": "Exporter en vidéo…", + "Automatically show full screen": "Afficher automatiquement en plein écran", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Une page par diapositive (le grand côté en pixels = points), avec la durée et le fondu de la diapositive pour le mode présentation du lecteur.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Fichiers JPEG numérotés (nom-001.jpg…) dans un nouveau dossier nommé à l'étape suivante.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vidéo AV1, chaque diapositive affichée pendant sa durée avec ses fondus. Sans son : la musique n'est pas encore exportée.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Les diapositives utilisent la mise en page, les incrustations et les écrans d'ouverture et de fin du diaporama, dans l'ordre de l'affichage.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Les photos affichées en diapositives, avec les réglages ci-dessus. La progression s'affiche dans la pile d'activités." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e40560762..df2d59b5a 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} を読み込みました", "Skipped {problem}": "スキップ: {problem}", "…and {more} more": "…ほか {more} 件", - "Template preview: {name}": "テンプレートのプレビュー: {name}" + "Template preview: {name}": "テンプレートのプレビュー: {name}", + "{n} photos in view · {slides} slides · {time}": "表示中の写真 {n} 枚 · スライド {slides} 枚 · {time}", + "{n} pages": "{n} ページ", + "{n} JPEG files": "JPEG ファイル {n} 個", + "{s} s of AV1 video": "AV1 ビデオ {s} 秒", + "Slideshow exported: {what}": "スライドショーを書き出しました:{what}", + " · {n} skipped (could not be rendered)": " · {n} 枚をスキップ(レンダリングできません)" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 62ce87d27..9305d980b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1964,5 +1964,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "明るいグレーの壁に白いマットと柔らかな影。写真の下にキャプションとレーティング。", "Every photo cropped to fill the whole screen, with slow crossfades.": "各写真を画面全体に合わせて切り抜き、ゆっくりクロスフェードします。", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "上下に黒帯のある16:9の画面、隅にタイトル、黒を経由するフェード、エンディングカード。", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "暗いスレート色の背景。各写真の下に露出・ISO・焦点距離、その下にカメラとレンズ。" + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "暗いスレート色の背景。各写真の下に露出・ISO・焦点距離、その下にカメラとレンズ。", + "Exporting slideshow": "スライドショーを書き出し中", + "Export Slideshow…": "スライドショーを書き出し…", + "Export Slideshow": "スライドショーを書き出し", + "Export Slideshow: folder for the JPEG files": "スライドショーを書き出し:JPEG ファイルのフォルダー", + "Frame rate": "フレームレート", + "Container": "コンテナー", + "Export PDF…": "PDF を書き出し…", + "Export JPEG…": "JPEG を書き出し…", + "Export Video…": "ビデオを書き出し…", + "Automatically show full screen": "自動的にフルスクリーンで表示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "スライドごとに 1 ページ(長辺のピクセル数 = ポイント数)。ビューアーのプレゼンテーションモード用にスライドの表示時間とフェードを含みます。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "番号付きの JPEG ファイル(name-001.jpg…)を、次の手順で名前を付ける新しいフォルダーに書き出します。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 ビデオ。各スライドを表示時間のあいだフェード付きで表示します。音声なし:音楽はまだ書き出されません。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "スライドにはスライドショーのレイアウト、オーバーレイ、イントロ画面とエンディング画面が表示順で使われます。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "表示中の写真を上の設定でスライドにします。進行状況はアクティビティ一覧に表示されます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 6b5b32988..330a6473b 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} importado", "Skipped {problem}": "Ignorado: {problem}", "…and {more} more": "…e mais {more}", - "Template preview: {name}": "Prévia do modelo: {name}" + "Template preview: {name}": "Prévia do modelo: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} fotos na visualização · {slides} slides · {time}", + "{n} pages": "{n} páginas", + "{n} JPEG files": "{n} arquivos JPEG", + "{s} s of AV1 video": "{s} s de vídeo AV1", + "Slideshow exported: {what}": "Apresentação exportada: {what}", + " · {n} skipped (could not be rendered)": " · {n} ignoradas (não foi possível renderizar)" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9329b2d23..a82853734 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Uma parede cinza-clara com passe-partout branco e sombra suave; a legenda e a classificação abaixo da foto.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para preencher a tela inteira, com transições lentas.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Um quadro 16:9 com faixas pretas acima e abaixo, o título no canto, transições pelo preto e uma cartela final.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Um fundo ardósia escuro com a exposição, ISO e distância focal, e depois a câmera e a lente, abaixo de cada foto." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Um fundo ardósia escuro com a exposição, ISO e distância focal, e depois a câmera e a lente, abaixo de cada foto.", + "Exporting slideshow": "Exportando apresentação", + "Export Slideshow…": "Exportar apresentação…", + "Export Slideshow": "Exportar apresentação", + "Export Slideshow: folder for the JPEG files": "Exportar apresentação: pasta para os arquivos JPEG", + "Frame rate": "Taxa de quadros", + "Container": "Contêiner", + "Export PDF…": "Exportar PDF…", + "Export JPEG…": "Exportar JPEG…", + "Export Video…": "Exportar vídeo…", + "Automatically show full screen": "Mostrar automaticamente em tela cheia", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Uma página por slide (o lado maior em pixels = pontos), com a duração e o esmaecimento do slide para o modo de apresentação do visualizador.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Arquivos JPEG numerados (nome-001.jpg…) em uma pasta nova cujo nome é escolhido na próxima etapa.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada slide exibido pela sua duração com seus esmaecimentos. Sem som: a música ainda não é exportada.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Os slides usam o layout, as sobreposições e as telas de abertura e encerramento da apresentação, na ordem da visualização.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "As fotos da visualização como slides, com os ajustes acima. O progresso aparece na pilha de atividades." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 177857dc4..a073dd0e2 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "Импортировано: {name}", "Skipped {problem}": "Пропущено: {problem}", "…and {more} more": "…и ещё {more}", - "Template preview: {name}": "Просмотр шаблона: {name}" + "Template preview: {name}": "Просмотр шаблона: {name}", + "{n} photos in view · {slides} slides · {time}": "Фото в просмотре: {n} · слайдов: {slides} · {time}", + "{n} pages": "Страниц: {n}", + "{n} JPEG files": "Файлов JPEG: {n}", + "{s} s of AV1 video": "{s} с видео AV1", + "Slideshow exported: {what}": "Слайд-шоу экспортировано: {what}", + " · {n} skipped (could not be rendered)": " · пропущено: {n} (не удалось отрисовать)" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 2a0b609eb..870089cd7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1678,5 +1678,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Светло-серая стена с белым паспарту и мягкой тенью; подпись и рейтинг под фото.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Каждое фото обрезано на весь экран, с медленной сменой наплывом.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 с чёрными полосами сверху и снизу, название в углу, затемнения через чёрный и финальная карточка.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Тёмный грифельный фон с экспозицией, ISO и фокусным расстоянием, а ниже камерой и объективом под каждым фото." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Тёмный грифельный фон с экспозицией, ISO и фокусным расстоянием, а ниже камерой и объективом под каждым фото.", + "Exporting slideshow": "Экспорт слайд-шоу", + "Export Slideshow…": "Экспортировать слайд-шоу…", + "Export Slideshow": "Экспорт слайд-шоу", + "Export Slideshow: folder for the JPEG files": "Экспорт слайд-шоу: папка для файлов JPEG", + "Frame rate": "Частота кадров", + "Container": "Контейнер", + "Export PDF…": "Экспорт в PDF…", + "Export JPEG…": "Экспорт в JPEG…", + "Export Video…": "Экспорт видео…", + "Automatically show full screen": "Автоматически открывать во весь экран", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Одна страница на слайд (длинная сторона в пикселях = пунктах), с длительностью и переходом слайда для режима презентации в просмотрщике.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумерованные файлы JPEG (имя-001.jpg…) в новой папке, имя которой задаётся на следующем шаге.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Видео AV1: каждый слайд показывается свою длительность, с переходами. Без звука: музыка пока не экспортируется.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайды используют макет, наложения, вступительный и финальный экраны слайд-шоу, в порядке просмотра.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографии в просмотре как слайды, с настройками выше. Ход выполнения виден в списке задач." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index d334f2649..578f3466f 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "Імпортовано: {name}", "Skipped {problem}": "Пропущено: {problem}", "…and {more} more": "…і ще {more}", - "Template preview: {name}": "Перегляд шаблону: {name}" + "Template preview: {name}": "Перегляд шаблону: {name}", + "{n} photos in view · {slides} slides · {time}": "Фото в перегляді: {n} · слайдів: {slides} · {time}", + "{n} pages": "Сторінок: {n}", + "{n} JPEG files": "Файлів JPEG: {n}", + "{s} s of AV1 video": "{s} с відео AV1", + "Slideshow exported: {what}": "Слайд-шоу експортовано: {what}", + " · {n} skipped (could not be rendered)": " · пропущено: {n} (не вдалося відтворити)" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index e7b3687bf..28b7cbf48 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2156,5 +2156,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Світло-сіра стіна з білим паспарту та м'якою тінню; підпис і рейтинг під фото.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Кожне фото обрізане на весь екран, з повільною зміною накладанням.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 з чорними смугами згори й знизу, назва в куті, затемнення через чорний і фінальна картка.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Темне грифельне тло з експозицією, ISO та фокусною відстанню, а нижче камерою й об'єктивом під кожним фото." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Темне грифельне тло з експозицією, ISO та фокусною відстанню, а нижче камерою й об'єктивом під кожним фото.", + "Exporting slideshow": "Експорт слайд-шоу", + "Export Slideshow…": "Експортувати слайд-шоу…", + "Export Slideshow": "Експорт слайд-шоу", + "Export Slideshow: folder for the JPEG files": "Експорт слайд-шоу: тека для файлів JPEG", + "Frame rate": "Частота кадрів", + "Container": "Контейнер", + "Export PDF…": "Експорт у PDF…", + "Export JPEG…": "Експорт у JPEG…", + "Export Video…": "Експорт відео…", + "Automatically show full screen": "Автоматично відкривати на весь екран", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Одна сторінка на слайд (довга сторона в пікселях = пунктах), з тривалістю та переходом слайда для режиму презентації в переглядачі.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумеровані файли JPEG (назва-001.jpg…) у новій теці, назву якої задаєте на наступному кроці.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Відео AV1: кожен слайд показується свою тривалість, з переходами. Без звуку: музика поки не експортується.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайди використовують макет, накладання, вступний і фінальний екрани слайд-шоу, у порядку перегляду.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографії в перегляді як слайди, з налаштуваннями вище. Поступ видно у списку завдань." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 0d6aa11ef..06d722647 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "已导入 {name}", "Skipped {problem}": "已跳过:{problem}", "…and {more} more": "…还有 {more} 个", - "Template preview: {name}": "模板预览:{name}" + "Template preview: {name}": "模板预览:{name}", + "{n} photos in view · {slides} slides · {time}": "视图中 {n} 张照片 · {slides} 张幻灯片 · {time}", + "{n} pages": "{n} 页", + "{n} JPEG files": "{n} 个 JPEG 文件", + "{s} s of AV1 video": "{s} 秒 AV1 视频", + "Slideshow exported: {what}": "幻灯片放映已导出:{what}", + " · {n} skipped (could not be rendered)": " · 已跳过 {n} 张(无法渲染)" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0780ecb42..78afa9aa5 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "浅灰色墙面、白色卡纸和柔和阴影;照片下方显示说明和星级。", "Every photo cropped to fill the whole screen, with slow crossfades.": "每张照片裁剪以填满整个屏幕,带缓慢交叉淡化。", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 画面,上下黑边,标题在角落,经黑色淡入淡出,并有结尾卡。", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每张照片下方显示曝光、ISO 和焦距,再下面是相机和镜头。" + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每张照片下方显示曝光、ISO 和焦距,再下面是相机和镜头。", + "Exporting slideshow": "正在导出幻灯片放映", + "Export Slideshow…": "导出幻灯片放映…", + "Export Slideshow": "导出幻灯片放映", + "Export Slideshow: folder for the JPEG files": "导出幻灯片放映:JPEG 文件所在的文件夹", + "Frame rate": "帧率", + "Container": "容器格式", + "Export PDF…": "导出 PDF…", + "Export JPEG…": "导出 JPEG…", + "Export Video…": "导出视频…", + "Automatically show full screen": "自动全屏显示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "每张幻灯片一页(长边像素数 = 点数),并带有幻灯片时长和淡化效果,供查看器的演示模式使用。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "带编号的 JPEG 文件(name-001.jpg…),放在下一步命名的新文件夹中。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 视频,每张幻灯片按其时长显示并带有淡化效果。无声音:暂不导出音乐。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻灯片使用幻灯片放映的版面、叠加以及片头和片尾画面,按视图顺序排列。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "将视图中的照片按上方设置制作为幻灯片。进度显示在活动列表中。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index e3e30a19b..5515cc6ea 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "已匯入 {name}", "Skipped {problem}": "已略過:{problem}", "…and {more} more": "…還有 {more} 個", - "Template preview: {name}": "範本預覽:{name}" + "Template preview: {name}": "範本預覽:{name}", + "{n} photos in view · {slides} slides · {time}": "檢視中 {n} 張照片 · {slides} 張幻燈片 · {time}", + "{n} pages": "{n} 頁", + "{n} JPEG files": "{n} 個 JPEG 檔案", + "{s} s of AV1 video": "{s} 秒 AV1 視訊", + "Slideshow exported: {what}": "幻燈片播放已匯出:{what}", + " · {n} skipped (could not be rendered)": " · 已略過 {n} 張(無法算繪)" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 66e802ef9..7b1286991 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "淺灰色牆面、白色卡紙和柔和陰影;照片下方顯示說明和星級。", "Every photo cropped to fill the whole screen, with slow crossfades.": "每張照片裁切以填滿整個螢幕,帶緩慢交叉淡化。", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 畫面,上下黑邊,標題在角落,經黑色淡入淡出,並有結尾卡。", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每張照片下方顯示曝光、ISO 和焦距,再下面是相機和鏡頭。" + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每張照片下方顯示曝光、ISO 和焦距,再下面是相機和鏡頭。", + "Exporting slideshow": "正在匯出幻燈片播放", + "Export Slideshow…": "匯出幻燈片播放…", + "Export Slideshow": "匯出幻燈片播放", + "Export Slideshow: folder for the JPEG files": "匯出幻燈片播放:JPEG 檔案所在的資料夾", + "Frame rate": "影格速率", + "Container": "容器格式", + "Export PDF…": "匯出 PDF…", + "Export JPEG…": "匯出 JPEG…", + "Export Video…": "匯出視訊…", + "Automatically show full screen": "自動全螢幕顯示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "每張幻燈片一頁(長邊像素數 = 點數),並附有幻燈片時長與淡化效果,供檢視器的簡報模式使用。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "編號的 JPEG 檔案(name-001.jpg…),放在下一步命名的新資料夾中。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 視訊,每張幻燈片依其時長顯示並附有淡化效果。無聲音:尚未匯出音樂。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻燈片使用幻燈片播放的版面、疊加以及片頭與片尾畫面,依檢視順序排列。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "將檢視中的照片依上方設定製作為幻燈片。進度顯示在活動清單中。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fec622092..13d0da5ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ pub mod pick; pub mod region; pub mod render; pub mod shortcuts; +pub mod slideshow_export; pub mod softpaint; pub mod state; pub mod sync; @@ -313,6 +314,8 @@ pub struct LightcraftApp { pub tasks: tasks::Tasks, /// The files of the last finished background export (`ui.inspect` → `export.last`). pub last_export_result: Option, + /// The last slideshow export's end has been told (a toast). + pub slideshow_export_reported: bool, /// The look the loupe shows while the pointer rests on a preset or profile (set by the /// panels each frame; nothing is committed, no history entry). pub hover_preview: Option, @@ -389,6 +392,7 @@ impl LightcraftApp { export: None, tasks: Default::default(), last_export_result: None, + slideshow_export_reported: true, hover_preview: None, window_is_fullscreen: false, gpu_applied: None, @@ -1036,6 +1040,7 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); export_task::poll(self, &ctx); + slideshow_export::poll(self, &ctx); panels::activity::show(self, &ctx); pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f93f7ad66..731ab2fb1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -141,6 +141,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.photoCounts", "Show Photo Counts", None, "View"), ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), ("view.slideshowOptions", "Slideshow Options…", None, "View"), + ("dialog.slideshowExport", "Export Slideshow…", None, "View"), + ("app.slideshowExport", "Export Slideshow", None, ""), // the slideshow's playback controls (Space, ←/→ and Esc while it plays) ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), ("slideshow.next", "Next Slide", None, ""), @@ -982,6 +984,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let format = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => lightcraft_engine::slideshow_export::Format::Jpeg, + Some("video") => lightcraft_engine::slideshow_export::Format::Video, + _ => lightcraft_engine::slideshow_export::Format::Pdf, + }; + app.ui.dialog = Some(Dialog::SlideshowExport { settings: lightcraft_engine::slideshow_export::ExportSettings::for_format(format) }); + Ok(Value::Null) + } + "app.slideshowExport" => crate::slideshow_export::start(app, id, p), "dialog.contactSheet" => { app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); Ok(Value::Null) @@ -1602,6 +1614,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option bool { match id { "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + "dialog.slideshowExport" | "app.slideshowExport" => { + !cfg!(target_arch = "wasm32") && !app.session.slideshow_export.as_ref().is_some_and(|r| r.running()) + } s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { app.session.active().is_some() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..344ed50cc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -109,6 +109,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::PasteSettings { .. } => "Paste Selected Settings", Dialog::Export { .. } => "Export", Dialog::ContactSheet { .. } => "Contact Sheet PDF", + Dialog::SlideshowExport { .. } => "Export Slideshow", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", @@ -477,6 +478,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); group_checklist(ui, "pasteGroup", groups); } + Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), Dialog::ContactSheet { options } => { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); @@ -976,6 +978,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label.as_str() } Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::SlideshowExport { .. } => "Export…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", Dialog::DenoiseModel { .. } => "Install", @@ -1040,6 +1043,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } | Dialog::Export { .. } | Dialog::ContactSheet { .. } + | Dialog::SlideshowExport { .. } | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } @@ -1359,6 +1363,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("app.contactSheet", json!(options)), + Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { @@ -1524,6 +1529,60 @@ fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { } /// A labelled row (fixed label column). +/// View ▸ Export Slideshow…: what to write (PDF, JPEG sequence, video) and how big; where comes +/// after (a save dialog). +fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { + use lightcraft_engine::slideshow_export::{ExportSettings, Format}; + let t = Tokens::get(ui.ctx()); + let photos = app.session.visible_cloned().len(); + let slide = app.session.slideshow.slide_duration; + let (intro, ending) = app.session.slide_titles.durations(slide); + let titles = usize::from(intro.is_some()) + usize::from(ending.is_some()); + let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0); + let summary = crate::i18n::tr_format!( + "{n} photos in view · {slides} slides · {time}", + n = photos, + slides = photos + titles, + time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64) + ); + ui.label(egui::RichText::new(summary).color(t.text_dim)); + let before = settings.format; + choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format); + if settings.format != before { + *settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) }; + } + match settings.format { + Format::Video => { + choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size); + choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps); + let mut webm = settings.container == "webm"; + choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm); + settings.container = if webm { "webm".into() } else { "mp4".into() }; + } + _ => { + choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size); + } + } + field(ui, "Quality", |ui| { + let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); + crate::widgets::register(ui.ctx(), "slider:slideshowExportQuality", r.rect); + }); + let note = match settings.format { + Format::Pdf => { + let r = ui.checkbox(&mut settings.full_screen, crate::i18n::tr("Automatically show full screen")); + crate::widgets::register(ui.ctx(), "check:slideshowExportFullScreen", r.rect); + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode." + } + Format::Jpeg => "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.", + Format::Video => "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.", + }; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Slides use the slideshow's layout, overlays and intro and ending screens, in view order.")) + .color(t.text_dim), + ); +} + fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3f59df629..bb273150d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -663,6 +663,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { super::slide_overlays::sections(app, ui, t); ui.add_space(6.0); super::slide_titles::sections(app, ui, t); + ui.add_space(6.0); + export_section(app, ui, t); }); }); ui.add_space(12.0); @@ -673,6 +675,26 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). +fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Export")); + let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); + ui.horizontal(|ui| { + for (id, label, format) in [ + ("slideshowExportPdf", "Export PDF…", "pdf"), + ("slideshowExportJpeg", "Export JPEG…", "jpeg"), + ("slideshowExportVideo", "Export Video…", "video"), + ] { + let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label))); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.clicked() { + let _ = app.run("dialog.slideshowExport", json!({"format": format})); + } + } + }); + hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack.")); +} + fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; heading(ui, t, crate::i18n::tr("Playback")); diff --git a/crates/ui-egui/src/slideshow_export.rs b/crates/ui-egui/src/slideshow_export.rs new file mode 100644 index 000000000..c8c0794c7 --- /dev/null +++ b/crates/ui-egui/src/slideshow_export.rs @@ -0,0 +1,166 @@ +//! View ▸ Export Slideshow…: the dialog's choices go to `app.slideshowExport`, which asks where +//! to write (a PDF or video file, or the folder of a JPEG sequence) and starts the engine's +//! `slideshow.export` in the background. The export's row in the activity stack shows its +//! progress and ✕ (the engine owns the row); [`poll`] tells the user how it ended. + +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::pick::{PickKind, PickRequest, Picked}; + +/// `app.slideshowExport {format, size?, quality?, fps?, container?, fullScreen?, speed?, path?}`: +/// without a path, a save dialog asks for one first. +pub fn start(app: &mut LightcraftApp, id: &str, p: &Value) -> Result { + if app.session.slideshow_export.as_ref().is_some_and(|r| r.running()) { + return Err("a slideshow export is already running".into()); + } + let mut params = p.clone(); + if p.get("path").and_then(Value::as_str).is_none_or(|x| x.trim().is_empty()) { + let req = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => PickRequest { + kind: PickKind::Save, + title: Some(crate::i18n::tr("Export Slideshow: folder for the JPEG files").to_string()), + filter: None, + file_name: Some("Slideshow".into()), + }, + Some("video") if p.get("container").and_then(Value::as_str) == Some("webm") => { + PickRequest::save(crate::i18n::tr("Export Slideshow"), "WebM", &["webm"], "Slideshow.webm") + } + Some("video") => PickRequest::save(crate::i18n::tr("Export Slideshow"), "MP4", &["mp4"], "Slideshow.mp4"), + _ => PickRequest::save(crate::i18n::tr("Export Slideshow"), "PDF", &["pdf"], "Slideshow.pdf"), + }; + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no save dialog on this platform; supply path".into()), + }; + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + params["path"] = json!(path); + } + let r = app.run("slideshow.export", params)?; + app.slideshow_export_reported = false; + Ok(r) +} + +/// Each frame: when the export has ended, say how (once). +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.slideshow_export_reported { + return; + } + let Some(run) = app.session.slideshow_export.clone() else { return }; + if run.running() { + return; + } + app.slideshow_export_reported = true; + let msg = match run.result() { + Some(Ok(r)) => { + let what = match r["format"].as_str() { + Some("pdf") => crate::i18n::tr_format!("{n} pages", n = r["pages"].as_u64().unwrap_or(0)), + Some("jpeg") => crate::i18n::tr_format!("{n} JPEG files", n = r["slides"].as_u64().unwrap_or(0)), + _ => crate::i18n::tr_format!("{s} s of AV1 video", s = r["seconds"].as_f64().unwrap_or(0.0).round()), + }; + let mut m = crate::i18n::tr_format!("Slideshow exported: {what}", what = what); + if let Some(n) = r["skipped"].as_u64().filter(|n| *n > 0) { + m += &crate::i18n::tr_format!(" · {n} skipped (could not be rendered)", n = n); + } + m + } + Some(Err(e)) => e, + None => crate::i18n::tr("Export stopped unexpectedly").to_string(), + }; + app.toast(ctx, msg); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::Dialog; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + /// The app with a picker that answers `answer` and remembers what it was asked. + fn app_answering(answer: String) -> (LightcraftApp, Arc>>) { + let asked = Arc::new(Mutex::new(Vec::::new())); + let requests = asked.clone(); + let services = crate::Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec![answer.clone()]).unwrap(); + Ok(rx) + })), + ..Default::default() + }; + let mut s = lightcraft_engine::Session::with_demo(); + s.execute("slideshow.playback", &json!({"slideDuration": 1, "fadeDuration": 0.5})).unwrap(); + (LightcraftApp::new(s, services), asked) + } + + fn run_dialog(h: &mut crate::headless::Headless) -> Value { + let dialog = h.app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); + let deadline = Instant::now() + Duration::from_secs(120); + let mut seen_row = false; + loop { + h.step(); + seen_row |= h.app.session.activity.list().iter().any(|t| t.kind == "export" && t.label == "Exporting slideshow"); + if h.app.slideshow_export_reported && h.app.session.slideshow_export.is_some() { + break; + } + assert!(Instant::now() < deadline, "export did not finish"); + std::thread::sleep(Duration::from_millis(5)); + } + let run = h.app.session.slideshow_export.clone().unwrap(); + let mut r = run.result().unwrap().unwrap(); + r["sawRow"] = json!(seen_row); + r["toast"] = json!(h.app.ui.toast.as_ref().map(|t| t.0.clone())); + r + } + + #[test] + fn menu_dialog_picker_and_background_pdf_export() { + let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Show.pdf").to_string_lossy().to_string(); + let (mut app, asked) = app_answering(path.clone()); + assert!(crate::menus::ui_enabled(&app, "dialog.slideshowExport")); + app.run("dialog.slideshowExport", json!({"format": "pdf"})).unwrap(); + let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no slideshow export dialog") }; + assert_eq!((settings.size, settings.quality, settings.full_screen), (1920, 90, true)); + settings.size = 96; + let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); + let r = run_dialog(&mut h); + let req = asked.lock().unwrap(); + assert_eq!(req[0].kind, PickKind::Save); + assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]); + assert_eq!(r["width"], 96); + assert!(r["pages"].as_u64().unwrap() > 1); + assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-1.4")); + assert!(r["toast"].as_str().unwrap().contains("Slideshow exported"), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn video_dialog_asks_for_an_mp4_and_jpeg_for_a_folder_name() { + let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-v-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Show.mp4").to_string_lossy().to_string(); + let (mut app, asked) = app_answering(path.clone()); + app.run("dialog.slideshowExport", json!({"format": "video"})).unwrap(); + let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no dialog") }; + assert_eq!((settings.size, settings.fps, settings.container.as_str()), (720, 30, "mp4")); + settings.size = 64; + settings.fps = 24; + let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); + let r = run_dialog(&mut h); + assert_eq!(asked.lock().unwrap()[0].filter.as_ref().unwrap().1, &["mp4"]); + assert_eq!((r["codec"].as_str(), r["height"].as_u64()), (Some("av1"), Some(64))); + assert!(r["sawRow"].as_bool().unwrap(), "the export showed in the activity stack"); + assert!(std::path::Path::new(&path).is_file()); + // JPEG: a save dialog names the folder (no file type) + let (mut app, asked) = app_answering(dir.join("Frames").to_string_lossy().to_string()); + app.run("app.slideshowExport", json!({"format": "jpeg", "size": 64})).unwrap(); + assert!(asked.lock().unwrap()[0].filter.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2eefe08a3..921899fb2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -537,6 +537,10 @@ pub enum Dialog { ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, + /// View ▸ Export Slideshow…: format, size, quality, frame rate, container. + SlideshowExport { + settings: lightcraft_engine::slideshow_export::ExportSettings, + }, /// `parent`: the folder to create it in (none: the top level). NewAlbum { name: String, diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 576b3868f..67ed1ca20 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -52,6 +52,7 @@ guard.detail(&file_name); |---|---|---| | Export, contact sheet | `crates/ui-egui/src/export_task.rs` | adopt the job's flag; update the row each frame | | Build Previews | `crates/engine/src/cmd/previews.rs` | move the guard into the worker thread | +| Slideshow export | `crates/engine/src/cmd/slideshow.rs`, `crates/engine/src/slideshow_export.rs` | an engine command that owns its worker and row; progress from the worker through a `TaskHandle`; results read back by command | | Model downloads | `crates/engine/src/face_download.rs` | report progress through a `TaskHandle` in shared state | | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bee1fa1d9..64e6b54db 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,6 +85,9 @@ filmstrip is visible. | `engine.execute {command: "slideshow.templates.apply"}` | `{id}` or `{name}`; `{id: "previous"}` | Replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's, saved with the library. No undo: the replaced settings become `previous` for the session → `{applied, name, warnings, previous, settings}` (`warnings`: a background photo not in this library, an unreadable plate or watermark image…) | | `engine.execute {command: "slideshow.templates.save"}` / `update` / `rename` / `delete` | `{name, replace?}` / `{id}` / `{id, name}` / `{id}` | Save the current settings as a user template (`.json`; path separators and reserved characters become `_`, a taken or built-in name is refused unless `replace`), update one with the current settings, rename (its file follows), delete. Built-ins can't change | | `engine.execute {command: "slideshow.templates.import"}` / `export` | `{path}` / `{id, path}` | Import a template `.json` (at most 256 KB; format, version and every setting checked; a taken name gets " (2)") → `{id, name, renamed, warnings}`; export any template as `.json` | +| `engine.execute {command: "slideshow.export"}` | `{format: "pdf"\|"jpeg"\|"video", path, size?, quality?, fps?, container?: "mp4"\|"webm", fullScreen?, speed?, name?, ids?, wait?}` | The slideshow (the photos in view, or `ids`, as composed slides between the intro and ending screens that are on) as a PDF (one page per slide; `size` = long side 64–8192 px, default 1920), a JPEG sequence (`path` = the folder, files `-001.jpg`…) or AV1 video in MP4 / WebM (`size` = frame height 64–2160 or `"720p"` / `"1080p"`, `fps` 24 / 25 / 30 / 60; fades rendered as frames; no audio; at most 2 hours). Runs in the background → `{taskId, background, slides, seconds, total, width, height, warnings}`; its row is in `activity.list` (`kind: "export"`, label "Exporting slideshow"), `activity.cancel {id: taskId}` stops it and leaves no file (outputs are written as `.part` and renamed when complete). `wait: true` runs it in the call and replies with the result. One at a time | +| `engine.execute {command: "slideshow.exportStatus"}` | — | The last slideshow export: `{taskId, format, path, running, done, total, cancelled, result?, error?}`; `result` has `pages` (PDF), `files` (JPEG) or `frames`, `samples`, `keyFrames`, `seconds`, `fps`, `container`, `codec: "av1"`, `audio: false` (video), and `skipped` / `warnings` for photos that could not be rendered | +| `engine.execute {command: "dialog.slideshowExport"}` / `app.slideshowExport` | `{format?}` / `{format, size?, quality?, fps?, container?, fullScreen?, path?}` | View ▸ Export Slideshow…: opens the dialog; `app.slideshowExport` asks for the path with a save dialog when none is given, then starts `slideshow.export` | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index b3b50f808..4ca0d1db4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -210,6 +210,12 @@ it skipped), `cmd_slideshow_templates_apply {id}` replaces the settings with one `{id: "previous"}` for the session), `cmd_slideshow_preview {template: id}` previews one without applying it, and `cmd_slideshow_templates_save` / `update` / `rename` / `delete` / `import` / `export` manage the user's own (JSON files in `/slideshow-templates`). +`cmd_slideshow_export {format: "pdf"|"jpeg"|"video", path, size?, quality?, fps?, container?: "mp4"|"webm", +fullScreen?, name?, ids?, wait?}` writes the slideshow (titles included) as a PDF (a page per slide), a numbered JPEG +sequence in a folder, or an AV1 video (no audio). It runs in the background and returns `{taskId, total, …}`: +`cmd_activity_list` shows its progress, `cmd_activity_cancel {id: taskId}` stops it (nothing is left on disk) and +`cmd_slideshow_exportStatus` gives the result (`pages`, `files`, or `frames` / `samples` / `seconds` for video) or the +error. With `wait: true` the call blocks and replies with the result. ## Resources diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index fd417806b..5290a22fe 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 35 | 20 | 28 | 9 | — | 23/25 (92%) | -| **Total** | 399 | 54 | 66 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 21 | 27 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 55 | 65 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.1%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 50.0% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 50.3% of 163. ## Top gaps and shortcut notes @@ -732,10 +732,10 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | | LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | -| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow), titles in slideshow export (LRC-SS-EXPORT) | +| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow). Slideshow export (LRC-SS-EXPORT) includes them: a PDF page / JPEG each, and in video they hold and fade like slides | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | -| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | +| LRC-SS-EXPORT | Slideshow export | P2 | 🟡 | `cmd:slideshow.export`, `cmd:slideshow.exportStatus`, `cmd:dialog.slideshowExport`, `cmd:app.slideshowExport`, `path:crates/engine/src/slideshow_export.rs`, `path:crates/codecs/src/video.rs`, `path:crates/ui-egui/src/slideshow_export.rs` | View ▸ Export Slideshow… (and Export PDF / JPEG / Video in Settings ▸ Slideshow): the photos in view as their composed slides (layout, overlays) between the intro and ending screens that are on, in view order. **PDF**: one page per slide at 1024 / 1920 / 3840 px (any 64–8192 by command; page = slide pixels in points, aspect from the aspect preview, "screen" = 16:9), JPEG quality, each page carrying the slide duration and a fade transition for presentation mode, "Automatically show full screen" (`/PageMode /FullScreen`). **JPEG**: `-001.jpg`… in a new folder. **Video**: AV1 (rav1e, fast preset) in MP4 or WebM written by our own muxers, 720p / 1080p (64–2160 by command), 24 / 30 fps (25 / 60 by command); each slide held for its duration (one long sample: variable frame rate) and fades rendered as blended frames (crossfade or through the fade colour); at most 2 hours. Runs in the background with a row in the activity stack (frames or slides done, ✕ cancels); files are written as `.part` and renamed when complete, so a cancelled or failed export leaves nothing; a photo that can't be rendered is skipped with a warning. Missing: H.264 (Classic's codec; patents, no pure-Rust encoder), audio / music in the video (LRC-SS-MUSIC), random order and pan & zoom in exports, the PDF's per-page fades follow the viewer (Acrobat), export presets, Classic's export dialog sizes beyond these, the web build (no export) | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | | LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing | diff --git a/docs/slideshow.md b/docs/slideshow.md new file mode 100644 index 000000000..79cf5f04e --- /dev/null +++ b/docs/slideshow.md @@ -0,0 +1,64 @@ +# Slideshow + +LightCraft's slideshow follows Lightroom Classic's Slideshow module (no Adobe assets, templates or designs): the photos +in view played full screen with a layout, overlays, intro and ending screens and fades, and exported as PDF, JPEG or +video. There is no Slideshow module view yet: the settings live in Settings ▸ Slideshow (View ▸ Slideshow Options…) +next to a live preview of the selected photo's slide, and View ▸ Slideshow (⌥⌘↩) plays. + +| Part | Commands | Code | +|---|---|---| +| Playback: slide and fade durations, fade colour, random order, repeat, preparing slides ahead | `slideshow.playback`, `view.slideshow` | `crates/engine/src/slideshow.rs` (the timeline), `crates/ui-egui/src/panels/slideshow.rs` | +| Layout, options, backdrop: margins, aspect preview, zoom to fill, stroke, shadow, colour, colour wash, background photo | `slideshow.layout` | `crates/slideshow` (the composer, no UI), `crates/engine/src/slide.rs` | +| Overlays: identity plate, rating stars, text with tokens, watermark | `slideshow.overlays` | `crates/engine/src/slide_overlay.rs` | +| Intro and ending screens | `slideshow.titles` | `crates/engine/src/slide_title.rs` | +| Templates: built-ins of our own and the user's (`/slideshow-templates/*.json`) | `slideshow.templates.*` | `crates/engine/src/slide_template.rs` | +| One slide or screen as a PNG | `slideshow.preview` | `crates/engine/src/cmd/slideshow.rs` | +| Export: PDF, JPEG sequence, AV1 video | `slideshow.export`, `slideshow.exportStatus` | `crates/engine/src/slideshow_export.rs`, `crates/codecs/src/video.rs` | + +All settings are library preferences (`prefs.json`); sizes and offsets are fractions of the slide's shorter side, so a +slide looks the same on any screen and at any export size. Playback, the preview, `slideshow.preview` and export draw +the same pixels through the same composer. + +## Export + +View ▸ Export Slideshow… (or Export PDF / JPEG / Video at the end of Settings ▸ Slideshow) chooses a format and size, +then asks where to write. The slides are the photos in view, in view order, between the intro and ending screens that +are on; random order and repeat are playback choices and don't apply. The slide aspect is the Layout's aspect preview +(Screen exports 16:9). + +- **PDF** — one page per slide, its picture a JPEG at the chosen quality, the page as many points as the slide has + pixels (1024 / 1920 / 3840 on the long side; 64–8192 by command). Each page has the slide duration (`/Dur`) and, + after the first, a fade transition (`/Trans`), which PDF viewers use in presentation mode; "Automatically show full + screen" sets `/PageMode /FullScreen`. Written by `crates/engine/src/pdf.rs` (shared with the contact sheet), streamed + to disk. +- **JPEG** — `-001.jpg`, `-002.jpg`… in the chosen folder (created; `name` defaults to the folder's name). +- **Video** — AV1 in MP4 (default) or WebM, 720p or 1080p (64–2160 by command), 24 or 30 fps (25 / 60 by command), no + audio (music is a later item, LRC-SS-MUSIC). The timeline is sampled at the frame rate: a slide's fade-in is rendered + frame by frame (a crossfade, or out to the fade colour and in from it), then the slide is held for the rest of its + duration as **one** sample lasting that long (variable frame rate), so a still slide costs one encoded frame. That + frame is a key frame: seeking lands on slides. Encoded by `rav1e` (pure Rust, BSD-2-Clause, already used for AVIF) at + speed 10, 8-bit 4:2:0 BT.709 limited range, in order (low latency, no frame reordering); at most 2 hours. + + The muxers are our own (`crates/codecs/src/video.rs`, from the public ISO/IEC 14496-12 + AV1-ISOBMFF and + Matroska/WebM specifications): MP4 is `ftyp`, one `mdat` (64-bit size patched at the end) and `moov` with an `av01` + sample entry (`av1C` holding the sequence header OBU, `colr nclx`, `pasp`), `stts` durations, `stss` key frames and a + single chunk; WebM is the EBML header and a Segment with SeekHead, Info (duration), Tracks (`V_AV1`, `CodecPrivate` = + `av1C`), Clusters of SimpleBlocks (the last frame a BlockGroup with its duration) and Cues. Samples are temporal units + without temporal delimiters. No AV1 decoder is in the product, so the tests check the container structure, sample + counts and durations; ffmpeg / browsers play the files (checked by hand when the muxers change). + +The export runs on a worker thread with a row in the activity stack ("Exporting slideshow": slides done, or frames for +video, and the slide being rendered; ✕ or `activity.cancel {id: taskId}` stops it). Every output is written as +`.part` and renamed when complete, so a cancelled, failed or interrupted export leaves nothing behind (for a JPEG +sequence, all files are renamed at the end). A photo that can't be rendered (its file gone or unreadable) is skipped +and named in the result's `warnings`; a full disk or an unwritable folder is an error. An original in the library is +never overwritten (`check_write_target` before and after). + +Encoding speed (rav1e speed 10, measured 2026-10-11 on a 32-thread Linux machine shared with other work): one minute of +slideshow (15 slides of 4 s with 1 s fades, 30 fps: 435 encoded frames) took 35 s at 720p and 74 s at 1080p, slide +rendering included. The cost is in the fades (one encoded frame per frame of fade); held slides cost one frame each. + +## Not yet + +The Slideshow module view (left-panel template browser, slide editor with draggable guides and overlays), music and +sync to music, pan & zoom, H.264 video, saving a slideshow with an album, export presets. diff --git a/docs/whats-new.md b/docs/whats-new.md index 2dfb22884..40814834d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,22 @@ ## 2026-10-11 +### Slideshow export: PDF, JPEG and video +- View ▸ Export Slideshow… (or the Export buttons at the end of Settings ▸ Slideshow) writes the slideshow out, like + Lightroom Classic's Export PDF, Export JPEG and Export Video: the photos in view as their slides, with the layout, + overlays and the intro and ending screens, in view order. +- **PDF:** one page per slide at 1024, 1920 or 3840 pixels on the long side, at a JPEG quality you choose. Each page + carries the slide duration and fade, so a PDF viewer's presentation mode plays it as a slideshow, and "Automatically + show full screen" asks the viewer to open it that way. +- **JPEG:** numbered files (`Holiday-001.jpg`, `Holiday-002.jpg`…) in a new folder. +- **Video:** AV1 video in an MP4 (or WebM) file at 720p or 1080p, 24 or 30 frames per second: each slide stays up for + its duration and the fades are rendered frame by frame (crossfades, or fades through the fade colour). The video has + no sound yet. It is AV1 rather than Lightroom's H.264, which has no open encoder we can ship; current browsers, + VLC and recent phones and TVs play it. +- The export runs in the background with its row in the activity stack; ✕ stops it and leaves no half-written file + behind. A photo that can't be read is skipped and named in the message at the end. Agents use `slideshow.export` + (`slideshow.exportStatus` for the result). + ### Slideshow templates - Settings ▸ Slideshow now opens with a template browser, like Lightroom Classic's Template Browser. A template holds everything about how a slideshow looks and plays: the playback timing and fades, the layout, options and backdrop,