LRC-SS-EXPORT: slideshow export to PDF, JPEG sequence and AV1 video

View > Export Slideshow... (and Export PDF / JPEG / Video in Settings >
Slideshow) writes the photos in view as their composed slides, between
the intro and ending screens that are on:

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

slideshow.export runs in the background with a row in the activity
stack (activity.cancel stops it); outputs are written as .part and
renamed when complete, so cancel or failure leaves nothing behind. A
photo that can't be rendered is skipped with a warning.
slideshow.exportStatus reports the result.
This commit is contained in:
Jason Baldwin
2026-10-11 10:35:09 +02:00
parent 6bfe2235af
commit b391307009
38 changed files with 3034 additions and 90 deletions
+2
View File
@@ -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::{
File diff suppressed because it is too large Load Diff
+84 -1
View File
@@ -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<Value> {
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<Value> {
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<Value> {
Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -426,5 +491,23 @@ pub fn specs() -> Vec<CommandSpec> {
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: <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
),
]
}
+6 -66
View File
@@ -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<u8>,
offsets: Vec<usize>,
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::<Vec<_>>().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<u8> {
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::*;
+7
View File
@@ -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<std::sync::Arc<slideshow_export::ExportRun>>,
/// The last (or running) Build Previews.
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
/// 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;
+110
View File
@@ -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: Write> {
w: W,
/// Bytes written so far.
pos: usize,
/// Each object's offset, by object number (index 0 unused).
offsets: Vec<usize>,
pages: Vec<usize>,
}
fn io(e: std::io::Error) -> String {
format!("PDF: {e}")
}
impl<W: Write> Pdf<W> {
pub(crate) fn new(w: W) -> Result<Self, String> {
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<W, String> {
if self.pages.is_empty() {
return Err("PDF: no pages".into());
}
let kids = self.pages.iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().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)
}
}
+760
View File
@@ -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**: `<name>-001.jpg`, `<name>-002.jpg`… in a folder;
//! - **video**: AV1 in MP4 or WebM ([`lightcraft_codecs::video`], our own muxers), the timeline
//! sampled at 24/25/30/60 fps: each slide held for its duration, fades rendered as blended
//! frames (crossfade, or through the fade colour). No audio track.
//!
//! The slides play once in view order (random order and repeat are playback choices, not
//! export ones). The slide aspect comes from Layout's aspect preview; "screen" exports 16:9.
//!
//! [`prepare`] reads the session (cheap: render jobs, no pixels) and [`Prepared::run`] does the
//! work on any thread, with progress and a cancel flag. Files are written as `<file>.part` and
//! renamed when everything is written, so a cancelled or failed export leaves nothing behind. A
//! slide whose photo can't be rendered (file gone, unreadable) is skipped with a warning.
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use lightcraft_catalog::PhotoId;
use lightcraft_codecs::video::{Av1Encoder, Container, VideoConfig, VideoWriter};
use lightcraft_pipeline::{OutputDepth, OutputSpace};
use lightcraft_raster::Rgba8;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::media::RenderJob;
use crate::originals::OriginalGuard;
use crate::slide_title::TitleCompose;
use crate::slideshow::{SLIDE_MAX, SLIDE_MIN, Title};
use crate::{Session, export::ExportFormat, export::ExportOptions};
/// The longest video exported (seconds): 2 hours.
pub const MAX_SECONDS: f64 = 2.0 * 3600.0;
/// The most slides an export takes.
pub const MAX_SLIDES: usize = 10_000;
/// Long side of PDF pages and JPEGs, in pixels.
pub const SIZE_MIN: usize = 64;
pub const SIZE_MAX: usize = lightcraft_slideshow::MAX_SIDE;
/// Video frame height, in pixels.
pub const VIDEO_MIN: usize = 64;
pub const VIDEO_MAX: usize = 2160;
/// Frame rates offered.
pub const FPS: [u32; 4] = [24, 25, 30, 60];
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Format {
Pdf,
Jpeg,
Video,
}
impl Format {
pub fn id(self) -> &'static str {
match self {
Format::Pdf => "pdf",
Format::Jpeg => "jpeg",
Format::Video => "video",
}
}
}
/// The export's settings (the dialog's state, and `slideshow.export`'s params without `path`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ExportSettings {
pub format: Format,
/// PDF / JPEG: the long side in pixels; video: the frame height (720 = 720p).
pub size: usize,
/// JPEG quality (PDF pages, JPEG files) or video quality, 1–100.
pub quality: u8,
/// Video frames per second.
pub fps: u32,
/// Video container: "mp4" or "webm".
pub container: String,
/// PDF: ask the viewer to open in full screen.
pub full_screen: bool,
/// Video: rav1e speed preset, 1 (slow, smaller) – 10 (fast).
pub speed: u8,
}
impl Default for ExportSettings {
fn default() -> Self {
ExportSettings { format: Format::Pdf, size: 1920, quality: 90, fps: 30, container: "mp4".into(), full_screen: true, speed: 10 }
}
}
impl ExportSettings {
/// The defaults for `format` (sizes: PDF/JPEG 1920 px, video 720p; video quality 75).
pub fn for_format(format: Format) -> Self {
match format {
Format::Video => ExportSettings { format, size: 720, quality: 75, ..Default::default() },
_ => ExportSettings { format, ..Default::default() },
}
}
/// The file extension the export's path takes (none: a JPEG sequence goes in a folder).
pub fn extension(&self) -> Option<&'static str> {
match self.format {
Format::Pdf => Some("pdf"),
Format::Jpeg => None,
Format::Video => Some(if self.container == "webm" { "webm" } else { "mp4" }),
}
}
}
/// `slideshow.export`'s params, checked: settings, path, file name prefix and photos.
#[derive(Clone, Debug, PartialEq)]
pub struct Request {
pub settings: ExportSettings,
pub path: String,
/// JPEG sequence: the files' name before `-001` (default: the folder's name).
pub name: Option<String>,
pub ids: Option<Vec<PhotoId>>,
}
const KEYS: &str = "format, path, size, quality, fps, container, fullScreen, speed, name, ids, wait";
fn uint(k: &str, v: &Value, lo: u64, hi: u64) -> Result<u64, String> {
match v.as_u64() {
Some(n) if (lo..=hi).contains(&n) => Ok(n),
_ => Err(format!("`{k}` must be a whole number from {lo} to {hi}, not {v}")),
}
}
impl Request {
pub fn parse(p: &Value) -> Result<Request, String> {
let o: &Map<String, Value> = p.as_object().ok_or("params must be an object")?;
if let Some(k) = o.keys().find(|k| !KEYS.split(", ").any(|x| x == k.as_str())) {
return Err(format!("unknown key `{k}` (known: {KEYS})"));
}
let format = match o.get("format").and_then(Value::as_str).map(str::trim) {
Some("pdf") => Format::Pdf,
Some("jpeg" | "jpg") => Format::Jpeg,
Some("video") => Format::Video,
_ => return Err(format!("`format` must be \"pdf\", \"jpeg\" or \"video\", not {}", o.get("format").unwrap_or(&Value::Null))),
};
let mut s = ExportSettings::for_format(format);
let path = o.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or("missing `path`")?.to_string();
if let Some(v) = o.get("size").filter(|v| !v.is_null()) {
// "720p" / "1080p" for video
let v = match v.as_str().map(|x| x.trim().trim_end_matches(['p', 'P'])) {
Some(x) => x.parse::<u64>().map(Value::from).map_err(|_| format!("`size` must be a number of pixels, not {v}"))?,
None => v.clone(),
};
s.size = match format {
Format::Video => uint("size", &v, VIDEO_MIN as u64, VIDEO_MAX as u64)? as usize,
_ => uint("size", &v, SIZE_MIN as u64, SIZE_MAX as u64)? as usize,
};
}
if let Some(v) = o.get("quality").filter(|v| !v.is_null()) {
s.quality = uint("quality", v, 1, 100)? as u8;
}
if let Some(v) = o.get("fps").filter(|v| !v.is_null()) {
let f =
v.as_u64().filter(|f| FPS.iter().any(|x| u64::from(*x) == *f)).ok_or_else(|| format!("`fps` must be 24, 25, 30 or 60, not {v}"))?;
s.fps = f as u32;
}
if let Some(v) = o.get("container").filter(|v| !v.is_null()) {
let c = v.as_str().and_then(Container::parse).ok_or_else(|| format!("`container` must be \"mp4\" or \"webm\", not {v}"))?;
s.container = c.id().into();
}
if let Some(v) = o.get("fullScreen").filter(|v| !v.is_null()) {
s.full_screen = v.as_bool().ok_or_else(|| format!("`fullScreen` must be true or false, not {v}"))?;
}
if let Some(v) = o.get("speed").filter(|v| !v.is_null()) {
s.speed = uint("speed", v, 1, 10)? as u8;
}
if let Some(v) = o.get("wait").filter(|v| !v.is_null() && !v.is_boolean()) {
return Err(format!("`wait` must be true or false, not {v}"));
}
let name = match o.get("name") {
None | Some(Value::Null) => None,
Some(Value::String(n)) => {
let n = n.trim();
if n.is_empty()
|| n.chars().count() > 120
|| n.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
{
return Err(format!("`name` must be 1–120 characters without path separators or reserved characters, not {n:?}"));
}
Some(n.to_string())
}
Some(v) => return Err(format!("`name` must be text, not {v}")),
};
if name.is_some() && format != Format::Jpeg {
return Err("`name` is for the JPEG sequence only".into());
}
let ids = match o.get("ids") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) => {
let ids: Option<Vec<PhotoId>> = a.iter().map(|v| v.as_u64().map(PhotoId)).collect();
Some(ids.ok_or("`ids` must be an array of photo ids")?)
}
Some(v) => return Err(format!("`ids` must be an array of photo ids, not {v}")),
};
Ok(Request { settings: s, path, name, ids })
}
}
/// What a slide shows.
enum Source {
Photo { name: String, job: Box<RenderJob> },
Title { title: Title, compose: Arc<TitleCompose> },
}
struct Slide {
source: Source,
/// Seconds it stays up, its fade in included.
duration: f64,
/// Seconds the transition into it takes (none for the first).
fade: f64,
}
impl Slide {
fn label(&self) -> String {
match &self.source {
Source::Photo { name, .. } => name.clone(),
Source::Title { title, .. } => title.id().to_string(),
}
}
/// The slide's pixels, `w × h`.
fn render(self, w: usize, h: usize) -> Result<Rgba8, String> {
let img = match self.source {
Source::Photo { job, .. } => job.run().rendered?.image,
Source::Title { compose, .. } => compose.compose(),
};
Ok(fit(img, w, h))
}
}
/// `img` at exactly `w × h`: as it is when it already is (the composer's output), else centred
/// on black (a slide bigger than the composer draws, or a surprise from a renderer).
fn fit(img: Rgba8, w: usize, h: usize) -> Rgba8 {
if img.width == w && img.height == h && img.data.len() == w * h {
return img;
}
let mut out = Rgba8::filled(w, h, [0, 0, 0, 255]);
let (cw, ch) = (img.width.min(w), img.height.min(h));
let (dx, dy) = ((w - cw) / 2, (h - ch) / 2);
let (sx, sy) = ((img.width - cw) / 2, (img.height - ch) / 2);
for y in 0..ch {
let (Some(src), Some(dst)) =
(img.data.get((sy + y) * img.width + sx..(sy + y) * img.width + sx + cw), out.data.get_mut((dy + y) * w + dx..(dy + y) * w + dx + cw))
else {
continue;
};
dst.copy_from_slice(src);
}
out
}
/// An export ready to run: everything it needs from the session.
pub struct Prepared {
pub request: Request,
slides: Vec<Slide>,
pub width: usize,
pub height: usize,
fade_color: Option<[u8; 3]>,
guard: OriginalGuard,
/// Problems found while preparing (photos not in the library).
pub warnings: Vec<String>,
}
/// The size of a slide whose long side is `long` at `ratio` (width / height).
fn sized(ratio: f64, long: usize) -> (usize, usize) {
let short = |l: usize, r: f64| ((l as f64 / r).round() as usize).max(1);
if ratio >= 1.0 { (long, short(long, ratio)) } else { (short(long, 1.0 / ratio), long) }
}
/// Read the session: the slides, their render jobs and durations, the output size.
pub fn prepare(s: &mut Session, p: &Value) -> Result<Prepared, String> {
let request = Request::parse(p)?;
let settings = &request.settings;
let ratio = s.slide_layout.aspect.ratio(16.0 / 9.0);
let (width, height) = match settings.format {
Format::Video => {
let h = settings.size & !1;
((((h as f64 * ratio).round() as usize) & !1).max(16), h)
}
_ => sized(ratio, settings.size),
};
let ids = match &request.ids {
Some(ids) => ids.clone(),
None => s.visible_cloned(),
};
if ids.is_empty() {
return Err("no photos in view to export".into());
}
if ids.len() > MAX_SLIDES {
return Err(format!("a slideshow export takes at most {MAX_SLIDES} photos; this view has {}", ids.len()));
}
let play = s.slideshow.clone().sanitized();
let (intro, ending) = s.slide_titles.durations(play.slide_duration);
let clamp = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { play.slide_duration };
let layout = s.slide_layout.clone().sanitized();
let mut warnings = Vec::new();
let mut slides = Vec::with_capacity(ids.len() + 2);
let title = |s: &Session, t: Title, d: f64| Slide {
source: Source::Title { title: t, compose: s.title_compose(t, width, height) },
duration: clamp(d),
fade: 0.0,
};
if let Some(d) = intro {
slides.push(title(s, Title::Intro, d));
}
for id in ids {
let Some(photo) = s.catalog.photo(id).cloned() else {
warnings.push(format!("photo {} is not in the library; skipped", id.0));
continue;
};
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
let (pw, ph) = lightcraft_slideshow::photo_size(&layout, aspect, width, height);
let job = match s.export_job(id, pw.max(8), ph.max(8), OutputSpace::Srgb, OutputDepth::U8) {
Ok(job) => job,
Err(e) => {
warnings.push(format!("{}: {e}; skipped", photo.file_name));
continue;
}
};
let compose = s.slide_compose(&layout, width, height, Some(id));
slides.push(Slide {
source: Source::Photo { name: photo.file_name.clone(), job: Box::new(job.with_slide(compose)) },
duration: play.slide_duration,
fade: 0.0,
});
}
if !slides.iter().any(|x| matches!(x.source, Source::Photo { .. })) {
return Err("none of the photos can be exported".into());
}
if let Some(d) = ending {
slides.push(title(s, Title::Ending, d));
}
// the transition into each slide after the first: the fade, never longer than the slide
for x in slides.iter_mut().skip(1) {
x.fade = play.fade_duration.min(x.duration).max(0.0);
}
if settings.format == Format::Video {
let total: f64 = slides.iter().map(|x| x.duration).sum();
if total > MAX_SECONDS {
return Err(format!(
"the slideshow lasts {} min; video export is limited to {} min (shorten the slides or export fewer photos)",
(total / 60.0).ceil(),
MAX_SECONDS / 60.0
));
}
if !lightcraft_codecs::video::AV1_AVAILABLE {
return Err("video export (AV1) is not available in this build".into());
}
}
s.check_write_target(&request.path)?;
Ok(Prepared { request, slides, width, height, fade_color: play.fade_color, guard: s.original_guard(), warnings })
}
/// What an export wrote.
#[derive(Clone, Debug, PartialEq)]
pub struct Outcome {
pub json: Value,
}
/// Progress: (done, total, the slide being worked on). `total` is slides (PDF, JPEG) or frames
/// at the nominal rate (video).
pub type Progress<'a> = &'a dyn Fn(u64, u64, &str);
const CANCELLED: &str = "Slideshow export cancelled";
/// A file written as `<path>.part` and renamed over `path` when complete; dropped before that,
/// the part file is removed.
struct PartFile {
part: PathBuf,
target: PathBuf,
done: bool,
}
impl PartFile {
fn new(target: &Path) -> Result<(PartFile, std::fs::File), String> {
if let Some(dir) = target.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let mut name = target.file_name().ok_or_else(|| format!("{}: not a file name", target.display()))?.to_os_string();
name.push(".part");
let part = target.with_file_name(name);
let f = std::fs::File::create(&part).map_err(|e| format!("{}: {e}", part.display()))?;
Ok((PartFile { part, target: target.to_path_buf(), done: false }, f))
}
fn commit(mut self, guard: &OriginalGuard) -> Result<(), String> {
guard.check(&self.target)?;
std::fs::rename(&self.part, &self.target).map_err(|e| format!("{}: {e}", self.target.display()))?;
self.done = true;
Ok(())
}
}
impl Drop for PartFile {
fn drop(&mut self) {
if !self.done {
let _ = std::fs::remove_file(&self.part);
}
}
}
/// Blend `a` → `b` at `k` (0 = a, 1 = b), or through `colour` (out to it, then in from it).
fn blend(a: &Rgba8, b: &Rgba8, k: f32, colour: Option<[u8; 3]>) -> Vec<[u8; 4]> {
let mix = |x: u8, y: u8, t: f32| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8;
match colour {
Some(c) => {
let (img, veil) = if k < 0.5 { (a, k * 2.0) } else { (b, 2.0 - k * 2.0) };
img.data.iter().map(|p| [mix(p[0], c[0], veil), mix(p[1], c[1], veil), mix(p[2], c[2], veil), 255]).collect()
}
None => a.data.iter().zip(&b.data).map(|(p, q)| [mix(p[0], q[0], k), mix(p[1], q[1], k), mix(p[2], q[2], k), 255]).collect(),
}
}
impl Prepared {
/// Slides to export (titles included).
pub fn len(&self) -> usize {
self.slides.len()
}
pub fn is_empty(&self) -> bool {
self.slides.is_empty()
}
/// Seconds the slideshow lasts.
pub fn seconds(&self) -> f64 {
self.slides.iter().map(|x| x.duration).sum()
}
/// Units of progress: slides, or frames at the nominal rate for video.
pub fn total(&self) -> u64 {
match self.request.settings.format {
Format::Video => (self.seconds() * f64::from(self.request.settings.fps)).round() as u64,
_ => self.slides.len() as u64,
}
}
/// Write the export. `cancel` is checked between slides and frames.
pub fn run(self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
match self.request.settings.format {
Format::Pdf => self.run_pdf(progress, cancel),
Format::Jpeg => self.run_jpeg(progress, cancel),
Format::Video => self.run_video(progress, cancel),
}
}
fn jpeg(&self, img: &Rgba8) -> Result<Vec<u8>, String> {
crate::export::encode_image(img, &ExportOptions { format: ExportFormat::Jpeg, quality: self.request.settings.quality, ..Default::default() })
}
fn summary(&self, format: Format, slides: usize, skipped: usize, warnings: Vec<String>) -> Value {
json!({
"format": format.id(),
"path": self.request.path,
"width": self.width,
"height": self.height,
"slides": slides,
"skipped": skipped,
"warnings": warnings,
})
}
fn run_pdf(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let target = PathBuf::from(&self.request.path);
let (part, file) = PartFile::new(&target)?;
let mut pdf = crate::pdf::Pdf::new(BufWriter::new(file))?;
let (w, h) = (self.width, self.height);
let total = self.slides.len() as u64;
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let slides = std::mem::take(&mut self.slides);
for (i, slide) in slides.into_iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(i as u64, total, &label);
let (duration, fade) = (slide.duration, slide.fade);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let jpg = self.jpeg(&img)?;
// presentation mode: how long the page stays, and the fade into it
let mut extra = format!(" /Dur {duration:.2}");
if fade > 0.0 {
extra += &format!(" /Trans << /S /Fade /D {fade:.2} >>");
}
pdf.image_page(&jpg, w, h, (w as f64, h as f64), &extra)?;
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let pages = pdf.pages();
if pages == 0 {
return Err("no slide could be rendered".into());
}
let bytes = pdf.len();
let catalog = if self.request.settings.full_screen { " /PageMode /FullScreen" } else { "" };
pdf.finish(catalog)?.into_inner().map_err(|e| format!("PDF: {}", e.error()))?.sync_all().map_err(|e| format!("PDF: {e}"))?;
part.commit(&self.guard)?;
progress(total, total, "");
let mut out = self.summary(Format::Pdf, pages, skipped, warnings);
out["pages"] = json!(pages);
out["bytes"] = json!(bytes);
out["fullScreen"] = json!(self.request.settings.full_screen);
Ok(Outcome { json: out })
}
fn run_jpeg(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let dir = PathBuf::from(&self.request.path);
let prefix = self
.request
.name
.clone()
.or_else(|| dir.file_name().map(|n| n.to_string_lossy().trim().to_string()).filter(|n| !n.is_empty()))
.unwrap_or_else(|| "Slideshow".into());
let digits = self.slides.len().to_string().len().max(3);
let (w, h) = (self.width, self.height);
let total = self.slides.len() as u64;
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let mut parts = Vec::new();
let mut bytes = 0usize;
let slides = std::mem::take(&mut self.slides);
for (i, slide) in slides.into_iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(i as u64, total, &label);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let jpg = self.jpeg(&img)?;
let target = dir.join(format!("{prefix}-{:0digits$}.jpg", parts.len() + 1));
self.guard.check(&target)?;
let (part, mut f) = PartFile::new(&target)?;
f.write_all(&jpg).map_err(|e| format!("{}: {e}", part.part.display()))?;
f.sync_all().map_err(|e| format!("{}: {e}", part.part.display()))?;
bytes = bytes.saturating_add(jpg.len());
parts.push(part);
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
if parts.is_empty() {
return Err("no slide could be rendered".into());
}
let files: Vec<String> = parts.iter().map(|p| p.target.to_string_lossy().to_string()).collect();
// all written: now they take their names (a failure here removes the rest)
for part in parts {
part.commit(&self.guard)?;
}
progress(total, total, "");
let mut out = self.summary(Format::Jpeg, files.len(), skipped, warnings);
out["files"] = json!(files);
out["bytes"] = json!(bytes);
Ok(Outcome { json: out })
}
fn run_video(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> {
let st = self.request.settings.clone();
let container = Container::parse(&st.container).unwrap_or(Container::Mp4);
let (w, h) = (self.width, self.height);
let fps = st.fps;
let mut enc = Av1Encoder::new(&VideoConfig { width: w as u32, height: h as u32, fps, quality: st.quality, speed: st.speed })
.map_err(|e| e.to_string())?;
let target = PathBuf::from(&self.request.path);
let (part, file) = PartFile::new(&target)?;
let mut mux = VideoWriter::new(BufWriter::new(file), container, w as u32, h as u32, fps).map_err(|e| e.to_string())?;
let total = self.total();
let mut warnings = std::mem::take(&mut self.warnings);
let mut skipped = 0;
let mut slides_done = 0;
let mut samples = 0u64;
let mut keys = 0u64;
// frames emitted so far, and the slideshow time (seconds) of the slides kept
let mut frames = 0u64;
let mut clock = 0.0f64;
let mut prev: Option<Rgba8> = None;
let mut emit = |mux: &mut VideoWriter<BufWriter<std::fs::File>>,
enc: &mut Av1Encoder,
pixels: &[[u8; 4]],
periods: u64,
key: bool|
-> Result<(), String> {
let out = enc.push(pixels, periods, key).map_err(|e| e.to_string())?;
for s in out {
if let Some(c) = enc.config_record() {
mux.set_config(c);
}
keys += u64::from(s.key);
samples += 1;
mux.add(&s).map_err(|e| e.to_string())?;
}
Ok(())
};
let fade_color = self.fade_color;
let slides = std::mem::take(&mut self.slides);
for slide in slides {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let label = slide.label();
progress(frames, total, &label);
let (duration, fade) = (slide.duration, slide.fade);
let img = match slide.render(w, h) {
Ok(img) => img,
Err(e) => {
warnings.push(format!("{label}: {e}; skipped"));
skipped += 1;
continue;
}
};
let start = (clock * f64::from(fps)).round() as u64;
clock += duration;
let end = ((clock * f64::from(fps)).round() as u64).max(start + 1);
let n = end - start;
let fade_frames = match &prev {
Some(_) => ((fade * f64::from(fps)).round() as u64).min(n),
None => 0,
};
if let Some(a) = &prev {
for j in 0..fade_frames {
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
let k = (j + 1) as f32 / (fade_frames + 1) as f32;
emit(&mut mux, &mut enc, &blend(a, &img, k, fade_color), 1, false)?;
frames += 1;
progress(frames, total, &label);
}
}
if n > fade_frames {
// a held slide is a key frame: clean of the slide before it, and a place to seek to
emit(&mut mux, &mut enc, &img.data, n - fade_frames, true)?;
frames += n - fade_frames;
}
slides_done += 1;
prev = Some(img);
}
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
if slides_done == 0 {
return Err("no slide could be rendered".into());
}
progress(frames, total, "AV1");
for s in enc.finish().map_err(|e| e.to_string())? {
if let Some(c) = enc.config_record() {
mux.set_config(c);
}
keys += u64::from(s.key);
samples += 1;
mux.add(&s).map_err(|e| e.to_string())?;
}
let periods = mux.periods();
let file = mux.finish().map_err(|e| e.to_string())?;
let file = file.into_inner().map_err(|e| format!("video: {}", e.error()))?;
file.sync_all().map_err(|e| format!("video: {e}"))?;
let bytes = file.metadata().map(|m| m.len()).unwrap_or(0);
drop(file);
if cancel.load(Ordering::Relaxed) {
return Err(CANCELLED.into());
}
part.commit(&self.guard)?;
progress(total, total, "");
let mut out = self.summary(Format::Video, slides_done, skipped, warnings);
out["container"] = json!(container.id());
out["codec"] = json!("av1");
out["fps"] = json!(fps);
out["frames"] = json!(periods);
out["samples"] = json!(samples);
out["keyFrames"] = json!(keys);
out["seconds"] = json!(periods as f64 / f64::from(fps));
out["bytes"] = json!(bytes);
out["audio"] = json!(false);
Ok(Outcome { json: out })
}
}
/// A slideshow export running in the background (`slideshow.export`; `slideshow.exportStatus`).
#[derive(Debug, Default)]
pub struct ExportRun {
/// Its row in the activity stack (`activity.cancel {id}` stops it).
pub task_id: u64,
pub format: String,
pub path: String,
pub total: std::sync::atomic::AtomicU64,
pub done: std::sync::atomic::AtomicU64,
pub cancel: Arc<AtomicBool>,
pub finished: AtomicBool,
/// The front end has told the user how it ended.
pub reported: AtomicBool,
pub result: std::sync::Mutex<Option<Result<Value, String>>>,
}
impl ExportRun {
pub fn running(&self) -> bool {
!self.finished.load(Ordering::Relaxed)
}
pub fn result(&self) -> Option<Result<Value, String>> {
self.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
}
pub fn json(&self) -> Value {
let mut out = json!({
"taskId": self.task_id,
"format": self.format,
"path": self.path,
"running": self.running(),
"done": self.done.load(Ordering::Relaxed),
"total": self.total.load(Ordering::Relaxed),
"cancelled": self.cancel.load(Ordering::Relaxed),
});
match self.result() {
Some(Ok(v)) => out["result"] = v,
Some(Err(e)) => out["error"] = json!(e),
None => {}
}
out
}
}
/// Run `prepared` to the end, recording progress and the outcome in `run` (and the row).
pub fn run_to_end(prepared: Prepared, run: &ExportRun, task: &crate::activity::TaskGuard) {
let handle = task.handle();
let progress = |done: u64, total: u64, detail: &str| {
run.done.store(done, Ordering::Relaxed);
run.total.store(total, Ordering::Relaxed);
handle.progress(done, total);
handle.detail(detail);
};
// a panic in a renderer or the encoder is an error, never a crash (the part file goes with it)
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepared.run(&progress, &run.cancel)))
.unwrap_or_else(|_| Err("slideshow export failed unexpectedly".into()))
.map(|o| o.json);
*run.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r);
run.finished.store(true, Ordering::Relaxed);
}
+334
View File
@@ -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<String> {
let mut v: Vec<String> = 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<u64>) {
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<String> {
let text = String::from_utf8_lossy(bytes);
let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().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<lightcraft_raster::Rgba8> {
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::<u64>() / 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<usize>) -> Vec<([u8; 4], std::ops::Range<usize>)> {
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<usize>, path: &[&[u8; 4]]) -> std::ops::Range<usize> {
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<u64> = 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}");
}
+7 -1
View File
@@ -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)"
}
+16 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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)"
}
+16 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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)"
}
+16 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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} 枚をスキップ(レンダリングできません)"
}
+16 -1
View File
@@ -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.": "表示中の写真を上の設定でスライドにします。進行状況はアクティビティ一覧に表示されます。"
}
+7 -1
View File
@@ -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)"
}
+16 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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} (не удалось отрисовать)"
}
+16 -1
View File
@@ -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.": "Фотографии в просмотре как слайды, с настройками выше. Ход выполнения виден в списке задач."
}
+7 -1
View File
@@ -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} (не вдалося відтворити)"
}
+16 -1
View File
@@ -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.": "Фотографії в перегляді як слайди, з налаштуваннями вище. Поступ видно у списку завдань."
}
+7 -1
View File
@@ -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} 张(无法渲染)"
}
+16 -1
View File
@@ -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.": "将视图中的照片按上方设置制作为幻灯片。进度显示在活动列表中。"
}
+7 -1
View File
@@ -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} 張(無法算繪)"
}
+16 -1
View File
@@ -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.": "將檢視中的照片依上方設定製作為幻燈片。進度顯示在活動清單中。"
}
+5
View File
@@ -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<Value>,
/// 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<HoverPreview>,
@@ -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);
+15
View File
@@ -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<Re
app.ui.dialog = Some(Dialog::CopySettings { groups });
Ok(Value::Null)
}
"dialog.slideshowExport" => {
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<Re
pub fn ui_enabled(app: &LightcraftApp, id: &str) -> 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()
+59
View File
@@ -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<serde_jso
}),
),
Dialog::ContactSheet { options } => 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<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
+22
View File
@@ -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"));
+166
View File
@@ -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<Value, String> {
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<Mutex<Vec<PickRequest>>>) {
let asked = Arc::new(Mutex::new(Vec::<PickRequest>::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);
}
}
+4
View File
@@ -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,
+1
View File
@@ -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 |
+3
View File
@@ -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 (`<name>.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 `<name>-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
+6
View File
@@ -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 `<settings>/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
+5 -5
View File
@@ -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.
<!-- /parity:summary -->
## 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**: `<name>-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 |
+64
View File
@@ -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 (`<settings>/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** — `<name>-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
`<file>.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.
+16
View File
@@ -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,