From 94e1c86d89a866771f80ad1610afda9aa12e85d8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:10:48 -0400 Subject: [PATCH] IO-2/SHP-5/PRV-2: PSD import, SVG import + vector/text conversions, persistent disk cache - effectcraft-psd (L1): PSD/PSB reader from Adobe's published file format spec (layers, groups, masks, blend modes, text engine data, lfx2 effects, fill/adjustment layers, vector masks; 1/8/16/32-bit, RGB/CMYK/Gray/Lab/Indexed, raw/RLE/ZIP) and a minimal writer for fixtures. - File > Import: importAs footage|composition|compositionLayerSizes, layer (Choose Layer); groups -> precomps (pass-through collapses), text -> editable text, styles -> Layer Styles, adjustments -> adjustment layers with effects; UI asks the Import Kind for Photoshop files. - effectcraft-svg (L2): SVG parser (shapes, paths, transforms, use, CSS, gradients) and rasteriser; .svg imports as footage, continuously rasterised when the switch is on. - layer.create: shapesFromVector, shapesFromText, masksFromText, editableText (stub removed). - Disk cache (render::disk_cache): LZ4 content-keyed frames and layer buffers, LRU size limit from prefs, atomic writes, restart persistence; edit.purge clears it, cache.diskStats query; viewer frames read/write it and the timeline shows disk-only frames in blue. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 2 + Cargo.lock | 44 + Cargo.toml | 2 + crates/engine/Cargo.toml | 3 + crates/engine/src/commands/create.rs | 139 +++ crates/engine/src/commands/edit.rs | 32 +- crates/engine/src/commands/file.rs | 98 +- crates/engine/src/commands/file_more.rs | 1 + crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/stubs.rs | 1 - crates/engine/src/lib.rs | 12 + crates/engine/src/psd_import.rs | 634 ++++++++++++ crates/engine/src/session_settings.rs | 37 + crates/engine/src/tests_disk_cache.rs | 121 +++ crates/engine/src/tests_stubs.rs | 1 + crates/engine/src/tests_track.rs | 2 + crates/engine/src/tests_vector_import.rs | 237 +++++ crates/engine/src/vector.rs | 250 +++++ crates/gpu/src/tests.rs | 1 + crates/host/Cargo.toml | 4 + crates/host/tests/vector_import.rs | 163 +++ crates/lottie/src/import.rs | 1 + crates/lottie/src/tests.rs | 1 + crates/media/Cargo.toml | 2 + crates/media/src/convert.rs | 17 + crates/media/src/layered.rs | 62 ++ crates/media/src/lib.rs | 6 +- crates/media/src/pool.rs | 16 + crates/media/src/probe.rs | 7 +- crates/project/src/lib.rs | 15 + crates/psd/Cargo.toml | 13 + crates/psd/README.md | 17 + crates/psd/src/descriptor.rs | 430 ++++++++ crates/psd/src/engine_data.rs | 288 ++++++ crates/psd/src/lib.rs | 965 ++++++++++++++++++ crates/psd/src/read.rs | 86 ++ crates/psd/src/tests.rs | 189 ++++ crates/psd/src/write.rs | 491 +++++++++ crates/render/Cargo.toml | 1 + crates/render/src/audio.rs | 1 + crates/render/src/cache.rs | 62 +- crates/render/src/disk_cache.rs | 829 +++++++++++++++ crates/render/src/lib.rs | 29 +- crates/render/src/tests_audio_fx.rs | 1 + crates/render/src/tests_color.rs | 1 + crates/render/src/tests_frame_blend.rs | 1 + crates/svg/Cargo.toml | 18 + crates/svg/README.md | 26 + crates/svg/src/color.rs | 254 +++++ crates/svg/src/lib.rs | 949 +++++++++++++++++ crates/svg/src/pathdata.rs | 293 ++++++ crates/svg/src/raster.rs | 220 ++++ crates/svg/src/tests.rs | 108 ++ crates/svg/tests/fixtures/basic-shapes.svg | 15 + .../fixtures/basic-shapes.svg.attribution | 7 + crates/svg/tests/fixtures/paths-gradients.svg | 26 + .../fixtures/paths-gradients.svg.attribution | 7 + crates/ui-egui/src/frames.rs | 108 +- crates/ui-egui/src/lib.rs | 18 +- crates/ui-egui/src/menus.rs | 6 +- crates/ui-egui/src/panels/forms.rs | 20 + crates/ui-egui/src/panels/timeline.rs | 25 + crates/ui-egui/src/theme.rs | 3 + xtask/src/main.rs | 2 + 64 files changed, 7388 insertions(+), 34 deletions(-) create mode 100644 crates/engine/src/commands/create.rs create mode 100644 crates/engine/src/psd_import.rs create mode 100644 crates/engine/src/tests_disk_cache.rs create mode 100644 crates/engine/src/tests_vector_import.rs create mode 100644 crates/engine/src/vector.rs create mode 100644 crates/host/tests/vector_import.rs create mode 100644 crates/media/src/layered.rs create mode 100644 crates/psd/Cargo.toml create mode 100644 crates/psd/README.md create mode 100644 crates/psd/src/descriptor.rs create mode 100644 crates/psd/src/engine_data.rs create mode 100644 crates/psd/src/lib.rs create mode 100644 crates/psd/src/read.rs create mode 100644 crates/psd/src/tests.rs create mode 100644 crates/psd/src/write.rs create mode 100644 crates/render/src/disk_cache.rs create mode 100644 crates/svg/Cargo.toml create mode 100644 crates/svg/README.md create mode 100644 crates/svg/src/color.rs create mode 100644 crates/svg/src/lib.rs create mode 100644 crates/svg/src/pathdata.rs create mode 100644 crates/svg/src/raster.rs create mode 100644 crates/svg/src/tests.rs create mode 100644 crates/svg/tests/fixtures/basic-shapes.svg create mode 100644 crates/svg/tests/fixtures/basic-shapes.svg.attribution create mode 100644 crates/svg/tests/fixtures/paths-gradients.svg create mode 100644 crates/svg/tests/fixtures/paths-gradients.svg.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9caf1c27..d0b936b8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -36,6 +36,8 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 | | `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 | | `crates/lottie/tests/fixtures/legacy-masks.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (legacy keyframes, masks) | MIT OR Apache-2.0 | +| `crates/svg/tests/fixtures/basic-shapes.svg` | EffectCraft contributors | Original work: hand-written SVG test fixture (basic shapes, groups, transforms) | MIT OR Apache-2.0 | +| `crates/svg/tests/fixtures/paths-gradients.svg` | EffectCraft contributors | Original work: hand-written SVG test fixture (path data, gradients, CSS, use) | MIT OR Apache-2.0 | ## Screenshots diff --git a/Cargo.lock b/Cargo.lock index 46f34e63..1d14bab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1198,12 +1198,15 @@ dependencies = [ "effectcraft-lottie", "effectcraft-path", "effectcraft-project", + "effectcraft-psd", "effectcraft-raster", "effectcraft-render", + "effectcraft-svg", "effectcraft-text", "effectcraft-time", "effectcraft-track", "image", + "kurbo", "log", "rayon", "serde", @@ -1287,6 +1290,10 @@ dependencies = [ "effectcraft-expr", "effectcraft-media", "effectcraft-project", + "effectcraft-psd", + "effectcraft-raster", + "effectcraft-svg", + "effectcraft-time", "serde_json", ] @@ -1324,8 +1331,10 @@ name = "effectcraft-media" version = "0.1.0" dependencies = [ "effectcraft-project", + "effectcraft-psd", "effectcraft-raster", "effectcraft-render", + "effectcraft-svg", "effectcraft-time", "filmcraft-codecs", "filmcraft-color", @@ -1363,6 +1372,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "effectcraft-psd" +version = "0.1.0" +dependencies = [ + "miniz_oxide 0.8.9", +] + [[package]] name = "effectcraft-raster" version = "0.1.0" @@ -1387,11 +1403,24 @@ dependencies = [ "effectcraft-time", "kurbo", "log", + "lz4_flex", "rayon", "serde", "web-time", ] +[[package]] +name = "effectcraft-svg" +version = "0.1.0" +dependencies = [ + "effectcraft-geom", + "effectcraft-path", + "effectcraft-raster", + "kurbo", + "rayon", + "roxmltree", +] + [[package]] name = "effectcraft-text" version = "0.1.0" @@ -2749,6 +2778,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + [[package]] name = "mach2" version = "0.4.3" @@ -3970,6 +4005,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index a1941cf4..7aabc4af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ effectcraft-media = { path = "crates/media" } effectcraft-export = { path = "crates/export" } effectcraft-expr = { path = "crates/expr" } effectcraft-lottie = { path = "crates/lottie" } +effectcraft-psd = { path = "crates/psd" } +effectcraft-svg = { path = "crates/svg" } effectcraft-engine = { path = "crates/engine" } effectcraft-host = { path = "crates/host" } effectcraft-ui-egui = { path = "crates/ui-egui" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 132bdcac..0830c78d 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,6 +22,9 @@ effectcraft-effects = { workspace = true } effectcraft-render = { workspace = true } effectcraft-track = { workspace = true } effectcraft-lottie = { workspace = true } +effectcraft-psd = { workspace = true } +kurbo = { workspace = true } +effectcraft-svg = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs new file mode 100644 index 00000000..72cb2420 --- /dev/null +++ b/crates/engine/src/commands/create.rs @@ -0,0 +1,139 @@ +//! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text, +//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage). + +use effectcraft_color::Label; +use effectcraft_project::{ItemKind, LayerSource, Solid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_layers, layers_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector"; + +fn create(s: &mut Session, p: &Value) -> Result { + let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; + let (cid, lids) = layers_p(s, p)?; + if lids.is_empty() { + return Err(bad("layer.create", "select a layer first")); + } + let time = s.time(); + let mut created = vec![]; + let mut skipped = vec![]; + for lid in lids { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let Some(src) = comp.layer(lid).cloned() else { continue }; + let footage = src.source.item().and_then(|i| match &s.project.item(i)?.kind { + ItemKind::Footage(f) => Some(f.clone()), + _ => None, + }); + match op { + "shapesFromVector" => { + let Some(f) = footage.filter(|f| f.codec == "SVG" || f.path.to_ascii_lowercase().ends_with(".svg")) else { + skipped.push(format!("{}: not a vector (SVG) footage layer", src.name)); + continue; + }; + let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; + let doc = effectcraft_svg::parse(&bytes).map_err(|e| EngineError::Other(format!("{}: {e}", f.path)))?; + let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| { + let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc); + let id = l.id; + insert_above(proj, cid, lid, l, true)?; + st.selected_layers = vec![id]; + Ok(id) + })?; + created.push(id.0); + } + "shapesFromText" => { + if !matches!(src.source, LayerSource::Text) { + skipped.push(format!("{}: not a text layer", src.name)); + continue; + } + let snapshot = s.project.clone(); + let id = s.edit("Create Shapes from Text", None, |proj, st| { + let l = crate::vector::shapes_from_text(proj, &comp, &snapshot, cid, &src, time) + .ok_or_else(|| bad("layer.create", "the text layer has no glyphs"))?; + let id = l.id; + insert_above(proj, cid, lid, l, true)?; + st.selected_layers = vec![id]; + Ok(id) + })?; + created.push(id.0); + } + "masksFromText" => { + if !matches!(src.source, LayerSource::Text) { + skipped.push(format!("{}: not a text layer", src.name)); + continue; + } + let outlines = crate::vector::text_outlines_in_comp(&s.project, cid, &comp, &src, time); + if outlines.is_empty() { + skipped.push(format!("{}: no glyphs", src.name)); + continue; + } + let id = s.edit("Create Masks from Text", None, |proj, st| { + let name = comp.unique_layer_name(&format!("{} Outlines", src.name)); + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let sid = proj.add_item( + &name, + Label::Red, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }), + ); + let mut l = crate::vector::masks_layer(proj, &comp, &name, outlines, sid); + l.in_point = src.in_point; + l.out_point = src.out_point; + let id = l.id; + insert_above(proj, cid, lid, l, true)?; + st.selected_layers = vec![id]; + Ok(id) + })?; + created.push(id.0); + } + "editableText" => { + let Some((f, sl)) = footage.and_then(|f| f.layer.clone().map(|l| (f, l))) else { + skipped.push(format!("{}: not a Photoshop layer", src.name)); + continue; + }; + let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; + let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let id = s.edit("Convert to Editable Text", None, |proj, st| { + let l = crate::psd_import::editable_text(proj, &comp, &src, &psd, sl.index as usize) + .ok_or_else(|| bad("layer.create", format!("{}: the Photoshop layer has no text", src.name)))?; + let id = l.id; + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let at = c.layers.iter().position(|x| x.id == lid).ok_or(EngineError::NoComp)?; + c.layers[at] = l; + st.selected_layers = vec![id]; + Ok(id) + })?; + created.push(id.0); + } + other => return Err(bad("layer.create", format!("`{other}` is not available yet (supported: {OPS})"))), + } + } + if created.is_empty() && !skipped.is_empty() { + return Err(bad("layer.create", skipped.join("; "))); + } + Ok(json!({"layers": created, "skipped": skipped})) +} + +/// Insert `l` directly above layer `below`; optionally switch the source's video off (as After +/// Effects does after converting). +fn insert_above( + proj: &mut effectcraft_project::Project, + cid: effectcraft_project::ItemId, + below: effectcraft_project::LayerId, + l: effectcraft_project::Layer, + hide_source: bool, +) -> Result<()> { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let at = c.layers.iter().position(|x| x.id == below).unwrap_or(0); + if hide_source && let Some(src) = c.layers.get_mut(at) { + src.switches.video = false; + } + c.layers.insert(at, l); + Ok(()) +} + +pub fn specs() -> Vec { + vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)] +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index e7ca4bd7..9a134b05 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -525,9 +525,38 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result { s.snapshot = None; s.state.viewer.show_snapshot = false; } + let memory = matches!(what.as_str(), "all" | "memoryAndDisk" | "memory" | "image"); + let disk = matches!(what.as_str(), "all" | "memoryAndDisk" | "disk"); + if memory { + s.layer_cache.clear(); + } + let mut disk_entries = 0; + if disk && let Some(dc) = &s.disk_cache { + disk_entries = dc.stats().entries; + dc.clear(); + } s.events.push(crate::Event::PurgeCaches); s.toast(format!("Purged {what} cache")); - Ok(json!({"purged": what})) + Ok(json!({"purged": what, "diskEntries": disk_entries})) +} + +/// `cache.diskStats`: the disk cache's folder, limit and contents. +pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result { + let Some(dc) = &s.disk_cache else { return Ok(json!({"enabled": false})) }; + let st = dc.stats(); + Ok(json!({ + "enabled": true, + "folder": dc.folder().to_string_lossy(), + "entries": st.entries, + "frames": dc.keys(effectcraft_render::disk_cache::Kind::Frame).len(), + "layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(), + "bytes": st.bytes, + "maxBytes": st.max_bytes, + "hits": st.hits, + "misses": st.misses, + "writes": st.writes, + "evictions": st.evictions, + })) } /// Path of the footage behind the selected layer or project item. @@ -596,6 +625,7 @@ pub fn specs() -> Vec { cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group), cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge), cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches), + crate::query!("cache.diskStats", "Disk Cache Statistics", "{}", disk_stats), cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original), ] } diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index a85a391f..a176c400 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -84,10 +84,48 @@ fn import(s: &mut Session, p: &Value) -> Result { Some(Value::String(x)) => vec![x.clone()], _ => return Err(bad("file.import", "missing `paths`")), }; - let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; + // Photoshop documents as compositions (Import As: Composition / – Retain Layer Sizes). + let import_as = str_p(p, "importAs").unwrap_or("footage"); + let retain = match import_as { + "footage" => None, + "composition" | "comp" => Some(false), + "compositionLayerSizes" | "compositionRetainLayerSizes" | "layerSizes" => Some(true), + other => return Err(bad("file.import", format!("importAs: footage|composition|compositionLayerSizes, not `{other}`"))), + }; + let mut paths = paths; + let mut out_comps = vec![]; let mut ids = vec![]; let mut errors = vec![]; + if let Some(retain) = retain { + let mut rest = vec![]; + for path in paths { + let bytes = match s.services.read_file(&path) { + Ok(b) if effectcraft_psd::is_psd(&b) => b, + _ => { + rest.push(path); + continue; + } + }; + match import_psd_comp(s, &path, bytes, retain) { + Ok((comp, items, warnings)) => { + out_comps.push(comp); + ids.extend(items); + errors.extend(warnings); + } + Err(e) => errors.push(format!("{path}: {e}")), + } + } + paths = rest; + if paths.is_empty() { + if let Some(c) = out_comps.first() { + s.open_comp(effectcraft_project::ItemId(*c)); + } + return Ok(json!({"items": ids, "comps": out_comps, "errors": errors})); + } + } + let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; let mut probed = vec![]; + let psd_layer = p.get("layer").cloned(); let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps); for path in &paths { match importer.probe(path) { @@ -99,6 +137,18 @@ fn import(s: &mut Session, p: &Value) -> Result { f.frame_rate = r; f.duration = r.tick_of(frames.max(1)); } + // Choose Layer: one layer of a Photoshop document (document-sized). + if let Some(sel) = &psd_layer + && f.codec == "PSD" + { + match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) { + Some((index, name)) => f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false }), + None => { + errors.push(format!("{path}: no layer {sel}")); + continue; + } + } + } probed.push((path.clone(), f)) } Err(e) => errors.push(format!("{path}: {e}")), @@ -107,6 +157,10 @@ fn import(s: &mut Session, p: &Value) -> Result { s.edit("Import", None, |proj, st| { for (path, f) in probed { let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + let name = match &f.layer { + Some(l) => format!("{}/{name}", l.name), + None => name, + }; let label = match f.kind { FootageKind::Still | FootageKind::Sequence => Label::Lavender, FootageKind::Audio => Label::SeaFoam, @@ -118,7 +172,37 @@ fn import(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(json!({"items": ids, "errors": errors})) + if let Some(c) = out_comps.first() { + s.open_comp(effectcraft_project::ItemId(*c)); + } + Ok(json!({"items": ids, "comps": out_comps, "errors": errors})) +} + +/// A Photoshop layer by index or name (pixel layers only). +fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, String)> { + let l = match sel { + Value::Number(n) => d.layers.get(n.as_u64()? as usize)?, + Value::String(name) => d.layers.iter().find(|l| &l.name == name)?, + _ => return None, + }; + Some((l.index, l.name.clone())) +} + +/// Import a Photoshop document as a composition (one undo step). Returns (comp, items, warnings). +fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retain: bool) -> Result<(u64, Vec, Vec)> { + let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Photoshop".into()); + let rate = effectcraft_time::FrameRate::FPS_29_97; + let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; + let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); + let r = s.edit("Import", None, |proj, st| { + let r = crate::psd_import::import(proj, &psd, path, &name, retain, rate, duration); + st.project_selection = vec![r.comp]; + Ok(r) + })?; + let mut items = vec![r.folder.0]; + items.extend(r.items.iter().map(|i| i.0)); + Ok((r.comp.0, items, r.warnings)) } fn project_settings(s: &mut Session, p: &Value) -> Result { @@ -224,7 +308,15 @@ pub fn specs() -> Vec { cmd!("file.saveAs", "Save As...", ["File", "Save As"], Some("Cmd+Shift+S"), "{path}", always, save_as), cmd!("file.incrementAndSave", "Increment and Save", ["File"], Some("Cmd+Alt+Shift+S"), "{}", has_path, increment_save), cmd!("file.revert", "Revert", ["File"], None, "{}", has_path, revert), - cmd!("file.import", "File...", ["File", "Import"], Some("Cmd+I"), "{paths: [string]}", always, import), + cmd!( + "file.import", + "File...", + ["File", "Import"], + Some("Cmd+I"), + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}", + always, + import + ), cmd!( "file.projectSettings", "Project Settings...", diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 87c1e2df..b62664af 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage { missing: true, sequence: vec![], color_profile: None, + layer: None, } } diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4537752f..0791d1a3 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod anim_tools; mod animation; mod comp; mod comp_more; +mod create; mod edit; mod effect; mod file; @@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(help::specs()); v.extend(query::specs()); v.extend(layer_menu::specs()); + v.extend(create::specs()); v.extend(animation::specs()); v.extend(view::specs()); v.extend(viewer_cmds::specs()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index f670ffd7..f8bca154 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -34,7 +34,6 @@ pub fn specs() -> Vec { // Time (timeline milestone). stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"), stub!("layer.environment", "Environment Layer", ["Layer"], "{}"), - stub!("layer.create", "Create", [], "{op}"), stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"), stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"), // Cameras / lights / materials / 3D views (3D milestone). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ba34488a..9d6afcc0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,10 +16,12 @@ pub mod links; pub mod mask_track; pub mod menus; pub mod prefs; +pub mod psd_import; pub mod render_queue; mod session_settings; pub mod shortcuts; pub mod tracking; +pub mod vector; pub mod viewer; pub mod warp; @@ -268,6 +270,8 @@ pub struct Session { pub journal: Vec<(String, Value)>, /// Processed-layer pixels reused across frames and edits (content-keyed, never stale). pub layer_cache: Arc, + /// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled. + pub disk_cache: Option>, /// Settings (Preferences). pub prefs: prefs::Prefs, /// Bumped whenever settings change (frontends re-apply theme, labels…). @@ -311,6 +315,7 @@ impl Default for Session { events: vec![], journal: vec![], layer_cache: Arc::new(LayerCache::default()), + disk_cache: None, prefs: prefs::Prefs::default(), prefs_revision: 0, config: None, @@ -393,6 +398,9 @@ impl Session { pub fn bump(&mut self) { self.revision += 1; + if self.disk_cache.is_some() { + self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project)); + } self.events.push(Event::ProjectChanged { revision: self.revision }); } @@ -574,6 +582,8 @@ mod tests_3d; #[cfg(test)] mod tests_anim_tools; #[cfg(test)] +mod tests_disk_cache; +#[cfg(test)] mod tests_effects; #[cfg(test)] mod tests_fidelity; @@ -602,6 +612,8 @@ mod tests_timeline; #[cfg(test)] mod tests_track; #[cfg(test)] +mod tests_vector_import; +#[cfg(test)] mod tests_viewer; /// Font families available to text layers (bundled + scanned system fonts). diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs new file mode 100644 index 00000000..c14888ea --- /dev/null +++ b/crates/engine/src/psd_import.rs @@ -0,0 +1,634 @@ +//! Photoshop import as a composition (File ▸ Import ▸ File… with Import As: Composition or +//! Composition – Retain Layer Sizes), and Layer ▸ Create ▸ Convert to Editable Text. +//! +//! Every pixel layer becomes a footage item that names its layer (`Footage::layer`), placed in +//! a composition the size of the document. Groups become precomps (pass-through groups collapse +//! so their blend modes act on the layers below, like Photoshop); type layers become editable +//! text layers; layer masks are baked into the layer's alpha and vector masks become masks; +//! layer effects map to Layer Styles; adjustment layers become adjustment layers with the +//! matching effect (best effort); clipped layers preserve the transparency underneath. + +use std::sync::Arc; + +use effectcraft_color::BlendMode; +use effectcraft_color::Label; +use effectcraft_keyframe::{Gradient, Justify, ShapePath, TextDoc, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::styles::{self, STYLE_BLEND_MODES}; +use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Project, PropGroup, Solid, SourceLayer}; +use effectcraft_psd::{Adjustment, DValue, Descriptor, Node, Psd}; +use effectcraft_time::{FrameRate, Tick}; + +/// Photoshop blend mode key → compositor mode (`pass` = pass through, handled by collapsing). +pub fn blend_mode(key: &str) -> BlendMode { + match key { + "diss" => BlendMode::Dissolve, + "dark" => BlendMode::Darken, + "mul " => BlendMode::Multiply, + "idiv" => BlendMode::ColorBurn, + "lbrn" => BlendMode::LinearBurn, + "dkCl" => BlendMode::DarkerColor, + "lite" => BlendMode::Lighten, + "scrn" => BlendMode::Screen, + "div " => BlendMode::ColorDodge, + "lddg" => BlendMode::LinearDodge, + "lgCl" => BlendMode::LighterColor, + "over" => BlendMode::Overlay, + "sLit" => BlendMode::SoftLight, + "hLit" => BlendMode::HardLight, + "vLit" => BlendMode::VividLight, + "lLit" => BlendMode::LinearLight, + "pLit" => BlendMode::PinLight, + "hMix" => BlendMode::HardMix, + "diff" => BlendMode::Difference, + "smud" => BlendMode::Exclusion, + "fsub" => BlendMode::Subtract, + "fdiv" => BlendMode::Divide, + "hue " => BlendMode::Hue, + "sat " => BlendMode::Saturation, + "colr" => BlendMode::Color, + "lum " => BlendMode::Luminosity, + _ => BlendMode::Normal, + } +} + +/// Descriptor blend mode enum (`BlnM`) → compositor mode. +fn desc_blend(v: Option<&str>) -> Option { + Some(match v? { + "Nrml" => BlendMode::Normal, + "Dslv" => BlendMode::Dissolve, + "Drkn" => BlendMode::Darken, + "Mltp" => BlendMode::Multiply, + "CBrn" => BlendMode::ColorBurn, + "linearBurn" => BlendMode::LinearBurn, + "darkerColor" => BlendMode::DarkerColor, + "Lghn" => BlendMode::Lighten, + "Scrn" => BlendMode::Screen, + "CDdg" => BlendMode::ColorDodge, + "linearDodge" => BlendMode::LinearDodge, + "lighterColor" => BlendMode::LighterColor, + "Ovrl" => BlendMode::Overlay, + "SftL" => BlendMode::SoftLight, + "HrdL" => BlendMode::HardLight, + "vividLight" => BlendMode::VividLight, + "linearLight" => BlendMode::LinearLight, + "pinLight" => BlendMode::PinLight, + "hardMix" => BlendMode::HardMix, + "Dfrn" => BlendMode::Difference, + "Xclu" => BlendMode::Exclusion, + "blendSubtraction" => BlendMode::Subtract, + "blendDivide" => BlendMode::Divide, + "H " => BlendMode::Hue, + "Strt" => BlendMode::Saturation, + "Clr " => BlendMode::Color, + "Lmns" => BlendMode::Luminosity, + _ => return None, + }) +} + +/// What the import created. +pub struct PsdImport { + pub comp: ItemId, + pub folder: ItemId, + pub items: Vec, + pub warnings: Vec, +} + +struct Ctx<'a> { + psd: &'a Psd, + path: &'a str, + retain: bool, + folder: ItemId, + rate: FrameRate, + duration: Tick, + items: Vec, + warnings: Vec, +} + +/// Import `psd` (read from `path`) as a composition named `name`. +pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: bool, rate: FrameRate, duration: Tick) -> PsdImport { + let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder); + let mut cx = Ctx { psd, path, retain, folder, rate, duration, items: vec![], warnings: vec![] }; + let comp = build_comp(proj, &mut cx, name, &psd.tree(), None); + PsdImport { comp, folder, items: cx.items, warnings: cx.warnings } +} + +fn new_comp(cx: &Ctx) -> Comp { + Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration) +} + +fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option) -> ItemId { + let cid = proj.add_item(name, Label::Sandstone, parent, ItemKind::Comp(Arc::new(new_comp(cx)))); + cx.items.push(cid); + let mut comp = new_comp(cx); + for n in nodes { + if let Some(l) = build_layer(proj, cx, &comp, n) { + comp.layers.push(l); + } + } + styles::sync_comp(&mut comp); + if let Some(item) = proj.item_mut(cid) { + item.kind = ItemKind::Comp(Arc::new(comp)); + } + cid +} + +fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (ItemId, (u32, u32)) { + let l = &cx.psd.layers[index]; + let retain = cx.retain; + let (w, h) = if retain && !l.rect.is_empty() { (l.rect.width(), l.rect.height()) } else { (cx.psd.width, cx.psd.height) }; + let file = std::path::Path::new(cx.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let f = Footage { + path: cx.path.to_string(), + kind: FootageKind::Still, + width: w, + height: h, + pixel_aspect: 1.0, + frame_rate: cx.rate, + native_rate: None, + duration: Tick::ZERO, + has_video: true, + has_audio: false, + alpha: AlphaMode::Straight, + premul_color: [0.0; 3], + loop_count: 1, + codec: "PSD".into(), + missing: false, + sequence: vec![], + color_profile: None, + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty() }), + }; + let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); + cx.items.push(id); + (id, (w, h)) +} + +fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option { + let (index, children) = match n { + Node::Layer(i) => (*i, None), + Node::Group { layer, children } => (*layer, Some(children)), + }; + let pl = cx.psd.layers[index].clone(); + let name = if pl.name.is_empty() { format!("Layer {}", index + 1) } else { pl.name.clone() }; + let mut layer = if let Some(children) = children { + let sub = build_comp(proj, cx, &name, children, Some(cx.folder)); + let mut l = build::layer(proj, comp, &name, LayerSource::Comp { item: sub }, (comp.width, comp.height), None); + // Pass-through groups composite their layers straight into the parent. + l.switches.collapse = pl.blend_key() == "pass"; + l + } else if let Some(t) = &pl.text { + text_layer(proj, comp, &name, t) + } else if let Some(adj) = &pl.adjustment { + let mut l = adjustment_layer(proj, comp, &name); + add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name); + l + } else if pl.has_pixels() || pl.fill.is_some() { + let (item, size) = footage_item(proj, cx, index, &name); + let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None); + if cx.retain && !pl.rect.is_empty() { + set_vec2(&mut l, "transform/position", [pl.rect.left as f64 + size.0 as f64 / 2.0, pl.rect.top as f64 + size.1 as f64 / 2.0]); + } + l + } else { + // Empty layers have nothing to show. + return None; + }; + layer.name = comp.unique_layer_name(&name); + layer.blend_mode = blend_mode(pl.blend_key()); + layer.switches.video = !pl.hidden; + layer.preserve_transparency = pl.clipping; + set_scalar(&mut layer, "transform/opacity", pl.opacity as f64 / 255.0 * 100.0); + // Vector mask → a mask in layer space. + let origin = layer_origin(&pl, cx.retain, &layer); + if let Some(vm) = pl.vector_mask.as_ref().filter(|v| !v.disabled) + && let Some(masks) = layer.props.sub_mut("masks") + { + let mut ids = Ids(&mut proj.next_id); + for (k, sp) in vm.subpaths.iter().enumerate() { + let n = sp.knots.len(); + let mut path = ShapePath { vertices: vec![], in_tangents: vec![], out_tangents: vec![], closed: sp.closed }; + for kn in &sp.knots { + let v = [kn[1][0] - origin[0], kn[1][1] - origin[1]]; + path.vertices.push(v); + path.in_tangents.push([kn[0][0] - kn[1][0], kn[0][1] - kn[1][1]]); + path.out_tangents.push([kn[2][0] - kn[1][0], kn[2][1] - kn[1][1]]); + } + if n == 0 { + continue; + } + let mode = if vm.inverted { MaskMode::Subtract } else { MaskMode::Add }; + masks.children.push(build::mask(&mut ids, &format!("Mask {}", k + 1), path, mode, [255, 255, 0]).into()); + } + } + // Layer effects → Layer Styles; Fill Opacity → Advanced Blending. + let gl = comp.global_light.clone(); + if let Some(fx) = &pl.effects + && fx.bool("masterFXSwitch") != Some(false) + && layer.can_have_styles() + { + apply_effects(proj, &mut layer, fx, &gl); + } + if pl.fill_opacity < 255 && layer.can_have_styles() { + let mut ids = Ids(&mut proj.next_id); + if layer.props.sub(styles::GROUP).is_none() { + let g = styles::layer_styles(&mut ids, &gl); + let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len()); + layer.props.children.insert(at, g.into()); + } + if let Some(p) = layer.props.prop_mut("layerStyles/blendingOptions/advancedBlending/fillOpacity") { + p.value = Value::Scalar(pl.fill_opacity as f64 / 255.0 * 100.0); + } + } + Some(layer) +} + +/// Where layer space's origin sits in document pixels. +fn layer_origin(pl: &effectcraft_psd::Layer, retain: bool, layer: &Layer) -> [f64; 2] { + if let Some(t) = &pl.text { + return [t.transform[4], t.transform[5]]; + } + if matches!(layer.source, LayerSource::Footage { .. }) && retain && !pl.rect.is_empty() { + return [pl.rect.left as f64, pl.rect.top as f64]; + } + [0.0, 0.0] +} + +fn set_scalar(l: &mut Layer, path: &str, v: f64) { + if let Some(p) = l.props.prop_mut(path) { + p.value = Value::Scalar(v); + } +} + +fn set_vec2(l: &mut Layer, path: &str, v: [f64; 2]) { + if let Some(p) = l.props.prop_mut(path) { + p.value = match p.value { + Value::Vec3(o) => Value::Vec3([v[0], v[1], o[2]]), + _ => Value::Vec2(v), + }; + } +} + +/// Split a PostScript font name into family and style (`Inter-Bold` → `Inter`, `Bold`). +fn font_names(ps: &str) -> (String, String) { + match ps.split_once('-') { + Some((fam, style)) => (fam.to_string(), style.to_string()), + None => (ps.to_string(), "Regular".to_string()), + } +} + +fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc { + let mut d = TextDoc::default(); + d.set_text(&t.text); + let [xx, xy, yx, yy, _, _] = t.transform; + let scale = (xx * yy - xy * yx).abs().sqrt().max(1e-6); + if let Some(s) = t.style.size { + d.size = s * scale; + } + if let Some(f) = &t.style.font { + let (fam, style) = font_names(f); + d.font = fam; + d.style = style; + } + if let Some(c) = t.style.color { + d.fill = [c[0] as f32, c[1] as f32, c[2] as f32, 1.0]; + } + d.justify = match t.style.justification { + Some(1) => Justify::Right, + Some(2) => Justify::Center, + Some(3) => Justify::JustifyLastLeft, + Some(4) => Justify::JustifyLastRight, + Some(5) => Justify::JustifyLastCenter, + Some(6) => Justify::JustifyAll, + _ => Justify::Left, + }; + if let Some(tr) = t.style.tracking { + d.tracking = tr; + } + if let Some(l) = t.style.leading { + d.leading = Some(l * scale); + } + d.faux_bold = t.style.faux_bold; + d.faux_italic = t.style.faux_italic; + if t.style.box_text + && let Some([l, top, r, b]) = t.bounds + { + d.box_size = Some([(r - l) * scale, (b - top) * scale]); + d.box_pos = [l * scale, top * scale]; + } + d +} + +fn text_layer(proj: &mut Project, comp: &Comp, name: &str, t: &effectcraft_psd::TextInfo) -> Layer { + let mut l = build::layer(proj, comp, name, LayerSource::Text, (0, 0), None); + if let Some(p) = l.props.prop_mut("text/sourceText") { + p.value = Value::Text(Box::new(text_doc(t))); + } + set_vec2(&mut l, "transform/position", [t.transform[4], t.transform[5]]); + let [xx, xy, _, _, _, _] = t.transform; + let rot = xy.atan2(xx).to_degrees(); + if rot.abs() > 1e-6 { + set_scalar(&mut l, "transform/rotation", rot); + } + l +} + +fn adjustment_layer(proj: &mut Project, comp: &Comp, name: &str) -> Layer { + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let sid = proj.add_item( + name, + Label::Red, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None); + l.switches.adjustment = true; + l.label = Label::Purple; + l +} + +fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, params: &[(&str, Value)]) { + let Some(spec) = effectcraft_effects::lookup(effect) else { return }; + let mut ids = Ids(&mut proj.next_id); + let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [comp.width as f64, comp.height as f64]); + for (k, v) in params { + if let Some(p) = g.get_mut(k) { + p.value = v.clone(); + } + } + if let Some(fx) = l.props.sub_mut("effects") { + fx.children.push(g.into()); + } +} + +fn add_adjustment_effect(proj: &mut Project, l: &mut Layer, adj: &Adjustment, comp: &Comp, warnings: &mut Vec, name: &str) { + match adj { + Adjustment::Invert => add_effect(proj, l, "Invert", comp, &[]), + Adjustment::BrightnessContrast { brightness, contrast, .. } => { + add_effect(proj, l, "Brightness & Contrast", comp, &[("brightness", Value::Scalar(*brightness)), ("contrast", Value::Scalar(*contrast))]) + } + Adjustment::HueSaturation { hue, saturation, lightness, colorize } => { + let mut p = vec![("hue", Value::Scalar(*hue)), ("saturation", Value::Scalar(*saturation)), ("lightness", Value::Scalar(*lightness))]; + if *colorize { + p = vec![ + ("colorize", Value::Bool(true)), + ("colorizeHue", Value::Scalar(*hue)), + ("colorizeSaturation", Value::Scalar(*saturation)), + ("colorizeLightness", Value::Scalar(*lightness)), + ]; + } + add_effect(proj, l, "Hue/Saturation", comp, &p) + } + Adjustment::Levels { in_black, in_white, gamma, out_black, out_white } => add_effect( + proj, + l, + "Levels", + comp, + &[ + ("inBlack", Value::Scalar(in_black / 255.0)), + ("inWhite", Value::Scalar(in_white / 255.0)), + ("gamma", Value::Scalar(*gamma)), + ("outBlack", Value::Scalar(out_black / 255.0)), + ("outWhite", Value::Scalar(out_white / 255.0)), + ], + ), + Adjustment::Exposure { exposure, offset, gamma } => add_effect( + proj, + l, + "Exposure", + comp, + &[("exposure", Value::Scalar(*exposure)), ("offset", Value::Scalar(*offset)), ("gamma", Value::Scalar(*gamma))], + ), + Adjustment::Other(k) => warnings.push(format!("{name}: adjustment `{k}` is not converted (layer kept as an empty adjustment layer)")), + } +} + +// ---------------------------------------------------------------- layer effects → styles + +fn style_mode(m: BlendMode) -> Value { + Value::Enum(STYLE_BLEND_MODES.iter().position(|x| x.1 == m).unwrap_or(0) as u32) +} + +fn set(g: &mut PropGroup, k: &str, v: Value) { + if let Some(p) = g.get_mut(k) { + p.value = v; + } +} + +fn color4(c: [f64; 3]) -> Value { + Value::Color([c[0], c[1], c[2], 1.0]) +} + +/// Copy the common style settings (`Md `, `Clr `, `Opct`, angle, distance, size…). +fn common(g: &mut PropGroup, d: &Descriptor) { + if let Some(m) = desc_blend(d.enum_value("Md ")) { + set(g, "blendMode", style_mode(m)); + } + if let Some(c) = d.color("Clr ") { + set(g, "color", color4(c)); + } + for (k, prop) in [("Opct", "opacity"), ("Nose", "noise"), ("Dstn", "distance"), ("blur", "size"), ("lagl", "angle"), ("ShdN", "jitter"), ("Inpr", "range")] + { + if let Some(v) = d.num(k) { + set(g, prop, Value::Scalar(v)); + } + } + if let Some(b) = d.bool("uglg") { + set(g, "useGlobalLight", Value::Bool(b)); + } +} + +fn gradient_of(d: &Descriptor) -> Option { + let g = d.obj("Grad")?; + let list = |k: &str| match g.get(k) { + Some(DValue::List(v)) => v.clone(), + _ => vec![], + }; + let colors: Vec<(f64, [f32; 4])> = list("Clrs") + .iter() + .filter_map(|c| match c { + DValue::Descriptor(s) => { + let col = s.color("Clr ")?; + Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, [col[0] as f32, col[1] as f32, col[2] as f32, 1.0])) + } + _ => None, + }) + .collect(); + let opacities: Vec<(f64, f32)> = list("Trns") + .iter() + .filter_map(|c| match c { + DValue::Descriptor(s) => Some((s.num("Lctn").unwrap_or(0.0) / 4096.0, (s.num("Opct").unwrap_or(100.0) / 100.0) as f32)), + _ => None, + }) + .collect(); + (!colors.is_empty()).then(|| Gradient { colors, opacities: if opacities.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { opacities } }) +} + +/// The effect descriptor for `key`, or the first enabled one of its `…Multi` list. +fn effect<'a>(fx: &'a Descriptor, key: &str, multi: &str) -> Option<&'a Descriptor> { + fx.obj(key).or_else(|| match fx.get(multi) { + Some(DValue::List(v)) => v.iter().find_map(|x| match x { + DValue::Descriptor(d) if d.bool("enab") != Some(false) => Some(d), + _ => None, + }), + _ => None, + }) +} + +fn apply_effects(proj: &mut Project, layer: &mut Layer, fx: &Descriptor, gl: &styles::GlobalLight) { + const MAP: &[(&str, &str, &str)] = &[ + ("DrSh", "dropShadowMulti", "dropShadow"), + ("IrSh", "innerShadowMulti", "innerShadow"), + ("OrGl", "outerGlowMulti", "outerGlow"), + ("IrGl", "innerGlowMulti", "innerGlow"), + ("ebbl", "bevelEmbossMulti", "bevelEmboss"), + ("ChFX", "satinMulti", "satin"), + ("SoFi", "solidFillMulti", "colorOverlay"), + ("GrFl", "gradientFillMulti", "gradientOverlay"), + ("FrFX", "frameFXMulti", "stroke"), + ]; + for (key, multi, id) in MAP { + let Some(d) = effect(fx, key, multi) else { continue }; + let enabled = d.bool("enab").unwrap_or(true); + let mut ids = Ids(&mut proj.next_id); + let Some(uid) = styles::add_style(layer, &mut ids, id, gl, enabled) else { continue }; + let Some(g) = layer.props.find_group_mut(uid) else { continue }; + common(g, d); + match *id { + "dropShadow" => { + if let Some(v) = d.num("Ckmt") { + set(g, "spread", Value::Scalar(v)); + } + if let Some(b) = d.bool("layerConceals") { + set(g, "knocksOut", Value::Bool(b)); + } + } + "innerShadow" | "innerGlow" => { + if let Some(v) = d.num("Ckmt") { + set(g, "choke", Value::Scalar(v)); + } + if d.enum_value("glwS") == Some("SrcC") { + set(g, "source", Value::Enum(0)); + } + } + "outerGlow" => { + if let Some(v) = d.num("Ckmt") { + set(g, "spread", Value::Scalar(v)); + } + } + "bevelEmboss" => { + let style = match d.enum_value("bvlS") { + Some("OtrB") => 0, + Some("Embs") => 2, + Some("PlEb") => 3, + Some("strokeEmboss") => 4, + _ => 1, + }; + set(g, "style", Value::Enum(style)); + let tech = match d.enum_value("bvlT") { + Some("PrBL") => 1, + Some("Slmt") => 2, + _ => 0, + }; + set(g, "technique", Value::Enum(tech)); + if let Some(v) = d.num("srgR") { + set(g, "depth", Value::Scalar(v)); + } + set(g, "direction", Value::Enum(u32::from(d.enum_value("bvlD") == Some("Out ")))); + if let Some(v) = d.num("Sftn") { + set(g, "soften", Value::Scalar(v)); + } + if let Some(v) = d.num("Lald") { + set(g, "altitude", Value::Scalar(v)); + } + for (k, p) in [("hglM", "highlightMode"), ("sdwM", "shadowMode")] { + if let Some(m) = desc_blend(d.enum_value(k)) { + set(g, p, style_mode(m)); + } + } + for (k, p) in [("hglC", "highlightColor"), ("sdwC", "shadowColor")] { + if let Some(c) = d.color(k) { + set(g, p, color4(c)); + } + } + for (k, p) in [("hglO", "highlightOpacity"), ("sdwO", "shadowOpacity")] { + if let Some(v) = d.num(k) { + set(g, p, Value::Scalar(v)); + } + } + } + "satin" => { + if let Some(b) = d.bool("Invr") { + set(g, "invert", Value::Bool(b)); + } + } + "gradientOverlay" => { + if let Some(gr) = gradient_of(d) { + set(g, "colors", Value::Gradient(gr)); + } + if let Some(a) = d.num("Angl") { + set(g, "angle", Value::Scalar(a)); + } + let st = match d.enum_value("Type") { + Some("Rdl ") => 1, + Some("Angl") => 2, + Some("Rflc") => 3, + Some("Dmnd") => 4, + _ => 0, + }; + set(g, "style", Value::Enum(st)); + if let Some(b) = d.bool("Rvrs") { + set(g, "reverse", Value::Bool(b)); + } + if let Some(b) = d.bool("Algn") { + set(g, "alignWithLayer", Value::Bool(b)); + } + if let Some(v) = d.num("Scl ") { + set(g, "scale", Value::Scalar(v)); + } + } + "stroke" => { + if let Some(v) = d.num("Sz ") { + set(g, "size", Value::Scalar(v)); + } + let pos = match d.enum_value("Styl") { + Some("InsF") => 1, + Some("CtrF") => 2, + _ => 0, + }; + set(g, "position", Value::Enum(pos)); + } + _ => {} + } + } +} + +/// Layer ▸ Create ▸ Convert to Editable Text: a footage layer showing a Photoshop type layer +/// becomes a text layer (`None` when the layer's source has no text data). +pub fn editable_text(proj: &mut Project, comp: &Comp, layer: &Layer, psd: &Psd, index: usize) -> Option { + let t = psd.layers.get(index)?.text.as_ref()?; + let mut l = text_layer(proj, comp, &layer.name, t); + l.name = layer.name.clone(); + l.blend_mode = layer.blend_mode; + l.switches.video = layer.switches.video; + l.in_point = layer.in_point; + l.out_point = layer.out_point; + l.start_time = layer.start_time; + if let (Some(o), Some(p)) = (layer.props.prop("transform/opacity"), l.props.prop_mut("transform/opacity")) { + *p = effectcraft_project::Property { uid: p.uid, ..o.clone() }; + } + Some(l) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blend_keys() { + assert_eq!(blend_mode("mul "), BlendMode::Multiply); + assert_eq!(blend_mode("smud"), BlendMode::Exclusion); + assert_eq!(blend_mode("pass"), BlendMode::Normal); + assert_eq!(desc_blend(Some("linearDodge")), Some(BlendMode::LinearDodge)); + assert_eq!(font_names("Inter-Bold"), ("Inter".into(), "Bold".into())); + } +} diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index b0512a6a..e54eb93b 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -27,6 +27,7 @@ impl Session { self.prefs.normalize(); self.layer_cache.set_budget(self.prefs.layer_cache_bytes()); self.footage.set_cache_budget(self.prefs.media_cache_bytes()); + self.configure_disk_cache(); // Fewer undo levels apply right away. let levels = self.prefs.general.undo_levels.max(1) as usize; if self.history.undo.len() > levels { @@ -36,6 +37,42 @@ impl Session { self.prefs_revision += 1; } + /// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache + /// and attach it to the layer cache. Without a folder setting the platform cache folder is + /// used, but only by frontends with a settings store (headless sessions stay off disk). + pub fn configure_disk_cache(&mut self) { + use effectcraft_render::disk_cache::{DiskCache, default_folder, footage_salt}; + let d = &self.prefs.disk; + let folder = if d.disk_cache_folder.trim().is_empty() { + self.config.is_some().then(default_folder) + } else { + Some(std::path::PathBuf::from(d.disk_cache_folder.trim())) + }; + let max = d.disk_cache_max_gb.max(1) as u64 * (1 << 30); + let Some(folder) = folder.filter(|_| d.disk_cache_enabled) else { + self.disk_cache = None; + self.layer_cache.set_disk(None); + return; + }; + let dc = match &self.disk_cache { + Some(c) if c.folder() == folder.as_path() => { + c.set_max_bytes(max); + c.clone() + } + _ => match DiskCache::open(&folder, max) { + Ok(c) => c, + Err(e) => { + log::warn!("disk cache at {}: {e}", folder.display()); + self.disk_cache = None; + self.layer_cache.set_disk(None); + return; + } + }, + }; + self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project)))); + self.disk_cache = Some(dc); + } + /// Write settings to the config store. pub fn save_prefs(&self) { if let Some(c) = &self.config diff --git a/crates/engine/src/tests_disk_cache.rs b/crates/engine/src/tests_disk_cache.rs new file mode 100644 index 00000000..65180ba8 --- /dev/null +++ b/crates/engine/src/tests_disk_cache.rs @@ -0,0 +1,121 @@ +//! The persistent disk cache (PRV-2) through the session: settings, layer cache persistence +//! across restarts, invalidation on edits, purging. + +use serde_json::json; + +use crate::Session; +use crate::render::RenderOpts; + +fn folder(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-session-disk-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d.to_string_lossy().to_string() +} + +fn session(dir: &str) -> Session { + let mut s = Session::default(); + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir, "disk.diskCacheEnabled": true}})).unwrap(); + // Write every layer buffer, however quick to render. + s.disk_cache.as_ref().unwrap().set_min_layer_ms(0); + s +} + +fn project(s: &mut Session) -> effectcraft_project::ItemId { + s.execute("comp.new", json!({"name": "D", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 80, "height": 40})).unwrap(); + s.execute("effect.apply", json!({"layer": "#1", "effect": "Gaussian Blur"})).unwrap(); + s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap(); + s.active_comp_id().unwrap() +} + +#[test] +fn disk_cache_settings_persistence_and_invalidation() { + let dir = folder("persist"); + let mut s = session(&dir); + let st = s.execute_checked("cache.diskStats", json!({})).unwrap(); + assert_eq!(st["enabled"], json!(true)); + assert_eq!(st["maxBytes"], json!(100u64 << 30)); + let cid = project(&mut s); + let t = s.time(); + let first = s.render(cid, t, RenderOpts::default()); + let dc = s.disk_cache.clone().unwrap(); + dc.flush(); + let written = dc.stats().entries; + assert!(written > 0, "layer buffers written"); + // "Restart": a new session on the same folder and project renders from disk. + let saved = s.project.clone(); + drop(s); + let mut s2 = session(&dir); + s2.project = saved; + s2.bump(); + s2.open_comp(cid); + let dc2 = s2.disk_cache.clone().unwrap(); + assert_eq!(dc2.stats().entries, written); + let again = s2.render(cid, t, RenderOpts::default()); + assert!(dc2.stats().hits > 0, "{:?}", dc2.stats()); + assert_eq!(again.data, first.data, "disk-cached pixels are exact"); + // An edit changes the keys: a miss, and new entries. + let hits = dc2.stats().hits; + s2.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 20})).unwrap(); + let edited = s2.render(cid, t, RenderOpts::default()); + assert_ne!(edited.data, first.data); + assert_eq!(dc2.stats().hits, hits); + dc2.flush(); + assert!(dc2.stats().entries > written); + // Purge ▸ All Disk Cache empties it; memory purge leaves the disk alone. + let r = s2.execute_checked("edit.purge", json!({"what": "memory"})).unwrap(); + assert_eq!(r["diskEntries"], json!(0)); + assert!(dc2.stats().entries > 0); + let r = s2.execute_checked("edit.purge", json!({"what": "disk"})).unwrap(); + assert!(r["diskEntries"].as_u64().unwrap() > 0); + assert_eq!(dc2.stats().entries, 0); + // Disabling detaches it. + s2.execute_checked("prefs.set", json!({"key": "disk.diskCacheEnabled", "value": false})).unwrap(); + assert!(s2.disk_cache.is_none()); + assert!(s2.layer_cache.disk().is_none()); +} + +#[test] +fn footage_changes_change_the_salt() { + let dir = folder("salt"); + let mut s = session(&dir); + let foot = std::env::temp_dir().join(format!("effectcraft-salt-{}.png", std::process::id())); + std::fs::write(&foot, b"one").unwrap(); + s.edit("add", None, |p, _| { + let f = effectcraft_project::Footage { + path: foot.to_string_lossy().to_string(), + kind: effectcraft_project::FootageKind::Still, + width: 1, + height: 1, + pixel_aspect: 1.0, + frame_rate: effectcraft_time::FrameRate::FPS_30, + native_rate: None, + duration: effectcraft_time::Tick::ZERO, + has_video: true, + has_audio: false, + alpha: effectcraft_project::AlphaMode::Ignore, + premul_color: [0.0; 3], + loop_count: 1, + codec: "PNG".into(), + missing: false, + sequence: vec![], + color_profile: None, + layer: None, + }; + p.add_item("f.png", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)); + Ok(()) + }) + .unwrap(); + let a = effectcraft_render::disk_cache::footage_salt(&s.project); + std::fs::write(&foot, b"two, longer").unwrap(); + let b = effectcraft_render::disk_cache::footage_salt(&s.project); + assert_ne!(a, b); + let cid = project(&mut s); + let k1 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap(); + let k2 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + assert_ne!(k1, k2); + // Unrelated comps do not change it. + s.execute("comp.new", json!({"name": "Other", "width": 10, "height": 10, "frameRate": 30, "duration": 1})).unwrap(); + assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 141858ca..17ffda7f 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -257,6 +257,7 @@ impl Importer for MockImporter { missing: false, sequence: vec![], color_profile: None, + layer: None, }) } } diff --git a/crates/engine/src/tests_track.rs b/crates/engine/src/tests_track.rs index adcf7df0..8cf020f9 100644 --- a/crates/engine/src/tests_track.rs +++ b/crates/engine/src/tests_track.rs @@ -101,6 +101,7 @@ pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Ses missing: false, sequence: vec![], color_profile: None, + layer: None, }; let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); @@ -410,6 +411,7 @@ fn analyze_1080p_single_point_speed() { missing: false, sequence: vec![], color_profile: None, + layer: None, }; let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests_vector_import.rs new file mode 100644 index 00000000..d205630c --- /dev/null +++ b/crates/engine/src/tests_vector_import.rs @@ -0,0 +1,237 @@ +//! Photoshop import as a composition (IO-2) and Layer ▸ Create text conversions (SHP-5). + +use effectcraft_color::BlendMode; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{ItemKind, LayerSource, MaskMode}; +use effectcraft_psd::write::*; +use effectcraft_psd::{DValue, Descriptor, Rect}; +use serde_json::json; + +use crate::Session; +use crate::render::RenderOpts; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-psd-tests-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +/// A layered document exercising groups, masks, blend modes, text, styles and adjustments. +pub(crate) fn sample_psd() -> Vec { + let mut d = WDoc::new(120, 80); + let shadow = Descriptor::new("null").with("masterFXSwitch", DValue::Bool(true)).with( + "DrSh", + DValue::Descriptor( + Descriptor::new("DrSh") + .with("enab", DValue::Bool(true)) + .with("Md ", DValue::Enum("BlnM".into(), "Mltp".into())) + .with("Clr ", DValue::Descriptor(Descriptor::rgb([0.0, 0.0, 0.5]))) + .with("Opct", DValue::UnitFloat("#Prc".into(), 60.0)) + .with("uglg", DValue::Bool(false)) + .with("lagl", DValue::UnitFloat("#Ang".into(), 45.0)) + .with("Dstn", DValue::UnitFloat("#Pxl".into(), 9.0)) + .with("blur", DValue::UnitFloat("#Pxl".into(), 4.0)), + ), + ); + let stroke = Descriptor::new("null").with( + "FrFX", + DValue::Descriptor( + Descriptor::new("FrFX") + .with("enab", DValue::Bool(true)) + .with("Styl", DValue::Enum("FStl".into(), "InsF".into())) + .with("Md ", DValue::Enum("BlnM".into(), "Nrml".into())) + .with("Opct", DValue::UnitFloat("#Prc".into(), 100.0)) + .with("Sz ", DValue::UnitFloat("#Pxl".into(), 2.0)) + .with("Clr ", DValue::Descriptor(Descriptor::rgb([1.0, 1.0, 0.0]))), + ), + ); + let mut masked = WLayer::solid("Masked Square", Rect::new(10, 10, 40, 40), [0.0, 0.8, 0.2, 1.0]).with_blend(b"mul "); + masked.mask = Some(WMask { + rect: Rect::new(10, 10, 40, 40), + data: (0..1600).map(|i| if i % 40 < 20 { 1.0 } else { 0.0 }).collect(), + default_color: 0, + disabled: false, + }); + let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 10, 10), [1.0, 0.0, 1.0, 1.0]); + hidden.hidden = true; + let mut clipped = WLayer::solid("Clipped", Rect::new(60, 30, 20, 20), [1.0, 1.0, 1.0, 1.0]).with_blend(b"scrn").with_opacity(128); + clipped.clipping = true; + let tri = vec![[[70.0, 10.0]; 3], [[110.0, 10.0]; 3], [[90.0, 40.0]; 3]]; + d.layers = vec![ + WLayer::solid("Background", Rect::new(0, 0, 120, 80), [0.2, 0.3, 0.4, 1.0]), + WLayer::group_end(), + masked, + hidden, + WLayer::group("Group A", true, *b"pass"), + WLayer::solid("Base", Rect::new(55, 25, 30, 30), [0.9, 0.1, 0.1, 1.0]).with_block(effects_block(&shadow)), + clipped, + WLayer::solid("Vector Masked", Rect::new(60, 0, 60, 50), [0.1, 0.1, 0.9, 1.0]) + .with_block(vector_mask_block(&[tri], 120, 80, false)) + .with_block(effects_block(&stroke)), + WLayer::empty("Hue Shift", vec![hue_sat_block(40, 10, -5)]), + WLayer::empty("Title", vec![text_block("Hello PSD", [8.0, 70.0], "Inter-Bold", 14.0, [1.0, 1.0, 1.0], 0)]), + ]; + write(&d) +} + +#[test] +fn psd_imports_as_composition() { + let path = tmp("sample.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + assert_eq!(s.project.item(cid).unwrap().name, "sample"); + let c = s.project.comp(cid).unwrap().clone(); + assert_eq!((c.width, c.height), (120, 80)); + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); + // Text layer: editable text, positioned at the type origin. + let title = &c.layers[0]; + assert!(matches!(title.source, LayerSource::Text)); + let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") }; + assert_eq!(doc.text, "Hello PSD"); + assert_eq!(doc.size, 14.0); + assert_eq!(doc.font, "Inter"); + assert_eq!(doc.style, "Bold"); + assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]); + // Adjustment layer with Hue/Saturation. + let hue = &c.layers[1]; + assert!(hue.switches.adjustment); + let fx = hue.effects().unwrap().groups().next().unwrap(); + assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0); + assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0); + // Vector mask → mask; Stroke style. + let vm = &c.layers[2]; + let m = vm.masks().unwrap().groups().next().unwrap(); + assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. })); + let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") }; + assert_eq!(p.vertices.len(), 3); + assert!((p.vertices[1][0] - 110.0).abs() < 1e-3 && (p.vertices[2][1] - 40.0).abs() < 1e-3); + let st = vm.props.group("layerStyles/stroke").unwrap(); + assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0); + assert_eq!(st.get("position").unwrap().value, KV::Enum(1)); + // Clipped → preserve transparency; opacity and blend mode. + let clipped = &c.layers[3]; + assert!(clipped.preserve_transparency); + assert_eq!(clipped.blend_mode, BlendMode::Screen); + assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9); + // Drop shadow. + let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap(); + assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0); + assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0); + assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false)); + assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0); + // Group → collapsed precomp (pass through) with its layers. + let g = &c.layers[5]; + assert!(g.switches.collapse); + let LayerSource::Comp { item } = g.source else { panic!("precomp") }; + let inner = s.project.comp(item).unwrap(); + let inner_names: Vec<&str> = inner.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(inner_names, ["Hidden", "Masked Square"]); + assert!(!inner.layers[0].switches.video); + assert_eq!(inner.layers[1].blend_mode, BlendMode::Multiply); + // Footage items name their Photoshop layer; document-sized footage. + let LayerSource::Footage { item } = inner.layers[1].source else { panic!("footage") }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; + let sl = f.layer.as_ref().unwrap(); + assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false)); + assert_eq!((f.width, f.height), (120, 80)); + assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd"); + // One undo step removes everything. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.comp(cid).is_none()); +} + +#[test] +fn psd_retain_layer_sizes() { + let path = tmp("retain.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + let c = s.project.comp(cid).unwrap(); + let base = c.layer_by_name("Base").unwrap(); + let LayerSource::Footage { item } = base.source else { panic!() }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; + assert_eq!((f.width, f.height), (30, 30)); + assert!(f.layer.as_ref().unwrap().layer_size); + assert_eq!(base.props.prop("transform/position").unwrap().value.as_vec2(), [70.0, 40.0]); + assert_eq!(base.props.prop("transform/anchor").unwrap().value.as_vec2(), [15.0, 15.0]); + // Vector mask vertices are in the layer's own space. + let vm = c.layer_by_name("Vector Masked").unwrap(); + let Some(KV::Path(p)) = vm.masks().unwrap().groups().next().unwrap().get("path").map(|p| p.value.clone()) else { panic!() }; + assert!((p.vertices[0][0] - 10.0).abs() < 1e-3, "{:?}", p.vertices); +} + +#[test] +fn import_rejects_unknown_kind() { + let mut s = Session::default(); + assert!(s.execute_checked("file.import", json!({"paths": ["x.psd"], "importAs": "banana"})).is_err()); +} + +fn text_session() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 120, "frameRate": 30, "duration": 2})).unwrap(); + let r = s.execute_checked("layer.newText", json!({"text": "Ab8", "size": 64, "position": [160, 80], "fill": "#ffcc00"})).unwrap(); + (s, r["layer"].as_u64().unwrap()) +} + +fn alpha_diff(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image) -> (f32, f32) { + let mut sum = 0.0; + let mut mass = 0.0; + for (p, q) in a.data.iter().zip(&b.data) { + sum += (p[3] - q[3]).abs(); + mass += p[3]; + } + (sum / a.data.len() as f32, mass) +} + +#[test] +fn create_shapes_from_text_matches_text_render() { + let (mut s, lid) = text_session(); + let cid = s.active_comp_id().unwrap(); + let opts = RenderOpts::default(); + let text_img = s.render(cid, s.time(), opts); + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let r = s.execute_checked("layer.create", json!({"op": "shapesFromText"})).unwrap(); + let sid = r["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.layers[0].id.0, sid); + assert!(matches!(c.layers[0].source, LayerSource::Shape)); + assert_eq!(c.layers[0].name, "Ab8 Outlines"); + assert!(!c.layer(effectcraft_project::LayerId(lid)).unwrap().switches.video, "source hidden"); + let groups: Vec = c.layers[0].props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect(); + assert_eq!(groups, ["A", "b", "8"]); + let shape_img = s.render(cid, s.time(), opts); + let (d, mass) = alpha_diff(&text_img, &shape_img); + assert!(mass > 500.0, "text drew something"); + assert!(d < 0.01, "mean alpha difference {d}"); + // Fill colour carried over. + let i = shape_img.data.iter().position(|p| p[3] > 0.99).unwrap(); + let p = shape_img.data[i]; + assert!((p[0] - 1.0).abs() < 0.02 && (p[1] - 0.8).abs() < 0.02 && p[2] < 0.02, "{p:?}"); +} + +#[test] +fn create_masks_from_text() { + let (mut s, lid) = text_session(); + let cid = s.active_comp_id().unwrap(); + let text_img = s.render(cid, s.time(), RenderOpts::default()); + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let r = s.execute_checked("layer.create", json!({"op": "masksFromText"})).unwrap(); + let mid = effectcraft_project::LayerId(r["layers"][0].as_u64().unwrap()); + let c = s.active_comp().unwrap(); + let l = c.layer(mid).unwrap(); + assert!(matches!(l.source, LayerSource::Solid { .. })); + // "A", "b" and "8" have 2, 2 and 3 contours. + assert_eq!(l.masks().unwrap().groups().count(), 7); + let img = s.render(cid, s.time(), RenderOpts::default()); + let (d, _) = alpha_diff(&text_img, &img); + assert!(d < 0.01, "mean alpha difference {d}"); + // Other ops need the right layer type. + assert!(s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).is_err()); + assert!(s.execute_checked("layer.create", json!({"op": "nullTracing", "layers": [lid]})).is_err()); +} diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs new file mode 100644 index 00000000..a3f3e040 --- /dev/null +++ b/crates/engine/src/vector.rs @@ -0,0 +1,250 @@ +//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG footage → +//! shape layer), Create Shapes from Text and Create Masks from Text (glyph outlines). + +use effectcraft_keyframe::{Gradient, ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, PropGroup}; +use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint}; +use kurbo::{BezPath, Point}; + +fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec { + effectcraft_path::from_kurbo(path) + .into_iter() + .enumerate() + .map(|(i, sp)| { + let mut g = build::shape_path(ids, sp); + g.name = format!("Path {}", i + 1); + g + }) + .collect() +} + +fn det_scale(m: Affine) -> f64 { + let c = m.as_coeffs(); + (c[0] * c[3] - c[1] * c[2]).abs().sqrt() +} + +/// Axis-aligned scale + translation only (basic shapes stay parametric). +fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> { + let c = m.as_coeffs(); + (c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5])) +} + +fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient { + Gradient { + colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(), + opacities: g.stops.iter().map(|(o, c)| (*o, c[3] as f32)).collect(), + } +} + +/// Gradient start/end points in shape-layer space (`m`: shape user space → layer space). +fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect) -> (bool, [f64; 2], [f64; 2]) { + let unit = if g.bbox_units { Affine::new([bbox.width(), 0.0, 0.0, bbox.height(), bbox.x0, bbox.y0]) } else { Affine::IDENTITY }; + let full = m * unit * g.transform; + let p = |x: f64, y: f64| { + let q = full * Point::new(x, y); + [q.x, q.y] + }; + match g.kind { + GradientKind::Linear { x1, y1, x2, y2 } => (false, p(x1, y1), p(x2, y2)), + GradientKind::Radial { cx, cy, r, .. } => (true, p(cx, cy), p(cx + r, cy)), + } +} + +fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec { + use kurbo::Shape as _; + let mut items = vec![]; + // Geometry: parametric when the transform keeps rectangles/ellipses axis-aligned. + match (&s.geom, axis_aligned(m)) { + (Geom::Rect { x, y, w, h, rx, ry }, Some((sx, sy, tx, ty))) if (rx - ry).abs() < 1e-9 && (*rx == 0.0 || (sx - sy).abs() < 1e-9) => { + let (w2, h2) = (w * sx, h * sy); + let pos = [tx + (x + w / 2.0) * sx, ty + (y + h / 2.0) * sy]; + let mut g = build::shape_rect(ids, [w2, h2], pos, rx * sx); + g.name = "Rectangle Path 1".into(); + items.push(g); + } + (Geom::Ellipse { cx, cy, rx, ry }, Some((sx, sy, tx, ty))) => { + items.push(build::shape_ellipse(ids, [2.0 * rx * sx, 2.0 * ry * sy], [tx + cx * sx, ty + cy * sy])); + } + (g, _) => items.extend(shape_paths(ids, &(m * g.to_path()))), + } + let bbox = s.geom.to_path().bounding_box(); + if let Some(st) = &s.stroke { + let width = st.width * det_scale(m); + let mut g = match &st.paint { + Paint::Color(c) => build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width), + Paint::Gradient(gr) => { + let (radial, a, b) = gradient_points(gr, m, bbox); + build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width) + } + }; + set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0)); + set(&mut g, "cap", Value::Enum(st.cap as u32)); + set(&mut g, "join", Value::Enum(st.join as u32)); + set(&mut g, "miter", Value::Scalar(st.miter)); + if let Some((d, off)) = &st.dash + && let Some(dg) = g.sub_mut("dashes") + { + let k = det_scale(m); + set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k)); + set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k)); + set(dg, "offset", Value::Scalar(off * k)); + } + items.push(g); + } + if let Some(f) = &s.fill { + let mut g = match f { + Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]), + Paint::Gradient(gr) => { + let (radial, a, b) = gradient_points(gr, m, bbox); + build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr)) + } + }; + set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0)); + set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd))); + items.push(g); + } + items +} + +fn set(g: &mut PropGroup, k: &str, v: Value) { + if let Some(p) = g.get_mut(k) { + p.value = v; + } +} + +fn group(ids: &mut Ids, name: &str, items: Vec, opacity: f64) -> PropGroup { + let mut g = build::shape_group(ids, name, items); + if let Some(p) = g.prop_mut("transform/opacity") { + p.value = Value::Scalar(opacity * 100.0); + } + g +} + +/// Shape layer contents for an SVG document, in document pixels (transforms are baked into the +/// geometry; group and element opacity go to the group transforms). Top of the paint order first. +pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { + fn walk(ids: &mut Ids, nodes: &[Node], m: Affine) -> Vec { + let mut out = vec![]; + for n in nodes.iter().rev() { + match n { + Node::Group(g) => { + let items = walk(ids, &g.children, m * g.transform); + if !items.is_empty() { + out.push(group(ids, &g.name, items, g.opacity)); + } + } + Node::Shape(s) => { + let items = shape_items(ids, s, m * s.transform); + if !items.is_empty() { + out.push(group(ids, &s.name, items, s.opacity)); + } + } + } + } + out + } + let root = &doc.root; + let items = walk(ids, &root.children, root.transform); + if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items } +} + +/// A copy of `src`'s transform group with fresh uids. +fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) { + let Some(tr) = src.props.sub("transform") else { return }; + let mut tr = tr.clone(); + let mut n = proj.next_id; + tr.reassign_uids(&mut n); + proj.next_id = n + 1; + if let Some(slot) = dst.props.children.iter_mut().find(|c| c.match_id() == "transform") { + *slot = tr.into(); + } +} + +fn like_source(dst: &mut Layer, src: &Layer) { + dst.start_time = src.start_time; + dst.in_point = src.in_point; + dst.out_point = src.out_point; + dst.stretch = src.stretch; + dst.parent = src.parent; + dst.switches.three_d = src.switches.three_d; +} + +/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. +pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer { + let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None); + let items = svg_contents(&mut Ids(&mut proj.next_id), doc); + if let Some(c) = l.props.sub_mut("contents") { + c.children.extend(items.into_iter().map(Into::into)); + } + copy_transform(proj, src, &mut l); + like_source(&mut l, src); + l.name = comp.unique_layer_name(&format!("{} Outlines", src.name)); + l +} + +/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and +/// stroke (as laid out at the comp time `ctx` was made for). +pub fn shapes_from_text(proj: &mut Project, comp: &Comp, ctx_project: &Project, cid: ItemId, src: &Layer, time: effectcraft_time::Tick) -> Option { + let ctx = effectcraft_render::EvalCtx::new(ctx_project, cid, comp, time); + let geom = effectcraft_render::text::text_geom(&ctx, src)?; + let chars: Vec = geom.doc.text.chars().filter(|c| !c.is_whitespace()).collect(); + let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None); + let mut groups = vec![]; + { + let mut ids = Ids(&mut proj.next_id); + for (i, g) in geom.glyphs.iter().enumerate() { + let path = g.path(); + if path.elements().is_empty() { + continue; + } + let mut items = shape_paths(&mut ids, &path); + if g.stroke_width > 0.0 && g.stroke[3] > 0.0 { + let c = g.stroke; + items.push(build::shape_stroke(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0], g.stroke_width)); + } + if g.apply_fill { + let c = g.fill; + let mut f = build::shape_fill(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]); + set(&mut f, "opacity", Value::Scalar(c[3] as f64 * 100.0)); + items.push(f); + } + let name = chars.get(i).map(|c| c.to_string()).unwrap_or_else(|| format!("Glyph {}", i + 1)); + groups.push(group(&mut ids, &name, items, 1.0)); + } + } + // First character on top, as in After Effects. + if let Some(c) = l.props.sub_mut("contents") { + c.children.extend(groups.into_iter().map(Into::into)); + } + copy_transform(proj, src, &mut l); + like_source(&mut l, src); + l.name = comp.unique_layer_name(&format!("{} Outlines", src.name)); + Some(l) +} + +/// Glyph outlines of a text layer in comp space (for Create Masks from Text). +pub fn text_outlines_in_comp(project: &Project, cid: ItemId, comp: &Comp, src: &Layer, time: effectcraft_time::Tick) -> Vec { + let ctx = effectcraft_render::EvalCtx::new(project, cid, comp, time); + let Some(geom) = effectcraft_render::text::text_geom(&ctx, src) else { return vec![] }; + let (m, _) = ctx.layer_to_comp(src); + let mut out = vec![]; + for g in &geom.glyphs { + let p = g.path(); + let p = effectcraft_path::transform(std::slice::from_ref(&p), &m).remove(0); + out.extend(effectcraft_path::from_kurbo(&p)); + } + out +} + +/// A comp-sized white solid with one mask per glyph contour (Difference, so counters stay open). +pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec, solid: ItemId) -> Layer { + let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: solid }, (comp.width, comp.height), None); + let mut ids = Ids(&mut proj.next_id); + if let Some(masks) = l.props.sub_mut("masks") { + for (i, sp) in outlines.into_iter().enumerate() { + masks.children.push(build::mask(&mut ids, &format!("Mask {}", i + 1), sp, MaskMode::Difference, [255, 255, 0]).into()); + } + } + l +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index ca2fb1b0..cf63e593 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -100,6 +100,7 @@ impl Scene { missing: false, sequence: vec![], color_profile: None, + layer: None, }; let fid = self.p.add_item("Pattern", Label::Lavender, None, ItemKind::Footage(f)); build::layer(&mut self.p, &self.comp, "Pattern", LayerSource::Footage { item: fid }, (w, h), None) diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index b43271c7..7143510a 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -18,3 +18,7 @@ effectcraft-export = { workspace = true } [dev-dependencies] serde_json = { workspace = true } +effectcraft-psd = { workspace = true } +effectcraft-svg = { workspace = true } +effectcraft-raster = { workspace = true } +effectcraft-time = { workspace = true } diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs new file mode 100644 index 00000000..eb5be076 --- /dev/null +++ b/crates/host/tests/vector_import.rs @@ -0,0 +1,163 @@ +//! Photoshop and SVG import through the fully wired session (media decoding included): pixels +//! of imported compositions, SVG footage at any scale, and Create Shapes from Vector Layer. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_project::ItemId; +use effectcraft_psd::Rect; +use effectcraft_psd::write::*; +use effectcraft_raster::Image; +use serde_json::json; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-host-vector-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +fn mean_diff(a: &Image, b: &Image) -> f32 { + assert_eq!((a.width, a.height), (b.width, b.height)); + let mut s = 0.0; + for (p, q) in a.data.iter().zip(&b.data) { + for c in 0..4 { + s += (p[c] - q[c]).abs(); + } + } + s / (a.data.len() * 4) as f32 +} + +fn psd_doc() -> WDoc { + let mut d = WDoc::new(64, 48); + let mut masked = WLayer::solid("Masked", Rect::new(30, 8, 24, 24), [0.1, 0.9, 0.3, 1.0]); + masked.mask = Some(WMask { + rect: Rect::new(30, 8, 24, 24), + data: (0..576).map(|i| if (i / 24) < 12 { 1.0 } else { 0.25 }).collect(), + default_color: 0, + disabled: false, + }); + d.layers = vec![ + WLayer::solid("Background", Rect::new(0, 0, 64, 48), [0.2, 0.25, 0.3, 1.0]), + WLayer::solid("Half Red", Rect::new(4, 4, 30, 20), [1.0, 0.0, 0.0, 1.0]).with_opacity(128), + masked, + WLayer::group_end(), + WLayer::solid("In Group", Rect::new(10, 30, 20, 12), [0.9, 0.9, 0.1, 1.0]), + WLayer::group("Group", true, *b"norm"), + ]; + d +} + +/// Straight-alpha over of the layers as Photoshop composites them (normal blending), as the +/// merged image to compare the imported composition with. +fn reference(bytes: &[u8]) -> Vec<[f32; 4]> { + let p = effectcraft_psd::Psd::parse(bytes.to_vec()).unwrap(); + let mut acc = vec![[0.0f32; 4]; (p.width * p.height) as usize]; + for l in p.layers.iter().filter(|l| l.has_pixels() && !l.hidden) { + let px = p.layer_pixels(l.index, true).unwrap(); + let o = l.opacity as f32 / 255.0; + for (d, s) in acc.iter_mut().zip(&px.data) { + let a = s[3] * o; + let oa = a + d[3] * (1.0 - a); + for c in 0..3 { + d[c] = if oa > 0.0 { (s[c] * a + d[c] * d[3] * (1.0 - a)) / oa } else { 0.0 }; + } + d[3] = oa; + } + } + acc +} + +#[test] +fn psd_composition_renders_like_the_document() { + let mut doc = psd_doc(); + let bytes = write(&doc); + let merged = reference(&bytes); + doc.composite = Some(merged.clone()); + let bytes = write(&doc); + let path = tmp("layers.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + // Footage (merged image). + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + let merged_item = r["items"][0].as_u64().unwrap(); + // Composition and Composition – Retain Layer Sizes. + let mut renders = vec![]; + for kind in ["composition", "compositionLayerSizes"] { + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": kind})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + renders.push(s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default())); + } + // The merged footage in a comp of the same size. + s.execute("comp.new", json!({"name": "Merged", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": merged_item})).unwrap(); + let mcid = s.active_comp_id().unwrap(); + let merged_img = s.render(mcid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let expected = Image { width: 64, height: 48, data: merged.iter().map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]).collect() }; + assert!(mean_diff(&merged_img, &expected) < 2.0 / 255.0, "merged footage"); + for (i, img) in renders.iter().enumerate() { + let d = mean_diff(img, &expected); + assert!(d < 2.0 / 255.0, "import kind {i}: mean diff {d}"); + } + // A single layer as footage (Choose Layer). + let r = s.execute_checked("file.import", json!({"paths": [path], "layer": "Masked"})).unwrap(); + let it = s.project.item(ItemId(r["items"][0].as_u64().unwrap())).unwrap(); + assert_eq!(it.name, "Masked/layers.psd"); +} + +#[test] +fn psd_16bit_cmyk_composition_imports() { + let mut doc = psd_doc(); + doc.depth = 16; + doc.mode = effectcraft_psd::ColorMode::Cmyk; + doc.rle = false; + let bytes = write(&doc); + let merged = reference(&bytes); + let path = tmp("layers16.psd"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let expected = Image { width: 64, height: 48, data: merged.iter().map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]).collect() }; + let d = mean_diff(&img, &expected); + assert!(d < 2.0 / 255.0, "mean diff {d}"); +} + +const SVG: &[u8] = include_bytes!("../../svg/tests/fixtures/basic-shapes.svg"); +const SVG2: &[u8] = include_bytes!("../../svg/tests/fixtures/paths-gradients.svg"); + +#[test] +fn svg_footage_and_shapes_from_vector_layer() { + for (name, bytes) in [("basic-shapes.svg", SVG), ("paths-gradients.svg", SVG2)] { + let path = tmp(name); + std::fs::write(&path, bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + let doc = effectcraft_svg::parse(bytes).unwrap(); + let (w, h) = doc.pixel_size(); + s.execute("comp.new", json!({"name": "V", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let t = effectcraft_time::Tick::ZERO; + let foot = s.render(cid, t, RenderOpts::default()); + let direct = effectcraft_svg::rasterize(&doc, w, h, 1.0); + assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "{name}: footage = rasterised SVG"); + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap(); + let sid = r["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.layers[0].id.0, sid); + assert!(!c.layers[1].switches.video); + let shapes = s.render(cid, t, RenderOpts::default()); + let d = mean_diff(&shapes, &direct); + assert!(d < 0.02, "{name}: shapes vs SVG mean diff {d}"); + // Scaled up 3×, the shape layer stays sharp and matches the SVG rasterised at 3×. + s.execute("prop.set", json!({"layer": sid, "path": "transform/scale", "value": [300, 300]})).unwrap(); + s.execute("prop.set", json!({"layer": sid, "path": "transform/position", "value": [0, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": sid, "path": "transform/anchor", "value": [0, 0]})).unwrap(); + let big = s.render(cid, t, RenderOpts::default()); + let direct3 = effectcraft_svg::rasterize(&doc, w, h, 3.0); + let d3 = mean_diff(&big, &direct3); + assert!(d3 < 0.03, "{name}: ×3 mean diff {d3}"); + } +} diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index c87dc187..22bc5023 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -171,6 +171,7 @@ pub fn import( missing: false, sequence: vec![], color_profile: None, + layer: None, }; let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot)); cx.items.push(item); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 5140133c..01c724d7 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -592,6 +592,7 @@ fn images_embed_as_base64_and_come_back() { missing: false, sequence: vec![], color_profile: None, + layer: None, }), ); let c = comp(&p, cid); diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 89992569..6e83c949 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -14,6 +14,8 @@ effectcraft-time = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } +effectcraft-psd = { workspace = true } +effectcraft-svg = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } filmcraft-frame = { workspace = true } diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index fa5df238..dce33d8a 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -284,6 +284,22 @@ pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image out } +/// Straight RGBA `f32` pixels (layered stills: Photoshop, SVG) to an [`Image`]. +pub(crate) fn straight_to_image(w: u32, h: u32, px: &[[f32; 4]], op: AlphaOp) -> Image { + let mut out = Image::new(w, h); + let wu = w as usize; + if wu == 0 || h == 0 { + return out; + } + out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let s = px[y * wu + x]; + *o = op.apply([s[0], s[1], s[2]], s[3].clamp(0.0, 1.0)); + } + }); + out +} + #[cfg(test)] mod tests { use super::*; @@ -376,3 +392,4 @@ mod tests { } } } + diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs new file mode 100644 index 00000000..204312a5 --- /dev/null +++ b/crates/media/src/layered.rs @@ -0,0 +1,62 @@ +//! Layered and vector stills: Photoshop documents (`effectcraft-psd`: the merged image, or one +//! layer for footage imported as a composition) and SVG (`effectcraft-svg`, rasterised). + +use effectcraft_project::{AlphaMode, Footage}; +use effectcraft_raster::Image; + +use crate::convert::{AlphaOp, straight_to_image}; +use crate::{MediaError, Result}; + +fn is_svg(path: &str, bytes: &[u8]) -> bool { + path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) +} + +/// Footage for a Photoshop or SVG file (`None` for other formats). +pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { + if effectcraft_psd::is_psd(bytes) { + let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let mut f = crate::probe::still_footage(path, psd.width, psd.height, image::ImageFormat::Png, true); + f.codec = if psd.psb { "PSB".into() } else { "PSD".into() }; + f.alpha = if psd.merged_alpha || !psd.layers.is_empty() { AlphaMode::Straight } else { AlphaMode::Ignore }; + return Ok(Some(f)); + } + if is_svg(path, bytes) { + let doc = effectcraft_svg::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let (w, h) = doc.pixel_size(); + let mut f = crate::probe::still_footage(path, w, h, image::ImageFormat::Png, true); + f.codec = "SVG".into(); + f.alpha = AlphaMode::Straight; + return Ok(Some(f)); + } + Ok(None) +} + +/// Decode a Photoshop or SVG still (`None` for other formats). +pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -> Result> { + if effectcraft_psd::is_psd(bytes) { + let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let px = match &footage.layer { + Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size), + None => psd.composite(), + } + .map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); + } + if is_svg(path, bytes) { + let doc = effectcraft_svg::parse(bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; + let (w, h) = doc.pixel_size(); + let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); + // The rasteriser produces premultiplied pixels already. + let _ = op; + return Ok(Some(img)); + } + Ok(None) +} + +/// An SVG rasterised at `scale` × its pixel size (Continuously Rasterize). +pub(crate) fn rasterize_svg(bytes: &[u8], scale: f64) -> Option { + let doc = effectcraft_svg::parse(bytes).ok()?; + let (w, h) = doc.pixel_size(); + let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); + Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 29546f7e..246054b3 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -10,9 +10,11 @@ //! Video containers (MP4/MOV, Matroska/WebM) and codecs (H.264, HEVC, VP9, AV1, ProRes, DNxHD, //! MJPEG; AAC, Opus, PCM, MP3/FLAC/Vorbis) come from FilmCraft's pure-Rust crates (git dependency, //! pinned; `plan/adr/0001`). FilmCraft types never leave this crate. Stills use the `image` crate -//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR). +//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR); Photoshop documents (merged image or one layer) come +//! from `effectcraft-psd` and SVG from `effectcraft-svg` (rasterised at any scale). mod convert; +mod layered; mod pool; mod probe; @@ -44,7 +46,7 @@ impl From for MediaError { } /// File extensions recognised as stills. -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; /// File extensions recognised as movies. diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index b4cc51ef..b5d94299 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -359,6 +359,11 @@ impl Inner { let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count); Loc { key: key(&footage.sequence[i as usize], 0), media_t: None } } + // A layer of a layered still is keyed by its layer index and size mode. + FootageKind::Still if footage.layer.is_some() => { + let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64); + Loc { key: key(&footage.path, l), media_t: None } + } FootageKind::Still | FootageKind::Sequence => Loc { key: key(&footage.path, 0), media_t: None }, FootageKind::Video | FootageKind::Audio => { let rate = footage.frame_rate; @@ -437,6 +442,9 @@ impl Inner { match loc.media_t { None => { let bytes = self.read(path)?; + if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { + return Ok(img); + } let img = image::load_from_memory(&bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; Ok(dynamic_to_image(&img, op)) } @@ -470,6 +478,14 @@ impl FootageSource for MediaPool { } } + fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option> { + if footage.missing || !effectcraft_render::is_vector_footage(footage) { + return None; + } + let bytes = self.inner.read(&footage.path).ok()?; + crate::layered::rasterize_svg(&bytes, scale).map(Arc::new) + } + fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { if !footage.has_audio || footage.missing { return None; diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 4e1c5611..23432cc6 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -33,6 +33,9 @@ pub fn probe_single(path: impl AsRef) -> Result { /// Probe in-memory file contents; `path` names the footage (and is stored as `Footage::path`). pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { + if let Some(f) = crate::layered::probe(path, &bytes)? { + return Ok(f); + } if let Ok(fmt) = image::guess_format(&bytes) { return probe_still_bytes(path, &bytes, fmt); } @@ -72,6 +75,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - missing: false, sequence: Vec::new(), color_profile: v.and_then(|v| profile_of(&v.color)), + layer: None, } } @@ -88,7 +92,7 @@ fn profile_of(c: &filmcraft_color::ColorInfo) -> Option Footage { +pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha: bool) -> Footage { let codec = fmt.extensions_str().first().map_or("image", |s| s).to_ascii_uppercase(); // OpenEXR stores premultiplied colour by convention; other formats straight alpha. let alpha = match (has_alpha, fmt) { @@ -114,6 +118,7 @@ fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha: missing: false, sequence: Vec::new(), color_profile: None, + layer: None, } } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 1ed35c09..b4854f54 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -579,6 +579,21 @@ pub struct Footage { /// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB. #[serde(default, skip_serializing_if = "Option::is_none")] pub color_profile: Option, + /// One layer of a layered file (Photoshop) instead of its merged image. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer: Option, +} + +/// A layer of a layered still (a Photoshop document) used as footage +/// (File ▸ Import ▸ Composition / Composition – Retain Layer Sizes, or Choose Layer). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceLayer { + /// Layer record index in the file (bottom of the stack = 0). + pub index: u32, + pub name: String, + /// The footage is the layer's own bounds (Retain Layer Sizes) rather than the document size. + #[serde(default)] + pub layer_size: bool, } fn one() -> u32 { diff --git a/crates/psd/Cargo.toml b/crates/psd/Cargo.toml new file mode 100644 index 00000000..49dda6a3 --- /dev/null +++ b/crates/psd/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "effectcraft-psd" +description = "Photoshop PSD/PSB reader (layers, groups, masks, blend modes, text, layer effects, adjustment layers; 8/16/32-bit, RGB/CMYK/Gray/Lab/Indexed, raw/RLE/ZIP) and a minimal writer, from Adobe's published file format specification" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +miniz_oxide = "0.8" diff --git a/crates/psd/README.md b/crates/psd/README.md new file mode 100644 index 00000000..e238b9b2 --- /dev/null +++ b/crates/psd/README.md @@ -0,0 +1,17 @@ +# effectcraft-psd + +Reads Photoshop documents (`.psd`, and `.psb` large documents) for File ▸ Import (as footage, as a +composition, or as a composition with layer sizes retained), and writes a minimal subset (used to +generate test fixtures). + +Implemented from Adobe's public **Adobe Photoshop File Formats Specification** (November 2019 +edition): file header, colour mode data, image resources, layer and mask information (layer +records, channel image data, layer mask data, additional layer information: `luni`, `lsct`/`lsdk`, +`lyid`, `iOpa`, `TySh`, `lfx2`, `SoCo`, `nvrt`, `brit`, `hue2`, `levl`, `expA`, `vmsk`/`vsms`, +`Lr16`/`Lr32`), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits) +and ZIP (with and without prediction, inflated with `miniz_oxide`) compression; 1/8/16/32-bit; +Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes. + +Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and +justification of the first style run. No Adobe code, sample files or assets were used; test +fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)). diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs new file mode 100644 index 00000000..e121f823 --- /dev/null +++ b/crates/psd/src/descriptor.rs @@ -0,0 +1,430 @@ +//! The descriptor ("action descriptor") structure: typed key/value trees used by text layers, +//! layer effects, fill layers and newer adjustment layers (spec: "Descriptor structure"). + +use crate::read::Reader; +use crate::{Error, Result}; + +/// A descriptor: a class and a list of keyed items (in file order). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Descriptor { + pub class_name: String, + pub class_id: String, + pub items: Vec<(String, DValue)>, +} + +/// One descriptor value. +#[derive(Clone, Debug, PartialEq)] +pub enum DValue { + Descriptor(Descriptor), + List(Vec), + Double(f64), + /// Unit (`#Pxl`, `#Prc`, `#Ang`, `#Pnt`…) and value. + UnitFloat(String, f64), + UnitFloats(String, Vec), + Text(String), + /// Enumerated value: type and value ids. + Enum(String, String), + Integer(i32), + LargeInteger(i64), + Bool(bool), + Class(String, String), + Raw(Vec), + Reference(Vec), +} + +/// An item of a reference (`obj `). +#[derive(Clone, Debug, PartialEq)] +pub enum RefItem { + Property(String, String), + Class(String), + Enum(String, String, String), + Offset(String, i32), + Identifier(i32), + Index(i32), + Name(String, String), +} + +impl Descriptor { + pub fn get(&self, key: &str) -> Option<&DValue> { + self.items.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } + pub fn obj(&self, key: &str) -> Option<&Descriptor> { + match self.get(key)? { + DValue::Descriptor(d) => Some(d), + _ => None, + } + } + /// A number: doubles, unit floats, integers. + pub fn num(&self, key: &str) -> Option { + match self.get(key)? { + DValue::Double(v) | DValue::UnitFloat(_, v) => Some(*v), + DValue::Integer(v) => Some(*v as f64), + DValue::LargeInteger(v) => Some(*v as f64), + _ => None, + } + } + pub fn bool(&self, key: &str) -> Option { + match self.get(key)? { + DValue::Bool(b) => Some(*b), + _ => None, + } + } + pub fn text(&self, key: &str) -> Option<&str> { + match self.get(key)? { + DValue::Text(s) => Some(s), + _ => None, + } + } + /// The value id of an enumerated item. + pub fn enum_value(&self, key: &str) -> Option<&str> { + match self.get(key)? { + DValue::Enum(_, v) => Some(v), + _ => None, + } + } + pub fn raw(&self, key: &str) -> Option<&[u8]> { + match self.get(key)? { + DValue::Raw(v) => Some(v), + _ => None, + } + } + /// An RGB colour object (`RGBC` with `Rd `/`Grn `/`Bl ` in 0..255) as 0..1 values; also + /// reads grayscale (`Gry `) colours. + pub fn color(&self, key: &str) -> Option<[f64; 3]> { + let c = self.obj(key)?; + if let (Some(r), Some(g), Some(b)) = (c.num("Rd "), c.num("Grn "), c.num("Bl ")) { + return Some([r / 255.0, g / 255.0, b / 255.0]); + } + if let (Some(r), Some(g), Some(b)) = (c.num("redFloat"), c.num("greenFloat"), c.num("blueFloat")) { + return Some([r, g, b]); + } + c.num("Gry ").map(|g| { + let v = 1.0 - g / 100.0; + [v, v, v] + }) + } +} + +/// A key or class id: length-prefixed string, or a 4-character code when the length is 0. +pub(crate) fn read_id(r: &mut Reader) -> Result { + let n = r.u32()? as usize; + let n = if n == 0 { 4 } else { n }; + let b = r.bytes(n)?; + Ok(String::from_utf8_lossy(b).into_owned()) +} + +pub(crate) fn read_unicode(r: &mut Reader) -> Result { + let n = r.u32()? as usize; + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(r.u16()?); + } + while v.last() == Some(&0) { + v.pop(); + } + Ok(String::from_utf16_lossy(&v)) +} + +/// Read a descriptor (after its version field). +pub fn read_descriptor(r: &mut Reader) -> Result { + let class_name = read_unicode(r)?; + let class_id = read_id(r)?; + let n = r.u32()? as usize; + if n > 100_000 { + return Err(Error::Invalid("descriptor item count".into())); + } + let mut items = Vec::with_capacity(n.min(64)); + for _ in 0..n { + let key = read_id(r)?; + let ty = r.tag()?; + let v = read_value(r, &ty, 0)?; + items.push((key, v)); + } + Ok(Descriptor { class_name, class_id, items }) +} + +fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result { + if depth > 64 { + return Err(Error::Invalid("descriptor nesting".into())); + } + Ok(match ty { + b"Objc" | b"GlbO" => DValue::Descriptor(read_descriptor(r)?), + b"VlLs" => { + let n = r.u32()? as usize; + if n > 1_000_000 { + return Err(Error::Invalid("descriptor list length".into())); + } + let mut v = Vec::with_capacity(n.min(256)); + for _ in 0..n { + let t = r.tag()?; + v.push(read_value(r, &t, depth + 1)?); + } + DValue::List(v) + } + b"doub" => DValue::Double(r.f64()?), + b"UntF" => { + let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); + DValue::UnitFloat(unit, r.f64()?) + } + b"UnFl" => { + let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); + let n = r.u32()? as usize; + let mut v = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + v.push(r.f64()?); + } + DValue::UnitFloats(unit, v) + } + b"TEXT" => DValue::Text(read_unicode(r)?), + b"enum" => { + let t = read_id(r)?; + DValue::Enum(t, read_id(r)?) + } + b"long" => DValue::Integer(r.u32()? as i32), + b"comp" => DValue::LargeInteger(r.u64()? as i64), + b"bool" => DValue::Bool(r.u8()? != 0), + b"type" | b"GlbC" => { + let name = read_unicode(r)?; + DValue::Class(name, read_id(r)?) + } + b"alis" | b"tdta" | b"Pth " => { + let n = r.u32()? as usize; + DValue::Raw(r.bytes(n)?.to_vec()) + } + b"obj " => { + let n = r.u32()? as usize; + let mut items = Vec::with_capacity(n.min(16)); + for _ in 0..n { + let t = r.tag()?; + items.push(match &t { + b"prop" => { + let _name = read_unicode(r)?; + let class = read_id(r)?; + RefItem::Property(class, read_id(r)?) + } + b"Clss" => { + let _name = read_unicode(r)?; + RefItem::Class(read_id(r)?) + } + b"Enmr" => { + let _name = read_unicode(r)?; + let class = read_id(r)?; + let ty = read_id(r)?; + RefItem::Enum(class, ty, read_id(r)?) + } + b"rele" => { + let _name = read_unicode(r)?; + let class = read_id(r)?; + RefItem::Offset(class, r.u32()? as i32) + } + b"Idnt" => RefItem::Identifier(r.u32()? as i32), + b"indx" => RefItem::Index(r.u32()? as i32), + b"name" => { + let _name = read_unicode(r)?; + let class = read_id(r)?; + RefItem::Name(class, read_unicode(r)?) + } + other => return Err(Error::Invalid(format!("reference item `{}`", String::from_utf8_lossy(other)))), + }); + } + DValue::Reference(items) + } + other => return Err(Error::Invalid(format!("descriptor type `{}`", String::from_utf8_lossy(other)))), + }) +} + +// ---------------------------------------------------------------- writing + +fn put_u32(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_be_bytes()); +} + +fn put_id(out: &mut Vec, id: &str) { + if id.len() == 4 { + put_u32(out, 0); + } else { + put_u32(out, id.len() as u32); + } + out.extend_from_slice(id.as_bytes()); +} + +pub(crate) fn put_unicode(out: &mut Vec, s: &str) { + let v: Vec = s.encode_utf16().chain(std::iter::once(0)).collect(); + put_u32(out, v.len() as u32); + for c in v { + out.extend_from_slice(&c.to_be_bytes()); + } +} + +/// Serialise a descriptor (without the version field). +pub fn write_descriptor(out: &mut Vec, d: &Descriptor) { + put_unicode(out, &d.class_name); + put_id(out, &d.class_id); + put_u32(out, d.items.len() as u32); + for (k, v) in &d.items { + put_id(out, k); + write_value(out, v); + } +} + +fn write_value(out: &mut Vec, v: &DValue) { + match v { + DValue::Descriptor(d) => { + out.extend_from_slice(b"Objc"); + write_descriptor(out, d); + } + DValue::List(l) => { + out.extend_from_slice(b"VlLs"); + put_u32(out, l.len() as u32); + for x in l { + write_value(out, x); + } + } + DValue::Double(x) => { + out.extend_from_slice(b"doub"); + out.extend_from_slice(&x.to_be_bytes()); + } + DValue::UnitFloat(u, x) => { + out.extend_from_slice(b"UntF"); + out.extend_from_slice(&tag4(u)); + out.extend_from_slice(&x.to_be_bytes()); + } + DValue::UnitFloats(u, xs) => { + out.extend_from_slice(b"UnFl"); + out.extend_from_slice(&tag4(u)); + put_u32(out, xs.len() as u32); + for x in xs { + out.extend_from_slice(&x.to_be_bytes()); + } + } + DValue::Text(s) => { + out.extend_from_slice(b"TEXT"); + put_unicode(out, s); + } + DValue::Enum(t, e) => { + out.extend_from_slice(b"enum"); + put_id(out, t); + put_id(out, e); + } + DValue::Integer(i) => { + out.extend_from_slice(b"long"); + out.extend_from_slice(&i.to_be_bytes()); + } + DValue::LargeInteger(i) => { + out.extend_from_slice(b"comp"); + out.extend_from_slice(&i.to_be_bytes()); + } + DValue::Bool(b) => { + out.extend_from_slice(b"bool"); + out.push(*b as u8); + } + DValue::Class(n, c) => { + out.extend_from_slice(b"type"); + put_unicode(out, n); + put_id(out, c); + } + DValue::Raw(b) => { + out.extend_from_slice(b"tdta"); + put_u32(out, b.len() as u32); + out.extend_from_slice(b); + } + DValue::Reference(items) => { + out.extend_from_slice(b"obj "); + put_u32(out, items.len() as u32); + for it in items { + match it { + RefItem::Property(c, k) => { + out.extend_from_slice(b"prop"); + put_unicode(out, ""); + put_id(out, c); + put_id(out, k); + } + RefItem::Class(c) => { + out.extend_from_slice(b"Clss"); + put_unicode(out, ""); + put_id(out, c); + } + RefItem::Enum(c, t, e) => { + out.extend_from_slice(b"Enmr"); + put_unicode(out, ""); + put_id(out, c); + put_id(out, t); + put_id(out, e); + } + RefItem::Offset(c, o) => { + out.extend_from_slice(b"rele"); + put_unicode(out, ""); + put_id(out, c); + out.extend_from_slice(&o.to_be_bytes()); + } + RefItem::Identifier(i) => { + out.extend_from_slice(b"Idnt"); + out.extend_from_slice(&i.to_be_bytes()); + } + RefItem::Index(i) => { + out.extend_from_slice(b"indx"); + out.extend_from_slice(&i.to_be_bytes()); + } + RefItem::Name(c, n) => { + out.extend_from_slice(b"name"); + put_unicode(out, ""); + put_id(out, c); + put_unicode(out, n); + } + } + } + } + } +} + +fn tag4(s: &str) -> [u8; 4] { + let mut t = *b" "; + for (i, b) in s.bytes().take(4).enumerate() { + t[i] = b; + } + t +} + +/// Builder helpers for descriptors (tests and the writer). +impl Descriptor { + pub fn new(class_id: &str) -> Descriptor { + Descriptor { class_name: String::new(), class_id: class_id.into(), items: vec![] } + } + pub fn with(mut self, key: &str, v: DValue) -> Descriptor { + self.items.push((key.into(), v)); + self + } + /// An `RGBC` colour object from 0..1 values. + pub fn rgb(c: [f64; 3]) -> Descriptor { + Descriptor::new("RGBC").with("Rd ", DValue::Double(c[0] * 255.0)).with("Grn ", DValue::Double(c[1] * 255.0)).with("Bl ", DValue::Double(c[2] * 255.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let d = Descriptor::new("null") + .with("Txt ", DValue::Text("Héllo".into())) + .with("Clr ", DValue::Descriptor(Descriptor::rgb([1.0, 0.5, 0.0]))) + .with("Opct", DValue::UnitFloat("#Prc".into(), 75.0)) + .with("Md ", DValue::Enum("BlnM".into(), "Mltp".into())) + .with("enab", DValue::Bool(true)) + .with("longKeyName", DValue::List(vec![DValue::Integer(3), DValue::Double(1.5)])) + .with("EngineData", DValue::Raw(b"<< >>".to_vec())) + .with("ref", DValue::Reference(vec![RefItem::Enum("Lyr ".into(), "Ordn".into(), "Trgt".into()), RefItem::Index(2)])); + let mut out = vec![]; + write_descriptor(&mut out, &d); + let back = read_descriptor(&mut Reader::new(&out)).unwrap(); + assert_eq!(back.class_id, "null"); + assert_eq!(back.text("Txt "), Some("Héllo")); + assert_eq!(back.color("Clr "), Some([1.0, 0.5, 0.0])); + assert_eq!(back.num("Opct"), Some(75.0)); + assert_eq!(back.enum_value("Md "), Some("Mltp")); + assert_eq!(back.bool("enab"), Some(true)); + assert_eq!(back.raw("EngineData"), Some(&b"<< >>"[..])); + assert_eq!(back, d); + } +} diff --git a/crates/psd/src/engine_data.rs b/crates/psd/src/engine_data.rs new file mode 100644 index 00000000..c4f23038 --- /dev/null +++ b/crates/psd/src/engine_data.rs @@ -0,0 +1,288 @@ +//! The text engine data of type layers (`EngineData` in the `TySh` descriptor): a PostScript-like +//! tree of dictionaries (`<< /Key value >>`), arrays (`[ ]`), numbers, names, booleans and +//! strings (`(þÿ` + UTF-16BE with backslash escapes `)`). Read best-effort. + +#[derive(Clone, Debug, PartialEq)] +pub enum EValue { + Dict(Vec<(String, EValue)>), + Array(Vec), + Num(f64), + Bool(bool), + Name(String), + Str(String), +} + +impl EValue { + pub fn get(&self, key: &str) -> Option<&EValue> { + match self { + EValue::Dict(v) => v.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + pub fn idx(&self, i: usize) -> Option<&EValue> { + match self { + EValue::Array(v) => v.get(i), + _ => None, + } + } + /// Follow a `/`-separated path; numeric segments index arrays. + pub fn path(&self, p: &str) -> Option<&EValue> { + let mut cur = self; + for seg in p.split('/').filter(|s| !s.is_empty()) { + cur = match seg.parse::() { + Ok(i) => cur.idx(i)?, + Err(_) => cur.get(seg)?, + }; + } + Some(cur) + } + pub fn num(&self) -> Option { + match self { + EValue::Num(n) => Some(*n), + _ => None, + } + } + pub fn str(&self) -> Option<&str> { + match self { + EValue::Str(s) | EValue::Name(s) => Some(s), + _ => None, + } + } +} + +struct P<'a> { + b: &'a [u8], + i: usize, +} + +impl P<'_> { + fn ws(&mut self) { + while self.i < self.b.len() && (self.b[self.i].is_ascii_whitespace() || self.b[self.i] == 0) { + self.i += 1; + } + } + fn peek(&self, s: &[u8]) -> bool { + self.b[self.i..].starts_with(s) + } + fn value(&mut self, depth: usize) -> Option { + if depth > 128 { + return None; + } + self.ws(); + if self.i >= self.b.len() { + return None; + } + if self.peek(b"<<") { + self.i += 2; + let mut v = vec![]; + loop { + self.ws(); + if self.i >= self.b.len() { + return Some(EValue::Dict(v)); + } + if self.peek(b">>") { + self.i += 2; + return Some(EValue::Dict(v)); + } + if self.b[self.i] != b'/' { + // Unexpected token: skip it. + self.i += 1; + continue; + } + let key = self.name(); + let val = self.value(depth + 1)?; + v.push((key, val)); + } + } + match self.b[self.i] { + b'[' => { + self.i += 1; + let mut v = vec![]; + loop { + self.ws(); + if self.i >= self.b.len() { + return Some(EValue::Array(v)); + } + if self.b[self.i] == b']' { + self.i += 1; + return Some(EValue::Array(v)); + } + let start = self.i; + match self.value(depth + 1) { + Some(x) => v.push(x), + None => return Some(EValue::Array(v)), + } + if self.i == start { + self.i += 1; + } + } + } + b'/' => Some(EValue::Name(self.name())), + b'(' => Some(EValue::Str(self.string())), + _ => { + let start = self.i; + while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !b"[]<>/(".contains(&self.b[self.i]) { + self.i += 1; + } + let tok = std::str::from_utf8(&self.b[start..self.i]).unwrap_or(""); + if self.i == start { + self.i += 1; + return Some(EValue::Name(String::new())); + } + Some(match tok { + "true" => EValue::Bool(true), + "false" => EValue::Bool(false), + t => t.parse::().map(EValue::Num).unwrap_or_else(|_| EValue::Name(t.to_string())), + }) + } + } + } + fn name(&mut self) -> String { + self.i += 1; // '/' + let start = self.i; + while self.i < self.b.len() && !self.b[self.i].is_ascii_whitespace() && !b"[]<>/(".contains(&self.b[self.i]) { + self.i += 1; + } + String::from_utf8_lossy(&self.b[start..self.i]).into_owned() + } + fn string(&mut self) -> String { + self.i += 1; // '(' + let mut raw = vec![]; + while self.i < self.b.len() { + let c = self.b[self.i]; + self.i += 1; + match c { + b'\\' if self.i < self.b.len() => { + let e = self.b[self.i]; + self.i += 1; + raw.push(match e { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + other => other, + }); + } + b')' => break, + _ => raw.push(c), + } + } + if raw.starts_with(&[0xFE, 0xFF]) { + let u: Vec = raw[2..].chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + String::from_utf16_lossy(&u).trim_end_matches('\0').to_string() + } else { + String::from_utf8_lossy(&raw).into_owned() + } + } +} + +/// Parse engine data (returns an empty dictionary on garbage). +pub fn parse(b: &[u8]) -> EValue { + let mut p = P { b, i: 0 }; + p.value(0).unwrap_or(EValue::Dict(vec![])) +} + +/// Encode a string the way engine data stores it (UTF-16BE with a BOM, escaped). +pub fn encode_string(s: &str) -> Vec { + let mut out = vec![b'(', 0xFE, 0xFF]; + for u in s.encode_utf16() { + for b in u.to_be_bytes() { + if matches!(b, b'(' | b')' | b'\\') { + out.push(b'\\'); + } + out.push(b); + } + } + out.push(b')'); + out +} + +/// The first style run's character attributes. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextStyle { + pub font: Option, + pub size: Option, + /// RGB 0..1. + pub color: Option<[f64; 3]>, + /// 0 left, 1 right, 2 centre, 3+ justified variants. + pub justification: Option, + pub tracking: Option, + pub leading: Option, + pub faux_bold: bool, + pub faux_italic: bool, + /// Paragraph (box) text rather than point text. + pub box_text: bool, +} + +pub fn text_style(ed: &EValue) -> TextStyle { + let engine = ed.get("EngineDict").unwrap_or(ed); + let resources = ed.get("ResourceDict").or_else(|| ed.get("DocumentResources")); + let run = engine.path("StyleRun/RunArray/0/StyleSheet/StyleSheetData"); + let normal = resources.and_then(|r| r.path("StyleSheetSet/0/StyleSheetData")); + let attr = |k: &str| run.and_then(|r| r.get(k)).or_else(|| normal.and_then(|n| n.get(k))); + let font = + attr("Font").and_then(EValue::num).and_then(|i| resources?.path(&format!("FontSet/{}/Name", i as usize))).and_then(EValue::str).map(str::to_string); + let color = attr("FillColor").and_then(|c| c.get("Values")).and_then(|v| match v { + EValue::Array(a) if a.len() >= 4 => Some([a[1].num()?, a[2].num()?, a[3].num()?]), + _ => None, + }); + let para = engine.path("ParagraphRun/RunArray/0/ParagraphSheet/Properties"); + let para_normal = resources.and_then(|r| r.path("ParagraphSheetSet/0/Properties")); + let justification = + para.and_then(|p| p.get("Justification")).or_else(|| para_normal.and_then(|p| p.get("Justification"))).and_then(EValue::num).map(|n| n as u8); + let auto_leading = attr("AutoLeading").is_none_or(|v| *v != EValue::Bool(false)); + let box_text = engine.path("Rendered/Shapes/Children/0/ShapeType").and_then(EValue::num) == Some(1.0); + TextStyle { + font, + size: attr("FontSize").and_then(EValue::num), + color, + justification, + tracking: attr("Tracking").and_then(EValue::num), + leading: if auto_leading { None } else { attr("Leading").and_then(EValue::num) }, + faux_bold: attr("FauxBold") == Some(&EValue::Bool(true)), + faux_italic: attr("FauxItalic") == Some(&EValue::Bool(true)), + box_text, + } +} + +/// Minimal engine data for a type layer with one style run (fixtures, and what a writer needs). +pub fn minimal(text: &str, font: &str, size: f64, color: [f64; 3], justification: u8) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"\n\n<<\n\t/EngineDict\n\t<<\n\t\t/Editor << /Text "); + out.extend_from_slice(&encode_string(&format!("{text}\r"))); + out.extend_from_slice(b" >>\n\t\t/ParagraphRun << /RunArray [ << /ParagraphSheet << /Properties << /Justification "); + out.extend_from_slice(justification.to_string().as_bytes()); + out.extend_from_slice(b" >> >> >> ] >>\n\t\t/StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /Font 0 /FontSize "); + out.extend_from_slice(format!("{size:.1}").as_bytes()); + out.extend_from_slice( + format!(" /FillColor << /Type 1 /Values [ 1.0 {:.5} {:.5} {:.5} ] >> /AutoLeading true >> >> >> ] >>\n\t>>\n", color[0], color[1], color[2]).as_bytes(), + ); + out.extend_from_slice(b"\t/ResourceDict << /FontSet [ << /Name "); + out.extend_from_slice(&encode_string(font)); + out.extend_from_slice(b" /Type 0 >> ] >>\n>>"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal() { + let b = minimal("Hi (there)", "Arial-BoldMT", 36.0, [1.0, 0.5, 0.0], 2); + let v = parse(&b); + assert_eq!(v.path("EngineDict/Editor/Text").and_then(EValue::str), Some("Hi (there)\r")); + let st = text_style(&v); + assert_eq!(st.font.as_deref(), Some("Arial-BoldMT")); + assert_eq!(st.size, Some(36.0)); + assert_eq!(st.color, Some([1.0, 0.5, 0.0])); + assert_eq!(st.justification, Some(2)); + assert!(!st.box_text); + } + + #[test] + fn garbage_is_harmless() { + let _ = parse(b"<< /A [ 1 2 << /B (unterminated"); + let _ = parse(b"]]]>>>> /x"); + let _ = parse(&[0xFF; 64]); + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs new file mode 100644 index 00000000..885ab3c2 --- /dev/null +++ b/crates/psd/src/lib.rs @@ -0,0 +1,965 @@ +//! Photoshop document (PSD / PSB) reader, implemented from Adobe's public *Photoshop File Formats +//! Specification* (see README.md), and a minimal writer ([`write`]) for fixtures. +//! +//! [`Psd::parse`] reads the structure (header, layer records and their additional information, +//! the layer tree) without decoding pixels; [`Psd::composite`] decodes the merged image and +//! [`Psd::layer_pixels`] one layer (with its layer mask applied to alpha) on demand. Pixels come +//! out as straight (unpremultiplied) RGBA `f32` in 0..1, display-encoded (32-bit documents, +//! which are linear, are encoded to sRGB). + +pub mod descriptor; +pub mod engine_data; +mod read; +pub mod write; + +use std::sync::Arc; + +pub use descriptor::{DValue, Descriptor}; +use read::Reader; + +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + NotPsd, + Truncated, + Unsupported(String), + Invalid(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotPsd => write!(f, "not a Photoshop document"), + Error::Truncated => write!(f, "truncated Photoshop document"), + Error::Unsupported(s) => write!(f, "unsupported Photoshop feature: {s}"), + Error::Invalid(s) => write!(f, "invalid Photoshop document: {s}"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +/// Whether `bytes` start like a Photoshop document. +pub fn is_psd(bytes: &[u8]) -> bool { + bytes.len() >= 6 && &bytes[..4] == b"8BPS" && matches!(u16::from_be_bytes([bytes[4], bytes[5]]), 1 | 2) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColorMode { + Bitmap, + Grayscale, + Indexed, + Rgb, + Cmyk, + Multichannel, + Duotone, + Lab, +} + +impl ColorMode { + pub fn from_u16(v: u16) -> Option { + Some(match v { + 0 => ColorMode::Bitmap, + 1 => ColorMode::Grayscale, + 2 => ColorMode::Indexed, + 3 => ColorMode::Rgb, + 4 => ColorMode::Cmyk, + 7 => ColorMode::Multichannel, + 8 => ColorMode::Duotone, + 9 => ColorMode::Lab, + _ => return None, + }) + } + pub fn to_u16(self) -> u16 { + match self { + ColorMode::Bitmap => 0, + ColorMode::Grayscale => 1, + ColorMode::Indexed => 2, + ColorMode::Rgb => 3, + ColorMode::Cmyk => 4, + ColorMode::Multichannel => 7, + ColorMode::Duotone => 8, + ColorMode::Lab => 9, + } + } + /// Number of colour channels (before alpha). + pub fn color_channels(self) -> usize { + match self { + ColorMode::Rgb | ColorMode::Lab => 3, + ColorMode::Cmyk => 4, + _ => 1, + } + } +} + +/// A rectangle in document pixels (`right`/`bottom` exclusive). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Rect { + pub top: i32, + pub left: i32, + pub bottom: i32, + pub right: i32, +} + +impl Rect { + pub fn new(left: i32, top: i32, width: i32, height: i32) -> Rect { + Rect { top, left, bottom: top + height, right: left + width } + } + pub fn width(&self) -> u32 { + (self.right - self.left).max(0) as u32 + } + pub fn height(&self) -> u32 { + (self.bottom - self.top).max(0) as u32 + } + pub fn is_empty(&self) -> bool { + self.width() == 0 || self.height() == 0 + } +} + +/// One channel of a layer: id (0.. colour, -1 transparency, -2 layer mask, -3 real user mask) and +/// where its data (compression + image data) sits in the file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ChannelInfo { + pub id: i16, + pub offset: usize, + pub len: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LayerMask { + pub rect: Rect, + /// Value outside the mask rectangle (0 or 255). + pub default_color: u8, + pub disabled: bool, + pub inverted: bool, +} + +/// Layer kind from the section divider setting (`lsct`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Section { + #[default] + Layer, + OpenFolder, + ClosedFolder, + /// The hidden "" record that closes a group (below its children). + Divider, +} + +/// A type layer (`TySh`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextInfo { + /// Text with `\n` line breaks. + pub text: String, + /// Affine transform (xx, xy, yx, yy, tx, ty): the text origin sits at (tx, ty). + pub transform: [f64; 6], + pub style: engine_data::TextStyle, + /// Text bounds (left, top, right, bottom) relative to the origin, when stored. + pub bounds: Option<[f64; 4]>, +} + +/// Adjustment layers (best effort). +#[derive(Clone, Debug, PartialEq)] +pub enum Adjustment { + Invert, + BrightnessContrast { + brightness: f64, + contrast: f64, + legacy: bool, + }, + HueSaturation { + hue: f64, + saturation: f64, + lightness: f64, + colorize: bool, + }, + Levels { + in_black: f64, + in_white: f64, + gamma: f64, + out_black: f64, + out_white: f64, + }, + Exposure { + exposure: f64, + offset: f64, + gamma: f64, + }, + /// Another adjustment (its tagged-block key, e.g. `curv`, `blnc`). + Other(String), +} + +/// A vector mask path: sub-paths of (in tangent, vertex, out tangent) points in document pixels. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct VectorMask { + pub inverted: bool, + pub disabled: bool, + pub subpaths: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SubPath { + pub closed: bool, + /// Per knot: [control point preceding, anchor, control point leaving] as (x, y). + pub knots: Vec<[[f64; 2]; 3]>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layer { + /// Index in [`Psd::layers`] (file order: bottom of the stack first). + pub index: usize, + pub name: String, + pub id: Option, + pub rect: Rect, + /// Blend mode key (`norm`, `mul `, `pass`…). + pub blend: [u8; 4], + pub opacity: u8, + /// Fill opacity (`iOpa`), 255 when absent. + pub fill_opacity: u8, + /// Clipped to the layer below. + pub clipping: bool, + pub hidden: bool, + pub protect_transparency: bool, + pub section: Section, + pub channels: Vec, + pub mask: Option, + pub text: Option, + pub adjustment: Option, + /// Solid colour fill layer (`SoCo`), RGB 0..1. + pub fill: Option<[f64; 3]>, + /// Layer effects (`lfx2` descriptor). + pub effects: Option, + pub vector_mask: Option, + /// Keys of every additional-information block the layer carries. + pub keys: Vec<[u8; 4]>, +} + +impl Layer { + pub fn is_group(&self) -> bool { + matches!(self.section, Section::OpenFolder | Section::ClosedFolder) + } + pub fn blend_key(&self) -> &str { + std::str::from_utf8(&self.blend).unwrap_or("norm") + } + pub fn has_pixels(&self) -> bool { + !self.rect.is_empty() && self.channels.iter().any(|c| c.id >= 0) + } +} + +/// The layer tree, top of the stack first. +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + Layer(usize), + Group { layer: usize, children: Vec }, +} + +/// Decoded pixels: straight RGBA, row-major. +#[derive(Clone, Debug, PartialEq)] +pub struct Pixels { + pub width: u32, + pub height: u32, + pub data: Vec<[f32; 4]>, +} + +/// A parsed Photoshop document. +#[derive(Clone, Debug)] +pub struct Psd { + /// PSB (large document format). + pub psb: bool, + pub width: u32, + pub height: u32, + pub channels: u16, + pub depth: u16, + pub mode: ColorMode, + /// Bottom of the stack first (file order). + pub layers: Vec, + /// The merged image's first extra channel is transparency. + pub merged_alpha: bool, + /// Indexed colour palette (256 R, then G, then B). + pub palette: Vec, + data: Arc<[u8]>, + merged_offset: usize, +} + +impl Psd { + pub fn parse(data: impl Into>) -> Result { + let data: Arc<[u8]> = data.into(); + let mut r = Reader::new(&data); + if r.bytes(4).ok() != Some(b"8BPS") { + return Err(Error::NotPsd); + } + let version = r.u16()?; + let psb = match version { + 1 => false, + 2 => true, + _ => return Err(Error::NotPsd), + }; + r.skip(6)?; + let channels = r.u16()?; + let height = r.u32()?; + let width = r.u32()?; + let depth = r.u16()?; + let mode_v = r.u16()?; + let mode = ColorMode::from_u16(mode_v).ok_or_else(|| Error::Unsupported(format!("colour mode {mode_v}")))?; + if !matches!(depth, 1 | 8 | 16 | 32) { + return Err(Error::Unsupported(format!("{depth}-bit"))); + } + if width == 0 || height == 0 || width > 300_000 || height > 300_000 { + return Err(Error::Invalid(format!("size {width}x{height}"))); + } + // Colour mode data. + let n = r.u32()? as usize; + let palette = r.bytes(n)?.to_vec(); + // Image resources. + let n = r.u32()? as usize; + r.skip(n)?; + // Layer and mask information. + let mut psd = Psd { psb, width, height, channels, depth, mode, layers: vec![], merged_alpha: false, palette, data: data.clone(), merged_offset: 0 }; + let lm_len = r.length(psb)?; + let lm_end = r.pos.checked_add(lm_len).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?; + if lm_len > 0 { + let li_len = r.length(psb)?; + let li_end = r.pos.checked_add(li_len).filter(|&e| e <= lm_end).ok_or(Error::Truncated)?; + if li_len > 0 { + psd.layer_info(&data, r.pos, li_end)?; + } + r.pos = li_end; + if r.pos + 4 <= lm_end { + let g = r.u32()? as usize; + r.pos = (r.pos + g).min(lm_end); + } + // Global additional layer information (16/32-bit layer info lives here). + while let Some((key, start, end)) = next_block(&data, &mut r.pos, lm_end, psb) { + if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") && psd.layers.is_empty() && end > start { + // The block holds the layer info, normally without its length field; accept + // both layouts. + if psd.layer_info(&data, start, end).is_err() || psd.layers.is_empty() { + psd.layers.clear(); + let skip = if psb { 8 } else { 4 }; + if start + skip < end && psd.layer_info(&data, start + skip, end).is_err() { + psd.layers.clear(); + } + } + } + r.pos = end; + } + } + psd.merged_offset = lm_end; + Ok(psd) + } + + fn layer_info(&mut self, data: &[u8], start: usize, end: usize) -> Result<()> { + let mut r = Reader::at(&data[..end], start); + let count = r.i16()?; + self.merged_alpha = count < 0; + let n = count.unsigned_abs() as usize; + let mut layers = Vec::with_capacity(n); + for i in 0..n { + layers.push(self.layer_record(&mut r, i)?); + } + // Channel image data follows in record order. + for l in &mut layers { + for c in &mut l.channels { + c.offset = r.pos; + r.skip(c.len)?; + } + } + self.layers = layers; + Ok(()) + } + + fn layer_record(&self, r: &mut Reader, index: usize) -> Result { + let psb = self.psb; + let (top, left, bottom, right) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); + let nch = r.u16()? as usize; + if nch > 64 { + return Err(Error::Invalid("channel count".into())); + } + let mut channels = Vec::with_capacity(nch); + for _ in 0..nch { + let id = r.i16()?; + let len = r.length(psb)?; + channels.push(ChannelInfo { id, offset: 0, len }); + } + if r.bytes(4)? != b"8BIM" { + return Err(Error::Invalid("layer record signature".into())); + } + let blend = r.tag()?; + let opacity = r.u8()?; + let clipping = r.u8()? != 0; + let flags = r.u8()?; + r.skip(1)?; + let extra = r.u32()? as usize; + let extra_end = r.pos.checked_add(extra).filter(|&e| e <= r.data.len()).ok_or(Error::Truncated)?; + let mut l = Layer { + index, + rect: Rect { top, left, bottom, right }, + blend, + opacity, + fill_opacity: 255, + clipping, + hidden: flags & 2 != 0, + protect_transparency: flags & 1 != 0, + channels, + ..Default::default() + }; + // Layer mask data. + let mlen = r.u32()? as usize; + let mstart = r.pos; + if mlen >= 18 { + let rect = Rect { top: r.i32()?, left: r.i32()?, bottom: r.i32()?, right: r.i32()? }; + let default_color = r.u8()?; + let mflags = r.u8()?; + l.mask = Some(LayerMask { rect, default_color, disabled: mflags & 2 != 0, inverted: mflags & 4 != 0 }); + } + r.pos = mstart + mlen; + // Blending ranges. + let blen = r.u32()? as usize; + r.skip(blen)?; + l.name = r.pascal(4)?; + let mut pos = r.pos; + let data = r.data; + while let Some((key, start, end)) = next_block(data, &mut pos, extra_end, psb) { + l.keys.push(key); + let block = &data[start..end]; + // A malformed block only loses that block's information. + let _ = self.layer_block(&mut l, &key, block); + pos = end; + } + r.pos = extra_end; + Ok(l) + } + + fn layer_block(&self, l: &mut Layer, key: &[u8; 4], b: &[u8]) -> Result<()> { + let mut r = Reader::new(b); + match key { + b"luni" => { + let name = descriptor::read_unicode(&mut r)?; + if !name.is_empty() { + l.name = name; + } + } + b"lyid" => l.id = Some(r.u32()?), + b"iOpa" => l.fill_opacity = r.u8()?, + b"lsct" | b"lsdk" => { + l.section = match r.u32()? { + 1 => Section::OpenFolder, + 2 => Section::ClosedFolder, + 3 => Section::Divider, + _ => Section::Layer, + }; + if b.len() >= 12 && &b[4..8] == b"8BIM" { + l.blend.copy_from_slice(&b[8..12]); + } + } + b"TySh" => { + let _ver = r.u16()?; + let mut t = [0.0; 6]; + for v in &mut t { + *v = r.f64()?; + } + let _text_ver = r.u16()?; + let _desc_ver = r.u32()?; + let d = descriptor::read_descriptor(&mut r)?; + let text = d.text("Txt ").unwrap_or_default().replace("\r\n", "\n").replace('\r', "\n"); + let text = text.strip_suffix('\n').map(str::to_string).unwrap_or(text); + let style = d.raw("EngineData").map(|e| engine_data::text_style(&engine_data::parse(e))).unwrap_or_default(); + let bounds = + d.obj("bounds").or_else(|| d.obj("boundingBox")).and_then(|o| Some([o.num("Left")?, o.num("Top ")?, o.num("Rght")?, o.num("Btom")?])); + l.text = Some(TextInfo { text, transform: t, style, bounds }); + } + b"lfx2" => { + let _ver = r.u32()?; + let _dver = r.u32()?; + l.effects = Some(descriptor::read_descriptor(&mut r)?); + } + b"SoCo" => { + let _ver = r.u32()?; + let d = descriptor::read_descriptor(&mut r)?; + l.fill = d.color("Clr "); + } + b"nvrt" => l.adjustment = Some(Adjustment::Invert), + b"brit" => { + let brightness = r.i16()? as f64; + let contrast = r.i16()? as f64; + if l.adjustment.is_none() { + l.adjustment = Some(Adjustment::BrightnessContrast { brightness, contrast, legacy: true }); + } + } + b"CgEd" => { + let _ver = r.u32()?; + let d = descriptor::read_descriptor(&mut r)?; + l.adjustment = Some(Adjustment::BrightnessContrast { + brightness: d.num("Brgh").unwrap_or(0.0), + contrast: d.num("Cntr").unwrap_or(0.0), + legacy: d.bool("useLegacy").unwrap_or(false), + }); + } + b"hue2" | b"hue " => { + let _ver = r.u16()?; + let colorize = r.u8()? != 0; + r.skip(1)?; + let ch = (r.i16()? as f64, r.i16()? as f64, r.i16()? as f64); + let m = (r.i16()? as f64, r.i16()? as f64, r.i16()? as f64); + let (hue, saturation, lightness) = if colorize { ch } else { m }; + l.adjustment = Some(Adjustment::HueSaturation { hue, saturation, lightness, colorize }); + } + b"levl" => { + let _ver = r.u16()?; + let (ib, iw, ob, ow, g) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?, r.i16()?); + l.adjustment = + Some(Adjustment::Levels { in_black: ib as f64, in_white: iw as f64, gamma: g as f64 / 100.0, out_black: ob as f64, out_white: ow as f64 }); + } + b"expA" => { + let _ver = r.u16()?; + let exposure = r.f32()? as f64; + let offset = r.f32()? as f64; + let gamma = r.f32()? as f64; + l.adjustment = Some(Adjustment::Exposure { exposure, offset, gamma }); + } + b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" => { + if l.adjustment.is_none() { + l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned())); + } + } + b"vmsk" | b"vsms" => { + let _ver = r.u32()?; + let flags = r.u32()?; + l.vector_mask = Some(self.vector_paths(&mut r, flags)?); + } + _ => {} + } + Ok(()) + } + + fn vector_paths(&self, r: &mut Reader, flags: u32) -> Result { + let (w, h) = (self.width as f64, self.height as f64); + let mut vm = VectorMask { inverted: flags & 1 != 0, disabled: flags & 4 != 0, subpaths: vec![] }; + let fixed = |r: &mut Reader| -> Result { Ok(r.i32()? as f64 / (1 << 24) as f64) }; + while r.remaining() >= 26 { + let sel = r.u16()?; + match sel { + 0 | 3 => { + let _n = r.u16()?; + r.skip(22)?; + vm.subpaths.push(SubPath { closed: sel == 0, knots: vec![] }); + } + 1 | 2 | 4 | 5 => { + let mut pts = [[0.0; 2]; 3]; + for p in &mut pts { + let y = fixed(r)?; + let x = fixed(r)?; + *p = [x * w, y * h]; + } + if let Some(sp) = vm.subpaths.last_mut() { + sp.knots.push(pts); + } + } + _ => r.skip(24)?, + } + } + vm.subpaths.retain(|s| !s.knots.is_empty()); + Ok(vm) + } + + /// The layer tree, top of the stack first. + pub fn tree(&self) -> Vec { + let mut stack: Vec> = vec![vec![]]; + for l in &self.layers { + match l.section { + Section::Divider => stack.push(vec![]), + Section::OpenFolder | Section::ClosedFolder => { + let children = if stack.len() > 1 { stack.pop().unwrap_or_default() } else { vec![] }; + if let Some(top) = stack.last_mut() { + top.push(Node::Group { layer: l.index, children }); + } + } + Section::Layer => { + if let Some(top) = stack.last_mut() { + top.push(Node::Layer(l.index)); + } + } + } + } + while stack.len() > 1 { + let c = stack.pop().unwrap_or_default(); + if let Some(top) = stack.last_mut() { + top.extend(c); + } + } + fn rev(v: &mut Vec) { + v.reverse(); + for n in v { + if let Node::Group { children, .. } = n { + rev(children); + } + } + } + let mut out = stack.pop().unwrap_or_default(); + rev(&mut out); + out + } + + fn bytes_per_sample(&self) -> usize { + match self.depth { + 16 => 2, + 32 => 4, + _ => 1, + } + } + + /// Decode one channel of `w`×`h` samples at `offset` (compression field first). Values 0..1. + fn decode_channel(&self, offset: usize, len: usize, w: usize, h: usize) -> Result> { + let end = offset.checked_add(len).filter(|&e| e <= self.data.len()).ok_or(Error::Truncated)?; + let mut r = Reader::at(&self.data[..end], offset); + let comp = r.u16()?; + let rowb = self.row_bytes(w); + let raw = match comp { + 0 => r.bytes(rowb * h)?.to_vec(), + 1 => { + let mut counts = Vec::with_capacity(h); + for _ in 0..h { + counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + let mut out = Vec::with_capacity(rowb * h); + for c in counts { + unpack_bits(r.bytes(c)?, rowb, &mut out)?; + } + out + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib(rest).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + v.resize(rowb * h, 0); + if comp == 3 { + self.unpredict(&mut v, w, h); + } + v + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + }; + Ok(self.samples(&raw, w, h)) + } + + fn row_bytes(&self, w: usize) -> usize { + if self.depth == 1 { w.div_ceil(8) } else { w * self.bytes_per_sample() } + } + + fn unpredict(&self, v: &mut [u8], w: usize, h: usize) { + match self.depth { + 16 => { + for row in v.chunks_exact_mut(w * 2).take(h) { + let mut prev = 0u16; + for c in row.chunks_exact_mut(2) { + let x = u16::from_be_bytes([c[0], c[1]]).wrapping_add(prev); + c.copy_from_slice(&x.to_be_bytes()); + prev = x; + } + } + } + 32 => { + for row in v.chunks_exact_mut(w * 4).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + // Bytes are stored plane by plane (all first bytes, then all second bytes…). + let planar = row.to_vec(); + for x in 0..w { + for b in 0..4 { + row[x * 4 + b] = planar[b * w + x]; + } + } + } + } + _ => { + for row in v.chunks_exact_mut(w).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + } + } + } + } + + fn samples(&self, raw: &[u8], w: usize, h: usize) -> Vec { + match self.depth { + 1 => { + let rowb = w.div_ceil(8); + let mut out = Vec::with_capacity(w * h); + for y in 0..h { + for x in 0..w { + let b = raw.get(y * rowb + x / 8).copied().unwrap_or(0); + // 1 = black. + out.push(if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 }); + } + } + out + } + 16 => raw.chunks_exact(2).take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), + 32 => raw.chunks_exact(4).take(w * h).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(), + _ => raw.iter().take(w * h).map(|&b| b as f32 / 255.0).collect(), + } + } + + /// Convert colour channels (in the document's mode) to RGB. + fn to_rgb(&self, ch: &[Option>], i: usize) -> [f32; 3] { + let g = |k: usize| ch.get(k).and_then(|c| c.as_ref()).map_or(0.0, |c| c[i]); + let rgb = match self.mode { + ColorMode::Rgb => [g(0), g(1), g(2)], + ColorMode::Cmyk => { + // Stored inverted (1 = no ink). + let k = g(3); + [g(0) * k, g(1) * k, g(2) * k] + } + ColorMode::Indexed if self.palette.len() >= 768 => { + let idx = (g(0) * 255.0).round() as usize & 255; + [self.palette[idx] as f32 / 255.0, self.palette[256 + idx] as f32 / 255.0, self.palette[512 + idx] as f32 / 255.0] + } + ColorMode::Lab => lab_to_rgb(g(0) * 100.0, g(1) * 255.0 - 128.0, g(2) * 255.0 - 128.0), + _ => [g(0); 3], + }; + if self.depth == 32 && self.mode != ColorMode::Lab { rgb.map(srgb_encode) } else { rgb } + } + + /// The merged (flattened) image. + pub fn composite(&self) -> Result { + let (w, h) = (self.width as usize, self.height as usize); + let mut r = Reader::at(&self.data, self.merged_offset); + let comp = r.u16()?; + let nch = self.channels as usize; + let cc = self.mode.color_channels(); + let want = (cc + usize::from(self.merged_alpha)).min(nch); + let rowb = self.row_bytes(w); + let mut planes: Vec>> = vec![None; nch]; + match comp { + 0 => { + for p in planes.iter_mut().take(want) { + *p = Some(self.samples(r.bytes(rowb * h)?, w, h)); + } + } + 1 => { + let mut counts = Vec::with_capacity(nch * h); + for _ in 0..nch * h { + counts.push(if self.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + for (c, p) in planes.iter_mut().enumerate().take(want) { + let mut out = Vec::with_capacity(rowb * h); + for row in 0..h { + unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; + } + *p = Some(self.samples(&out, w, h)); + } + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib(rest).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + for (c, p) in planes.iter_mut().enumerate().take(want) { + let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); + if comp == 3 { + self.unpredict(&mut plane, w, h); + } + *p = Some(self.samples(&plane, w, h)); + } + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + } + let alpha = if self.merged_alpha && nch > cc { planes[cc].take() } else { None }; + let mut data = Vec::with_capacity(w * h); + for i in 0..w * h { + let [r, g, b] = self.to_rgb(&planes, i); + let a = alpha.as_ref().map_or(1.0, |a| a[i]); + data.push([r, g, b, a]); + } + Ok(Pixels { width: w as u32, height: h as u32, data }) + } + + /// One layer's pixels with its layer mask (and vector-less fill colour) applied to alpha. + /// `canvas`: document-sized with the layer in place; otherwise the layer's own bounds. + pub fn layer_pixels(&self, index: usize, canvas: bool) -> Result { + let l = self.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; + let doc = Rect { top: 0, left: 0, bottom: self.height as i32, right: self.width as i32 }; + // Fill layers often have no pixel data: they cover the canvas (or the mask). + let fill_rect = + if l.fill.is_some() && !l.has_pixels() { l.mask.filter(|m| !m.rect.is_empty() && m.default_color == 0).map_or(doc, |m| m.rect) } else { l.rect }; + let out_rect = if canvas { + doc + } else if fill_rect.is_empty() { + Rect { top: 0, left: 0, bottom: 1, right: 1 } + } else { + fill_rect + }; + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); + let mut out = vec![[0.0f32; 4]; ow * oh]; + let src = fill_rect; + let (sw, sh) = (src.width() as usize, src.height() as usize); + if sw > 0 && sh > 0 { + let mut planes: Vec>> = vec![None; self.mode.color_channels()]; + let mut alpha = None; + if l.has_pixels() { + for c in &l.channels { + match c.id { + -1 => alpha = Some(self.decode_channel(c.offset, c.len, sw, sh)?), + id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(self.decode_channel(c.offset, c.len, sw, sh)?), + _ => {} + } + } + } + let fill = l.fill.map(|c| c.map(|v| v as f32)); + for y in 0..sh { + let dy = src.top + y as i32 - out_rect.top; + if dy < 0 || dy >= oh as i32 { + continue; + } + for x in 0..sw { + let dx = src.left + x as i32 - out_rect.left; + if dx < 0 || dx >= ow as i32 { + continue; + } + let i = y * sw + x; + let (rgb, a) = match fill { + Some(f) if !l.has_pixels() => (f, 1.0), + _ => (self.to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), + }; + out[dy as usize * ow + dx as usize] = [rgb[0], rgb[1], rgb[2], a]; + } + } + } + // Layer mask: multiplies alpha (also outside the mask rect, by its default colour). + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let mc = l.channels.iter().find(|c| c.id == -2); + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + let mask = match mc { + Some(c) if mw > 0 && mh > 0 => Some(self.decode_channel(c.offset, c.len, mw, mh)?), + _ => None, + }; + let def = m.default_color as f32 / 255.0; + for y in 0..oh { + let py = out_rect.top + y as i32; + for x in 0..ow { + let px = out_rect.left + x as i32; + let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; + let mut v = match (&mask, inside) { + (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], + _ => def, + }; + if m.inverted { + v = 1.0 - v; + } + out[y * ow + x][3] *= v; + } + } + } + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) + } +} + +/// The next additional-information block at `*pos` (signature `8BIM`/`8B64`, key, length): +/// returns (key, data start, data end). Skips up to 3 padding bytes before a signature. +fn next_block(data: &[u8], pos: &mut usize, end: usize, psb: bool) -> Option<([u8; 4], usize, usize)> { + let mut p = *pos; + for _ in 0..4 { + if p + 12 > end { + return None; + } + if &data[p..p + 4] == b"8BIM" || &data[p..p + 4] == b"8B64" { + break; + } + p += 1; + } + if p + 12 > end || !(&data[p..p + 4] == b"8BIM" || &data[p..p + 4] == b"8B64") { + return None; + } + let mut key = [0u8; 4]; + key.copy_from_slice(&data[p + 4..p + 8]); + let wide = + psb && matches!(&key, b"LMsk" | b"Lr16" | b"Lr32" | b"Layr" | b"Mt16" | b"Mt32" | b"Mtrn" | b"Alph" | b"FMsk" | b"lnk2" | b"FEid" | b"FXid" | b"PxSD"); + let mut r = Reader::at(&data[..end], p + 8); + let len = r.length(wide).ok()?; + let start = r.pos; + let stop = start.checked_add(len)?; + if stop > end { + return None; + } + *pos = stop; + Some((key, start, stop)) +} + +/// PackBits decoding of one row (appends exactly `row` bytes, zero-padded). +fn unpack_bits(src: &[u8], row: usize, out: &mut Vec) -> Result<()> { + let start = out.len(); + let mut i = 0; + while i < src.len() && out.len() - start < row { + let n = src[i] as i8; + i += 1; + if n >= 0 { + let k = n as usize + 1; + let s = src.get(i..i + k).ok_or(Error::Truncated)?; + out.extend_from_slice(s); + i += k; + } else if n != -128 { + let k = (1 - n as isize) as usize; + let v = *src.get(i).ok_or(Error::Truncated)?; + out.extend(std::iter::repeat_n(v, k)); + i += 1; + } + } + out.resize(start + row, 0); + Ok(()) +} + +/// PackBits encoding of one row. +pub(crate) fn pack_bits(src: &[u8]) -> Vec { + let mut out = vec![]; + let mut i = 0; + while i < src.len() { + // run of identical bytes + let mut run = 1; + while i + run < src.len() && src[i + run] == src[i] && run < 128 { + run += 1; + } + if run >= 2 { + out.push((1 - run as isize) as i8 as u8); + out.push(src[i]); + i += run; + continue; + } + // literal run + let start = i; + let mut n = 0; + while i < src.len() && n < 128 { + if i + 1 < src.len() && src[i + 1] == src[i] { + break; + } + i += 1; + n += 1; + } + if n == 0 { + i += 1; + n = 1; + } + out.push((n - 1) as u8); + out.extend_from_slice(&src[start..start + n]); + } + out +} + +fn srgb_encode(v: f32) -> f32 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// CIE L*a*b* (D50) to sRGB (D65 via the Bradford-adapted matrix), clamped. +fn lab_to_rgb(l: f32, a: f32, b: f32) -> [f32; 3] { + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + let f = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + let (x, y, z) = (0.964_22 * f(fx), f(fy), 0.825_21 * f(fz)); + // XYZ (D50) → linear sRGB. + let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z; + let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z; + let bb = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z; + [r, g, bb].map(|v| srgb_encode(v.clamp(0.0, 1.0))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/psd/src/read.rs b/crates/psd/src/read.rs new file mode 100644 index 00000000..97651b2b --- /dev/null +++ b/crates/psd/src/read.rs @@ -0,0 +1,86 @@ +//! Big-endian byte reader. + +use crate::{Error, Result}; + +#[derive(Clone)] +pub struct Reader<'a> { + pub data: &'a [u8], + pub pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(data: &'a [u8]) -> Self { + Reader { data, pos: 0 } + } + pub fn at(data: &'a [u8], pos: usize) -> Self { + Reader { data, pos } + } + pub fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { + if self.remaining() < n { + return Err(Error::Truncated); + } + let s = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(s) + } + pub fn skip(&mut self, n: usize) -> Result<()> { + self.bytes(n).map(|_| ()) + } + pub fn u8(&mut self) -> Result { + Ok(self.bytes(1)?[0]) + } + pub fn u16(&mut self) -> Result { + let b = self.bytes(2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) + } + pub fn i16(&mut self) -> Result { + Ok(self.u16()? as i16) + } + pub fn u32(&mut self) -> Result { + let b = self.bytes(4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + pub fn i32(&mut self) -> Result { + Ok(self.u32()? as i32) + } + pub fn u64(&mut self) -> Result { + let b = self.bytes(8)?; + let mut a = [0u8; 8]; + a.copy_from_slice(b); + Ok(u64::from_be_bytes(a)) + } + pub fn f32(&mut self) -> Result { + Ok(f32::from_bits(self.u32()?)) + } + pub fn f64(&mut self) -> Result { + Ok(f64::from_bits(self.u64()?)) + } + pub fn tag(&mut self) -> Result<[u8; 4]> { + let b = self.bytes(4)?; + Ok([b[0], b[1], b[2], b[3]]) + } + /// A length field: 4 bytes in PSD, 8 bytes in PSB (`wide`). + pub fn length(&mut self, wide: bool) -> Result { + let v = if wide { self.u64()? } else { self.u32()? as u64 }; + usize::try_from(v).map_err(|_| Error::Invalid("length".into())) + } + /// A Pascal string padded so that length byte + text is a multiple of `pad`. + pub fn pascal(&mut self, pad: usize) -> Result { + let n = self.u8()? as usize; + let s = self.bytes(n)?; + let total = 1 + n; + let padded = total.div_ceil(pad) * pad; + self.skip(padded - total)?; + // Pascal strings are in the system (Mac Roman) encoding; ASCII covers the common case. + Ok(s.iter().map(|&b| if b < 0x80 { b as char } else { mac_roman(b) }).collect()) + } +} + +/// Mac OS Roman upper half (0x80..=0xFF), as published by the Unicode Consortium mapping table. +fn mac_roman(b: u8) -> char { + const T: &str = "ÄÅÇÉÑÖÜáàâäãåçéèêëíìîïñóòôöõúùûü†°¢£§•¶ß®©™´¨≠ÆØ∞±≤≥¥µ∂∑∏π∫ªºΩæø¿¡¬√ƒ≈∆«»… ÀÃÕŒœ–—“”‘’÷◊ÿŸ⁄€‹›fifl‡·‚„‰ÂÊÁËÈÍÎÏÌÓÔ\u{F8FF}ÒÚÛÙıˆ˜¯˘˙˚¸˝˛ˇ"; + T.chars().nth((b - 0x80) as usize).unwrap_or('?') +} diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs new file mode 100644 index 00000000..68680fe7 --- /dev/null +++ b/crates/psd/src/tests.rs @@ -0,0 +1,189 @@ +use super::write::*; +use super::*; + +fn gradient(w: u32, h: u32) -> Vec<[f32; 4]> { + (0..w * h).map(|i| [(i % w) as f32 / (w - 1).max(1) as f32, (i / w) as f32 / (h - 1).max(1) as f32, 0.25, 1.0]).collect() +} + +fn close(a: &[[f32; 4]], b: &[[f32; 4]], tol: f32) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (0..4).all(|c| (x[c] - y[c]).abs() <= tol)) +} + +fn doc_with_layers(depth: u16, mode: ColorMode, rle: bool) -> WDoc { + let mut d = WDoc::new(40, 30); + d.depth = depth; + d.mode = mode; + d.rle = rle; + let mut bg = WLayer::pixels("Background", Rect::new(0, 0, 40, 30), gradient(40, 30)); + bg.opacity = 255; + let mut red = WLayer::solid("Red box", Rect::new(5, 6, 10, 8), [1.0, 0.0, 0.0, 1.0]).with_blend(b"mul ").with_opacity(128); + red.hidden = true; + let mut masked = WLayer::solid("Masked", Rect::new(20, 10, 12, 12), [0.0, 0.5, 1.0, 1.0]).with_blend(b"scrn"); + // Mask: left half opaque, right half transparent; default (outside) black. + let mrect = Rect::new(20, 10, 12, 12); + masked.mask = Some(WMask { rect: mrect, data: (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.0 }).collect(), default_color: 0, disabled: false }); + d.layers = vec![bg, red, masked]; + d +} + +#[test] +fn layers_round_trip_8bit_rle() { + let bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + assert!(is_psd(&bytes)); + let p = Psd::parse(bytes).unwrap(); + assert_eq!((p.width, p.height, p.depth, p.mode), (40, 30, 8, ColorMode::Rgb)); + assert_eq!(p.layers.len(), 3); + let names: Vec<&str> = p.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Background", "Red box", "Masked"]); + assert_eq!(p.layers[1].blend_key(), "mul "); + assert_eq!(p.layers[1].opacity, 128); + assert!(p.layers[1].hidden && !p.layers[0].hidden); + assert_eq!(p.layers[2].blend_key(), "scrn"); + assert_eq!(p.layers[1].rect, Rect::new(5, 6, 10, 8)); + // Pixels (8-bit quantisation). + let bg = p.layer_pixels(0, false).unwrap(); + assert!(close(&bg.data, &gradient(40, 30), 1.0 / 255.0)); + // Retain layer size vs canvas. + let red = p.layer_pixels(1, false).unwrap(); + assert_eq!((red.width, red.height), (10, 8)); + let red_c = p.layer_pixels(1, true).unwrap(); + assert_eq!((red_c.width, red_c.height), (40, 30)); + assert_eq!(red_c.data[6 * 40 + 5], [1.0, 0.0, 0.0, 1.0]); + assert_eq!(red_c.data[0][3], 0.0); + // Layer mask becomes alpha. + let m = p.layer_pixels(2, false).unwrap(); + assert_eq!(m.data[0][3], 1.0); + assert_eq!(m.data[11][3], 0.0); + // Merged composite. + let c = p.composite().unwrap(); + assert_eq!((c.width, c.height), (40, 30)); + assert!(p.merged_alpha); +} + +#[test] +fn layers_round_trip_16bit_raw_and_cmyk_gray() { + for (depth, mode, rle) in + [(16, ColorMode::Rgb, false), (16, ColorMode::Rgb, true), (8, ColorMode::Cmyk, true), (16, ColorMode::Cmyk, false), (8, ColorMode::Grayscale, false)] + { + let bytes = write(&doc_with_layers(depth, mode, rle)); + let p = Psd::parse(bytes).unwrap_or_else(|e| panic!("{depth} {mode:?} {rle}: {e}")); + assert_eq!(p.layers.len(), 3, "{depth} {mode:?}"); + assert_eq!(p.depth, depth); + let bg = p.layer_pixels(0, true).unwrap(); + let tol = if depth == 16 { 1.0 / 30000.0 } else { 1.5 / 255.0 }; + let want: Vec<[f32; 4]> = + if mode == ColorMode::Grayscale { gradient(40, 30).iter().map(|p| [p[0], p[0], p[0], 1.0]).collect() } else { gradient(40, 30) }; + assert!(close(&bg.data, &want, tol), "{depth} {mode:?} {rle}: {:?} vs {:?}", bg.data[41], want[41]); + let c = p.composite().unwrap(); + assert_eq!(c.data.len(), 1200); + } +} + +#[test] +fn groups_build_a_tree() { + let mut d = WDoc::new(16, 16); + d.layers = vec![ + WLayer::solid("Bottom", Rect::new(0, 0, 16, 16), [1.0; 4]), + WLayer::group_end(), + WLayer::solid("Inner A", Rect::new(0, 0, 4, 4), [1.0, 0.0, 0.0, 1.0]), + WLayer::group_end(), + WLayer::solid("Deep", Rect::new(2, 2, 4, 4), [0.0, 1.0, 0.0, 1.0]), + WLayer::group("Nested", false, *b"norm"), + WLayer::group("Group 1", true, *b"pass"), + WLayer::solid("Top", Rect::new(8, 8, 4, 4), [0.0, 0.0, 1.0, 1.0]), + ]; + let p = Psd::parse(write(&d)).unwrap(); + assert_eq!(p.layers[6].section, Section::OpenFolder); + assert_eq!(p.layers[6].blend_key(), "pass"); + assert_eq!(p.layers[5].section, Section::ClosedFolder); + let t = p.tree(); + assert_eq!( + t, + vec![ + Node::Layer(7), + Node::Group { layer: 6, children: vec![Node::Group { layer: 5, children: vec![Node::Layer(4)] }, Node::Layer(2)] }, + Node::Layer(0) + ] + ); +} + +#[test] +fn text_effects_fill_and_adjustments() { + let mut d = WDoc::new(64, 32); + let fx = Descriptor::new("null").with("masterFXSwitch", DValue::Bool(true)).with( + "DrSh", + DValue::Descriptor( + Descriptor::new("DrSh") + .with("enab", DValue::Bool(true)) + .with("Md ", DValue::Enum("BlnM".into(), "Mltp".into())) + .with("Clr ", DValue::Descriptor(Descriptor::rgb([0.0, 0.0, 1.0]))) + .with("Opct", DValue::UnitFloat("#Prc".into(), 60.0)) + .with("Dstn", DValue::UnitFloat("#Pxl".into(), 7.0)), + ), + ); + d.layers = vec![ + WLayer::empty("Fill", vec![solid_color_block([0.0, 1.0, 0.0])]), + WLayer::empty("Title", vec![text_block("Hello\nWorld", [10.0, 20.0], "Inter-Bold", 18.0, [1.0, 0.0, 0.0], 2)]).with_block(effects_block(&fx)), + WLayer::empty("Hue", vec![hue_sat_block(30, -20, 10)]), + WLayer::empty("Lev", vec![levels_block(10, 240, 5, 250, 1.2)]), + WLayer::empty("Inv", vec![invert_block()]), + WLayer::empty("BC", vec![brightness_block(20, -10)]), + WLayer::empty("Exp", vec![exposure_block(1.5, 0.0, 1.0)]), + WLayer::solid("Vec", Rect::new(0, 0, 64, 32), [1.0; 4]).with_block(vector_mask_block( + &[vec![[[8.0, 4.0]; 3], [[40.0, 4.0]; 3], [[40.0, 20.0]; 3]]], + 64, + 32, + false, + )), + ]; + let p = Psd::parse(write(&d)).unwrap(); + assert_eq!(p.layers[0].fill, Some([0.0, 1.0, 0.0])); + let fill = p.layer_pixels(0, false).unwrap(); + assert_eq!((fill.width, fill.height), (64, 32)); + assert_eq!(fill.data[5], [0.0, 1.0, 0.0, 1.0]); + let t = p.layers[1].text.as_ref().unwrap(); + assert_eq!(t.text, "Hello\nWorld"); + assert_eq!(t.transform[4..], [10.0, 20.0]); + assert_eq!(t.style.font.as_deref(), Some("Inter-Bold")); + assert_eq!(t.style.size, Some(18.0)); + assert_eq!(t.style.color, Some([1.0, 0.0, 0.0])); + assert_eq!(t.style.justification, Some(2)); + let e = p.layers[1].effects.as_ref().unwrap(); + let ds = e.obj("DrSh").unwrap(); + assert_eq!(ds.enum_value("Md "), Some("Mltp")); + assert_eq!(ds.num("Dstn"), Some(7.0)); + assert_eq!(p.layers[2].adjustment, Some(Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 10.0, colorize: false })); + assert_eq!(p.layers[3].adjustment, Some(Adjustment::Levels { in_black: 10.0, in_white: 240.0, gamma: 1.2, out_black: 5.0, out_white: 250.0 })); + assert_eq!(p.layers[4].adjustment, Some(Adjustment::Invert)); + assert_eq!(p.layers[5].adjustment, Some(Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: true })); + assert_eq!(p.layers[6].adjustment, Some(Adjustment::Exposure { exposure: 1.5, offset: 0.0, gamma: 1.0 })); + let vm = p.layers[7].vector_mask.as_ref().unwrap(); + assert_eq!(vm.subpaths.len(), 1); + assert!(vm.subpaths[0].closed); + let k = &vm.subpaths[0].knots; + assert_eq!(k.len(), 3); + assert!((k[1][1][0] - 40.0).abs() < 1e-4 && (k[2][1][1] - 20.0).abs() < 1e-4); +} + +#[test] +fn packbits_round_trip() { + for src in [vec![], vec![1u8], vec![5; 300], (0..=255).collect::>(), vec![1, 1, 2, 3, 3, 3, 4, 5, 5]] { + let enc = pack_bits(&src); + let mut out = vec![]; + unpack_bits(&enc, src.len(), &mut out).unwrap(); + assert_eq!(out, src); + } +} + +#[test] +fn truncated_and_garbage_input_errors() { + let bytes = write(&doc_with_layers(8, ColorMode::Rgb, true)); + for n in [0, 3, 10, 26, 40, 100, bytes.len() / 2] { + let r = Psd::parse(bytes[..n].to_vec()); + if let Ok(p) = r { + let _ = p.composite(); + } + } + assert!(Psd::parse(b"8BPS\0\x09garbage".to_vec()).is_err()); + assert!(Psd::parse(b"PNG".to_vec()).is_err()); +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs new file mode 100644 index 00000000..fb78cc78 --- /dev/null +++ b/crates/psd/src/write.rs @@ -0,0 +1,491 @@ +//! A minimal Photoshop document writer, per the same specification as the reader: RGB, CMYK or +//! Grayscale, 8 or 16 bits, raw or RLE channels, pixel layers with layer masks, groups and any +//! additional-information blocks (helpers below build text, effects, fill and adjustment +//! blocks). Used to generate test fixtures; not a full exporter. + +use crate::descriptor::{self, DValue, Descriptor}; +use crate::{ColorMode, Rect, Section, engine_data, pack_bits}; + +/// A layer mask for [`WLayer`]. +#[derive(Clone, Debug)] +pub struct WMask { + pub rect: Rect, + /// `rect.width() * rect.height()` values in 0..1. + pub data: Vec, + pub default_color: u8, + pub disabled: bool, +} + +/// A layer to write. +#[derive(Clone, Debug)] +pub struct WLayer { + pub name: String, + pub rect: Rect, + /// Straight RGBA, `rect.width() * rect.height()` pixels. + pub pixels: Vec<[f32; 4]>, + pub blend: [u8; 4], + pub opacity: u8, + pub hidden: bool, + pub clipping: bool, + pub mask: Option, + pub section: Section, + /// Extra additional-information blocks (key, data). + pub blocks: Vec<([u8; 4], Vec)>, +} + +impl WLayer { + /// A pixel layer. + pub fn pixels(name: &str, rect: Rect, pixels: Vec<[f32; 4]>) -> WLayer { + WLayer { + name: name.into(), + rect, + pixels, + blend: *b"norm", + opacity: 255, + hidden: false, + clipping: false, + mask: None, + section: Section::Layer, + blocks: vec![], + } + } + /// A layer filled with one colour. + pub fn solid(name: &str, rect: Rect, rgba: [f32; 4]) -> WLayer { + let n = (rect.width() * rect.height()) as usize; + WLayer::pixels(name, rect, vec![rgba; n]) + } + /// The top record of a group (its children come *before* it in file order, after a + /// [`WLayer::group_end`]). + pub fn group(name: &str, open: bool, blend: [u8; 4]) -> WLayer { + let mut l = WLayer::pixels(name, Rect::default(), vec![]); + l.section = if open { Section::OpenFolder } else { Section::ClosedFolder }; + l.blend = blend; + l + } + /// The hidden record that closes a group (written below its children). + pub fn group_end() -> WLayer { + let mut l = WLayer::pixels("", Rect::default(), vec![]); + l.section = Section::Divider; + l + } + /// A layer without pixels carrying `blocks` (adjustment and fill layers, type layers). + pub fn empty(name: &str, blocks: Vec<([u8; 4], Vec)>) -> WLayer { + let mut l = WLayer::pixels(name, Rect::default(), vec![]); + l.blocks = blocks; + l + } + pub fn with_blend(mut self, key: &[u8; 4]) -> WLayer { + self.blend = *key; + self + } + pub fn with_opacity(mut self, o: u8) -> WLayer { + self.opacity = o; + self + } + pub fn with_block(mut self, b: ([u8; 4], Vec)) -> WLayer { + self.blocks.push(b); + self + } +} + +/// A document to write. +#[derive(Clone, Debug)] +pub struct WDoc { + pub width: u32, + pub height: u32, + /// 8 or 16. + pub depth: u16, + /// Rgb, Cmyk or Grayscale. + pub mode: ColorMode, + pub rle: bool, + /// Bottom of the stack first (file order). + pub layers: Vec, + /// Merged image (straight RGBA, document-sized); `None` composites visible pixel layers + /// with Normal blending. + pub composite: Option>, +} + +impl WDoc { + pub fn new(width: u32, height: u32) -> WDoc { + WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None } + } +} + +fn u16b(out: &mut Vec, v: u16) { + out.extend_from_slice(&v.to_be_bytes()); +} +fn u32b(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_be_bytes()); +} + +/// Colour channels for a straight RGB pixel in the document mode. +fn mode_channels(mode: ColorMode, p: [f32; 4]) -> Vec { + match mode { + ColorMode::Cmyk => { + // Stored values are inverted ink amounts: k = max, c = r / max (reader: r = c * k). + let m = p[0].max(p[1]).max(p[2]); + if m <= 0.0 { vec![0.0, 0.0, 0.0, 0.0] } else { vec![p[0] / m, p[1] / m, p[2] / m, m] } + } + ColorMode::Grayscale => vec![p[0]], + _ => vec![p[0], p[1], p[2]], + } +} + +fn encode_samples(v: &[f32], depth: u16) -> Vec { + let mut out = Vec::with_capacity(v.len() * if depth == 16 { 2 } else { 1 }); + for &x in v { + let x = x.clamp(0.0, 1.0); + if depth == 16 { + u16b(&mut out, (x * 65535.0).round() as u16); + } else { + out.push((x * 255.0).round() as u8); + } + } + out +} + +/// One channel's data (compression field + data) for a `w`×`h` plane. +fn channel_data(v: &[f32], w: usize, h: usize, depth: u16, rle: bool) -> Vec { + let raw = encode_samples(v, depth); + let mut out = vec![]; + let rowb = raw.len() / h.max(1); + if !rle || w == 0 || h == 0 { + u16b(&mut out, 0); + out.extend_from_slice(&raw); + return out; + } + u16b(&mut out, 1); + let rows: Vec> = raw.chunks(rowb.max(1)).map(pack_bits).collect(); + for r in &rows { + u16b(&mut out, r.len() as u16); + } + for r in rows { + out.extend_from_slice(&r); + } + out +} + +fn pascal4(out: &mut Vec, s: &str) { + let b: Vec = s.bytes().map(|c| if c < 0x80 { c } else { b'?' }).take(255).collect(); + out.push(b.len() as u8); + out.extend_from_slice(&b); + let total = 1 + b.len(); + out.resize(out.len() + total.div_ceil(4) * 4 - total, 0); +} + +fn block(out: &mut Vec, key: &[u8; 4], data: &[u8]) { + out.extend_from_slice(b"8BIM"); + out.extend_from_slice(key); + let padded = data.len().div_ceil(2) * 2; + u32b(out, padded as u32); + out.extend_from_slice(data); + out.resize(out.len() + padded - data.len(), 0); +} + +fn layer_info(doc: &WDoc) -> Vec { + let mut out = vec![]; + out.extend_from_slice(&(-(doc.layers.len() as i16)).to_be_bytes()); + let mut chan_data: Vec)>> = vec![]; + for l in &doc.layers { + let (w, h) = (l.rect.width() as usize, l.rect.height() as usize); + let mut chans: Vec<(i16, Vec)> = vec![]; + let n = w * h; + let alpha: Vec = l.pixels.iter().take(n).map(|p| p[3]).collect(); + chans.push((-1, channel_data(&alpha, w, h, doc.depth, doc.rle))); + let cc = doc.mode.color_channels(); + let planes: Vec> = l.pixels.iter().take(n).map(|p| mode_channels(doc.mode, *p)).collect(); + for c in 0..cc { + let plane: Vec = planes.iter().map(|v| v[c]).collect(); + chans.push((c as i16, channel_data(&plane, w, h, doc.depth, doc.rle))); + } + if let Some(m) = &l.mask { + chans.push((-2, channel_data(&m.data, m.rect.width() as usize, m.rect.height() as usize, doc.depth, doc.rle))); + } + // record + for v in [l.rect.top, l.rect.left, l.rect.bottom, l.rect.right] { + out.extend_from_slice(&v.to_be_bytes()); + } + u16b(&mut out, chans.len() as u16); + for (id, d) in &chans { + out.extend_from_slice(&id.to_be_bytes()); + u32b(&mut out, d.len() as u32); + } + out.extend_from_slice(b"8BIM"); + out.extend_from_slice(&l.blend); + out.push(l.opacity); + out.push(l.clipping as u8); + let mut flags = 8u8; + if l.hidden { + flags |= 2; + } + out.push(flags); + out.push(0); + let mut extra = vec![]; + match &l.mask { + Some(m) => { + u32b(&mut extra, 20); + for v in [m.rect.top, m.rect.left, m.rect.bottom, m.rect.right] { + extra.extend_from_slice(&v.to_be_bytes()); + } + extra.push(m.default_color); + extra.push(if m.disabled { 2 } else { 0 }); + extra.extend_from_slice(&[0, 0]); + } + None => u32b(&mut extra, 0), + } + u32b(&mut extra, 0); // blending ranges + pascal4(&mut extra, &l.name); + let luni = unicode_plain(&l.name); + block(&mut extra, b"luni", &luni); + match l.section { + Section::Layer => {} + s => { + let mut d = vec![]; + u32b( + &mut d, + match s { + Section::OpenFolder => 1, + Section::ClosedFolder => 2, + _ => 3, + }, + ); + d.extend_from_slice(b"8BIM"); + d.extend_from_slice(&l.blend); + block(&mut extra, b"lsct", &d); + } + } + for (k, d) in &l.blocks { + block(&mut extra, k, d); + } + u32b(&mut out, extra.len() as u32); + out.extend_from_slice(&extra); + chan_data.push(chans); + } + for chans in chan_data { + for (_, d) in chans { + out.extend_from_slice(&d); + } + } + if out.len() % 2 == 1 { + out.push(0); + } + out +} + +fn unicode_plain(s: &str) -> Vec { + let v: Vec = s.encode_utf16().collect(); + let mut out = vec![]; + u32b(&mut out, v.len() as u32); + for c in v { + u16b(&mut out, c); + } + out +} + +/// Normal "over" of the visible pixel layers (groups ignored), straight alpha. +fn flatten(doc: &WDoc) -> Vec<[f32; 4]> { + let (w, h) = (doc.width as i32, doc.height as i32); + let mut acc = vec![[0.0f32; 4]; (w * h) as usize]; + for l in doc.layers.iter().filter(|l| !l.hidden && l.section == Section::Layer) { + let lw = l.rect.width() as i32; + for (i, p) in l.pixels.iter().enumerate() { + let (x, y) = (l.rect.left + i as i32 % lw.max(1), l.rect.top + i as i32 / lw.max(1)); + if x < 0 || y < 0 || x >= w || y >= h { + continue; + } + let a = p[3] * l.opacity as f32 / 255.0; + let d = &mut acc[(y * w + x) as usize]; + let oa = a + d[3] * (1.0 - a); + for c in 0..3 { + d[c] = if oa > 0.0 { (p[c] * a + d[c] * d[3] * (1.0 - a)) / oa } else { 0.0 }; + } + d[3] = oa; + } + } + acc +} + +/// Serialise a document. +pub fn write(doc: &WDoc) -> Vec { + let cc = doc.mode.color_channels(); + let mut out = vec![]; + out.extend_from_slice(b"8BPS"); + u16b(&mut out, 1); + out.extend_from_slice(&[0; 6]); + u16b(&mut out, (cc + 1) as u16); + u32b(&mut out, doc.height); + u32b(&mut out, doc.width); + u16b(&mut out, doc.depth); + u16b(&mut out, doc.mode.to_u16()); + u32b(&mut out, 0); // colour mode data + u32b(&mut out, 0); // image resources + // Layer and mask information. + let mut lm = vec![]; + if !doc.layers.is_empty() { + let li = layer_info(doc); + if doc.depth == 16 { + u32b(&mut lm, 0); + u32b(&mut lm, 0); // global layer mask info + let mut padded = li; + while !padded.len().is_multiple_of(4) { + padded.push(0); + } + lm.extend_from_slice(b"8BIM"); + lm.extend_from_slice(b"Lr16"); + u32b(&mut lm, padded.len() as u32); + lm.extend_from_slice(&padded); + } else { + u32b(&mut lm, li.len() as u32); + lm.extend_from_slice(&li); + u32b(&mut lm, 0); + } + } + u32b(&mut out, lm.len() as u32); + out.extend_from_slice(&lm); + // Merged image. + let comp = doc.composite.clone().unwrap_or_else(|| flatten(doc)); + let (w, h) = (doc.width as usize, doc.height as usize); + let planes: Vec> = comp.iter().map(|p| mode_channels(doc.mode, *p)).collect(); + let mut chans: Vec> = (0..cc).map(|c| planes.iter().map(|v| v[c]).collect()).collect(); + chans.push(comp.iter().map(|p| p[3]).collect()); + if doc.rle { + u16b(&mut out, 1); + let rows: Vec>> = + chans.iter().map(|c| encode_samples(c, doc.depth).chunks((w * if doc.depth == 16 { 2 } else { 1 }).max(1)).map(pack_bits).collect()).collect(); + for c in &rows { + for r in c { + u16b(&mut out, r.len() as u16); + } + } + for c in rows { + for r in c { + out.extend_from_slice(&r); + } + } + } else { + u16b(&mut out, 0); + for c in &chans { + out.extend_from_slice(&encode_samples(c, doc.depth)); + } + } + let _ = h; + out +} + +// ---------------------------------------------------------------- block helpers + +/// `TySh`: a type layer with one style run; the text origin sits at `origin`. +pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f64; 3], justification: u8) -> ([u8; 4], Vec) { + let mut d = vec![]; + u16b(&mut d, 1); + for v in [1.0, 0.0, 0.0, 1.0, origin[0], origin[1]] { + d.extend_from_slice(&f64::to_be_bytes(v)); + } + u16b(&mut d, 50); + u32b(&mut d, 16); + let desc = Descriptor::new("TxLr") + .with("Txt ", DValue::Text(text.replace('\n', "\r"))) + .with("textGridding", DValue::Enum("textGridding".into(), "None".into())) + .with("Ornt", DValue::Enum("Ornt".into(), "Hrzn".into())) + .with("EngineData", DValue::Raw(engine_data::minimal(text, font, size, color, justification))); + descriptor::write_descriptor(&mut d, &desc); + // Warp: version, descriptor version, an empty warp descriptor. + u16b(&mut d, 1); + u32b(&mut d, 16); + descriptor::write_descriptor(&mut d, &Descriptor::new("warp").with("warpStyle", DValue::Enum("warpStyle".into(), "warpNone".into()))); + for _ in 0..4 { + u32b(&mut d, 0); + } + (*b"TySh", d) +} + +/// `lfx2`: layer effects from an effects descriptor (`null` class with `DrSh`, `FrFX`…). +pub fn effects_block(desc: &Descriptor) -> ([u8; 4], Vec) { + let mut d = vec![]; + u32b(&mut d, 0); + u32b(&mut d, 16); + descriptor::write_descriptor(&mut d, desc); + (*b"lfx2", d) +} + +/// `SoCo`: solid colour fill. +pub fn solid_color_block(rgb: [f64; 3]) -> ([u8; 4], Vec) { + let mut d = vec![]; + u32b(&mut d, 16); + descriptor::write_descriptor(&mut d, &Descriptor::new("null").with("Clr ", DValue::Descriptor(Descriptor::rgb(rgb)))); + (*b"SoCo", d) +} + +/// `hue2`: Hue/Saturation (master). +pub fn hue_sat_block(hue: i16, sat: i16, light: i16) -> ([u8; 4], Vec) { + let mut d = vec![]; + u16b(&mut d, 2); + d.extend_from_slice(&[0, 0]); + for v in [0i16, 0, 0, hue, sat, light] { + d.extend_from_slice(&v.to_be_bytes()); + } + // Six colour ranges (unused). + d.resize(d.len() + 6 * 24, 0); + (*b"hue2", d) +} + +/// `levl`: Levels (composite record). +pub fn levels_block(in_black: i16, in_white: i16, out_black: i16, out_white: i16, gamma: f64) -> ([u8; 4], Vec) { + let mut d = vec![]; + u16b(&mut d, 2); + for v in [in_black, in_white, out_black, out_white, (gamma * 100.0).round() as i16] { + d.extend_from_slice(&v.to_be_bytes()); + } + for _ in 0..28 { + for v in [0i16, 255, 0, 255, 100] { + d.extend_from_slice(&v.to_be_bytes()); + } + } + (*b"levl", d) +} + +pub fn invert_block() -> ([u8; 4], Vec) { + (*b"nvrt", vec![]) +} + +pub fn brightness_block(brightness: i16, contrast: i16) -> ([u8; 4], Vec) { + let mut d = vec![]; + for v in [brightness, contrast, 127] { + d.extend_from_slice(&v.to_be_bytes()); + } + d.push(0); + (*b"brit", d) +} + +pub fn exposure_block(exposure: f32, offset: f32, gamma: f32) -> ([u8; 4], Vec) { + let mut d = vec![]; + u16b(&mut d, 1); + for v in [exposure, offset, gamma] { + d.extend_from_slice(&v.to_be_bytes()); + } + (*b"expA", d) +} + +/// `vmsk`: a vector mask from closed polygons/curves given as (in, anchor, out) knots in pixels. +pub fn vector_mask_block(subpaths: &[Vec<[[f64; 2]; 3]>], width: u32, height: u32, inverted: bool) -> ([u8; 4], Vec) { + let mut d = vec![]; + u32b(&mut d, 3); + u32b(&mut d, inverted as u32); + // Path fill rule record. + u16b(&mut d, 6); + d.resize(d.len() + 24, 0); + let fx = |v: f64| ((v * (1 << 24) as f64).round() as i32).to_be_bytes(); + for sp in subpaths { + u16b(&mut d, 0); + u16b(&mut d, sp.len() as u16); + d.resize(d.len() + 22, 0); + for k in sp { + u16b(&mut d, 1); + for p in k { + d.extend_from_slice(&fx(p[1] / height as f64)); + d.extend_from_slice(&fx(p[0] / width as f64)); + } + } + } + (*b"vmsk", d) +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 4c8a9787..cdaa85c1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,6 +19,7 @@ effectcraft-raster = { workspace = true } effectcraft-path = { workspace = true } effectcraft-text = { workspace = true } effectcraft-effects = { workspace = true } +lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] } kurbo = { workspace = true } rayon = { workspace = true } web-time = { workspace = true } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 4884411f..4b872ad2 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -330,6 +330,7 @@ mod tests { missing: false, sequence: vec![], color_profile: None, + layer: None, }; let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 8cd27d59..23702fd6 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -25,9 +25,13 @@ use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; use crate::eval::EvalCtx; -/// Thread-safe, memory-budgeted cache of processed layer buffers. +/// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the +/// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and +/// buffers that were slow to render are written there (keys salted per project, see +/// [`LayerCache::set_disk`]). pub struct LayerCache { inner: Mutex, + disk: Mutex, u64)>>, } struct Entry { @@ -43,6 +47,8 @@ struct Inner { clock: u64, hits: u64, misses: u64, + /// When each recent miss happened (to time the render that follows it). + missed_at: HashMap, } /// Hit/miss counters and memory use (for perf readouts and tests). @@ -63,7 +69,37 @@ impl Default for LayerCache { impl LayerCache { /// A cache holding at most `budget` bytes of pixels. pub fn new(budget: usize) -> LayerCache { - LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0 }) } + LayerCache { + inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0, missed_at: HashMap::new() }), + disk: Mutex::new(None), + } + } + + /// Attach (or detach) the disk cache. `salt` separates projects and footage versions (see + /// `footage_salt`); keys on disk are `(salt, key)`. + pub fn set_disk(&self, disk: Option<(Arc, u64)>) { + if let Ok(mut d) = self.disk.lock() { + *d = disk; + } + } + + /// Change the disk salt (after footage changed) keeping the attached disk cache. + pub fn set_disk_salt(&self, salt: u64) { + if let Ok(mut d) = self.disk.lock() + && let Some((_, s)) = d.as_mut() + { + *s = salt; + } + } + + pub fn disk(&self) -> Option> { + self.disk.lock().ok()?.as_ref().map(|d| d.0.clone()) + } + + fn disk_key(&self, key: u64) -> Option<(Arc, u128)> { + let d = self.disk.lock().ok()?; + let (dc, salt) = d.as_ref()?; + Some((dc.clone(), ((*salt as u128) << 64) | key as u128)) } pub fn get(&self, key: u64) -> Option> { @@ -79,12 +115,32 @@ impl LayerCache { } None => { g.misses += 1; - None + if g.missed_at.len() > 4096 { + g.missed_at.clear(); + } + g.missed_at.insert(key, web_time::Instant::now()); + drop(g); + // Disk cache: a hit comes back into memory. + let (dc, dk) = self.disk_key(key)?; + let buf = Arc::new(dc.get_layer(dk)?); + self.insert_mem(key, buf.clone()); + Some(buf) } } } pub fn insert(&self, key: u64, buf: Arc) { + let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); + if let Some((dc, dk)) = self.disk_key(key) { + let slow = started.is_some_and(|t| t.elapsed().as_secs_f64() * 1e3 >= dc.min_layer_ms() as f64); + if slow { + dc.put_layer(dk, &buf); + } + } + self.insert_mem(key, buf); + } + + fn insert_mem(&self, key: u64, buf: Arc) { let bytes = buf.img.data.len() * std::mem::size_of::(); let Ok(mut g) = self.inner.lock() else { return }; if bytes > g.budget / 4 { diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs new file mode 100644 index 00000000..1afc9bbb --- /dev/null +++ b/crates/render/src/disk_cache.rs @@ -0,0 +1,829 @@ +//! The persistent disk cache (Settings ▸ Media & Disk Cache ▸ Disk Cache): RAM-preview frames +//! and layer cache entries kept on disk between sessions, like After Effects' disk cache. +//! +//! * Entries are files keyed by a 128-bit content hash (`/v1//<2 hex>/<32 hex>.ecc`): +//! frames by the hash of the composition's content (the comp and everything it uses, footage +//! files' size and modification time included) plus frame, scale and view; layer buffers by +//! the layer cache's content key salted with the project's footage fingerprint. A changed +//! project therefore never reads stale pixels: its keys simply change. +//! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA, layer +//! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh +//! renders are bit-identical). Every file ends in a checksum; damaged files read as misses and +//! are deleted. +//! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the +//! folder when the cache opens (file modification times order it), so the cache survives +//! restarts; reads refresh a file's time. +//! * Safe concurrent access: writes go to a unique temporary file that is renamed into place +//! (atomic on every desktop OS), so readers in this or another process never see a partial +//! entry; a missing or damaged file is just a miss. Writes run on a background thread with a +//! bounded queue (a full queue drops the write rather than stalling rendering). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; + +use effectcraft_effects::Buf; +use effectcraft_raster::Image; + +/// File format version (part of the folder layout). +const VERSION: &str = "v1"; +const MAGIC: &[u8; 4] = b"ECDC"; +/// Pending writes before new ones are dropped. +const QUEUE_LIMIT: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Kind { + Frame, + Layer, +} + +impl Kind { + fn dir(self) -> &'static str { + match self { + Kind::Frame => "frames", + Kind::Layer => "layers", + } + } + fn tag(self) -> u8 { + match self { + Kind::Frame => 1, + Kind::Layer => 2, + } + } +} + +/// A cached viewer frame: 8-bit premultiplied RGBA. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Frame8 { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// Counters for tests, the Info panel and `cache.diskStats`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DiskStats { + pub entries: usize, + pub bytes: u64, + pub max_bytes: u64, + pub hits: u64, + pub misses: u64, + pub writes: u64, + pub evictions: u64, + pub dropped: u64, +} + +struct Meta { + bytes: u64, + last_use: u64, +} + +#[derive(Default)] +struct Index { + map: HashMap<(Kind, u128), Meta>, + total: u64, + clock: u64, +} + +enum Job { + Write(Kind, u128, Vec), +} + +struct Queue { + jobs: std::collections::VecDeque, + busy: bool, +} + +pub struct DiskCache { + dir: PathBuf, + index: Mutex, + max_bytes: AtomicU64, + hits: AtomicU64, + misses: AtomicU64, + writes: AtomicU64, + evictions: AtomicU64, + dropped: AtomicU64, + tmp_counter: AtomicU64, + queue: Arc<(Mutex, Condvar)>, + /// Layer buffers that took less than this to render are not written (ms). + min_layer_ms: AtomicU64, +} + +/// FNV-1a 128-bit (content keys). +#[derive(Clone, Copy)] +pub struct Hash128(u128); + +impl Default for Hash128 { + fn default() -> Self { + Hash128(0x6c62_272e_07bb_0142_62b8_2175_6295_c58d) + } +} + +impl Hash128 { + pub fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 ^= b as u128; + self.0 = self.0.wrapping_mul(0x0000_0000_0100_0000_0000_0000_0000_013b); + } + } + pub fn write_u64(&mut self, v: u64) { + self.write(&v.to_le_bytes()); + } + pub fn finish(&self) -> u128 { + self.0 + } +} + +fn checksum(data: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in data { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// The platform's default disk cache folder. +pub fn default_folder() -> PathBuf { + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") + && let Some(h) = &home + { + return h.join("Library/Caches/EffectCraft/Disk Cache"); + } + if cfg!(windows) + && let Some(l) = std::env::var_os("LOCALAPPDATA") + { + return PathBuf::from(l).join("EffectCraft").join("Disk Cache"); + } + if let Some(x) = std::env::var_os("XDG_CACHE_HOME") { + return PathBuf::from(x).join("effectcraft/disk-cache"); + } + home.map(|h| h.join(".cache/effectcraft/disk-cache")).unwrap_or_else(|| std::env::temp_dir().join("effectcraft-disk-cache")) +} + +impl DiskCache { + /// Open (or create) the cache in `folder`, holding at most `max_bytes`. + pub fn open(folder: impl AsRef, max_bytes: u64) -> std::io::Result> { + let dir = folder.as_ref().join(VERSION); + std::fs::create_dir_all(&dir)?; + let dc = Arc::new(DiskCache { + dir, + index: Mutex::new(Index::default()), + max_bytes: AtomicU64::new(max_bytes), + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + writes: AtomicU64::new(0), + evictions: AtomicU64::new(0), + dropped: AtomicU64::new(0), + tmp_counter: AtomicU64::new(0), + queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())), + min_layer_ms: AtomicU64::new(30), + }); + dc.rescan(); + dc.evict(); + #[cfg(not(target_arch = "wasm32"))] + { + let weak = Arc::downgrade(&dc); + let q = dc.queue.clone(); + std::thread::Builder::new().name("ec-disk-cache".into()).spawn(move || writer(weak, q))?; + } + Ok(dc) + } + + pub fn folder(&self) -> &Path { + self.dir.parent().unwrap_or(&self.dir) + } + + /// Rebuild the index from the folder (oldest files first). + pub fn rescan(&self) { + let mut found: Vec<(std::time::SystemTime, Kind, u128, u64)> = vec![]; + for kind in [Kind::Frame, Kind::Layer] { + let Ok(shards) = std::fs::read_dir(self.dir.join(kind.dir())) else { continue }; + for shard in shards.flatten() { + let Ok(files) = std::fs::read_dir(shard.path()) else { continue }; + for f in files.flatten() { + let p = f.path(); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.contains(".tmp") { + // A writer that died mid-write; old temporaries are removed. + if f.metadata().and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|e| e.as_secs() > 600)) { + let _ = std::fs::remove_file(&p); + } + continue; + } + let Some(hex) = name.strip_suffix(".ecc") else { continue }; + let Ok(key) = u128::from_str_radix(hex, 16) else { continue }; + let Ok(md) = f.metadata() else { continue }; + found.push((md.modified().unwrap_or(std::time::UNIX_EPOCH), kind, key, md.len())); + } + } + } + found.sort_by_key(|e| e.0); + let mut idx = Index::default(); + for (_, kind, key, bytes) in found { + idx.clock += 1; + let clock = idx.clock; + idx.total += bytes; + idx.map.insert((kind, key), Meta { bytes, last_use: clock }); + } + if let Ok(mut g) = self.index.lock() { + *g = idx; + } + } + + fn path(&self, kind: Kind, key: u128) -> PathBuf { + let hex = format!("{key:032x}"); + self.dir.join(kind.dir()).join(&hex[..2]).join(format!("{hex}.ecc")) + } + + pub fn max_bytes(&self) -> u64 { + self.max_bytes.load(Ordering::Relaxed) + } + + /// Change the size limit (evicts the least recently used entries when shrinking). + pub fn set_max_bytes(&self, b: u64) { + self.max_bytes.store(b, Ordering::Relaxed); + self.evict(); + } + + /// Layer buffers faster to render than this are not worth a disk write. + pub fn set_min_layer_ms(&self, ms: u64) { + self.min_layer_ms.store(ms, Ordering::Relaxed); + } + + pub fn min_layer_ms(&self) -> u64 { + self.min_layer_ms.load(Ordering::Relaxed) + } + + pub fn contains(&self, kind: Kind, key: u128) -> bool { + self.index.lock().is_ok_and(|g| g.map.contains_key(&(kind, key))) + } + + /// Keys of one kind (unordered). + pub fn keys(&self, kind: Kind) -> Vec { + self.index.lock().map(|g| g.map.keys().filter(|k| k.0 == kind).map(|k| k.1).collect()).unwrap_or_default() + } + + pub fn stats(&self) -> DiskStats { + let (entries, bytes) = self.index.lock().map(|g| (g.map.len(), g.total)).unwrap_or_default(); + DiskStats { + entries, + bytes, + max_bytes: self.max_bytes(), + hits: self.hits.load(Ordering::Relaxed), + misses: self.misses.load(Ordering::Relaxed), + writes: self.writes.load(Ordering::Relaxed), + evictions: self.evictions.load(Ordering::Relaxed), + dropped: self.dropped.load(Ordering::Relaxed), + } + } + + /// Delete every entry (Edit ▸ Purge ▸ All Disk Cache / Empty Disk Cache). + pub fn clear(&self) { + self.flush(); + if let Ok(mut g) = self.index.lock() { + *g = Index::default(); + } + for kind in [Kind::Frame, Kind::Layer] { + let _ = std::fs::remove_dir_all(self.dir.join(kind.dir())); + } + } + + /// Wait until queued writes are on disk. + pub fn flush(&self) { + let (m, cv) = &*self.queue; + let Ok(mut q) = m.lock() else { return }; + #[cfg(target_arch = "wasm32")] + { + q.jobs.clear(); + } + while !q.jobs.is_empty() || q.busy { + q = match cv.wait(q) { + Ok(q) => q, + Err(_) => return, + }; + } + } + + fn read(&self, kind: Kind, key: u128) -> Option> { + if !self.contains(kind, key) { + self.misses.fetch_add(1, Ordering::Relaxed); + return None; + } + let path = self.path(kind, key); + let data = std::fs::read(&path).ok(); + let ok = data.as_deref().and_then(|d| verify(d, kind)); + match ok { + Some(payload) => { + let payload = payload.to_vec(); + self.hits.fetch_add(1, Ordering::Relaxed); + if let Ok(mut g) = self.index.lock() { + g.clock += 1; + let c = g.clock; + if let Some(m) = g.map.get_mut(&(kind, key)) { + m.last_use = c; + } + } + // Persist the recency for the next session (best effort). + if let Ok(f) = std::fs::File::options().append(true).open(&path) { + let _ = f.set_modified(std::time::SystemTime::now()); + } + Some(payload) + } + None => { + self.misses.fetch_add(1, Ordering::Relaxed); + // Gone (another process evicted it) or damaged: forget it. + if data.is_some() { + let _ = std::fs::remove_file(&path); + } + self.forget(kind, key); + None + } + } + } + + fn forget(&self, kind: Kind, key: u128) { + if let Ok(mut g) = self.index.lock() + && let Some(m) = g.map.remove(&(kind, key)) + { + g.total -= m.bytes; + } + } + + fn enqueue(&self, kind: Kind, key: u128, payload: Vec) { + #[cfg(target_arch = "wasm32")] + { + self.write_now(kind, key, &payload); + } + #[cfg(not(target_arch = "wasm32"))] + { + let (m, cv) = &*self.queue; + let Ok(mut q) = m.lock() else { return }; + if q.jobs.len() >= QUEUE_LIMIT { + self.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + q.jobs.push_back(Job::Write(kind, key, payload)); + cv.notify_all(); + } + } + + /// Write an entry now (atomic rename), then evict down to the limit. + fn write_now(&self, kind: Kind, key: u128, payload: &[u8]) { + if self.contains(kind, key) { + return; + } + let path = self.path(kind, key); + let Some(parent) = path.parent() else { return }; + if std::fs::create_dir_all(parent).is_err() { + return; + } + let mut data = Vec::with_capacity(payload.len() + 16); + data.extend_from_slice(MAGIC); + data.push(kind.tag()); + data.extend_from_slice(&[0, 0, 0]); + data.extend_from_slice(payload); + let sum = checksum(&data); + data.extend_from_slice(&sum.to_le_bytes()); + let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed); + let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id())); + if std::fs::write(&tmp, &data).is_err() { + let _ = std::fs::remove_file(&tmp); + return; + } + if std::fs::rename(&tmp, &path).is_err() { + let _ = std::fs::remove_file(&tmp); + return; + } + self.writes.fetch_add(1, Ordering::Relaxed); + if let Ok(mut g) = self.index.lock() { + g.clock += 1; + let c = g.clock; + let bytes = data.len() as u64; + if let Some(old) = g.map.insert((kind, key), Meta { bytes, last_use: c }) { + g.total -= old.bytes; + } + g.total += bytes; + } + self.evict(); + } + + /// Remove least recently used entries until the cache fits its limit. + fn evict(&self) { + let max = self.max_bytes(); + let victims: Vec<(Kind, u128)> = { + let Ok(mut g) = self.index.lock() else { return }; + if g.total <= max { + return; + } + let mut by_age: Vec<(u64, (Kind, u128), u64)> = g.map.iter().map(|(k, m)| (m.last_use, *k, m.bytes)).collect(); + by_age.sort_unstable_by_key(|e| e.0); + let mut out = vec![]; + for (_, k, bytes) in by_age { + if g.total <= max { + break; + } + g.map.remove(&k); + g.total -= bytes; + out.push(k); + } + out + }; + for (kind, key) in victims { + let _ = std::fs::remove_file(self.path(kind, key)); + self.evictions.fetch_add(1, Ordering::Relaxed); + } + } + + // ------------------------------------------------------------ typed entries + + pub fn get_frame(&self, key: u128) -> Option { + let p = self.read(Kind::Frame, key)?; + decode_frame(&p) + } + + /// Queue a frame for writing (8-bit premultiplied RGBA, `width * height * 4` bytes). + pub fn put_frame(&self, key: u128, width: u32, height: u32, rgba: &[u8]) { + if self.contains(Kind::Frame, key) || rgba.len() != width as usize * height as usize * 4 { + return; + } + self.enqueue(Kind::Frame, key, encode_frame(width, height, rgba)); + } + + pub fn get_layer(&self, key: u128) -> Option { + let p = self.read(Kind::Layer, key)?; + decode_layer(&p) + } + + /// Queue a layer buffer for writing. + pub fn put_layer(&self, key: u128, buf: &Buf) { + if self.contains(Kind::Layer, key) { + return; + } + self.enqueue(Kind::Layer, key, encode_layer(buf)); + } + + /// Write a layer buffer synchronously (tests). + pub fn put_layer_now(&self, key: u128, buf: &Buf) { + self.write_now(Kind::Layer, key, &encode_layer(buf)); + } + + /// Write a frame synchronously (tests). + pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8]) { + self.write_now(Kind::Frame, key, &encode_frame(width, height, rgba)); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn writer(dc: std::sync::Weak, q: Arc<(Mutex, Condvar)>) { + let (m, cv) = &*q; + loop { + let job = { + let Ok(mut g) = m.lock() else { return }; + loop { + if let Some(j) = g.jobs.pop_front() { + g.busy = true; + break j; + } + if dc.strong_count() == 0 { + return; + } + g = match cv.wait_timeout(g, std::time::Duration::from_millis(500)) { + Ok((g, _)) => g, + Err(_) => return, + }; + } + }; + let Some(cache) = dc.upgrade() else { return }; + match job { + Job::Write(kind, key, payload) => cache.write_now(kind, key, &payload), + } + drop(cache); + if let Ok(mut g) = m.lock() { + g.busy = false; + } + cv.notify_all(); + } +} + +/// Check magic, kind and checksum; returns the payload. +fn verify(d: &[u8], kind: Kind) -> Option<&[u8]> { + if d.len() < 16 || &d[..4] != MAGIC || d[4] != kind.tag() { + return None; + } + let (body, sum) = d.split_at(d.len() - 8); + let mut s = [0u8; 8]; + s.copy_from_slice(sum); + (checksum(body) == u64::from_le_bytes(s)).then_some(&body[8..]) +} + +fn encode_frame(w: u32, h: u32, rgba: &[u8]) -> Vec { + let mut out = Vec::with_capacity(rgba.len() / 2 + 16); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + out.extend_from_slice(&lz4_flex::compress_prepend_size(rgba)); + out +} + +fn decode_frame(p: &[u8]) -> Option { + if p.len() < 8 { + return None; + } + let w = u32::from_le_bytes(p[0..4].try_into().ok()?); + let h = u32::from_le_bytes(p[4..8].try_into().ok()?); + let rgba = lz4_flex::decompress_size_prepended(&p[8..]).ok()?; + (rgba.len() == w as usize * h as usize * 4).then_some(Frame8 { width: w, height: h, rgba }) +} + +fn encode_layer(b: &Buf) -> Vec { + let (w, h) = (b.img.width, b.img.height); + let n = b.img.data.len(); + // Byte planes: all first bytes of every float, then all second bytes… (compresses well). + let mut planes = vec![0u8; n * 16]; + for (i, px) in b.img.data.iter().enumerate() { + for c in 0..4 { + let bytes = px[c].to_le_bytes(); + for (k, byte) in bytes.iter().enumerate() { + planes[(k * 4 + c) * n + i] = *byte; + } + } + } + let mut out = Vec::with_capacity(planes.len() / 4 + 40); + out.extend_from_slice(&w.to_le_bytes()); + out.extend_from_slice(&h.to_le_bytes()); + out.extend_from_slice(&b.offset[0].to_le_bytes()); + out.extend_from_slice(&b.offset[1].to_le_bytes()); + out.extend_from_slice(&b.scale.to_le_bytes()); + out.extend_from_slice(&lz4_flex::compress_prepend_size(&planes)); + out +} + +fn decode_layer(p: &[u8]) -> Option { + if p.len() < 32 { + return None; + } + let w = u32::from_le_bytes(p[0..4].try_into().ok()?); + let h = u32::from_le_bytes(p[4..8].try_into().ok()?); + let f = |i: usize| -> Option { Some(f64::from_le_bytes(p[i..i + 8].try_into().ok()?)) }; + let offset = [f(8)?, f(16)?]; + let scale = f(24)?; + let planes = lz4_flex::decompress_size_prepended(&p[32..]).ok()?; + let n = w as usize * h as usize; + if planes.len() != n * 16 { + return None; + } + let mut img = Image::new(w, h); + for (i, px) in img.data.iter_mut().enumerate() { + for c in 0..4 { + let mut bytes = [0u8; 4]; + for (k, byte) in bytes.iter_mut().enumerate() { + *byte = planes[(k * 4 + c) * n + i]; + } + px[c] = f32::from_le_bytes(bytes); + } + } + Some(Buf { img, offset, scale }) +} + +// ---------------------------------------------------------------- content keys + +/// Bump when rendering changes in a way that makes old disk entries wrong. +const RENDER_VERSION: &str = "effectcraft-render-1"; + +fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { + h.write(format!("{v:?}").as_bytes()); + h.write(&[0xff]); +} + +/// A file's size and modification time (so edited footage gets new keys). +fn file_stamp(h: &mut Hash128, path: &str) { + if path.is_empty() { + return; + } + if let Ok(md) = std::fs::metadata(path) { + h.write_u64(md.len()); + if let Ok(t) = md.modified() + && let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) + { + h.write_u64(d.as_nanos() as u64); + } + } +} + +fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) { + file_stamp(h, &f.path); + for p in &f.sequence { + file_stamp(h, p); + } +} + +/// Salt for layer cache keys on disk: the renderer version and every footage file's identity +/// (layer keys name footage items, not file contents). +pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { + let mut h = Hash128::default(); + h.write(RENDER_VERSION.as_bytes()); + for (id, it) in &project.items { + if let effectcraft_project::ItemKind::Footage(f) = &it.kind { + h.write_u64(id.0); + hash_debug(&mut h, f); + footage_stamp(&mut h, f); + } + } + let v = h.finish(); + (v as u64) ^ ((v >> 64) as u64) +} + +/// Content key of a composition's pixels: project render settings, the comp and everything it +/// uses (nested comps, footage, solids, footage files' size and time). With expressions in play +/// (which can read any comp) the whole project counts. +pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { + use effectcraft_project::{ItemKind, LayerSource}; + let mut h = Hash128::default(); + h.write(RENDER_VERSION.as_bytes()); + hash_debug(&mut h, &project.settings); + let mut seen = std::collections::BTreeSet::new(); + let mut stack = vec![comp]; + let mut expressions = false; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(it) = project.item(id) else { continue }; + if let ItemKind::Comp(c) = &it.kind { + for l in &c.layers { + if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { + stack.push(item); + } + if !expressions { + l.props.walk("", &mut |_, p| expressions |= p.expr.is_some()); + } + } + } + } + let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; + for id in ids { + let Some(it) = project.item(id) else { continue }; + h.write_u64(id.0); + h.write(it.name.as_bytes()); + hash_debug(&mut h, &it.kind); + if let ItemKind::Footage(f) = &it.kind { + footage_stamp(&mut h, f); + } + } + h.write_u64(comp.0); + h.finish() +} + +/// Disk key of one viewer frame. +pub fn frame_key(content: u128, frame: i64, scale_milli: u32, view: u64) -> u128 { + let mut h = Hash128::default(); + h.write(&content.to_le_bytes()); + h.write_u64(frame as u64); + h.write_u64(scale_milli as u64); + h.write_u64(view); + h.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmpdir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("effectcraft-disk-cache-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn buf(seed: u32, w: u32, h: u32) -> Buf { + let mut img = Image::new(w, h); + for (i, p) in img.data.iter_mut().enumerate() { + let v = ((i as u32).wrapping_mul(2654435761u32).wrapping_add(seed) % 1000) as f32 / 999.0; + *p = [v, v * 0.5, 1.0 - v, if i % 7 == 0 { 0.0 } else { 1.0 }]; + } + Buf { img, offset: [1.5, -2.25], scale: 0.5 } + } + + #[test] + fn hit_miss_and_exact_round_trip() { + let dir = tmpdir("hit"); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + assert!(dc.get_layer(42).is_none()); + let b = buf(1, 33, 17); + dc.put_layer(42, &b); + dc.flush(); + let back = dc.get_layer(42).unwrap(); + assert_eq!(back.img, b.img); + assert_eq!((back.offset, back.scale), (b.offset, b.scale)); + let rgba: Vec = (0..20 * 10 * 4).map(|i| (i % 251) as u8).collect(); + dc.put_frame(7, 20, 10, &rgba); + dc.flush(); + assert_eq!(dc.get_frame(7).unwrap().rgba, rgba); + let st = dc.stats(); + assert_eq!((st.entries, st.hits, st.misses, st.writes), (2, 2, 1, 2)); + // Wrong kind is a miss. + assert!(dc.get_frame(42).is_none()); + } + + #[test] + fn persists_across_restarts_and_clears() { + let dir = tmpdir("persist"); + { + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_layer_now(1, &buf(1, 8, 8)); + dc.put_frame_now(2, 2, 2, &[9; 16]); + } + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(dc.stats().entries, 2); + assert!(dc.contains(Kind::Layer, 1) && dc.contains(Kind::Frame, 2)); + assert_eq!(dc.get_frame(2).unwrap().rgba, vec![9; 16]); + dc.clear(); + assert_eq!(dc.stats().entries, 0); + assert!(dc.get_frame(2).is_none()); + let again = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(again.stats().entries, 0); + } + + #[test] + fn lru_eviction_respects_the_limit() { + let dir = tmpdir("lru"); + let dc = DiskCache::open(&dir, u64::MAX).unwrap(); + for k in 0..6u128 { + dc.put_layer_now(k, &buf(k as u32, 64, 64)); + } + let per = dc.stats().bytes / 6; + // Touch 0 so it is the most recently used. + assert!(dc.get_layer(0).is_some()); + dc.set_max_bytes(per * 3 + per / 2); + let st = dc.stats(); + assert!(st.bytes <= per * 3 + per / 2, "{st:?}"); + assert_eq!(st.entries, 3); + assert!(dc.contains(Kind::Layer, 0), "recently used survives"); + assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3)); + assert_eq!(st.evictions, 3); + // Files are really gone. + let files = walk(&dir); + assert_eq!(files, 3); + // New writes keep evicting. + dc.put_layer_now(99, &buf(9, 64, 64)); + assert!(dc.stats().bytes <= per * 3 + per / 2); + } + + fn walk(d: &Path) -> usize { + let mut n = 0; + if let Ok(rd) = std::fs::read_dir(d) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + n += walk(&p); + } else if p.extension().is_some_and(|x| x == "ecc") { + n += 1; + } + } + } + n + } + + #[test] + fn damaged_files_are_misses() { + let dir = tmpdir("damaged"); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + dc.put_frame_now(5, 4, 4, &[1; 64]); + let p = dc.path(Kind::Frame, 5); + let mut d = std::fs::read(&p).unwrap(); + let n = d.len(); + d[n / 2] ^= 0xff; + std::fs::write(&p, d).unwrap(); + assert!(dc.get_frame(5).is_none()); + assert!(!dc.contains(Kind::Frame, 5)); + assert!(!p.exists()); + } + + #[test] + fn concurrent_readers_and_writers() { + let dir = tmpdir("concurrent"); + let a = DiskCache::open(&dir, 1 << 30).unwrap(); + // A second instance on the same folder (another process, in effect). + let b = DiskCache::open(&dir, 1 << 30).unwrap(); + std::thread::scope(|s| { + for t in 0..4u32 { + let (a, b) = (a.clone(), b.clone()); + s.spawn(move || { + for i in 0..20u128 { + let k = i % 5; + let w = if t % 2 == 0 { &a } else { &b }; + w.put_layer_now(k, &buf(k as u32, 16, 16)); + if let Some(x) = w.get_layer(k) { + assert_eq!(x.img, buf(k as u32, 16, 16).img); + } + } + }); + } + }); + b.rescan(); + for k in 0..5u128 { + assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 14a81575..a4e718a3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ pub mod audio; pub mod cache; pub mod color; +pub mod disk_cache; use color::Region; pub mod eval; pub mod masks; @@ -46,6 +47,16 @@ pub trait FootageSource: Send + Sync { fn cache_budget(&self) -> Option { None } + /// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize. + /// `None` when the footage is not vector or cannot be read. + fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option> { + None + } +} + +/// Footage that can be rasterised at any scale (SVG). +pub fn is_vector_footage(f: &Footage) -> bool { + f.codec == "SVG" } /// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). @@ -348,7 +359,12 @@ impl<'a> Renderer<'a> { /// stays sharp when scaled up). pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; - if !layer.switches.collapse || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) { + let vector = match layer.source { + LayerSource::Text | LayerSource::Shape => true, + LayerSource::Footage { item } => matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)), + _ => false, + }; + if !layer.switches.collapse || !vector { return s; } let (l2c, _) = ctx.layer_to_comp(layer); @@ -631,6 +647,17 @@ impl<'a> Renderer<'a> { if !f.has_video { return None; } + // Continuously rasterised vector footage: drawn at the on-screen scale. + if layer.switches.collapse && is_vector_footage(f) { + let k = self.raster_scale(ctx, layer); + if let Some(img) = self.footage.vector_frame(*item, f, k) { + let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; + if let Some(c) = self.pipe.media_in(f.color_profile) { + color::convert(&mut buf.img, &c); + } + return Some(buf); + } + } let lt = ctx.source_time(layer); let img = self.footage_frame(ctx, layer, *item, f, lt)?; let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 }; diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index dfd49ccc..991fac32 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -51,6 +51,7 @@ fn audio_footage() -> Footage { missing: false, sequence: vec![], color_profile: None, + layer: None, } } diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index 87f9dd1a..016cdc93 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -172,6 +172,7 @@ fn footage(profile: Option) -> Footage { missing: false, sequence: vec![], color_profile: profile, + layer: None, } } diff --git a/crates/render/src/tests_frame_blend.rs b/crates/render/src/tests_frame_blend.rs index c172b81c..3c301d38 100644 --- a/crates/render/src/tests_frame_blend.rs +++ b/crates/render/src/tests_frame_blend.rs @@ -80,6 +80,7 @@ fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) { missing: false, sequence: vec![], color_profile: None, + layer: None, }; let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f)); let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None); diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml new file mode 100644 index 00000000..0e1af771 --- /dev/null +++ b/crates/svg/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "effectcraft-svg" +description = "SVG import: parses SVG 1.1 / SVG 2 static vector content (paths, basic shapes, groups, transforms, use, CSS styles, solid and gradient fills/strokes, opacity) and rasterises it at any scale" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-geom = { workspace = true } +effectcraft-raster = { workspace = true } +effectcraft-path = { workspace = true } +kurbo = { workspace = true } +roxmltree = "0.21" +rayon = { workspace = true } diff --git a/crates/svg/README.md b/crates/svg/README.md new file mode 100644 index 00000000..862bd01d --- /dev/null +++ b/crates/svg/README.md @@ -0,0 +1,26 @@ +# effectcraft-svg + +SVG import for EffectCraft: `.svg` files import as footage (rasterised at any scale, so +Continuously Rasterize stays sharp) and Layer ▸ Create ▸ Create Shapes from Vector Layer turns +them into editable shape layers. + +Implemented from the W3C **Scalable Vector Graphics (SVG) 1.1 (Second Edition)** recommendation +(16 August 2011) and the **SVG 2** candidate recommendation (4 October 2018) for the parts listed +here, plus **CSS Color Module Level 3** for colour keywords and functions: + +- document: `svg` (width/height, `viewBox`, `preserveAspectRatio`), nested `svg`, `g`, `defs`, + `use` (x/y, `href` / `xlink:href`), `symbol` (through `use`), `title`/`desc`/`metadata` ignored; +- shapes: `path` (all path data commands incl. elliptical arcs), `rect` (rx/ry), `circle`, + `ellipse`, `line`, `polyline`, `polygon`; +- `transform` lists (matrix, translate, scale, rotate with centre, skewX, skewY); +- painting: `fill`, `fill-rule`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-linecap`, + `stroke-linejoin`, `stroke-miterlimit`, `stroke-dasharray`, `stroke-dashoffset`, + `stroke-opacity`, `opacity`, `display`, `visibility`, `color`/`currentColor`; +- `linearGradient` and `radialGradient` (with `fx`/`fy` focal point), `gradientUnits`, + `gradientTransform`, `spreadMethod`, `href` inheritance of stops and attributes; +- styling through presentation attributes, the `style` attribute and ` + + + + + + + + + + + + + + + + + diff --git a/crates/svg/tests/fixtures/paths-gradients.svg.attribution b/crates/svg/tests/fixtures/paths-gradients.svg.attribution new file mode 100644 index 00000000..d4b8758f --- /dev/null +++ b/crates/svg/tests/fixtures/paths-gradients.svg.attribution @@ -0,0 +1,7 @@ +asset: paths-gradients.svg +title: SVG test fixture: path data, gradients, CSS styles and use +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-02 by EffectCraft contributors (Claude agent, SVG import) +notes: Hand-written for the effectcraft-svg parser and Create Shapes from Vector Layer tests. diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 62239f19..d0e5a6d9 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -7,6 +7,11 @@ //! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] renders them //! on the UI thread between egui frames, within a time budget. //! +//! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also +//! written to disk under a content key (the comp's content hash, frame, scale, view and render +//! options), and a job first looks there: frames survive restarts and project reopenings. The +//! timeline shows disk-only frames as a blue cache bar. +//! //! With Mercury GPU Acceleration (a [`Gpu`] on egui-wgpu's device and the project's renderer set //! to the GPU) frames stay on the GPU: the compositor writes an RGBA8 texture that the viewer //! registers with egui-wgpu and draws directly; pixels are read back only when something needs @@ -16,6 +21,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use effectcraft_engine::project::{ItemId, Project}; +use effectcraft_engine::render::disk_cache::{self, DiskCache}; use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; use effectcraft_gpu::{DisplayFrame, Gpu}; @@ -122,6 +128,37 @@ pub struct RenderSource { /// Show Channel / exposure / region-of-interest drawing read the CPU frame texture, so GPU /// frames are read back (the compositing still runs on the GPU). pub gpu_display: bool, + /// The persistent disk cache, when enabled. + pub disk: Option>, +} + +/// Content keys of (project revision, comp), computed once per revision. +type ContentKeys = Arc>>; + +fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { + if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { + return k; + } + let k = disk_cache::comp_content_key(project, ItemId(comp)); + if let Ok(mut m) = keys.lock() { + if m.len() > 256 { + m.clear(); + } + m.insert((revision, comp), k); + } + k +} + +fn opts_hash(o: &RenderOpts) -> u64 { + let mut h = disk_cache::Hash128::default(); + h.write(format!("{o:?}").as_bytes()); + h.finish() as u64 +} + +/// Disk key of a viewer frame (render options included: region of interest, draft…). +fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 { + let content = content_key(keys, project, key.revision, key.comp); + disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash) } pub struct Frames { @@ -135,6 +172,7 @@ pub struct Frames { ctx: Option, /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, + content_keys: ContentKeys, } impl Default for Frames { @@ -149,6 +187,7 @@ impl Default for Frames { pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"), ctx: None, last_ms: Arc::new(Mutex::new(0.0)), + content_keys: Arc::default(), } } } @@ -195,6 +234,21 @@ impl Frames { v } + /// Frames of (revision, comp, scale) in the disk cache but not in RAM — the blue cache bar. + /// `opts` are the viewer's render options (part of the disk key). + pub fn disk_frames(&self, src: &RenderSource, revision: u64, comp: u64, scale: u32, view: u64, frames: i64, opts: &RenderOpts) -> Vec { + let Some(dc) = &src.disk else { return vec![] }; + let ram: HashSet = self.cached_frames(revision, comp, scale).into_iter().collect(); + let oh = opts_hash(opts); + (0..frames.clamp(0, 100_000)) + .filter(|f| !ram.contains(f)) + .filter(|f| { + let key = FrameKey { revision, comp, frame: *f, scale, view }; + dc.contains(disk_cache::Kind::Frame, frame_disk_key(&self.content_keys, &src.project, &key, oh)) + }) + .collect() + } + /// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest /// frames when it shrinks. pub fn set_budget(&self, bytes: usize) { @@ -278,7 +332,14 @@ impl Frames { } fn worker(&self) -> Worker { - Worker { queue: self.queue.clone(), cache: self.cache.clone(), inflight: self.inflight.clone(), ctx: self.ctx.clone(), last: self.last_ms.clone() } + Worker { + queue: self.queue.clone(), + cache: self.cache.clone(), + inflight: self.inflight.clone(), + ctx: self.ctx.clone(), + last: self.last_ms.clone(), + content_keys: self.content_keys.clone(), + } } /// Render queued frames on this thread, most important first, until the queue is empty or @@ -306,12 +367,13 @@ struct Worker { inflight: Arc>>, ctx: Option, last: Arc>, + content_keys: ContentKeys, } impl Worker { /// Render the queued job that matters most right now. `false` when the queue was empty. fn run_next(&self) -> bool { - let Worker { queue, cache, inflight, ctx, last } = self; + let Worker { queue, cache, inflight, ctx, last, content_keys } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; // Urgent first (newest), then prefetch in request order. @@ -325,19 +387,18 @@ impl Worker { job }; let t0 = web_time::Instant::now(); - let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); - r.expr = job.src.expr.as_deref(); - r.cache = Some(&job.src.layer_cache); - r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator); - // The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no - // adapter, or a frame the GPU cannot finish here) the CPU renders it. - let gpu_frame = match (&job.src.gpu, r.active_accel()) { - (Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t), - _ => None, - }; - let ci = match gpu_frame { - Some(f) => FrameImage::Gpu(Arc::new(f)), - None => FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))), + let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)))); + let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k)); + let ci = match from_disk { + Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), + None => { + let ci = self.render_job(&job); + if let (Some((dc, k)), FrameImage::Cpu(img)) = (&disk, &ci) { + let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); + dc.put_frame(*k, img.size[0] as u32, img.size[1] as u32, &rgba); + } + ci + } }; if job.urgent && let Ok(mut l) = last.lock() @@ -361,6 +422,23 @@ impl Worker { } true } + + fn render_job(&self, job: &Job) -> FrameImage { + let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + r.expr = job.src.expr.as_deref(); + r.cache = Some(&job.src.layer_cache); + r.accel = job.src.gpu.as_ref().map(|g| g as &dyn effectcraft_engine::render::Accelerator); + // The GPU leaves the frame in a texture for the viewer; otherwise (Software Only, no + // adapter, or a frame the GPU cannot finish here) the CPU renders it. + let gpu_frame = match (&job.src.gpu, r.active_accel()) { + (Some(g), Some(_)) if job.src.gpu_display => g.render_display(&r, job.comp, job.t), + _ => None, + }; + match gpu_frame { + Some(f) => FrameImage::Gpu(Arc::new(f)), + None => FrameImage::Cpu(Arc::new(to_color_image(&r.comp_frame(job.comp, job.t)))), + } + } } struct Job { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 071c1948..ec6a2278 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -308,6 +308,7 @@ impl EffectcraftApp { layer_cache: self.session.layer_cache.clone(), gpu: self.gpu.clone(), gpu_display: false, + disk: self.session.disk_cache.clone(), } } @@ -405,13 +406,11 @@ impl EffectcraftApp { self.request_frame_with(comp, frame, scale, true); } - fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) { - let Some(c) = self.session.project.comp(comp) else { return }; - let key = self.frame_key(comp, frame, scale); - let t = c.frame_rate.tick_of(frame); + /// Render options of viewer frames of `comp` at `scale`. + pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts { let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); - let opts = RenderOpts { + RenderOpts { scale, motion_blur: true, guides: true, @@ -419,7 +418,14 @@ impl EffectcraftApp { view: self.session.view_camera(comp), roi, backend: effectcraft_engine::render::Backend::Auto, - }; + } + } + + fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) { + let Some(c) = self.session.project.comp(comp) else { return }; + let key = self.frame_key(comp, frame, scale); + let t = c.frame_rate.tick_of(frame); + let opts = self.frame_opts(comp, scale); if urgent { self.frames.request_urgent(&self.render_source(), key, comp, t, opts); } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index da5eaec2..7ebadbf4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -716,7 +716,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option None, @@ -739,6 +739,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option bool { } /// If `id` is a dialog command invoked without its parameters, open its form and return true. +/// The import includes a Photoshop document. +fn is_layered(p: &Value) -> bool { + let psd = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("psd" | "psb"))); + match p.get("paths").or(p.get("path")) { + Some(Value::Array(a)) => a.iter().any(psd), + Some(v) => psd(v), + None => false, + } +} + pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let s = &app.session; let comp = s.active_comp(); @@ -116,6 +126,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); let base = p.clone(); let (title, fields): (String, Vec) = match id { + // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). + "file.import" if !has(p, &["importAs"]) && is_layered(p) => ( + "Import Photoshop File".into(), + vec![Field::choice( + "importAs", + "Import Kind", + &[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))], + 1, + )], + ), "layer.setTransform" if !has(p, &["value"]) => { let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position"); let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5b6c085f..785491d7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -602,6 +602,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some((a, b)) = run { draw_run(a, b); } + // Disk-cached frames not in RAM (blue), like After Effects. + if app.session.disk_cache.is_some() { + let frames = comp.frame_rate.frame_at(comp.duration); + let opts = app.frame_opts(cid, scale_key as f64 / 1000.0); + let disk = app.frames.disk_frames(&app.render_source(), app.session.revision, cid.0, scale_key, app.view_hash(cid), frames, &opts); + let draw_blue = |a: i64, b: i64| { + let x0 = tm.x(a as f64 * fd); + let x1 = tm.x((b + 1) as f64 * fd); + p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue); + }; + let mut run: Option<(i64, i64)> = None; + for f in disk { + run = match run { + Some((a, b)) if f == b + 1 => Some((a, f)), + Some((a, b)) => { + draw_blue(a, b); + Some((f, f)) + } + None => Some((f, f)), + }; + } + if let Some((a, b)) = run { + draw_blue(a, b); + } + } // Ticks + labels. // Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels). let fps_i = fr.as_f64().round().max(1.0) as i64; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 5c82e916..c32ecb24 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -60,6 +60,8 @@ pub struct Tokens { pub cti: Color32, pub work_area: Color32, pub cache_green: Color32, + /// Timeline cache bar for frames held in the disk cache (not in RAM). + pub cache_blue: Color32, pub keyframe: Color32, pub keyframe_selected: Color32, pub pasteboard: Color32, @@ -108,6 +110,7 @@ impl Tokens { cti: Color32::from_rgb(0x2d, 0x8c, 0xeb), work_area: Color32::from_rgb(0x4a, 0x4a, 0x4a), cache_green: Color32::from_rgb(0x3c, 0xa6, 0x4c), + cache_blue: Color32::from_rgb(0x3a, 0x6f, 0xd8), keyframe: Color32::from_rgb(0xa8, 0xa8, 0xa8), keyframe_selected: Color32::from_rgb(0x3d, 0x8f, 0xf5), pasteboard: Color32::from_rgb(0x1a, 0x1a, 0x1a), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 76ff6fa3..09e15047 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -27,10 +27,12 @@ const LAYERS: &[(&str, u8)] = &[ ("raster", 1), ("keyframe", 1), ("path", 1), + ("psd", 1), ("project", 2), ("text", 2), ("effects", 2), ("track", 2), + ("svg", 2), ("render", 3), ("media", 3), ("expr", 3),