mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LRC-SS-LAYOUT: slideshow layout, options and backdrop: slides composed with margins, stroke, shadow and backdrop
- New crate lightcraft-slideshow (L1, no UI): SlideSettings (margins linked or
independent, guides, aspect preview, zoom to fill, stroke border, cast shadow,
backdrop colour, colour wash, background image; lengths relative to the slide's
shorter side; validated and clamped) and a CPU compose() used by playback, the
Settings preview and slideshow.preview.
- Engine: slideshow.layout (partial merge, saved in prefs.json) and
slideshow.preview {id?, width?, height?, path?} (PNG to path or base64);
RenderJob::with_slide composes on the render worker; the background photo is
rendered once and shared.
- Playback: with a non-default layout each slide is the composed screen-sized
slide, prepared ahead like before; the default layout plays as before.
- Settings > Slideshow: Layout, Options and Backdrop sections and a debounced
live preview of the active photo's slide (with margin guides).
- Tests: composer geometry/pixels/hostile input, command get/set/persist/preview,
headless playback of a composed slide and of the default layout.
This commit is contained in:
Generated
+11
@@ -3130,6 +3130,7 @@ dependencies = [
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-segment",
|
||||
"lightcraft-slideshow",
|
||||
"lightcraft-tiff",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3326,6 +3327,15 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-slideshow"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.5.0"
|
||||
@@ -3357,6 +3367,7 @@ dependencies = [
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-preview",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-slideshow",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -35,6 +35,7 @@ lightcraft-mcp = { path = "crates/mcp" }
|
||||
lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
lightcraft-slideshow = { path = "crates/slideshow" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
||||
@@ -165,6 +165,8 @@ struct Busy {
|
||||
level: SourceLevel,
|
||||
key: u64,
|
||||
cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
/// A slideshow slide: the worker renders the photo, it is composed here when it comes back.
|
||||
slide: Option<Arc<lightcraft_engine::slide::SlideCompose>>,
|
||||
}
|
||||
|
||||
struct W {
|
||||
@@ -369,6 +371,13 @@ impl Workers {
|
||||
});
|
||||
}
|
||||
g.remote_done += 1;
|
||||
let rendered = match (rendered, &b.slide) {
|
||||
(Ok(mut r), Some(slide)) => {
|
||||
r.image = slide.compose(&r.image);
|
||||
Ok(r)
|
||||
}
|
||||
(r, _) => r,
|
||||
};
|
||||
g.done.push((
|
||||
b.slot,
|
||||
RenderResult {
|
||||
@@ -490,6 +499,7 @@ impl RenderOffload for Workers {
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
cache: job.cache,
|
||||
slide: job.slide,
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ lightcraft-meta = { workspace = true }
|
||||
lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
lightcraft-slideshow = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour,
|
||||
//! random order, repeat, preparing previews in advance). A library preference, saved in the
|
||||
//! library's `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them.
|
||||
//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks
|
||||
//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop
|
||||
//! colour, colour wash, background image). Both are library preferences, saved in the library's
|
||||
//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.preview`
|
||||
//! composes one slide as a PNG.
|
||||
|
||||
use serde_json::Value;
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd};
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Largest side `slideshow.preview` renders.
|
||||
pub const PREVIEW_MAX: usize = 4096;
|
||||
|
||||
fn playback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changes = p.as_object().is_some_and(|o| !o.is_empty());
|
||||
if changes {
|
||||
@@ -19,14 +26,123 @@ fn playback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(s.slideshow.to_json())
|
||||
}
|
||||
|
||||
fn layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changes = p.as_object().is_some_and(|o| !o.is_empty());
|
||||
if changes {
|
||||
let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?;
|
||||
if next != s.slide_layout {
|
||||
s.slide_layout = next;
|
||||
s.save_prefs()?;
|
||||
}
|
||||
}
|
||||
Ok(s.slide_layout.to_json())
|
||||
}
|
||||
|
||||
/// A slide size from the request: both sides given, one (the other from the aspect preview), or
|
||||
/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`].
|
||||
fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> {
|
||||
const C: &str = "slideshow.preview";
|
||||
let side = |k: &str| -> Result<Option<usize>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)),
|
||||
_ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))),
|
||||
},
|
||||
}
|
||||
};
|
||||
let ratio = s.slide_layout.aspect.ratio(f64::NAN);
|
||||
let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX);
|
||||
Ok(match (side("width")?, side("height")?) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) => (w, clamp(w as f64 / ratio)),
|
||||
(None, Some(h)) => (clamp(h as f64 * ratio), h),
|
||||
(None, None) => (1280, clamp(1280.0 / ratio)),
|
||||
})
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.preview";
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?,
|
||||
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?),
|
||||
};
|
||||
if s.catalog.photo(id).is_none() {
|
||||
return Err(bad(C, format!("no photo {}", id.0)));
|
||||
}
|
||||
let (w, h) = preview_size(s, p)?;
|
||||
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
|
||||
if let Some(path) = path {
|
||||
s.check_write_target(path).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
let settings = s.slide_layout.clone();
|
||||
let img = s.slide_now(&settings, id, w, h).map_err(crate::EngineError::Other)?;
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))?;
|
||||
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
|
||||
let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height);
|
||||
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
let mut out = json!({
|
||||
"id": id.0,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"cell": rect(l.cell),
|
||||
"photo": rect(l.visible),
|
||||
});
|
||||
match path {
|
||||
Some(path) => {
|
||||
crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
None => out["png"] = json!(base64(&png)),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Standard padded base64 (for an image returned in the reply).
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
||||
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
|
||||
let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char;
|
||||
out.push(at(18));
|
||||
out.push(at(12));
|
||||
out.push(if chunk.len() > 1 { at(6) } else { '=' });
|
||||
out.push(if chunk.len() > 2 { at(0) } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"slideshow.playback",
|
||||
"Slideshow Playback Settings",
|
||||
[],
|
||||
None,
|
||||
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
|
||||
always,
|
||||
playback
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
"slideshow.playback",
|
||||
"Slideshow Playback Settings",
|
||||
[],
|
||||
None,
|
||||
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
|
||||
always,
|
||||
playback
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.layout",
|
||||
"Slideshow Layout Settings",
|
||||
[],
|
||||
None,
|
||||
"{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings",
|
||||
always,
|
||||
layout
|
||||
),
|
||||
cmd!(
|
||||
query "slideshow.preview",
|
||||
"Slideshow Slide Preview",
|
||||
[],
|
||||
None,
|
||||
"{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), path | png (base64 PNG)}",
|
||||
always,
|
||||
preview
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1386,6 +1386,7 @@ impl Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
slide: None,
|
||||
};
|
||||
let embedded = match (&self.media.preview_loader, c.kind) {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
|
||||
@@ -55,6 +55,7 @@ pub mod presets;
|
||||
pub mod rename;
|
||||
pub mod segment;
|
||||
pub mod sidecar;
|
||||
pub mod slide;
|
||||
pub mod slideshow;
|
||||
pub mod smart;
|
||||
pub mod sync;
|
||||
@@ -274,6 +275,11 @@ pub struct Session {
|
||||
pub cache_mb: u32,
|
||||
/// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json).
|
||||
pub slideshow: slideshow::PlaybackSettings,
|
||||
/// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in
|
||||
/// prefs.json).
|
||||
pub slide_layout: slide::SlideSettings,
|
||||
/// The background image the slides last rendered (shared by slide jobs: rendered once).
|
||||
pub slide_backdrop: std::sync::Arc<slide::BackdropCache>,
|
||||
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
|
||||
pub smart_previews_dir: Option<std::path::PathBuf>,
|
||||
/// Untouched Local records of folders not browsed for this many days are forgotten when the
|
||||
@@ -357,6 +363,8 @@ impl Session {
|
||||
import_defaults: import::ImportDefaults::default(),
|
||||
cache_mb: 0,
|
||||
slideshow: slideshow::PlaybackSettings::default(),
|
||||
slide_layout: slide::SlideSettings::default(),
|
||||
slide_backdrop: Default::default(),
|
||||
smart_previews_dir: None,
|
||||
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
|
||||
}
|
||||
@@ -910,6 +918,8 @@ mod tests_segment;
|
||||
#[cfg(test)]
|
||||
mod tests_settings_files;
|
||||
#[cfg(test)]
|
||||
mod tests_slide;
|
||||
#[cfg(test)]
|
||||
mod tests_spots;
|
||||
#[cfg(test)]
|
||||
mod tests_sync;
|
||||
|
||||
@@ -160,6 +160,8 @@ struct PrefsFile {
|
||||
forget_local_days: Option<u32>,
|
||||
/// Slideshow playback settings.
|
||||
slideshow: crate::slideshow::PlaybackSettings,
|
||||
/// Slideshow slide layout (margins, options, backdrop).
|
||||
slide_layout: crate::slide::SlideSettings,
|
||||
}
|
||||
|
||||
fn presets_json(s: &Session) -> String {
|
||||
@@ -343,6 +345,7 @@ impl Session {
|
||||
self.import_defaults = prefs.import;
|
||||
self.cache_mb = prefs.cache_mb;
|
||||
self.slideshow = prefs.slideshow.sanitized();
|
||||
self.slide_layout = prefs.slide_layout.sanitized();
|
||||
self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS);
|
||||
self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from);
|
||||
if let Some(d) = &self.smart_previews_dir {
|
||||
@@ -580,6 +583,7 @@ impl Session {
|
||||
smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
|
||||
forget_local_days: Some(self.forget_local_days),
|
||||
slideshow: self.slideshow.clone(),
|
||||
slide_layout: self.slide_layout.clone(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
|
||||
@@ -676,6 +676,8 @@ pub struct RenderJob {
|
||||
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
/// Render for this monitor profile ([`Self::with_display`]).
|
||||
pub display: Option<Arc<crate::display::Display>>,
|
||||
/// Compose the render into a slideshow slide ([`Self::with_slide`]).
|
||||
pub slide: Option<Arc<crate::slide::SlideCompose>>,
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
@@ -744,7 +746,8 @@ impl RenderJob {
|
||||
/// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are:
|
||||
/// [`crate::display::present`] converts their sRGB results.
|
||||
pub fn with_display(mut self, display: Option<Arc<crate::display::Display>>) -> Self {
|
||||
if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
|
||||
// (a slide is composed in sRGB, backdrop colours included; the frontend presents it)
|
||||
if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
|
||||
return self;
|
||||
}
|
||||
let k = |d: &Option<Arc<crate::display::Display>>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93);
|
||||
@@ -754,7 +757,30 @@ impl RenderJob {
|
||||
self
|
||||
}
|
||||
|
||||
/// Compose the render into a slideshow slide (layout, options, backdrop): the result is the
|
||||
/// slide, not the photo. Gets its own result key; the photo's cached view preview stays the
|
||||
/// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone).
|
||||
pub fn with_slide(mut self, slide: Arc<crate::slide::SlideCompose>) -> Self {
|
||||
let k = |s: &Option<Arc<crate::slide::SlideCompose>>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407);
|
||||
self.key ^= k(&self.slide);
|
||||
self.key ^= k(&Some(slide.clone()));
|
||||
self.slide = Some(slide);
|
||||
self
|
||||
}
|
||||
|
||||
/// [`Self::run_photo`], composed into its slide when this is a slide job.
|
||||
pub fn run(self) -> RenderResult {
|
||||
let slide = self.slide.clone();
|
||||
let mut r = self.run_photo();
|
||||
if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) {
|
||||
rendered.image = slide.compose(&rendered.image);
|
||||
rendered.deep = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The photo's render (without slide composition).
|
||||
fn run_photo(self) -> RenderResult {
|
||||
if let Some((cache, key)) = &self.cache
|
||||
&& let Some(img) = cache.get_at(self.cache_generation, *key)
|
||||
{
|
||||
@@ -1020,6 +1046,7 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
slide: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1071,6 +1098,7 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
slide: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1117,6 +1145,7 @@ impl crate::Session {
|
||||
stages: None,
|
||||
view_cache: None,
|
||||
display: None,
|
||||
slide: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom
|
||||
//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command) and render jobs
|
||||
//! that compose a photo into its slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]).
|
||||
//!
|
||||
//! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings
|
||||
//! preview and `slideshow.preview` all compose through it, so they draw the same pixels.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_raster::Rgba8;
|
||||
pub use lightcraft_slideshow::{Aspect, Layout, SlideSettings};
|
||||
|
||||
use crate::media::RenderJob;
|
||||
|
||||
/// The last rendered background image: (its render key, the image). Shared by the slide jobs of
|
||||
/// a session, so the backdrop photo is rendered once, not once per slide.
|
||||
pub type BackdropCache = Mutex<Option<(u64, Arc<Rgba8>)>>;
|
||||
|
||||
/// The background image of a slide: a library photo's render job and the shared cache it goes to.
|
||||
#[derive(Clone)]
|
||||
pub struct Backdrop {
|
||||
pub photo: PhotoId,
|
||||
job: Box<RenderJob>,
|
||||
cache: Arc<BackdropCache>,
|
||||
}
|
||||
|
||||
impl Backdrop {
|
||||
/// The rendered background image (from the cache when it holds this render; otherwise
|
||||
/// rendered now, on the calling thread). `None` when it can't be rendered (the slide then
|
||||
/// shows the backdrop colour alone).
|
||||
pub fn image(&self) -> Option<Arc<Rgba8>> {
|
||||
let key = self.job.key;
|
||||
if let Some((k, img)) = self.cache.lock().unwrap_or_else(PoisonError::into_inner).as_ref()
|
||||
&& *k == key
|
||||
{
|
||||
return Some(img.clone());
|
||||
}
|
||||
let img = Arc::new((*self.job).clone().run().rendered.ok()?.image);
|
||||
*self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
}
|
||||
|
||||
/// How to turn a photo's render into its slide: the layout, the slide's size in pixels and the
|
||||
/// background image, if any.
|
||||
#[derive(Clone)]
|
||||
pub struct SlideCompose {
|
||||
pub settings: SlideSettings,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub backdrop: Option<Backdrop>,
|
||||
}
|
||||
|
||||
impl SlideCompose {
|
||||
/// What makes the slide's pixels differ (for render keys).
|
||||
pub fn key(&self) -> u64 {
|
||||
self.settings.pixel_key()
|
||||
^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15)
|
||||
^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f)
|
||||
^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29))
|
||||
}
|
||||
|
||||
/// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size).
|
||||
pub fn compose(&self, photo: &Rgba8) -> Rgba8 {
|
||||
let bg = self.backdrop.as_ref().and_then(Backdrop::image);
|
||||
lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// The aspect (width / height) photo `id` shows at: cropped, straightened, lens-corrected.
|
||||
pub fn slide_photo_aspect(&self, id: PhotoId) -> Option<f64> {
|
||||
let p = self.catalog.photo(id)?;
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
|
||||
p.width.max(1) as usize,
|
||||
p.height.max(1) as usize,
|
||||
&p.develop,
|
||||
true,
|
||||
p.embedded_lens.as_ref(),
|
||||
);
|
||||
let a = frame.aspect();
|
||||
Some(if a.is_finite() && a > 0.0 { a } else { 1.5 })
|
||||
}
|
||||
|
||||
/// The composition of `settings` at `width × height`, with its background image's render job
|
||||
/// (the photo `settings.background_image` names, when it is in the catalog).
|
||||
pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize) -> Arc<SlideCompose> {
|
||||
let settings = settings.clone().sanitized();
|
||||
let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| {
|
||||
let id = PhotoId(bid);
|
||||
let aspect = self.slide_photo_aspect(id)?;
|
||||
let fill =
|
||||
SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..settings.clone() };
|
||||
let (w, h) = lightcraft_slideshow::photo_size(&fill, aspect, width, height);
|
||||
let job = self.render_job(id, w, h, false, true)?;
|
||||
Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() })
|
||||
});
|
||||
Arc::new(SlideCompose { settings, width, height, backdrop })
|
||||
}
|
||||
|
||||
/// A render job whose result is photo `id`'s slide at `width × height` with the library's
|
||||
/// slide layout: the photo is rendered at the size its cell needs (its long edge at most
|
||||
/// `photo_edge`, when given), then composed on the worker. The photo's render is kept as its
|
||||
/// view preview, like the loupe's.
|
||||
pub fn slide_job(&mut self, id: PhotoId, width: usize, height: usize, photo_edge: Option<usize>) -> Option<RenderJob> {
|
||||
let settings = self.slide_layout.clone();
|
||||
self.slide_job_with(&settings, id, width, height, photo_edge, true)
|
||||
}
|
||||
|
||||
/// [`Self::slide_job`] with `settings` in place of the library's layout; `view` keeps the
|
||||
/// photo's render as its view preview.
|
||||
pub fn slide_job_with(
|
||||
&mut self,
|
||||
settings: &SlideSettings,
|
||||
id: PhotoId,
|
||||
width: usize,
|
||||
height: usize,
|
||||
photo_edge: Option<usize>,
|
||||
view: bool,
|
||||
) -> Option<RenderJob> {
|
||||
let aspect = self.slide_photo_aspect(id)?;
|
||||
let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height);
|
||||
if let Some(edge) = photo_edge.filter(|e| *e > 0 && w.max(h) > *e) {
|
||||
let k = edge as f64 / w.max(h) as f64;
|
||||
(w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1));
|
||||
}
|
||||
let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? };
|
||||
let compose = self.slide_compose(settings, width, height);
|
||||
Some(job.with_slide(compose))
|
||||
}
|
||||
|
||||
/// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread.
|
||||
pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result<Rgba8, String> {
|
||||
let job = self.slide_job_with(settings, id, width, height, None, false).ok_or("no such photo")?;
|
||||
let r = job.run();
|
||||
self.accept(&r);
|
||||
r.rendered.map(|r| r.image)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Slideshow slides: `slideshow.layout` (validated, saved with the library) and
|
||||
//! `slideshow.preview` (a slide composed with the layout, as a PNG).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-slide-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn png_size(bytes: &[u8]) -> (u32, u32) {
|
||||
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "a PNG");
|
||||
let be = |o: usize| u32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]);
|
||||
(be(16), be(20))
|
||||
}
|
||||
|
||||
fn base64_decode(text: &str) -> Vec<u8> {
|
||||
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32);
|
||||
for c in text.bytes().take_while(|c| *c != b'=') {
|
||||
acc = (acc << 6) | A.iter().position(|a| *a == c).unwrap() as u32;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_layout_is_validated_and_persists_with_the_library() {
|
||||
let lib = temp_dir("layout");
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let r = s.execute("slideshow.layout", &json!({})).unwrap();
|
||||
assert_eq!(r["marginLeft"], 0.0, "defaults: {r}");
|
||||
assert_eq!(r["backdropColor"], "#000000");
|
||||
let r = s
|
||||
.execute(
|
||||
"slideshow.layout",
|
||||
&json!({"marginTop": 0.1, "strokeBorder": true, "strokeColor": [255, 255, 255], "castShadow": true, "backdropColor": "#203040", "aspect": "4:3"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((r["marginLeft"].as_f64(), r["marginBottom"].as_f64()), (Some(0.1), Some(0.1)), "linked margins: {r}");
|
||||
assert_eq!(r["strokeColor"], "#ffffff");
|
||||
// a bad value changes nothing (the good keys beside it included)
|
||||
for bad in [json!({"marginLeft": "wide", "zoomToFill": true}), json!({"backdropColor": "red"}), json!({"glitter": 1})] {
|
||||
assert!(s.execute("slideshow.layout", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(!s.slide_layout.zoom_to_fill);
|
||||
// out of range: clamped
|
||||
let r = s.execute("slideshow.layout", &json!({"linkMargins": false, "marginRight": 9, "shadowOpacity": -1})).unwrap();
|
||||
assert_eq!((r["marginRight"].as_f64(), r["marginLeft"].as_f64(), r["shadowOpacity"].as_f64()), (Some(0.45), Some(0.1), Some(0.0)));
|
||||
drop(s);
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let r = s.execute("slideshow.layout", &json!({})).unwrap();
|
||||
assert_eq!((r["marginRight"].as_f64(), r["aspect"].as_str(), r["backdropColor"].as_str()), (Some(0.45), Some("4:3"), Some("#203040")));
|
||||
assert_eq!(r["linkMargins"], false);
|
||||
drop(s);
|
||||
// hand-edited values out of range are clamped when the library opens
|
||||
std::fs::write(lib.join("prefs.json"), r#"{"slideLayout": {"marginTop": 1e9, "shadowAngle": 720, "strokeWidth": -3}}"#).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!((s.slide_layout.margin_top, s.slide_layout.shadow_angle, s.slide_layout.stroke_width), (0.45, 0.0, 0.0));
|
||||
// and the playback settings beside them are untouched by a layout change
|
||||
let before = s.slideshow.clone();
|
||||
s.execute("slideshow.layout", &json!({"zoomToFill": true})).unwrap();
|
||||
assert_eq!(s.slideshow, before);
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_preview_returns_the_composed_slide_at_the_asked_size() {
|
||||
let mut s = Session::with_demo();
|
||||
let photos = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap();
|
||||
s.execute("slideshow.layout", &json!({"margins": 0.1, "backdropColor": "#ff0000"})).unwrap();
|
||||
let r = s.execute("slideshow.preview", &json!({"width": 320, "height": 200})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["id"].as_u64()), (Some(320), Some(200), Some(photos[0].0)));
|
||||
assert_eq!(r["cell"], json!([20.0, 20.0, 300.0, 180.0]), "10 % of the 200 px short side");
|
||||
let png = base64_decode(r["png"].as_str().unwrap());
|
||||
assert_eq!(png_size(&png), (320, 200));
|
||||
// written to a file instead
|
||||
let dir = temp_dir("preview");
|
||||
let path = dir.join("slide.png");
|
||||
let r = s.execute("slideshow.preview", &json!({"id": photos[1].0, "width": 240, "path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["path"], path.to_string_lossy().as_ref());
|
||||
assert!(r.get("png").is_none());
|
||||
assert_eq!(png_size(&std::fs::read(&path).unwrap()), (240, 150), "the height from the aspect preview (screen: 16:10)");
|
||||
s.execute("slideshow.layout", &json!({"aspect": "16:9"})).unwrap();
|
||||
let r = s.execute("slideshow.preview", &json!({"height": 90})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
|
||||
// the pixels: red margins, the photo inside
|
||||
let img = s.slide_now(&s.slide_layout.clone(), photos[0], 320, 200).unwrap();
|
||||
assert_eq!((img.width, img.height), (320, 200));
|
||||
assert_eq!(img.get(3, 3), [255, 0, 0, 255]);
|
||||
assert_eq!(img.get(160, 196), [255, 0, 0, 255]);
|
||||
assert_ne!(img.get(160, 100), [255, 0, 0, 255], "the photo in the cell");
|
||||
// refused: bad sizes, a photo that isn't there
|
||||
for bad in [json!({"width": 4}), json!({"height": 100000}), json!({"width": "big"}), json!({"id": 999_999}), json!({"id": "one"})] {
|
||||
assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_image_and_the_slide_key() {
|
||||
let mut s = Session::with_demo();
|
||||
let photos = s.visible_cloned();
|
||||
let plain = s.slide_layout.clone();
|
||||
let job = s.slide_job(photos[0], 320, 200, None).unwrap();
|
||||
let loupe = s.loupe_job(photos[0], 320, 200, true).unwrap();
|
||||
assert_ne!(job.key, loupe.key, "a slide is not the photo's render");
|
||||
// a background image fills the slide around the photo
|
||||
let bg =
|
||||
s.slide_layout.merged(&json!({"margins": 0.2, "backdropColor": "#ff0000", "backgroundImage": photos[2].0, "backgroundOpacity": 1})).unwrap();
|
||||
let img = s.slide_now(&bg, photos[0], 320, 200).unwrap();
|
||||
assert_ne!(img.get(3, 3), [255, 0, 0, 255], "the background photo, not the colour");
|
||||
// it is rendered once and reused
|
||||
assert!(s.slide_backdrop.lock().unwrap().is_some());
|
||||
let other = s.slide_job_with(&bg, photos[1], 320, 200, None, false).unwrap();
|
||||
assert_ne!(other.key, s.slide_job_with(&plain, photos[1], 320, 200, None, false).unwrap().key);
|
||||
// a background photo that isn't in the catalog: the colour alone
|
||||
let gone = bg.merged(&json!({"backgroundImage": 987_654})).unwrap();
|
||||
let img = s.slide_now(&gone, photos[0], 320, 200).unwrap();
|
||||
assert_eq!(img.get(3, 3), [255, 0, 0, 255]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "lightcraft-slideshow"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft slideshow slides: layout, options and backdrop settings and the CPU slide composer (L1)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,513 @@
|
||||
//! The slide composer: [`layout`] (where the image cell, the photo, its frame and shadow go) and
|
||||
//! [`compose`] (the slide's pixels, on the CPU).
|
||||
//!
|
||||
//! Drawing order: backdrop colour → background image (at its opacity, filling the slide) → colour
|
||||
//! wash → cast shadow → stroke border → photo. The stroke is drawn around the outside of the
|
||||
//! photo (the photo keeps its size); the shadow falls from the photo with its stroke.
|
||||
|
||||
use lightcraft_raster::blur::gaussian;
|
||||
use lightcraft_raster::resample::{Filter, resize};
|
||||
use lightcraft_raster::{Image, Plane, Rgba8, par_rows};
|
||||
|
||||
use crate::settings::{MAX_SIDE, SlideSettings};
|
||||
|
||||
/// A rectangle in slide pixels (`x0 ≤ x1`, `y0 ≤ y1`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x0: f64,
|
||||
pub y0: f64,
|
||||
pub x1: f64,
|
||||
pub y1: f64,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn width(&self) -> f64 {
|
||||
self.x1 - self.x0
|
||||
}
|
||||
pub fn height(&self) -> f64 {
|
||||
self.y1 - self.y0
|
||||
}
|
||||
fn intersect(&self, o: &Rect) -> Rect {
|
||||
let (x0, y0) = (self.x0.max(o.x0), self.y0.max(o.y0));
|
||||
Rect { x0, y0, x1: self.x1.min(o.x1).max(x0), y1: self.y1.min(o.y1).max(y0) }
|
||||
}
|
||||
fn expand(&self, d: f64) -> Rect {
|
||||
Rect { x0: self.x0 - d, y0: self.y0 - d, x1: self.x1 + d, y1: self.y1 + d }
|
||||
}
|
||||
fn shift(&self, dx: f64, dy: f64) -> Rect {
|
||||
Rect { x0: self.x0 + dx, y0: self.y0 + dy, x1: self.x1 + dx, y1: self.y1 + dy }
|
||||
}
|
||||
/// Whole pixel bounds clipped to `w × h`: (x0, y0, x1, y1).
|
||||
fn pixels(&self, w: usize, h: usize) -> (usize, usize, usize, usize) {
|
||||
let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 };
|
||||
let (x0, y0) = (c(self.x0, w), c(self.y0, h));
|
||||
(x0, y0, c(self.x1, w).max(x0), c(self.y1, h).max(y0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a slide's parts go, in slide pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Layout {
|
||||
/// The slide.
|
||||
pub slide: Rect,
|
||||
/// The image cell: the slide inside its margins.
|
||||
pub cell: Rect,
|
||||
/// The whole photo, scaled (with zoom to fill it extends past the cell).
|
||||
pub photo: Rect,
|
||||
/// The part of the photo that shows (the photo inside the cell).
|
||||
pub visible: Rect,
|
||||
/// The visible photo with its stroke border (the visible photo when there is none).
|
||||
pub frame: Rect,
|
||||
/// The rectangle that casts the shadow (the frame, offset), and the blur's sigma in pixels.
|
||||
pub shadow: Option<(Rect, f64)>,
|
||||
}
|
||||
|
||||
/// A usable aspect: a non-finite, zero or absurd one takes `fallback`.
|
||||
fn sane_aspect(a: f64, fallback: f64) -> f64 {
|
||||
if a.is_finite() && a > 1e-3 && a < 1e3 { a } else { fallback }
|
||||
}
|
||||
|
||||
/// The slide size `out_w × out_h` limited to [`MAX_SIDE`] (proportionally) and at least 1 × 1.
|
||||
fn slide_size(out_w: usize, out_h: usize) -> (usize, usize) {
|
||||
let (w, h) = (out_w.max(1), out_h.max(1));
|
||||
let long = w.max(h);
|
||||
if long <= MAX_SIDE {
|
||||
return (w, h);
|
||||
}
|
||||
let k = MAX_SIDE as f64 / long as f64;
|
||||
(((w as f64 * k).round() as usize).clamp(1, MAX_SIDE), ((h as f64 * k).round() as usize).clamp(1, MAX_SIDE))
|
||||
}
|
||||
|
||||
/// The layout of a `out_w × out_h` slide showing a photo of aspect `photo_aspect` (width /
|
||||
/// height; a bad one fills the cell).
|
||||
pub fn layout(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> Layout {
|
||||
let s = s.clone().sanitized();
|
||||
let (w, h) = slide_size(out_w, out_h);
|
||||
let (wf, hf) = (w as f64, h as f64);
|
||||
let short = wf.min(hf);
|
||||
// margins in pixels; when they leave no room (a tiny slide), they shrink to leave one pixel
|
||||
let fit = |a: f64, b: f64, room: f64| {
|
||||
let (a, b) = ((a * short).round(), (b * short).round());
|
||||
if a + b <= room - 1.0 {
|
||||
(a, b)
|
||||
} else {
|
||||
let k = ((room - 1.0).max(0.0)) / (a + b).max(1e-9);
|
||||
((a * k).floor(), (b * k).floor())
|
||||
}
|
||||
};
|
||||
let (ml, mr) = fit(s.margin_left, s.margin_right, wf);
|
||||
let (mt, mb) = fit(s.margin_top, s.margin_bottom, hf);
|
||||
let slide = Rect { x0: 0.0, y0: 0.0, x1: wf, y1: hf };
|
||||
let cell = Rect { x0: ml, y0: mt, x1: wf - mr, y1: hf - mb };
|
||||
let (cw, ch) = (cell.width().max(1.0), cell.height().max(1.0));
|
||||
let a = sane_aspect(photo_aspect, cw / ch);
|
||||
let wider = a > cw / ch;
|
||||
// fit: the photo's longer side (relative to the cell) meets the cell; fill: the shorter
|
||||
let (dw, dh) = if wider != s.zoom_to_fill { (cw, cw / a) } else { (ch * a, ch) };
|
||||
let (dw, dh) = (dw.round().max(1.0), dh.round().max(1.0));
|
||||
let x0 = (cell.x0 + (cw - dw) / 2.0).round();
|
||||
let y0 = (cell.y0 + (ch - dh) / 2.0).round();
|
||||
let photo = Rect { x0, y0, x1: x0 + dw, y1: y0 + dh };
|
||||
let visible = photo.intersect(&cell);
|
||||
let stroke = if s.stroke_border && s.stroke_width > 0.0 { (s.stroke_width * short).round().max(1.0) } else { 0.0 };
|
||||
let frame = visible.expand(stroke).intersect(&slide);
|
||||
let shadow = (s.cast_shadow && s.shadow_opacity > 0.0).then(|| {
|
||||
let (d, t) = (s.shadow_offset * short, s.shadow_angle.to_radians());
|
||||
// counter-clockwise from the right, y down on screen
|
||||
(frame.shift(d * t.cos(), -d * t.sin()), s.shadow_radius * short / 2.0)
|
||||
});
|
||||
Layout { slide, cell, photo, visible, frame, shadow }
|
||||
}
|
||||
|
||||
/// The size to render a photo of aspect `photo_aspect` at for a `out_w × out_h` slide: the
|
||||
/// photo's whole size in the slide (with zoom to fill, larger than its cell), at most 4× the
|
||||
/// slide's long side.
|
||||
pub fn photo_size(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> (usize, usize) {
|
||||
let l = layout(s, photo_aspect, out_w, out_h);
|
||||
let (w, h) = (l.photo.width().max(1.0), l.photo.height().max(1.0));
|
||||
let cap = 4.0 * l.slide.width().max(l.slide.height());
|
||||
let k = (cap / w.max(h)).min(1.0);
|
||||
(((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1))
|
||||
}
|
||||
|
||||
fn mix(a: u8, b: u8, t: f32) -> u8 {
|
||||
(a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
fn mix_px(dst: &mut [u8; 4], src: [u8; 3], t: f32) {
|
||||
for (d, s) in dst.iter_mut().zip(src) {
|
||||
*d = mix(*d, s, t);
|
||||
}
|
||||
dst[3] = 255;
|
||||
}
|
||||
|
||||
/// Draw `src` scaled to `dest` (slide pixels), only where it meets `clip`, at `opacity`.
|
||||
/// Only the part of `src` that shows is resampled (a zoomed-to-fill panorama stays cheap).
|
||||
fn draw_image(out: &mut Rgba8, src: &Rgba8, dest: &Rect, clip: &Rect, opacity: f32) {
|
||||
if src.width == 0 || src.height == 0 || src.data.len() != src.width * src.height || opacity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (out.width, out.height);
|
||||
let (vx0, vy0, vx1, vy1) = dest.intersect(clip).pixels(w, h);
|
||||
let (vw, vh) = (vx1 - vx0, vy1 - vy0);
|
||||
if vw == 0 || vh == 0 || dest.width() <= 0.0 || dest.height() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// the source pixels behind the visible part
|
||||
let (sx, sy) = (src.width as f64 / dest.width(), src.height as f64 / dest.height());
|
||||
let to_src = |v: f64, origin: f64, k: f64, max: usize| ((v - origin) * k).clamp(0.0, max as f64);
|
||||
let (s0x, s1x) = (to_src(vx0 as f64, dest.x0, sx, src.width).floor(), to_src(vx1 as f64, dest.x0, sx, src.width).ceil());
|
||||
let (s0y, s1y) = (to_src(vy0 as f64, dest.y0, sy, src.height).floor(), to_src(vy1 as f64, dest.y0, sy, src.height).ceil());
|
||||
let (cx, cy) = (s0x as usize, s0y as usize);
|
||||
let (cw, ch) = (((s1x - s0x) as usize).max(1), ((s1y - s0y) as usize).max(1));
|
||||
let part = if (cx, cy, cw, ch) == (0, 0, src.width, src.height) { None } else { Some(src.crop(cx, cy, cw, ch)) };
|
||||
let part = part.as_ref().unwrap_or(src);
|
||||
let scaled: Rgba8 = if (part.width, part.height) == (vw, vh) {
|
||||
part.clone()
|
||||
} else {
|
||||
let f = part.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32]);
|
||||
let filter = if vw < part.width { Filter::Lanczos3 } else { Filter::Mitchell };
|
||||
resize(&f, vw, vh, filter).map(|p| [0, 1, 2, 3].map(|i| p[i].round().clamp(0.0, 255.0) as u8))
|
||||
};
|
||||
for (row, y) in (vy0..vy1).enumerate() {
|
||||
let (Some(dst), Some(s)) = (out.data.get_mut(y * w + vx0..y * w + vx1), scaled.data.get(row * vw..(row + 1) * vw)) else {
|
||||
continue;
|
||||
};
|
||||
for (d, p) in dst.iter_mut().zip(s) {
|
||||
let a = opacity * p[3] as f32 / 255.0;
|
||||
mix_px(d, [p[0], p[1], p[2]], a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill `r` with `c`.
|
||||
fn fill(out: &mut Rgba8, r: &Rect, c: [u8; 3]) {
|
||||
let w = out.width;
|
||||
let (x0, y0, x1, y1) = r.pixels(out.width, out.height);
|
||||
for y in y0..y1 {
|
||||
if let Some(row) = out.data.get_mut(y * w + x0..y * w + x1) {
|
||||
row.fill([c[0], c[1], c[2], 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 1-D blurred step: 1 inside `[a, b)`, blurred with `sigma` (pixels), over `0..n`.
|
||||
fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec<f32> {
|
||||
let inside = Plane::from_fn(n, 1, |x, _| {
|
||||
let c = x as f64 + 0.5;
|
||||
if c >= a && c < b { 1.0 } else { 0.0 }
|
||||
});
|
||||
gaussian(&inside, sigma as f32).data
|
||||
}
|
||||
|
||||
/// The slide: `photo` (the developed photo, any size: it is scaled into its cell) and the
|
||||
/// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels
|
||||
/// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone.
|
||||
pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 {
|
||||
let s = s.clone().sanitized();
|
||||
let (w, h) = slide_size(out_w, out_h);
|
||||
let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height;
|
||||
let aspect = if has_photo { photo.width as f64 / photo.height as f64 } else { f64::NAN };
|
||||
let l = layout(&s, aspect, w, h);
|
||||
let c = s.backdrop_color;
|
||||
let mut out = Image::filled(w, h, [c[0], c[1], c[2], 255]);
|
||||
// background image: the photo filling the slide, at its opacity
|
||||
if let Some(bg) = backdrop_image.filter(|_| s.background_image.is_some() && s.background_opacity > 0.0)
|
||||
&& bg.width > 0
|
||||
&& bg.height > 0
|
||||
{
|
||||
let a = bg.width as f64 / bg.height as f64;
|
||||
let fill_all = SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..s.clone() };
|
||||
let bl = layout(&fill_all, a, w, h);
|
||||
draw_image(&mut out, bg, &bl.photo, &bl.slide, s.background_opacity as f32);
|
||||
}
|
||||
// colour wash: full wash colour at the side the angle points to, none at the opposite side
|
||||
if s.color_wash && s.wash_opacity > 0.0 {
|
||||
let t = s.wash_angle.to_radians();
|
||||
let (dx, dy) = (t.cos(), -t.sin());
|
||||
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
|
||||
let extent = (cx * dx.abs() + cy * dy.abs()).max(1e-9);
|
||||
let (wc, op) = (s.wash_color, s.wash_opacity);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let py = y as f64 + 0.5 - cy;
|
||||
for (x, p) in row.iter_mut().enumerate() {
|
||||
let proj = (x as f64 + 0.5 - cx) * dx + py * dy;
|
||||
let k = ((proj / extent + 1.0) / 2.0).clamp(0.0, 1.0);
|
||||
mix_px(p, wc, (k * op) as f32);
|
||||
}
|
||||
});
|
||||
}
|
||||
// cast shadow: a blurred rectangle (separable: the product of two blurred steps)
|
||||
if let Some((r, sigma)) = l.shadow {
|
||||
let op = s.shadow_opacity as f32;
|
||||
let fx = blurred_step(w, r.x0, r.x1, sigma);
|
||||
let fy = blurred_step(h, r.y0, r.y1, sigma);
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let ky = fy.get(y).copied().unwrap_or(0.0);
|
||||
if ky <= 1e-4 {
|
||||
return;
|
||||
}
|
||||
for (p, kx) in row.iter_mut().zip(&fx) {
|
||||
let a = op * ky * kx;
|
||||
if a > 1e-4 {
|
||||
mix_px(p, [0, 0, 0], a);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if l.frame != l.visible {
|
||||
fill(&mut out, &l.frame, s.stroke_color);
|
||||
}
|
||||
if has_photo {
|
||||
draw_image(&mut out, photo, &l.photo, &l.cell, 1.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::settings::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX};
|
||||
use serde_json::json;
|
||||
|
||||
fn solid(w: usize, h: usize, c: [u8; 3]) -> Rgba8 {
|
||||
Image::filled(w, h, [c[0], c[1], c[2], 255])
|
||||
}
|
||||
|
||||
fn with(p: serde_json::Value) -> SlideSettings {
|
||||
SlideSettings::default().merged(&p).unwrap()
|
||||
}
|
||||
|
||||
fn px(img: &Rgba8, x: usize, y: usize) -> [u8; 3] {
|
||||
let p = img.get(x, y);
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const GREEN: [u8; 3] = [0, 200, 0];
|
||||
|
||||
#[test]
|
||||
fn margins_make_the_cell_and_the_photo_fits_inside_it() {
|
||||
// 1000 × 500 slide, 10 % margins (of the 500 px short side = 50 px)
|
||||
let s = with(json!({"margins": 0.1}));
|
||||
let l = layout(&s, 1.0, 1000, 500);
|
||||
assert_eq!(l.cell, Rect { x0: 50.0, y0: 50.0, x1: 950.0, y1: 450.0 });
|
||||
// a square photo fits the 900 × 400 cell's height, centred
|
||||
assert_eq!(l.photo, Rect { x0: 300.0, y0: 50.0, x1: 700.0, y1: 450.0 });
|
||||
assert_eq!(l.visible, l.photo);
|
||||
// independent margins
|
||||
let s = with(json!({"linkMargins": false, "marginLeft": 0.2, "marginTop": 0.0, "marginRight": 0.0, "marginBottom": 0.4}));
|
||||
let l = layout(&s, 3.0, 1000, 500);
|
||||
assert_eq!(l.cell, Rect { x0: 100.0, y0: 0.0, x1: 1000.0, y1: 300.0 });
|
||||
assert_eq!((l.photo.width(), l.photo.height()), (900.0, 300.0), "3:1 fits 900 × 300 exactly");
|
||||
|
||||
let img = compose(&solid(64, 64, GREEN), None, &with(json!({"margins": 0.1, "backdropColor": "#ff0000"})), 1000, 500);
|
||||
assert_eq!((img.width, img.height), (1000, 500));
|
||||
assert_eq!(px(&img, 10, 10), RED, "margin: backdrop");
|
||||
assert_eq!(px(&img, 500, 250), GREEN, "cell centre: photo");
|
||||
assert_eq!(px(&img, 290, 250), RED, "beside a fitted square photo: backdrop");
|
||||
assert_eq!(px(&img, 310, 60), GREEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fill_covers_the_cell_and_crops() {
|
||||
let s = with(json!({"margins": 0.1, "zoomToFill": true}));
|
||||
let l = layout(&s, 1.0, 1000, 500);
|
||||
assert_eq!((l.photo.width(), l.photo.height()), (900.0, 900.0), "the square photo covers the 900 × 400 cell");
|
||||
assert_eq!(l.visible, l.cell, "and shows only inside it");
|
||||
assert_eq!(photo_size(&s, 1.0, 1000, 500), (900, 900), "rendered whole at the covering size");
|
||||
// a gradient photo: zoomed to fill, its middle rows show, its top and bottom are cut
|
||||
let photo = Rgba8::from_fn(100, 100, |_, y| [(y * 2) as u8, 0, 0, 255]);
|
||||
let img = compose(&photo, None, &s, 1000, 500);
|
||||
assert_eq!(px(&img, 500, 10), [0, 0, 0], "margin stays the backdrop");
|
||||
assert_eq!(px(&img, 60, 250), px(&img, 940, 250), "covers the cell edge to edge");
|
||||
let top = img.get(500, 51)[0];
|
||||
assert!(top > 40, "the photo's top rows are cropped away ({top})");
|
||||
// fit draws the whole photo (its first rows near black)
|
||||
let img = compose(&photo, None, &with(json!({"margins": 0.1})), 1000, 500);
|
||||
assert!(img.get(500, 51)[0] < 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_preview_ratios() {
|
||||
use crate::settings::Aspect;
|
||||
let s = with(json!({"aspect": "4:3"}));
|
||||
let r = s.aspect.ratio(2.0);
|
||||
assert!((r - 4.0 / 3.0).abs() < 1e-12);
|
||||
assert_eq!(with(json!({"aspect": "screen"})).aspect.ratio(2.0), 2.0);
|
||||
assert_eq!(Aspect::parse("16:9"), Some(Aspect::Wide));
|
||||
// a 4:3 slide of a 3:2 photo letterboxes top and bottom
|
||||
let l = layout(&s, 1.5, 800, 600);
|
||||
assert_eq!((l.photo.width(), l.photo.height(), l.photo.y0), (800.0, 533.0, 34.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_surrounds_the_photo_and_shadow_falls_at_its_angle() {
|
||||
let s = with(json!({"margins": 0.2, "strokeBorder": true, "strokeWidth": 0.02, "strokeColor": "#ffffff"}));
|
||||
let l = layout(&s, 1.0, 1000, 1000);
|
||||
assert_eq!(l.visible, Rect { x0: 200.0, y0: 200.0, x1: 800.0, y1: 800.0 });
|
||||
assert_eq!(l.frame, Rect { x0: 180.0, y0: 180.0, x1: 820.0, y1: 820.0 }, "20 px outside the photo");
|
||||
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
|
||||
assert_eq!(px(&img, 190, 500), [255, 255, 255], "stroke left");
|
||||
assert_eq!(px(&img, 810, 810), [255, 255, 255], "stroke corner");
|
||||
assert_eq!(px(&img, 175, 500), [0, 0, 0], "outside the stroke");
|
||||
assert_eq!(px(&img, 205, 500), GREEN, "the photo keeps its size");
|
||||
|
||||
// shadow at −45° (down-right) by 5 %, hard edged (radius 0), opaque
|
||||
let s = with(
|
||||
json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": -45, "shadowRadius": 0, "shadowOpacity": 1, "backdropColor": "#ffffff"}),
|
||||
);
|
||||
let l = layout(&s, 1.0, 1000, 1000);
|
||||
let (r, _) = l.shadow.unwrap();
|
||||
let d = 50.0 / 2f64.sqrt();
|
||||
assert!((r.x0 - (200.0 + d)).abs() < 1e-9 && (r.y0 - (200.0 + d)).abs() < 1e-9, "{r:?}");
|
||||
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
|
||||
assert_eq!(px(&img, 820, 820), [0, 0, 0], "shadow below right");
|
||||
assert_eq!(px(&img, 180, 180), [255, 255, 255], "no shadow above left");
|
||||
assert_eq!(px(&img, 850, 500), [255, 255, 255], "shadow ends at its offset");
|
||||
// at 90° the shadow goes straight up; a soft radius spreads it but fades out
|
||||
let s = with(
|
||||
json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": 90, "shadowRadius": 0.04, "shadowOpacity": 0.8, "backdropColor": "#ffffff"}),
|
||||
);
|
||||
let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000);
|
||||
assert!(img.get(500, 170)[0] < 200, "above: in shadow");
|
||||
assert_eq!(px(&img, 500, 950), [255, 255, 255], "below: none");
|
||||
assert_eq!(px(&img, 500, 50), [255, 255, 255], "beyond the blur: none");
|
||||
let mid = img.get(500, 151)[0];
|
||||
assert!(mid > 60 && mid < 250, "a soft edge ({mid})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_wash_follows_its_angle() {
|
||||
let wash = |angle: f64| {
|
||||
compose(
|
||||
&Rgba8::new(0, 0),
|
||||
None,
|
||||
&with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 1, "washAngle": angle})),
|
||||
200,
|
||||
100,
|
||||
)
|
||||
};
|
||||
// 0°: white at the right, black at the left
|
||||
let img = wash(0.0);
|
||||
assert!(img.get(199, 50)[0] > 250 && img.get(0, 50)[0] < 5);
|
||||
assert!((img.get(100, 50)[0] as i32 - 128).abs() < 3, "half way in the middle");
|
||||
assert_eq!(img.get(150, 0), img.get(150, 99), "constant along the other axis");
|
||||
// 90°: white at the top
|
||||
let img = wash(90.0);
|
||||
assert!(img.get(100, 0)[0] > 245 && img.get(100, 99)[0] < 10);
|
||||
// 180°: white at the left
|
||||
assert!(wash(180.0).get(0, 50)[0] > 250);
|
||||
// −45°: bottom-right is the brightest corner
|
||||
let img = wash(-45.0);
|
||||
assert!(img.get(199, 99)[0] > 250 && img.get(0, 0)[0] < 5);
|
||||
// opacity scales it
|
||||
let img =
|
||||
compose(&Rgba8::new(0, 0), None, &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 0.5, "washAngle": 0})), 200, 100);
|
||||
assert!((img.get(199, 50)[0] as i32 - 127).abs() < 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_image_fills_the_slide_at_its_opacity() {
|
||||
let bg = solid(30, 10, [200, 100, 0]);
|
||||
let s = with(json!({"backgroundImage": 5, "backgroundOpacity": 0.5, "backdropColor": "#000000", "margins": 0.25}));
|
||||
let img = compose(&solid(4, 4, GREEN), Some(&bg), &s, 400, 200);
|
||||
assert_eq!(px(&img, 2, 2), [100, 50, 0], "half the image over black");
|
||||
assert_eq!(px(&img, 398, 198), [100, 50, 0], "fills the whole slide");
|
||||
assert_eq!(px(&img, 200, 100), GREEN, "the photo on top");
|
||||
// off, or no image given: the backdrop colour
|
||||
let off = SlideSettings { background_image: None, ..s.clone() };
|
||||
assert_eq!(px(&compose(&solid(4, 4, GREEN), Some(&bg), &off, 400, 200), 2, 2), [0, 0, 0]);
|
||||
assert_eq!(px(&compose(&solid(4, 4, GREEN), None, &s, 400, 200), 2, 2), [0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_are_resolution_independent() {
|
||||
let s = with(
|
||||
json!({"linkMargins": false, "marginLeft": 0.1, "marginTop": 0.05, "marginRight": 0.2, "marginBottom": 0.08, "strokeBorder": true, "strokeWidth": 0.01, "castShadow": true, "shadowOffset": 0.02}),
|
||||
);
|
||||
let a = layout(&s, 1.5, 800, 450);
|
||||
let b = layout(&s, 1.5, 1600, 900);
|
||||
for (ra, rb) in [(a.cell, b.cell), (a.photo, b.photo), (a.frame, b.frame), (a.shadow.unwrap().0, b.shadow.unwrap().0)] {
|
||||
for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] {
|
||||
assert!((va * 2.0 - vb).abs() <= 2.0, "{ra:?} vs {rb:?}");
|
||||
}
|
||||
}
|
||||
assert!((a.shadow.unwrap().1 * 2.0 - b.shadow.unwrap().1).abs() < 1e-9);
|
||||
// and the pixels agree: the big slide, halved, is the small one (away from edges)
|
||||
let photo = Rgba8::from_fn(60, 40, |x, y| [(x * 4) as u8, (y * 6) as u8, 90, 255]);
|
||||
let small = compose(&photo, None, &s, 800, 450);
|
||||
let big = compose(&photo, None, &s, 1600, 900);
|
||||
for (x, y) in [(10, 10), (400, 225), (300, 100), (700, 400)] {
|
||||
let (p, q) = (small.get(x, y), big.get(x * 2, y * 2));
|
||||
assert!((0..3).all(|i| (p[i] as i32 - q[i] as i32).abs() < 24), "({x}, {y}): {p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_slide_is_the_photo_fitted_on_black() {
|
||||
let photo = Rgba8::from_fn(300, 200, |x, y| [x as u8, y as u8, 7, 255]);
|
||||
let s = SlideSettings::default();
|
||||
assert!(s.is_plain());
|
||||
// a 3:2 photo on a 16:10 screen: the photo at its own pixels, black bars beside it
|
||||
assert_eq!(photo_size(&s, 1.5, 320, 200), (300, 200));
|
||||
let img = compose(&photo, None, &s, 320, 200);
|
||||
assert_eq!(px(&img, 5, 100), [0, 0, 0]);
|
||||
assert_eq!(px(&img, 314, 100), [0, 0, 0]);
|
||||
for (x, y) in [(0, 0), (150, 100), (299, 199)] {
|
||||
assert_eq!(img.get(x + 10, y), photo.get(x, y), "unchanged pixels at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
let wild = SlideSettings {
|
||||
margin_left: f64::NAN,
|
||||
margin_right: 1e300,
|
||||
margin_top: -5.0,
|
||||
margin_bottom: f64::INFINITY,
|
||||
stroke_border: true,
|
||||
stroke_width: 1e9,
|
||||
cast_shadow: true,
|
||||
shadow_opacity: f64::NAN,
|
||||
shadow_offset: -1.0,
|
||||
shadow_radius: f64::INFINITY,
|
||||
shadow_angle: f64::NAN,
|
||||
color_wash: true,
|
||||
wash_opacity: 9.0,
|
||||
wash_angle: f64::INFINITY,
|
||||
background_image: Some(u64::MAX),
|
||||
background_opacity: f64::NEG_INFINITY,
|
||||
zoom_to_fill: true,
|
||||
..SlideSettings::default()
|
||||
};
|
||||
let photos = [Rgba8::new(0, 0), Rgba8::new(1, 1), Rgba8::new(5000, 1), Rgba8::new(1, 3000), Rgba8 { width: 4, height: 4, data: vec![] }];
|
||||
for photo in &photos {
|
||||
for (w, h) in [(0, 0), (1, 1), (3, 2), (1, 400), (100_000, 2), (64, 48)] {
|
||||
let img = compose(photo, Some(&Rgba8::new(0, 5)), &wild, w, h);
|
||||
assert!(img.width >= 1 && img.height >= 1 && img.width <= MAX_SIDE && img.height <= MAX_SIDE);
|
||||
assert_eq!(img.data.len(), img.width * img.height);
|
||||
let (pw, ph) = photo_size(&wild, photo.width as f64 / photo.height.max(1) as f64, w, h);
|
||||
assert!(pw >= 1 && ph >= 1 && pw <= 4 * MAX_SIDE && ph <= 4 * MAX_SIDE);
|
||||
}
|
||||
}
|
||||
for a in [f64::NAN, 0.0, -1.0, f64::INFINITY, 1e-300, 1e300] {
|
||||
let l = layout(&wild, a, 640, 480);
|
||||
assert!(l.photo.width() >= 1.0 && l.photo.width().is_finite());
|
||||
}
|
||||
// the clamps: biggest margins still leave a cell, biggest stroke and shadow stay bounded
|
||||
let s = SlideSettings {
|
||||
margin_left: MARGIN_MAX,
|
||||
margin_right: MARGIN_MAX,
|
||||
stroke_border: true,
|
||||
stroke_width: STROKE_MAX,
|
||||
cast_shadow: true,
|
||||
shadow_offset: SHADOW_OFFSET_MAX,
|
||||
shadow_radius: SHADOW_RADIUS_MAX,
|
||||
..SlideSettings::default()
|
||||
};
|
||||
let l = layout(&s, 1.0, 100, 100);
|
||||
assert!(l.cell.width() >= 1.0, "{l:?}");
|
||||
assert!(l.frame.x0 >= 0.0 && l.frame.x1 <= 100.0, "the stroke stays on the slide");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Slideshow slides for LightCraft: the settings of Lightroom Classic's Slideshow Layout, Options
|
||||
//! and Backdrop panels ([`SlideSettings`]) and the CPU composer that draws one slide from a
|
||||
//! rendered photo ([`compose`]).
|
||||
//!
|
||||
//! No UI and no catalog here: playback, the settings preview, the `slideshow.preview` command and
|
||||
//! (later) slideshow export all call [`compose`], so every one of them draws the same pixels.
|
||||
//!
|
||||
//! **Resolution independence:** every length is relative to the slide: margins, the stroke width
|
||||
//! and the shadow's offset and radius are fractions of the slide's *shorter side* (so linked
|
||||
//! margins are equal in pixels, and a slide composed at two sizes has the same proportions).
|
||||
//! Angles are degrees counter-clockwise from the right (−45° points down and to the right).
|
||||
//!
|
||||
//! **Never crash:** settings are validated by [`SlideSettings::merged`] (a wrong type is an error)
|
||||
//! and clamped by [`SlideSettings::sanitized`] (NaN, ±∞, negative and huge values land in range);
|
||||
//! [`compose`] accepts any size and any photo (an empty one draws the backdrop alone).
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod compose;
|
||||
mod settings;
|
||||
|
||||
pub use compose::{Layout, Rect, compose, layout, photo_size};
|
||||
pub use settings::{Aspect, MARGIN_MAX, MAX_SIDE, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX, SlideSettings, hex, parse_color};
|
||||
@@ -0,0 +1,401 @@
|
||||
//! [`SlideSettings`]: Layout (margins, guides, aspect preview), Options (zoom to fill, stroke
|
||||
//! border, cast shadow) and Backdrop (colour, colour wash, background image).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Largest margin (fraction of the slide's shorter side).
|
||||
pub const MARGIN_MAX: f64 = 0.45;
|
||||
/// Widest stroke border (fraction of the slide's shorter side).
|
||||
pub const STROKE_MAX: f64 = 0.05;
|
||||
/// Longest shadow offset (fraction of the slide's shorter side).
|
||||
pub const SHADOW_OFFSET_MAX: f64 = 0.1;
|
||||
/// Largest shadow radius (fraction of the slide's shorter side).
|
||||
pub const SHADOW_RADIUS_MAX: f64 = 0.1;
|
||||
/// Largest slide side [`crate::compose`] draws (bigger requests are scaled down to it).
|
||||
pub const MAX_SIDE: usize = 8192;
|
||||
|
||||
/// The aspect the slide is previewed at (Lightroom Classic's Aspect Preview). Playback always
|
||||
/// fills the screen; the settings preview and `slideshow.preview` use this.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Aspect {
|
||||
/// The screen's own aspect.
|
||||
#[default]
|
||||
#[serde(rename = "screen")]
|
||||
Screen,
|
||||
#[serde(rename = "16:9")]
|
||||
Wide,
|
||||
#[serde(rename = "4:3")]
|
||||
Standard,
|
||||
}
|
||||
|
||||
impl Aspect {
|
||||
pub const ALL: [Aspect; 3] = [Aspect::Screen, Aspect::Wide, Aspect::Standard];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Aspect::Screen => "screen",
|
||||
Aspect::Wide => "16:9",
|
||||
Aspect::Standard => "4:3",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Aspect> {
|
||||
Aspect::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
|
||||
/// Width / height, given the screen's (used for [`Aspect::Screen`]; a bad one gives 16:10).
|
||||
pub fn ratio(self, screen: f64) -> f64 {
|
||||
match self {
|
||||
Aspect::Screen if screen.is_finite() && screen > 0.05 && screen < 20.0 => screen,
|
||||
Aspect::Screen => 1.6,
|
||||
Aspect::Wide => 16.0 / 9.0,
|
||||
Aspect::Standard => 4.0 / 3.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a slide looks (everything but the photo). Lengths are fractions of the slide's shorter
|
||||
/// side, angles degrees counter-clockwise from the right, opacities 0–1.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SlideSettings {
|
||||
// Layout
|
||||
pub margin_left: f64,
|
||||
pub margin_right: f64,
|
||||
pub margin_top: f64,
|
||||
pub margin_bottom: f64,
|
||||
/// Changing one margin changes all four.
|
||||
pub link_margins: bool,
|
||||
/// Show the margin guides in the slide editor (never in playback or export).
|
||||
pub show_guides: bool,
|
||||
pub aspect: Aspect,
|
||||
// Options
|
||||
/// Crop the photo to fill its cell (off: the whole photo fits inside it).
|
||||
pub zoom_to_fill: bool,
|
||||
pub stroke_border: bool,
|
||||
pub stroke_color: [u8; 3],
|
||||
pub stroke_width: f64,
|
||||
pub cast_shadow: bool,
|
||||
pub shadow_opacity: f64,
|
||||
pub shadow_offset: f64,
|
||||
pub shadow_radius: f64,
|
||||
pub shadow_angle: f64,
|
||||
// Backdrop
|
||||
pub backdrop_color: [u8; 3],
|
||||
/// A gradient from `wash_color` (full at the side `wash_angle` points to) to the backdrop.
|
||||
pub color_wash: bool,
|
||||
pub wash_color: [u8; 3],
|
||||
pub wash_opacity: f64,
|
||||
pub wash_angle: f64,
|
||||
/// A library photo (its id) drawn over the backdrop colour, filling the slide.
|
||||
pub background_image: Option<u64>,
|
||||
pub background_opacity: f64,
|
||||
}
|
||||
|
||||
impl Default for SlideSettings {
|
||||
fn default() -> Self {
|
||||
SlideSettings {
|
||||
margin_left: 0.0,
|
||||
margin_right: 0.0,
|
||||
margin_top: 0.0,
|
||||
margin_bottom: 0.0,
|
||||
link_margins: true,
|
||||
show_guides: true,
|
||||
aspect: Aspect::Screen,
|
||||
zoom_to_fill: false,
|
||||
stroke_border: false,
|
||||
stroke_color: [255, 255, 255],
|
||||
stroke_width: 0.004,
|
||||
cast_shadow: false,
|
||||
shadow_opacity: 0.4,
|
||||
shadow_offset: 0.012,
|
||||
shadow_radius: 0.02,
|
||||
shadow_angle: -45.0,
|
||||
backdrop_color: [0, 0, 0],
|
||||
color_wash: false,
|
||||
wash_color: [96, 96, 96],
|
||||
wash_opacity: 0.6,
|
||||
wash_angle: 90.0,
|
||||
background_image: None,
|
||||
background_opacity: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys [`SlideSettings::merged`] takes (for error messages and docs).
|
||||
const KEYS: &str = "margins, marginLeft, marginRight, marginTop, marginBottom, linkMargins, showGuides, aspect, zoomToFill, strokeBorder, strokeColor, strokeWidth, castShadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle, backdropColor, colorWash, washColor, washOpacity, washAngle, backgroundImage, backgroundOpacity";
|
||||
|
||||
fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { fallback }
|
||||
}
|
||||
|
||||
/// An angle in degrees into (−180, 180]; non-finite → `fallback`.
|
||||
fn wrap_angle(a: f64, fallback: f64) -> f64 {
|
||||
if !a.is_finite() {
|
||||
return fallback;
|
||||
}
|
||||
let w = (a + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if w <= -180.0 { 180.0 } else { w }
|
||||
}
|
||||
|
||||
impl SlideSettings {
|
||||
/// The settings with every value in range: lengths and opacities clamped, angles wrapped,
|
||||
/// non-finite numbers replaced by the defaults.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
let d = SlideSettings::default();
|
||||
for m in [&mut self.margin_left, &mut self.margin_right, &mut self.margin_top, &mut self.margin_bottom] {
|
||||
*m = clamp_or(*m, 0.0, MARGIN_MAX, 0.0);
|
||||
}
|
||||
self.stroke_width = clamp_or(self.stroke_width, 0.0, STROKE_MAX, d.stroke_width);
|
||||
self.shadow_opacity = clamp_or(self.shadow_opacity, 0.0, 1.0, d.shadow_opacity);
|
||||
self.shadow_offset = clamp_or(self.shadow_offset, 0.0, SHADOW_OFFSET_MAX, d.shadow_offset);
|
||||
self.shadow_radius = clamp_or(self.shadow_radius, 0.0, SHADOW_RADIUS_MAX, d.shadow_radius);
|
||||
self.shadow_angle = wrap_angle(self.shadow_angle, d.shadow_angle);
|
||||
self.wash_opacity = clamp_or(self.wash_opacity, 0.0, 1.0, d.wash_opacity);
|
||||
self.wash_angle = wrap_angle(self.wash_angle, d.wash_angle);
|
||||
self.background_opacity = clamp_or(self.background_opacity, 0.0, 1.0, d.background_opacity);
|
||||
self
|
||||
}
|
||||
|
||||
/// Does a slide with these settings show nothing but the photo fitted on black (the plain
|
||||
/// slideshow)? Settings that are switched on but draw nothing (a 0-wide stroke, a shadow at
|
||||
/// 0 % opacity) count as plain.
|
||||
pub fn is_plain(&self) -> bool {
|
||||
let s = self.clone().sanitized();
|
||||
s.margin_left == 0.0
|
||||
&& s.margin_right == 0.0
|
||||
&& s.margin_top == 0.0
|
||||
&& s.margin_bottom == 0.0
|
||||
&& !s.zoom_to_fill
|
||||
&& !(s.stroke_border && s.stroke_width > 0.0)
|
||||
&& !(s.cast_shadow && s.shadow_opacity > 0.0)
|
||||
&& s.backdrop_color == [0, 0, 0]
|
||||
&& !(s.color_wash && s.wash_opacity > 0.0)
|
||||
&& !(s.background_image.is_some() && s.background_opacity > 0.0)
|
||||
}
|
||||
|
||||
/// A hash of what changes the slide's pixels (guides and the aspect preview don't), for
|
||||
/// render keys. Stable within a run.
|
||||
pub fn pixel_key(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut s = self.clone().sanitized();
|
||||
s.show_guides = false;
|
||||
s.aspect = Aspect::Screen;
|
||||
s.link_margins = false;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
s.to_json().to_string().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"marginLeft": self.margin_left,
|
||||
"marginRight": self.margin_right,
|
||||
"marginTop": self.margin_top,
|
||||
"marginBottom": self.margin_bottom,
|
||||
"linkMargins": self.link_margins,
|
||||
"showGuides": self.show_guides,
|
||||
"aspect": self.aspect.id(),
|
||||
"zoomToFill": self.zoom_to_fill,
|
||||
"strokeBorder": self.stroke_border,
|
||||
"strokeColor": hex(self.stroke_color),
|
||||
"strokeWidth": self.stroke_width,
|
||||
"castShadow": self.cast_shadow,
|
||||
"shadowOpacity": self.shadow_opacity,
|
||||
"shadowOffset": self.shadow_offset,
|
||||
"shadowRadius": self.shadow_radius,
|
||||
"shadowAngle": self.shadow_angle,
|
||||
"backdropColor": hex(self.backdrop_color),
|
||||
"colorWash": self.color_wash,
|
||||
"washColor": hex(self.wash_color),
|
||||
"washOpacity": self.wash_opacity,
|
||||
"washAngle": self.wash_angle,
|
||||
"backgroundImage": self.background_image,
|
||||
"backgroundOpacity": self.background_opacity,
|
||||
})
|
||||
}
|
||||
|
||||
/// These settings with the keys `p` names changed (the others kept), validated and clamped.
|
||||
/// A wrong type, an unknown key or a non-finite number is an error, and nothing changes.
|
||||
/// With `linkMargins` on (already, or set in `p`), a margin given sets all four; `margins`
|
||||
/// sets all four in any case.
|
||||
pub fn merged(&self, p: &Value) -> Result<Self, String> {
|
||||
let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) };
|
||||
let mut s = self.clone();
|
||||
let mut margin: Option<f64> = None;
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"margins" => {
|
||||
let m = number(k, v)?;
|
||||
(s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m);
|
||||
}
|
||||
"marginLeft" => s.margin_left = *margin.insert(number(k, v)?),
|
||||
"marginRight" => s.margin_right = *margin.insert(number(k, v)?),
|
||||
"marginTop" => s.margin_top = *margin.insert(number(k, v)?),
|
||||
"marginBottom" => s.margin_bottom = *margin.insert(number(k, v)?),
|
||||
"linkMargins" => s.link_margins = boolean(k, v)?,
|
||||
"showGuides" => s.show_guides = boolean(k, v)?,
|
||||
"aspect" => {
|
||||
s.aspect =
|
||||
v.as_str().and_then(Aspect::parse).ok_or_else(|| format!("`aspect` must be \"screen\", \"16:9\" or \"4:3\", not {v}"))?
|
||||
}
|
||||
"zoomToFill" => s.zoom_to_fill = boolean(k, v)?,
|
||||
"strokeBorder" => s.stroke_border = boolean(k, v)?,
|
||||
"strokeColor" => s.stroke_color = color(k, v)?,
|
||||
"strokeWidth" => s.stroke_width = number(k, v)?,
|
||||
"castShadow" => s.cast_shadow = boolean(k, v)?,
|
||||
"shadowOpacity" => s.shadow_opacity = number(k, v)?,
|
||||
"shadowOffset" => s.shadow_offset = number(k, v)?,
|
||||
"shadowRadius" => s.shadow_radius = number(k, v)?,
|
||||
"shadowAngle" => s.shadow_angle = number(k, v)?,
|
||||
"backdropColor" => s.backdrop_color = color(k, v)?,
|
||||
"colorWash" => s.color_wash = boolean(k, v)?,
|
||||
"washColor" => s.wash_color = color(k, v)?,
|
||||
"washOpacity" => s.wash_opacity = number(k, v)?,
|
||||
"washAngle" => s.wash_angle = number(k, v)?,
|
||||
"backgroundImage" => {
|
||||
s.background_image = match v {
|
||||
Value::Null => None,
|
||||
_ => Some(v.as_u64().ok_or_else(|| format!("`backgroundImage` must be a photo id or null, not {v}"))?),
|
||||
}
|
||||
}
|
||||
"backgroundOpacity" => s.background_opacity = number(k, v)?,
|
||||
other => return Err(format!("unknown setting `{other}` ({KEYS})")),
|
||||
}
|
||||
}
|
||||
if s.link_margins
|
||||
&& let Some(m) = margin
|
||||
{
|
||||
(s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m);
|
||||
}
|
||||
Ok(s.sanitized())
|
||||
}
|
||||
}
|
||||
|
||||
fn number(k: &str, v: &Value) -> Result<f64, String> {
|
||||
match v.as_f64() {
|
||||
Some(x) if x.is_finite() => Ok(x),
|
||||
_ => Err(format!("`{k}` must be a finite number, not {v}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean(k: &str, v: &Value) -> Result<bool, String> {
|
||||
v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}"))
|
||||
}
|
||||
|
||||
fn color(k: &str, v: &Value) -> Result<[u8; 3], String> {
|
||||
parse_color(v).ok_or_else(|| format!("`{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}"))
|
||||
}
|
||||
|
||||
/// `"#rrggbb"` (the `#` optional) or `[r, g, b]` with 0–255 components.
|
||||
pub fn parse_color(v: &Value) -> Option<[u8; 3]> {
|
||||
match v {
|
||||
Value::String(s) => {
|
||||
let h = s.trim().strip_prefix('#').unwrap_or(s.trim());
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||
Some([byte(0)?, byte(2)?, byte(4)?])
|
||||
}
|
||||
Value::Array(a) if a.len() == 3 => {
|
||||
let mut c = [0u8; 3];
|
||||
for (o, x) in c.iter_mut().zip(a) {
|
||||
*o = u8::try_from(x.as_u64()?).ok()?;
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `#rrggbb`.
|
||||
pub fn hex(c: [u8; 3]) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_validates_links_margins_and_round_trips() {
|
||||
let d = SlideSettings::default();
|
||||
assert!(d.is_plain(), "the default slide is the plain slideshow");
|
||||
let s = d.merged(&json!({"marginLeft": 0.1})).unwrap();
|
||||
assert_eq!((s.margin_left, s.margin_right, s.margin_top, s.margin_bottom), (0.1, 0.1, 0.1, 0.1), "linked: all four");
|
||||
let s = d.merged(&json!({"linkMargins": false, "marginTop": 0.2})).unwrap();
|
||||
assert_eq!((s.margin_left, s.margin_top), (0.0, 0.2), "unlinked: one");
|
||||
let s = s.merged(&json!({"margins": 0.05})).unwrap();
|
||||
assert_eq!((s.margin_left, s.margin_bottom), (0.05, 0.05));
|
||||
let s = d
|
||||
.merged(&json!({"strokeBorder": true, "strokeColor": "#FF8000", "washColor": [1, 2, 3], "aspect": "16:9", "backgroundImage": 7}))
|
||||
.unwrap();
|
||||
assert_eq!(s.stroke_color, [255, 128, 0]);
|
||||
assert_eq!(s.wash_color, [1, 2, 3]);
|
||||
assert_eq!(s.aspect, Aspect::Wide);
|
||||
assert_eq!(s.background_image, Some(7));
|
||||
assert!(!s.is_plain());
|
||||
// the JSON form reads back as the same settings
|
||||
assert_eq!(d.merged(&s.to_json()).unwrap(), s);
|
||||
// serde (prefs.json) round trip, and missing keys take the defaults
|
||||
let back: SlideSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
let partial: SlideSettings = serde_json::from_str(r#"{"marginTop": 0.3}"#).unwrap();
|
||||
assert_eq!(partial, SlideSettings { margin_top: 0.3, ..d.clone() });
|
||||
assert_eq!(d.merged(&json!({"backgroundImage": null})).unwrap().background_image, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_settings_are_rejected_or_clamped() {
|
||||
let d = SlideSettings::default();
|
||||
for bad in [
|
||||
json!({"marginLeft": "wide"}),
|
||||
json!({"margins": null}),
|
||||
json!({"zoomToFill": 1}),
|
||||
json!({"strokeColor": "#12345"}),
|
||||
json!({"strokeColor": "#ééééé"}),
|
||||
json!({"washColor": [256, 0, 0]}),
|
||||
json!({"washColor": [-1, 0, 0]}),
|
||||
json!({"backdropColor": [1, 2]}),
|
||||
json!({"aspect": "21:9"}),
|
||||
json!({"backgroundImage": -3}),
|
||||
json!({"backgroundImage": "photo"}),
|
||||
json!({"sparkle": true}),
|
||||
json!([1]),
|
||||
] {
|
||||
assert!(d.merged(&bad).is_err(), "{bad}");
|
||||
}
|
||||
let s = d
|
||||
.merged(&json!({"margins": 1e300, "strokeWidth": -2, "shadowOpacity": 7, "shadowOffset": 1e9, "shadowRadius": -1, "shadowAngle": 540, "washAngle": -190, "washOpacity": -0.5}))
|
||||
.unwrap();
|
||||
assert_eq!((s.margin_left, s.margin_top), (MARGIN_MAX, MARGIN_MAX));
|
||||
assert_eq!((s.stroke_width, s.shadow_opacity, s.shadow_offset, s.shadow_radius), (0.0, 1.0, SHADOW_OFFSET_MAX, 0.0));
|
||||
assert_eq!((s.shadow_angle, s.wash_angle, s.wash_opacity), (180.0, 170.0, 0.0));
|
||||
// NaN / ±∞ (a hand-edited prefs file, an in-process caller) take the defaults
|
||||
let s = SlideSettings {
|
||||
margin_left: f64::NAN,
|
||||
stroke_width: f64::INFINITY,
|
||||
shadow_angle: f64::NEG_INFINITY,
|
||||
background_opacity: f64::NAN,
|
||||
..d.clone()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(
|
||||
(s.margin_left, s.stroke_width, s.shadow_angle, s.background_opacity),
|
||||
(0.0, d.stroke_width, d.shadow_angle, d.background_opacity)
|
||||
);
|
||||
assert_eq!(Aspect::Screen.ratio(f64::NAN), 1.6);
|
||||
assert_eq!(Aspect::Screen.ratio(1.5), 1.5);
|
||||
assert!((Aspect::Wide.ratio(1.0) - 16.0 / 9.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_key_ignores_what_does_not_change_pixels() {
|
||||
let d = SlideSettings::default();
|
||||
let guides = SlideSettings { show_guides: false, aspect: Aspect::Standard, ..d.clone() };
|
||||
assert_eq!(d.pixel_key(), guides.pixel_key());
|
||||
assert_ne!(d.pixel_key(), SlideSettings { backdrop_color: [9, 9, 9], ..d.clone() }.pixel_key());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ lightcraft-pipeline = { workspace = true }
|
||||
lightcraft-catalog = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-slideshow = { workspace = true }
|
||||
lightcraft-preview = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -2031,5 +2031,31 @@
|
||||
"Downloading model": "Modell wird heruntergeladen",
|
||||
"Finding faces": "Gesichter werden gesucht",
|
||||
"Synchronizing folder": "Ordner wird synchronisiert",
|
||||
"Looking for changes": "Änderungen werden gesucht"
|
||||
"Looking for changes": "Änderungen werden gesucht",
|
||||
"Playback": "Wiedergabe",
|
||||
"Layout": "Layout",
|
||||
"Link all margins": "Alle Ränder verknüpfen",
|
||||
"Margins": "Ränder",
|
||||
"Left": "Links",
|
||||
"Right": "Rechts",
|
||||
"Top": "Oben",
|
||||
"Bottom": "Unten",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.",
|
||||
"Show guides": "Hilfslinien anzeigen",
|
||||
"Aspect preview": "Seitenverhältnis-Vorschau",
|
||||
"Zoom to fill frame": "Zoomen, um Rahmen zu füllen",
|
||||
"Stroke border": "Konturlinie",
|
||||
"Colour": "Farbe",
|
||||
"Cast shadow": "Schlagschatten",
|
||||
"Offset": "Versatz",
|
||||
"Backdrop": "Hintergrund",
|
||||
"Background colour": "Hintergrundfarbe",
|
||||
"Colour wash": "Farbverlauf",
|
||||
"Background image": "Hintergrundbild",
|
||||
"(not in the library)": "(nicht in der Bibliothek)",
|
||||
"Use selected photo": "Ausgewähltes Foto verwenden",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Wählen Sie ein Foto aus und schalten Sie dies ein, um die Folie hinter jedem Foto damit zu füllen.",
|
||||
"Reset layout": "Layout zurücksetzen",
|
||||
"Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau."
|
||||
}
|
||||
|
||||
@@ -1876,5 +1876,31 @@
|
||||
"Downloading model": "Descargando modelo",
|
||||
"Finding faces": "Buscando caras",
|
||||
"Synchronizing folder": "Sincronizando carpeta",
|
||||
"Looking for changes": "Buscando cambios"
|
||||
"Looking for changes": "Buscando cambios",
|
||||
"Playback": "Reproducción",
|
||||
"Layout": "Diseño",
|
||||
"Link all margins": "Vincular todos los márgenes",
|
||||
"Margins": "Márgenes",
|
||||
"Left": "Izquierda",
|
||||
"Right": "Derecha",
|
||||
"Top": "Arriba",
|
||||
"Bottom": "Abajo",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Los márgenes son una parte del lado más corto de la diapositiva, así que las diapositivas se ven igual en cualquier pantalla.",
|
||||
"Show guides": "Mostrar guías",
|
||||
"Aspect preview": "Vista previa de proporción",
|
||||
"Zoom to fill frame": "Ampliar para llenar el marco",
|
||||
"Stroke border": "Borde de trazo",
|
||||
"Colour": "Color",
|
||||
"Cast shadow": "Proyectar sombra",
|
||||
"Offset": "Desplazamiento",
|
||||
"Backdrop": "Fondo",
|
||||
"Background colour": "Color de fondo",
|
||||
"Colour wash": "Degradado de color",
|
||||
"Background image": "Imagen de fondo",
|
||||
"(not in the library)": "(no está en la biblioteca)",
|
||||
"Use selected photo": "Usar la foto seleccionada",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecciona una foto y activa esta opción para llenar con ella el fondo detrás de cada foto.",
|
||||
"Reset layout": "Restablecer diseño",
|
||||
"Select a photo to preview its slide": "Selecciona una foto para previsualizar su diapositiva",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa."
|
||||
}
|
||||
|
||||
@@ -1876,5 +1876,31 @@
|
||||
"Downloading model": "Téléchargement du modèle",
|
||||
"Finding faces": "Recherche des visages",
|
||||
"Synchronizing folder": "Synchronisation du dossier",
|
||||
"Looking for changes": "Recherche des modifications"
|
||||
"Looking for changes": "Recherche des modifications",
|
||||
"Playback": "Lecture",
|
||||
"Layout": "Disposition",
|
||||
"Link all margins": "Lier toutes les marges",
|
||||
"Margins": "Marges",
|
||||
"Left": "Gauche",
|
||||
"Right": "Droite",
|
||||
"Top": "Haut",
|
||||
"Bottom": "Bas",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Les marges sont une part du petit côté de la diapositive : les diapositives sont identiques sur tous les écrans.",
|
||||
"Show guides": "Afficher les repères",
|
||||
"Aspect preview": "Aperçu des proportions",
|
||||
"Zoom to fill frame": "Zoom pour remplir le cadre",
|
||||
"Stroke border": "Contour",
|
||||
"Colour": "Couleur",
|
||||
"Cast shadow": "Ombre portée",
|
||||
"Offset": "Décalage",
|
||||
"Backdrop": "Toile de fond",
|
||||
"Background colour": "Couleur d’arrière-plan",
|
||||
"Colour wash": "Dégradé de couleur",
|
||||
"Background image": "Image d’arrière-plan",
|
||||
"(not in the library)": "(absente de la bibliothèque)",
|
||||
"Use selected photo": "Utiliser la photo sélectionnée",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Sélectionnez une photo, puis activez cette option pour en remplir le fond derrière chaque photo.",
|
||||
"Reset layout": "Réinitialiser la disposition",
|
||||
"Select a photo to preview its slide": "Sélectionnez une photo pour prévisualiser sa diapositive",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu."
|
||||
}
|
||||
|
||||
@@ -1878,5 +1878,31 @@
|
||||
"Downloading model": "モデルをダウンロード中",
|
||||
"Finding faces": "顔を検索中",
|
||||
"Synchronizing folder": "フォルダを同期中",
|
||||
"Looking for changes": "変更を確認中"
|
||||
"Looking for changes": "変更を確認中",
|
||||
"Playback": "再生",
|
||||
"Layout": "レイアウト",
|
||||
"Link all margins": "すべての余白をリンク",
|
||||
"Margins": "余白",
|
||||
"Left": "左",
|
||||
"Right": "右",
|
||||
"Top": "上",
|
||||
"Bottom": "下",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "余白はスライドの短辺に対する割合なので、どの画面でもスライドは同じように見えます。",
|
||||
"Show guides": "ガイドを表示",
|
||||
"Aspect preview": "縦横比プレビュー",
|
||||
"Zoom to fill frame": "ズームして枠を埋める",
|
||||
"Stroke border": "境界線",
|
||||
"Colour": "カラー",
|
||||
"Cast shadow": "ドロップシャドウ",
|
||||
"Offset": "オフセット",
|
||||
"Backdrop": "背景",
|
||||
"Background colour": "背景色",
|
||||
"Colour wash": "カラーウォッシュ",
|
||||
"Background image": "背景画像",
|
||||
"(not in the library)": "(ライブラリにありません)",
|
||||
"Use selected photo": "選択した写真を使用",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "写真を選択してからこれをオンにすると、各写真の背後をその写真で埋めます。",
|
||||
"Reset layout": "レイアウトをリセット",
|
||||
"Select a photo to preview its slide": "写真を選択するとスライドをプレビューできます",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。"
|
||||
}
|
||||
|
||||
@@ -1876,5 +1876,31 @@
|
||||
"Downloading model": "Baixando modelo",
|
||||
"Finding faces": "Procurando rostos",
|
||||
"Synchronizing folder": "Sincronizando pasta",
|
||||
"Looking for changes": "Procurando alterações"
|
||||
"Looking for changes": "Procurando alterações",
|
||||
"Playback": "Reprodução",
|
||||
"Layout": "Layout",
|
||||
"Link all margins": "Vincular todas as margens",
|
||||
"Margins": "Margens",
|
||||
"Left": "Esquerda",
|
||||
"Right": "Direita",
|
||||
"Top": "Superior",
|
||||
"Bottom": "Inferior",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "As margens são uma parte do lado menor do slide, então os slides ficam iguais em qualquer tela.",
|
||||
"Show guides": "Mostrar guias",
|
||||
"Aspect preview": "Visualização da proporção",
|
||||
"Zoom to fill frame": "Zoom para preencher o quadro",
|
||||
"Stroke border": "Borda",
|
||||
"Colour": "Cor",
|
||||
"Cast shadow": "Projetar sombra",
|
||||
"Offset": "Deslocamento",
|
||||
"Backdrop": "Fundo",
|
||||
"Background colour": "Cor de fundo",
|
||||
"Colour wash": "Degradê de cor",
|
||||
"Background image": "Imagem de fundo",
|
||||
"(not in the library)": "(não está na biblioteca)",
|
||||
"Use selected photo": "Usar a foto selecionada",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecione uma foto e ative esta opção para preencher com ela o fundo atrás de cada foto.",
|
||||
"Reset layout": "Redefinir layout",
|
||||
"Select a photo to preview its slide": "Selecione uma foto para visualizar o slide dela",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização."
|
||||
}
|
||||
|
||||
@@ -1590,5 +1590,31 @@
|
||||
"Finding faces": "Поиск лиц",
|
||||
"Quit Anyway": "Всё равно выйти",
|
||||
"Synchronizing folder": "Синхронизация папки",
|
||||
"Looking for changes": "Поиск изменений"
|
||||
"Looking for changes": "Поиск изменений",
|
||||
"Playback": "Воспроизведение",
|
||||
"Layout": "Макет",
|
||||
"Link all margins": "Связать все поля",
|
||||
"Margins": "Поля",
|
||||
"Left": "Слева",
|
||||
"Right": "Справа",
|
||||
"Top": "Сверху",
|
||||
"Bottom": "Снизу",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаются долей короткой стороны слайда, поэтому слайды выглядят одинаково на любом экране.",
|
||||
"Show guides": "Показывать направляющие",
|
||||
"Aspect preview": "Предпросмотр пропорций",
|
||||
"Zoom to fill frame": "Масштабировать по рамке",
|
||||
"Stroke border": "Обводка",
|
||||
"Colour": "Цвет",
|
||||
"Cast shadow": "Отбрасывать тень",
|
||||
"Offset": "Смещение",
|
||||
"Backdrop": "Фон",
|
||||
"Background colour": "Цвет фона",
|
||||
"Colour wash": "Цветовой градиент",
|
||||
"Background image": "Фоновое изображение",
|
||||
"(not in the library)": "(нет в библиотеке)",
|
||||
"Use selected photo": "Использовать выбранное фото",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Выберите фото и включите этот параметр, чтобы заполнить им фон за каждым фото.",
|
||||
"Reset layout": "Сбросить макет",
|
||||
"Select a photo to preview its slide": "Выберите фото, чтобы увидеть его слайд",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр."
|
||||
}
|
||||
|
||||
@@ -2070,5 +2070,31 @@
|
||||
"Downloading model": "Завантаження моделі",
|
||||
"Finding faces": "Пошук облич",
|
||||
"Synchronizing folder": "Синхронізація папки",
|
||||
"Looking for changes": "Пошук змін"
|
||||
"Looking for changes": "Пошук змін",
|
||||
"Playback": "Відтворення",
|
||||
"Layout": "Макет",
|
||||
"Link all margins": "Зв’язати всі поля",
|
||||
"Margins": "Поля",
|
||||
"Left": "Ліворуч",
|
||||
"Right": "Праворуч",
|
||||
"Top": "Угорі",
|
||||
"Bottom": "Унизу",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаються часткою короткої сторони слайда, тож слайди виглядають однаково на будь-якому екрані.",
|
||||
"Show guides": "Показувати напрямні",
|
||||
"Aspect preview": "Попередній перегляд пропорцій",
|
||||
"Zoom to fill frame": "Масштабувати за рамкою",
|
||||
"Stroke border": "Обведення",
|
||||
"Colour": "Колір",
|
||||
"Cast shadow": "Відкидати тінь",
|
||||
"Offset": "Зсув",
|
||||
"Backdrop": "Тло",
|
||||
"Background colour": "Колір тла",
|
||||
"Colour wash": "Кольоровий градієнт",
|
||||
"Background image": "Фонове зображення",
|
||||
"(not in the library)": "(немає в бібліотеці)",
|
||||
"Use selected photo": "Використати вибране фото",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "Виберіть фото й увімкніть цей параметр, щоб заповнити ним тло за кожним фото.",
|
||||
"Reset layout": "Скинути макет",
|
||||
"Select a photo to preview its slide": "Виберіть фото, щоб переглянути його слайд",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд."
|
||||
}
|
||||
|
||||
@@ -1876,5 +1876,31 @@
|
||||
"Downloading model": "正在下载模型",
|
||||
"Finding faces": "正在查找人脸",
|
||||
"Synchronizing folder": "正在同步文件夹",
|
||||
"Looking for changes": "正在查找更改"
|
||||
"Looking for changes": "正在查找更改",
|
||||
"Playback": "播放",
|
||||
"Layout": "布局",
|
||||
"Link all margins": "链接所有边距",
|
||||
"Margins": "边距",
|
||||
"Left": "左",
|
||||
"Right": "右",
|
||||
"Top": "上",
|
||||
"Bottom": "下",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "边距按幻灯片短边的比例设置,因此幻灯片在任何屏幕上看起来都一样。",
|
||||
"Show guides": "显示参考线",
|
||||
"Aspect preview": "长宽比预览",
|
||||
"Zoom to fill frame": "缩放以填充框架",
|
||||
"Stroke border": "描边",
|
||||
"Colour": "颜色",
|
||||
"Cast shadow": "投影",
|
||||
"Offset": "偏移",
|
||||
"Backdrop": "背景",
|
||||
"Background colour": "背景颜色",
|
||||
"Colour wash": "颜色渐变",
|
||||
"Background image": "背景图像",
|
||||
"(not in the library)": "(不在图库中)",
|
||||
"Use selected photo": "使用所选照片",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "选择一张照片,然后打开此项,用它填充每张照片后面的幻灯片背景。",
|
||||
"Reset layout": "重置布局",
|
||||
"Select a photo to preview its slide": "选择一张照片以预览其幻灯片",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。"
|
||||
}
|
||||
|
||||
@@ -1876,5 +1876,31 @@
|
||||
"Downloading model": "正在下載模型",
|
||||
"Finding faces": "正在尋找臉孔",
|
||||
"Synchronizing folder": "正在同步資料夾",
|
||||
"Looking for changes": "正在尋找變更"
|
||||
"Looking for changes": "正在尋找變更",
|
||||
"Playback": "播放",
|
||||
"Layout": "版面",
|
||||
"Link all margins": "連結所有邊界",
|
||||
"Margins": "邊界",
|
||||
"Left": "左",
|
||||
"Right": "右",
|
||||
"Top": "上",
|
||||
"Bottom": "下",
|
||||
"Margins are a share of the slide's shorter side, so slides look the same on any screen.": "邊界以投影片短邊的比例設定,因此投影片在任何螢幕上看起來都一樣。",
|
||||
"Show guides": "顯示參考線",
|
||||
"Aspect preview": "長寬比預覽",
|
||||
"Zoom to fill frame": "縮放以填滿框架",
|
||||
"Stroke border": "描邊",
|
||||
"Colour": "顏色",
|
||||
"Cast shadow": "投射陰影",
|
||||
"Offset": "偏移",
|
||||
"Backdrop": "背景",
|
||||
"Background colour": "背景顏色",
|
||||
"Colour wash": "顏色漸層",
|
||||
"Background image": "背景影像",
|
||||
"(not in the library)": "(不在圖庫中)",
|
||||
"Use selected photo": "使用所選照片",
|
||||
"Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。",
|
||||
"Reset layout": "重設版面",
|
||||
"Select a photo to preview its slide": "選取一張照片以預覽其投影片",
|
||||
"The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod right;
|
||||
pub mod rules_editor;
|
||||
pub mod second;
|
||||
pub mod settings;
|
||||
pub mod slide_layout;
|
||||
pub mod slideshow;
|
||||
pub mod strip;
|
||||
pub mod topbar;
|
||||
|
||||
@@ -68,7 +68,7 @@ pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) {
|
||||
ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text));
|
||||
}
|
||||
|
||||
fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
pub(super) fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(LABEL_W);
|
||||
@@ -642,7 +642,32 @@ fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &s
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the Slideshow tab's settings column (the slide preview is beside it).
|
||||
const SLIDESHOW_COLUMN_W: f32 = 480.0;
|
||||
|
||||
fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
// the settings scroll on the left, the composed slide stays in view on the right
|
||||
let top_down = egui::Layout::top_down(egui::Align::Min);
|
||||
// as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt)
|
||||
let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| {
|
||||
ui.set_width(SLIDESHOW_COLUMN_W);
|
||||
egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| {
|
||||
playback_section(app, ui, t);
|
||||
ui.add_space(6.0);
|
||||
super::slide_layout::sections(app, ui, t);
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| {
|
||||
ui.set_width(super::slide_layout::PREVIEW_W);
|
||||
super::slide_layout::preview(app, ui, t)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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"));
|
||||
let s = app.session.slideshow.clone();
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Settings ▸ Slideshow: the slide's Layout, Options and Backdrop sections (Lightroom Classic's
|
||||
//! Slideshow panels of the same names) and a small live preview of the composed slide.
|
||||
//!
|
||||
//! Every control goes through `slideshow.layout` (validated there, saved with the library).
|
||||
//! Sliders change the layout in memory while dragged (the preview follows) and save it when let
|
||||
//! go. The preview is the active photo's slide rendered off the UI thread ([`Slot::SlidePreview`])
|
||||
//! by the same composer playback uses; it is requested once the layout has been still for
|
||||
//! [`DEBOUNCE`] seconds, so a drag doesn't queue a render per frame.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use lightcraft_engine::slide::{Aspect, SlideSettings};
|
||||
use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::settings::{check, choices, heading, hint, row};
|
||||
use crate::LightcraftApp;
|
||||
use crate::render::Slot;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Seconds the layout must stay unchanged before the preview is rendered again.
|
||||
pub const DEBOUNCE: f64 = 0.15;
|
||||
/// The preview's width in points.
|
||||
pub const PREVIEW_W: f32 = 300.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Unit {
|
||||
/// A fraction shown as a percentage (of the slide's shorter side, or an opacity).
|
||||
Percent,
|
||||
Degrees,
|
||||
}
|
||||
|
||||
fn set(app: &mut LightcraftApp, p: Value) {
|
||||
let _ = app.run("slideshow.layout", p);
|
||||
}
|
||||
|
||||
/// A layout slider (`slider:{id}`): `key`'s value, 0–`max` (percent) or −180–180° (degrees).
|
||||
fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, max: f64, unit: Unit) {
|
||||
let (mut v, range, suffix) = match unit {
|
||||
Unit::Percent => (value * 100.0, 0.0..=max * 100.0, " %"),
|
||||
Unit::Degrees => (value, -180.0..=180.0, "°"),
|
||||
};
|
||||
let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(1));
|
||||
register(ui.ctx(), format!("slider:{id}"), r.rect);
|
||||
let value = match unit {
|
||||
Unit::Percent => v / 100.0,
|
||||
Unit::Degrees => v,
|
||||
};
|
||||
if r.changed()
|
||||
&& let Ok(next) = app.session.slide_layout.merged(&json!({ key: value }))
|
||||
{
|
||||
app.session.slide_layout = next;
|
||||
}
|
||||
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||||
set(app, json!({ key: value }));
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour button (`button:{id}`) for `key`.
|
||||
fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: [u8; 3]) {
|
||||
let mut c = value;
|
||||
let r = egui::color_picker::color_edit_button_srgb(ui, &mut c);
|
||||
register(ui.ctx(), format!("button:{id}"), r.rect);
|
||||
if r.changed() {
|
||||
set(app, json!({ key: c }));
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkbox (`check:settings.{id}`) for `key`; true when it is on.
|
||||
fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: bool, label: &str) -> bool {
|
||||
let mut v = value;
|
||||
if check(ui, &format!("settings.{id}"), &mut v, label) {
|
||||
set(app, json!({ key: v }));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The Layout, Options and Backdrop sections.
|
||||
pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let s = app.session.slide_layout.clone();
|
||||
|
||||
heading(ui, t, "Layout");
|
||||
let linked = toggle(app, ui, "slideshowLinkMargins", "linkMargins", s.link_margins, "Link all margins");
|
||||
if linked {
|
||||
row(ui, t, "Margins", |ui| slider(app, ui, "slideshowMargins", "marginLeft", s.margin_left, MARGIN_MAX, Unit::Percent));
|
||||
} else {
|
||||
for (id, key, label, v) in [
|
||||
("slideshowMarginLeft", "marginLeft", "Left", s.margin_left),
|
||||
("slideshowMarginRight", "marginRight", "Right", s.margin_right),
|
||||
("slideshowMarginTop", "marginTop", "Top", s.margin_top),
|
||||
("slideshowMarginBottom", "marginBottom", "Bottom", s.margin_bottom),
|
||||
] {
|
||||
row(ui, t, label, |ui| slider(app, ui, id, key, v, MARGIN_MAX, Unit::Percent));
|
||||
}
|
||||
}
|
||||
hint(ui, t, "Margins are a share of the slide's shorter side, so slides look the same on any screen.");
|
||||
toggle(app, ui, "slideshowGuides", "showGuides", s.show_guides, "Show guides");
|
||||
row(ui, t, "Aspect preview", |ui| {
|
||||
let mut a = s.aspect;
|
||||
let opts = [(Aspect::Screen, "Screen"), (Aspect::Wide, "16:9"), (Aspect::Standard, "4:3")];
|
||||
if choices(ui, "settingsSlideshowAspect", &opts, &mut a) {
|
||||
set(app, json!({"aspect": a.id()}));
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
heading(ui, t, "Options");
|
||||
toggle(app, ui, "slideshowZoomToFill", "zoomToFill", s.zoom_to_fill, "Zoom to fill frame");
|
||||
if toggle(app, ui, "slideshowStroke", "strokeBorder", s.stroke_border, "Stroke border") {
|
||||
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStrokeColor", "strokeColor", s.stroke_color));
|
||||
row(ui, t, "Width", |ui| slider(app, ui, "slideshowStrokeWidth", "strokeWidth", s.stroke_width, STROKE_MAX, Unit::Percent));
|
||||
}
|
||||
if toggle(app, ui, "slideshowShadow", "castShadow", s.cast_shadow, "Cast shadow") {
|
||||
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowShadowOpacity", "shadowOpacity", s.shadow_opacity, 1.0, Unit::Percent));
|
||||
row(ui, t, "Offset", |ui| slider(app, ui, "slideshowShadowOffset", "shadowOffset", s.shadow_offset, SHADOW_OFFSET_MAX, Unit::Percent));
|
||||
row(ui, t, "Radius", |ui| slider(app, ui, "slideshowShadowRadius", "shadowRadius", s.shadow_radius, SHADOW_RADIUS_MAX, Unit::Percent));
|
||||
row(ui, t, "Angle", |ui| slider(app, ui, "slideshowShadowAngle", "shadowAngle", s.shadow_angle, 180.0, Unit::Degrees));
|
||||
}
|
||||
|
||||
ui.add_space(6.0);
|
||||
heading(ui, t, "Backdrop");
|
||||
row(ui, t, "Background colour", |ui| colour(app, ui, "slideshowBackdropColor", "backdropColor", s.backdrop_color));
|
||||
if toggle(app, ui, "slideshowWash", "colorWash", s.color_wash, "Colour wash") {
|
||||
row(ui, t, "Colour", |ui| colour(app, ui, "slideshowWashColor", "washColor", s.wash_color));
|
||||
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowWashOpacity", "washOpacity", s.wash_opacity, 1.0, Unit::Percent));
|
||||
row(ui, t, "Angle", |ui| slider(app, ui, "slideshowWashAngle", "washAngle", s.wash_angle, 180.0, Unit::Degrees));
|
||||
}
|
||||
let active = app.session.active();
|
||||
let mut on = s.background_image.is_some();
|
||||
let r = ui.add_enabled(on || active.is_some(), egui::Checkbox::new(&mut on, crate::i18n::tr("Background image")));
|
||||
register(ui.ctx(), "check:settings.slideshowBackgroundImage", r.rect);
|
||||
if r.changed() {
|
||||
set(app, json!({"backgroundImage": if on { active.map(|a| a.0) } else { None }}));
|
||||
}
|
||||
if let Some(bid) = s.background_image {
|
||||
let name = app.session.catalog.photo(lightcraft_catalog::PhotoId(bid)).map(|p| p.file_name.clone());
|
||||
row(ui, t, "Photo", |ui| {
|
||||
let text = name.unwrap_or_else(|| crate::i18n::tr("(not in the library)").to_string());
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
if let Some(a) = active.filter(|a| a.0 != bid)
|
||||
&& crate::widgets::text_button(ui, "slideshowUseSelected", "Use selected photo", false).clicked()
|
||||
{
|
||||
set(app, json!({"backgroundImage": a.0}));
|
||||
}
|
||||
});
|
||||
row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowBackgroundOpacity", "backgroundOpacity", s.background_opacity, 1.0, Unit::Percent));
|
||||
} else {
|
||||
hint(ui, t, "Select a photo, then turn this on to fill the slide behind every photo with it.");
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
if crate::widgets::text_button(ui, "slideshowResetLayout", "Reset layout", false).clicked() {
|
||||
reset(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// The aspect of the screen a slideshow plays on (the monitor, else the window).
|
||||
fn screen_aspect(ctx: &egui::Context) -> f64 {
|
||||
let monitor = ctx.input(|i| i.viewport().monitor_size).filter(|m| m.x > 0.0 && m.y > 0.0);
|
||||
let r = monitor.unwrap_or_else(|| ctx.content_rect().size());
|
||||
if r.y > 0.0 { (r.x / r.y) as f64 } else { 1.6 }
|
||||
}
|
||||
|
||||
/// The small composed slide of the active photo, with the margin guides when they are on.
|
||||
pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
heading(ui, t, "Preview");
|
||||
let s = app.session.slide_layout.clone();
|
||||
let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32;
|
||||
let h = (PREVIEW_W / ratio).clamp(80.0, 320.0);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover());
|
||||
register(ui.ctx(), "slideshow:preview", rect);
|
||||
let p = ui.painter_at(rect.expand(1.0));
|
||||
p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside);
|
||||
let Some(id) = app.session.active() else {
|
||||
p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2]));
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a photo to preview its slide"), t.font(11.5), Color32::from_gray(200));
|
||||
return;
|
||||
};
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16));
|
||||
let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64);
|
||||
let now = ui.input(|i| i.time);
|
||||
let mem = egui::Id::new("settingsSlideshowPreviewKey");
|
||||
let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY));
|
||||
let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id);
|
||||
if last != key {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now)));
|
||||
}
|
||||
let still = if last == key { now - since } else { 0.0 };
|
||||
// the first preview (or another photo's) at once, a changing layout once it has settled
|
||||
if current.is_none() || still >= DEBOUNCE {
|
||||
let side = super::detail::texture_side(ui.ctx());
|
||||
let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side);
|
||||
if let Some(job) = app.session.slide_job_with(&s, id, pw, ph, Some(edge), false) {
|
||||
app.renderer.request(Slot::SlidePreview, job, 90);
|
||||
}
|
||||
} else {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE)));
|
||||
}
|
||||
let Some(tex) = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id) else {
|
||||
p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2]));
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200));
|
||||
return;
|
||||
};
|
||||
let drawn = super::detail::fit_texture_rect(rect, tex.size);
|
||||
p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if s.show_guides {
|
||||
// the margins, as lines across the slide (where the image cell's edges are)
|
||||
let aspect = app.session.slide_photo_aspect(id).unwrap_or(1.5);
|
||||
let l = lightcraft_slideshow::layout(&s, aspect, tex.size[0], tex.size[1]);
|
||||
let (kx, ky) = (drawn.width() / tex.size[0].max(1) as f32, drawn.height() / tex.size[1].max(1) as f32);
|
||||
let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 170, 255, 200));
|
||||
let x = |v: f64| drawn.left() + v as f32 * kx;
|
||||
let y = |v: f64| drawn.top() + v as f32 * ky;
|
||||
for gx in [x(l.cell.x0), x(l.cell.x1)] {
|
||||
p.line_segment([pos2(gx, drawn.top()), pos2(gx, drawn.bottom())], stroke);
|
||||
}
|
||||
for gy in [y(l.cell.y0), y(l.cell.y1)] {
|
||||
p.line_segment([pos2(drawn.left(), gy), pos2(drawn.right(), gy)], stroke);
|
||||
}
|
||||
}
|
||||
hint(ui, t, "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.");
|
||||
}
|
||||
|
||||
/// The default layout (for the Reset button).
|
||||
pub fn reset(app: &mut LightcraftApp) {
|
||||
let d = SlideSettings::default().to_json();
|
||||
set(app, d);
|
||||
}
|
||||
@@ -12,8 +12,10 @@
|
||||
//! transition until the next slide's texture exists ([`ready`]): the current slide is held
|
||||
//! instead of flashing a stand-in. With it off, a slide is rendered when it is due.
|
||||
//!
|
||||
//! Which picture a slide shows comes from one place, [`slide_job`], so a later slide composer
|
||||
//! (layout, overlays, backdrop) replaces that function and the playback stays as it is.
|
||||
//! Which picture a slide shows comes from one place, [`slide_job`]: the photo itself with the
|
||||
//! plain layout, else the slide composed with the library's layout (`slideshow.layout`: margins,
|
||||
//! zoom to fill, stroke, shadow, backdrop) by `lightcraft-slideshow` on the render worker. A
|
||||
//! composed slide is a screen-sized texture; the playback (fades, controls) is the same.
|
||||
|
||||
use egui::{Align2, Color32, Pos2, Rect, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
@@ -167,21 +169,47 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The render that shows slide `id` in `area` (logical points at `ppp`): the photo as developed,
|
||||
/// fitted to the screen. The one place that decides what a slide's picture is.
|
||||
/// The render that shows slide `id` in `area` (logical points at `ppp`). The one place that
|
||||
/// decides what a slide's picture is:
|
||||
/// - with the plain layout (no margins, border, shadow or backdrop: [`SlideSettings::is_plain`])
|
||||
/// the photo as developed, fitted to the screen (drawn over black);
|
||||
/// - otherwise the slide composed with the library's layout (`slideshow.layout`) at the screen's
|
||||
/// pixels: the photo rendered at its image cell's size, then composed on the render worker, so
|
||||
/// a slide is ready (and fades) like any other texture.
|
||||
///
|
||||
/// [`SlideSettings::is_plain`]: lightcraft_engine::slide::SlideSettings::is_plain
|
||||
fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option<lightcraft_engine::RenderJob> {
|
||||
let photo = app.session.catalog.photo(id)?.clone();
|
||||
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref());
|
||||
let native = super::detail::output_px(&frame);
|
||||
if !app.session.slide_layout.is_plain() {
|
||||
let (w, h) = slide_px(area, ppp, texture_side);
|
||||
let edge = app.ui.settings.loupe_edge(w.max(h) as f32, native[0].max(native[1]), texture_side);
|
||||
return app.session.slide_job(id, w, h, Some(edge));
|
||||
}
|
||||
let aspect = frame.aspect() as f32;
|
||||
let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.5 };
|
||||
let fit = (area.width() / aspect).min(area.height());
|
||||
let drawn_long = fit.max(fit * aspect) * ppp;
|
||||
let native = super::detail::output_px(&frame);
|
||||
let edge = app.ui.settings.loupe_edge(drawn_long, native[0].max(native[1]), texture_side);
|
||||
let (w, h) = if aspect >= 1.0 { (edge, (edge as f32 / aspect) as usize) } else { ((edge as f32 * aspect) as usize, edge) };
|
||||
app.session.loupe_job(id, w.max(8), h.max(8), true)
|
||||
}
|
||||
|
||||
/// A composed slide's size in pixels: `area` at `ppp`, its long side at most the texture side
|
||||
/// (and the composer's limit), at least 16 × 16.
|
||||
pub fn slide_px(area: Rect, ppp: f32, texture_side: usize) -> (usize, usize) {
|
||||
let px = |v: f32| if v.is_finite() { (v * ppp).round().max(16.0) as usize } else { 16 };
|
||||
let (w, h) = (px(area.width()), px(area.height()));
|
||||
let cap = texture_side.clamp(16, lightcraft_slideshow::MAX_SIDE);
|
||||
let long = w.max(h);
|
||||
if long <= cap {
|
||||
return (w, h);
|
||||
}
|
||||
let k = cap as f64 / long as f64;
|
||||
(((w as f64 * k).round() as usize).max(16), ((h as f64 * k).round() as usize).max(16))
|
||||
}
|
||||
|
||||
/// The slides to keep rendered now, most wanted first, with their render priority.
|
||||
fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> {
|
||||
let f = play.frame(now);
|
||||
|
||||
@@ -55,6 +55,9 @@ pub enum Slot {
|
||||
/// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see
|
||||
/// [`crate::panels::slideshow`]).
|
||||
Slide(PhotoId),
|
||||
/// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being
|
||||
/// edited).
|
||||
SlidePreview,
|
||||
}
|
||||
|
||||
pub struct Tex {
|
||||
|
||||
@@ -240,3 +240,107 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() {
|
||||
assert!(photos.len() > 3);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
fn painted(img: &egui::ColorImage, x: usize, y: usize) -> [u8; 3] {
|
||||
let c = img.pixels[y * img.size[0] + x];
|
||||
[c.r(), c.g(), c.b()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_composed_slide_shows_during_playback() {
|
||||
let mut h = grid();
|
||||
let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"}));
|
||||
assert_eq!(r["marginTop"], 0.1);
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0}));
|
||||
let current = play(&h).current();
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready");
|
||||
h.step();
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap();
|
||||
assert_eq!(tex.size, [1000, 700], "a composed slide fills the screen");
|
||||
let img = h.paint();
|
||||
assert_eq!(img.size, [1000, 700]);
|
||||
// 10 % of the 700 px short side = 70 px of red backdrop around the image cell
|
||||
for (x, y) in [(5, 5), (995, 5), (500, 30), (500, 690), (30, 350), (985, 350)] {
|
||||
assert_eq!(painted(&img, x, y), [255, 0, 0], "margin at ({x}, {y})");
|
||||
}
|
||||
let mid = painted(&img, 500, 350);
|
||||
assert_ne!(mid, [255, 0, 0], "the photo in the cell");
|
||||
// the next slide is composed too, and stepping shows it
|
||||
let next = play(&h).upcoming(1)[0];
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, next)));
|
||||
run(&mut h, "slideshow.next", json!({}));
|
||||
h.step();
|
||||
assert_eq!(painted(&h.paint(), 5, 5), [255, 0, 0]);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_layout_plays_the_photo_as_before() {
|
||||
let mut h = grid();
|
||||
assert!(h.app.session.slide_layout.is_plain());
|
||||
run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0}));
|
||||
let current = play(&h).current();
|
||||
assert!(h.step_until(SETTLE, |h| has_slide(h, current)));
|
||||
h.step();
|
||||
// the photo's own render (its aspect, not the screen's), fitted on black, as before
|
||||
let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap();
|
||||
let aspect = h.app.session.slide_photo_aspect(current).unwrap();
|
||||
let tex_aspect = tex.size[0] as f64 / tex.size[1] as f64;
|
||||
assert!((tex_aspect - aspect).abs() < 0.02, "{tex_aspect} vs {aspect}");
|
||||
assert_ne!(tex.size, [1000, 700]);
|
||||
let img = h.paint();
|
||||
let r = crate::panels::detail::fit_texture_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)), tex.size);
|
||||
if r.left() > 2.0 {
|
||||
assert_eq!(painted(&img, 1, 350), [0, 0, 0], "black beside the photo");
|
||||
}
|
||||
if r.top() > 2.0 {
|
||||
assert_eq!(painted(&img, 500, 1), [0, 0, 0], "black above the photo");
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_shows_the_slide_layout_and_a_live_preview() {
|
||||
let mut h = grid();
|
||||
let first = h.app.session.visible_cloned()[0];
|
||||
run(&mut h, "library.select", json!({"ids": [first.0]}));
|
||||
run(&mut h, "view.slideshowOptions", json!({}));
|
||||
h.step();
|
||||
let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w);
|
||||
for w in [
|
||||
"slider:slideshowMargins",
|
||||
"check:settings.slideshowZoomToFill",
|
||||
"check:settings.slideshowStroke",
|
||||
"check:settings.slideshowShadow",
|
||||
"button:slideshowBackdropColor",
|
||||
"check:settings.slideshowWash",
|
||||
"slideshow:preview",
|
||||
] {
|
||||
assert!(has(&h, w), "{w}");
|
||||
}
|
||||
// the preview is the composed slide, rendered off the UI thread
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first)));
|
||||
// a checkbox goes through slideshow.layout, and its controls appear (scrolled into view)
|
||||
let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.scroll", json!({"dy": -600}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowShadow"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
assert!(h.app.session.slide_layout.cast_shadow);
|
||||
assert!(has(&h, "slider:slideshowShadowAngle"));
|
||||
// a changed layout renders the preview again (once it has settled)
|
||||
let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key);
|
||||
run(&mut h, "slideshow.layout", json!({"margins": 0.2, "backdropColor": "#00ff00"}));
|
||||
assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before));
|
||||
h.step();
|
||||
let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap();
|
||||
let img = h.paint();
|
||||
let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize);
|
||||
assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) |
|
||||
| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |
|
||||
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
||||
| `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes |
|
||||
| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels). Sides 16–4096; one side given takes the other from the aspect preview |
|
||||
| `app.quit` | — | Close the app |
|
||||
|
||||
Image navigation is also available directly as the UI command `view.navigate`, with
|
||||
|
||||
@@ -196,6 +196,12 @@ JSON params (documented in each tool's description and by `list_commands`). Pass
|
||||
leave these per-command tools out when a client struggles with a large tool list; `run_command`
|
||||
still reaches every command.
|
||||
|
||||
Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide layout (margins,
|
||||
zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; saved with
|
||||
the library) and `cmd_slideshow_preview {id?, width?, height?, path?}` composes one slide with it —
|
||||
written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent can look at a layout
|
||||
before playing it. `cmd_slideshow_playback` sets the timing and fades.
|
||||
|
||||
## Resources
|
||||
|
||||
`resources/list` / `resources/read` serve JSON snapshots:
|
||||
|
||||
@@ -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 | 33 | 16 | 34 | 9 | — | 23/25 (92%) |
|
||||
| **Total** | 397 | 50 | 72 | 36 | 192/200 (96%) | 143/155 (92%) |
|
||||
| Lightroom Classic extras | 35 | 17 | 31 | 9 | — | 23/25 (92%) |
|
||||
| **Total** | 399 | 51 | 69 | 36 | 192/200 (96%) | 143/155 (92%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.3%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.5% of 163.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.1% of 163.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps and shortcut notes
|
||||
@@ -728,13 +728,13 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | |
|
||||
| LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | |
|
||||
| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | |
|
||||
| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | | |
|
||||
| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | | |
|
||||
| LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists |
|
||||
| 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 | ⬜ | | |
|
||||
| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | |
|
||||
| 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 | ⬜ | | |
|
||||
| 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, slides composed with layout/overlays/backdrop (LRC-SS-LAYOUT…) |
|
||||
| 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, overlays and titles on the slides (LRC-SS-OVERLAYS, LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) |
|
||||
| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | |
|
||||
| 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 | ⬜ | | |
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
## 2026-10-10
|
||||
|
||||
### Slideshow layout, options and backdrop
|
||||
- Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four
|
||||
linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width),
|
||||
a cast shadow (opacity, offset, radius, angle), and a backdrop colour with an optional colour wash (a gradient from a
|
||||
second colour, at an angle and opacity) and a background image (a photo from the library, at an opacity).
|
||||
- Set them in Settings ▸ Slideshow, which now shows the selected photo as a slide as you change them (with the margin
|
||||
guides, at the screen's aspect or 16:9 / 4:3). Sizes are a share of the slide, so a slide looks the same on any
|
||||
screen. They are saved with the library.
|
||||
- View ▸ Slideshow plays the composed slides, prepared ahead off the UI thread like before, so fades and the playback
|
||||
controls work as they did. With the default layout (no margins, border, shadow or backdrop) it plays the photos as
|
||||
before. Agents use `slideshow.layout`, and `slideshow.preview` returns one composed slide as a PNG.
|
||||
|
||||
### Slideshow playback
|
||||
- View ▸ Slideshow (⌥⌘↩) now plays with Lightroom Classic's Playback settings: how long each slide stays up, a fade
|
||||
between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per
|
||||
|
||||
@@ -48,6 +48,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("develop", Class::Layer(1)),
|
||||
("faces", Class::Layer(1)),
|
||||
("scenes", Class::Layer(1)),
|
||||
("slideshow", Class::Layer(1)),
|
||||
("pipeline", Class::Layer(2)),
|
||||
("gpu", Class::Layer(3)),
|
||||
("catalog", Class::Layer(3)),
|
||||
|
||||
Reference in New Issue
Block a user