mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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.
335 lines
17 KiB
Rust
335 lines
17 KiB
Rust
//! `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}");
|
||
}
|