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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:10:48 -04:00
parent e911cc5c93
commit 94e1c86d89
64 changed files with 7388 additions and 34 deletions

View File

@ -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

44
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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 }

View File

@ -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<Value> {
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<CommandSpec> {
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]
}

View File

@ -525,9 +525,38 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<CommandSpec> {
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),
]
}

View File

@ -84,10 +84,48 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
}
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<u8>, retain: bool) -> Result<(u64, Vec<u64>, Vec<String>)> {
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<Value> {
@ -224,7 +308,15 @@ pub fn specs() -> Vec<CommandSpec> {
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...",

View File

@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
missing: true,
sequence: vec![],
color_profile: None,
layer: None,
}
}

View File

@ -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());

View File

@ -34,7 +34,6 @@ pub fn specs() -> Vec<CommandSpec> {
// 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).

View File

@ -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<LayerCache>,
/// The persistent disk cache (Settings ▸ Media & Disk Cache), when enabled.
pub disk_cache: Option<Arc<effectcraft_render::disk_cache::DiskCache>>,
/// 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).

View File

@ -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<BlendMode> {
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<ItemId>,
pub warnings: Vec<String>,
}
struct Ctx<'a> {
psd: &'a Psd,
path: &'a str,
retain: bool,
folder: ItemId,
rate: FrameRate,
duration: Tick,
items: Vec<ItemId>,
warnings: Vec<String>,
}
/// 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>) -> 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<Layer> {
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<String>, 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<Gradient> {
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<Layer> {
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()));
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -257,6 +257,7 @@ impl Importer for MockImporter {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
})
}
}

View File

@ -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();

View File

@ -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<u8> {
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<String> = 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());
}

250
crates/engine/src/vector.rs Normal file
View File

@ -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<PropGroup> {
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<PropGroup> {
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<PropGroup>, 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<PropGroup> {
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine) -> Vec<PropGroup> {
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<Layer> {
let ctx = effectcraft_render::EvalCtx::new(ctx_project, cid, comp, time);
let geom = effectcraft_render::text::text_geom(&ctx, src)?;
let chars: Vec<char> = 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<ShapePath> {
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<ShapePath>, 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
}

View File

@ -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)

View File

@ -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 }

View File

@ -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}");
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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 }

View File

@ -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 {
}
}
}

View File

@ -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<Option<Footage>> {
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<Option<Image>> {
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<Image> {
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))
}

View File

@ -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<filmcraft_media::MediaError> 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.

View File

@ -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<Arc<Image>> {
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<Vec<f32>> {
if !footage.has_audio || footage.missing {
return None;

View File

@ -33,6 +33,9 @@ pub fn probe_single(path: impl AsRef<Path>) -> Result<Footage> {
/// 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<Footage> {
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<effectcraft_project::Col
}
}
fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha: bool) -> 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,
}
}

View File

@ -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<ColorSpace>,
/// One layer of a layered file (Photoshop) instead of its merged image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer: Option<SourceLayer>,
}
/// 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 {

13
crates/psd/Cargo.toml Normal file
View File

@ -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"

17
crates/psd/README.md Normal file
View File

@ -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)).

View File

@ -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<DValue>),
Double(f64),
/// Unit (`#Pxl`, `#Prc`, `#Ang`, `#Pnt`…) and value.
UnitFloat(String, f64),
UnitFloats(String, Vec<f64>),
Text(String),
/// Enumerated value: type and value ids.
Enum(String, String),
Integer(i32),
LargeInteger(i64),
Bool(bool),
Class(String, String),
Raw(Vec<u8>),
Reference(Vec<RefItem>),
}
/// 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<f64> {
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<bool> {
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<String> {
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<String> {
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<Descriptor> {
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<DValue> {
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<u8>, v: u32) {
out.extend_from_slice(&v.to_be_bytes());
}
fn put_id(out: &mut Vec<u8>, 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<u8>, s: &str) {
let v: Vec<u16> = 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<u8>, 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<u8>, 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);
}
}

View File

@ -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<EValue>),
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::<usize>() {
Ok(i) => cur.idx(i)?,
Err(_) => cur.get(seg)?,
};
}
Some(cur)
}
pub fn num(&self) -> Option<f64> {
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<EValue> {
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::<f64>().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<u16> = 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<u8> {
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<String>,
pub size: Option<f64>,
/// RGB 0..1.
pub color: Option<[f64; 3]>,
/// 0 left, 1 right, 2 centre, 3+ justified variants.
pub justification: Option<u8>,
pub tracking: Option<f64>,
pub leading: Option<f64>,
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<u8> {
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]);
}
}

965
crates/psd/src/lib.rs Normal file
View File

@ -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<T> = std::result::Result<T, Error>;
/// 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<ColorMode> {
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 "</Layer group>" 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<SubPath>,
}
#[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<u32>,
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<ChannelInfo>,
pub mask: Option<LayerMask>,
pub text: Option<TextInfo>,
pub adjustment: Option<Adjustment>,
/// Solid colour fill layer (`SoCo`), RGB 0..1.
pub fill: Option<[f64; 3]>,
/// Layer effects (`lfx2` descriptor).
pub effects: Option<Descriptor>,
pub vector_mask: Option<VectorMask>,
/// 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<Node> },
}
/// 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<Layer>,
/// 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<u8>,
data: Arc<[u8]>,
merged_offset: usize,
}
impl Psd {
pub fn parse(data: impl Into<Arc<[u8]>>) -> Result<Psd> {
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<Layer> {
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<VectorMask> {
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<f64> { 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<Node> {
let mut stack: Vec<Vec<Node>> = 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<Node>) {
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<Vec<f32>> {
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<f32> {
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<Vec<f32>>], 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<Pixels> {
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<Option<Vec<f32>>> = 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<Pixels> {
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<Option<Vec<f32>>> = 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<u8>) -> 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<u8> {
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;

86
crates/psd/src/read.rs Normal file
View File

@ -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<u8> {
Ok(self.bytes(1)?[0])
}
pub fn u16(&mut self) -> Result<u16> {
let b = self.bytes(2)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
pub fn i16(&mut self) -> Result<i16> {
Ok(self.u16()? as i16)
}
pub fn u32(&mut self) -> Result<u32> {
let b = self.bytes(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
pub fn i32(&mut self) -> Result<i32> {
Ok(self.u32()? as i32)
}
pub fn u64(&mut self) -> Result<u64> {
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<f32> {
Ok(f32::from_bits(self.u32()?))
}
pub fn f64(&mut self) -> Result<f64> {
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<usize> {
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<String> {
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('?')
}

189
crates/psd/src/tests.rs Normal file
View File

@ -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<u8>>(), 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());
}

491
crates/psd/src/write.rs Normal file
View File

@ -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<f32>,
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<WMask>,
pub section: Section,
/// Extra additional-information blocks (key, data).
pub blocks: Vec<([u8; 4], Vec<u8>)>,
}
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("</Layer group>", 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<u8>)>) -> 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<u8>)) -> 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<WLayer>,
/// Merged image (straight RGBA, document-sized); `None` composites visible pixel layers
/// with Normal blending.
pub composite: Option<Vec<[f32; 4]>>,
}
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<u8>, v: u16) {
out.extend_from_slice(&v.to_be_bytes());
}
fn u32b(out: &mut Vec<u8>, 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<f32> {
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<u8> {
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<u8> {
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<Vec<u8>> = 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<u8>, s: &str) {
let b: Vec<u8> = 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<u8>, 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<u8> {
let mut out = vec![];
out.extend_from_slice(&(-(doc.layers.len() as i16)).to_be_bytes());
let mut chan_data: Vec<Vec<(i16, Vec<u8>)>> = 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<u8>)> = vec![];
let n = w * h;
let alpha: Vec<f32> = 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<Vec<f32>> = l.pixels.iter().take(n).map(|p| mode_channels(doc.mode, *p)).collect();
for c in 0..cc {
let plane: Vec<f32> = 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<u8> {
let v: Vec<u16> = 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<u8> {
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<Vec<f32>> = comp.iter().map(|p| mode_channels(doc.mode, *p)).collect();
let mut chans: Vec<Vec<f32>> = (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<Vec<Vec<u8>>> =
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<u8>) {
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<u8>) {
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<u8>) {
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<u8>) {
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<u8>) {
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<u8>) {
(*b"nvrt", vec![])
}
pub fn brightness_block(brightness: i16, contrast: i16) -> ([u8; 4], Vec<u8>) {
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<u8>) {
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<u8>) {
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)
}

View File

@ -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 }

View File

@ -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));

View File

@ -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<Inner>,
disk: Mutex<Option<(Arc<crate::disk_cache::DiskCache>, 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<u64, web_time::Instant>,
}
/// 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<crate::disk_cache::DiskCache>, 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<Arc<crate::disk_cache::DiskCache>> {
self.disk.lock().ok()?.as_ref().map(|d| d.0.clone())
}
fn disk_key(&self, key: u64) -> Option<(Arc<crate::disk_cache::DiskCache>, 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<Arc<Buf>> {
@ -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<Buf>) {
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<Buf>) {
let bytes = buf.img.data.len() * std::mem::size_of::<effectcraft_raster::Px>();
let Ok(mut g) = self.inner.lock() else { return };
if bytes > g.budget / 4 {

View File

@ -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 (`<folder>/v1/<kind>/<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<u8>,
}
/// 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<u8>),
}
struct Queue {
jobs: std::collections::VecDeque<Job>,
busy: bool,
}
pub struct DiskCache {
dir: PathBuf,
index: Mutex<Index>,
max_bytes: AtomicU64,
hits: AtomicU64,
misses: AtomicU64,
writes: AtomicU64,
evictions: AtomicU64,
dropped: AtomicU64,
tmp_counter: AtomicU64,
queue: Arc<(Mutex<Queue>, 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<Path>, max_bytes: u64) -> std::io::Result<Arc<DiskCache>> {
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<u128> {
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<Vec<u8>> {
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<u8>) {
#[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<Frame8> {
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<Buf> {
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<DiskCache>, q: Arc<(Mutex<Queue>, 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<u8> {
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<Frame8> {
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<u8> {
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<Buf> {
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<f64> { 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<effectcraft_project::ItemId> = 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<u8> = (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);
}
}
}

View File

@ -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<usize> {
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<Arc<Image>> {
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 };

View File

@ -51,6 +51,7 @@ fn audio_footage() -> Footage {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
}
}

View File

@ -172,6 +172,7 @@ fn footage(profile: Option<ColorSpace>) -> Footage {
missing: false,
sequence: vec![],
color_profile: profile,
layer: None,
}
}

View File

@ -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);

18
crates/svg/Cargo.toml Normal file
View File

@ -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 }

26
crates/svg/README.md Normal file
View File

@ -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 `<style>` sheets with
type, class, id and universal selectors (simple selectors and selector lists).
Not supported (ignored): text, images, filters, clip paths, masks, patterns, markers, animation.
XML parsing uses `roxmltree` (MIT/Apache-2.0). Test fixtures are original work (see
`tests/fixtures/*.attribution`).

254
crates/svg/src/color.rs Normal file
View File

@ -0,0 +1,254 @@
//! CSS colour values (CSS Color Module Level 3): keywords, `#rgb[a]`, `#rrggbb[aa]`, `rgb[a]()`,
//! `hsl[a]()`.
/// The CSS colour keywords (CSS Color Module Level 3, §4.3 extended colour keywords).
const NAMED: &[(&str, u32)] = &[
("aliceblue", 0xF0F8FF),
("antiquewhite", 0xFAEBD7),
("aqua", 0x00FFFF),
("aquamarine", 0x7FFFD4),
("azure", 0xF0FFFF),
("beige", 0xF5F5DC),
("bisque", 0xFFE4C4),
("black", 0x000000),
("blanchedalmond", 0xFFEBCD),
("blue", 0x0000FF),
("blueviolet", 0x8A2BE2),
("brown", 0xA52A2A),
("burlywood", 0xDEB887),
("cadetblue", 0x5F9EA0),
("chartreuse", 0x7FFF00),
("chocolate", 0xD2691E),
("coral", 0xFF7F50),
("cornflowerblue", 0x6495ED),
("cornsilk", 0xFFF8DC),
("crimson", 0xDC143C),
("cyan", 0x00FFFF),
("darkblue", 0x00008B),
("darkcyan", 0x008B8B),
("darkgoldenrod", 0xB8860B),
("darkgray", 0xA9A9A9),
("darkgreen", 0x006400),
("darkgrey", 0xA9A9A9),
("darkkhaki", 0xBDB76B),
("darkmagenta", 0x8B008B),
("darkolivegreen", 0x556B2F),
("darkorange", 0xFF8C00),
("darkorchid", 0x9932CC),
("darkred", 0x8B0000),
("darksalmon", 0xE9967A),
("darkseagreen", 0x8FBC8F),
("darkslateblue", 0x483D8B),
("darkslategray", 0x2F4F4F),
("darkslategrey", 0x2F4F4F),
("darkturquoise", 0x00CED1),
("darkviolet", 0x9400D3),
("deeppink", 0xFF1493),
("deepskyblue", 0x00BFFF),
("dimgray", 0x696969),
("dimgrey", 0x696969),
("dodgerblue", 0x1E90FF),
("firebrick", 0xB22222),
("floralwhite", 0xFFFAF0),
("forestgreen", 0x228B22),
("fuchsia", 0xFF00FF),
("gainsboro", 0xDCDCDC),
("ghostwhite", 0xF8F8FF),
("gold", 0xFFD700),
("goldenrod", 0xDAA520),
("gray", 0x808080),
("grey", 0x808080),
("green", 0x008000),
("greenyellow", 0xADFF2F),
("honeydew", 0xF0FFF0),
("hotpink", 0xFF69B4),
("indianred", 0xCD5C5C),
("indigo", 0x4B0082),
("ivory", 0xFFFFF0),
("khaki", 0xF0E68C),
("lavender", 0xE6E6FA),
("lavenderblush", 0xFFF0F5),
("lawngreen", 0x7CFC00),
("lemonchiffon", 0xFFFACD),
("lightblue", 0xADD8E6),
("lightcoral", 0xF08080),
("lightcyan", 0xE0FFFF),
("lightgoldenrodyellow", 0xFAFAD2),
("lightgray", 0xD3D3D3),
("lightgreen", 0x90EE90),
("lightgrey", 0xD3D3D3),
("lightpink", 0xFFB6C1),
("lightsalmon", 0xFFA07A),
("lightseagreen", 0x20B2AA),
("lightskyblue", 0x87CEFA),
("lightslategray", 0x778899),
("lightslategrey", 0x778899),
("lightsteelblue", 0xB0C4DE),
("lightyellow", 0xFFFFE0),
("lime", 0x00FF00),
("limegreen", 0x32CD32),
("linen", 0xFAF0E6),
("magenta", 0xFF00FF),
("maroon", 0x800000),
("mediumaquamarine", 0x66CDAA),
("mediumblue", 0x0000CD),
("mediumorchid", 0xBA55D3),
("mediumpurple", 0x9370DB),
("mediumseagreen", 0x3CB371),
("mediumslateblue", 0x7B68EE),
("mediumspringgreen", 0x00FA9A),
("mediumturquoise", 0x48D1CC),
("mediumvioletred", 0xC71585),
("midnightblue", 0x191970),
("mintcream", 0xF5FFFA),
("mistyrose", 0xFFE4E1),
("moccasin", 0xFFE4B5),
("navajowhite", 0xFFDEAD),
("navy", 0x000080),
("oldlace", 0xFDF5E6),
("olive", 0x808000),
("olivedrab", 0x6B8E23),
("orange", 0xFFA500),
("orangered", 0xFF4500),
("orchid", 0xDA70D6),
("palegoldenrod", 0xEEE8AA),
("palegreen", 0x98FB98),
("paleturquoise", 0xAFEEEE),
("palevioletred", 0xDB7093),
("papayawhip", 0xFFEFD5),
("peachpuff", 0xFFDAB9),
("peru", 0xCD853F),
("pink", 0xFFC0CB),
("plum", 0xDDA0DD),
("powderblue", 0xB0E0E6),
("purple", 0x800080),
("rebeccapurple", 0x663399),
("red", 0xFF0000),
("rosybrown", 0xBC8F8F),
("royalblue", 0x4169E1),
("saddlebrown", 0x8B4513),
("salmon", 0xFA8072),
("sandybrown", 0xF4A460),
("seagreen", 0x2E8B57),
("seashell", 0xFFF5EE),
("sienna", 0xA0522D),
("silver", 0xC0C0C0),
("skyblue", 0x87CEEB),
("slateblue", 0x6A5ACD),
("slategray", 0x708090),
("slategrey", 0x708090),
("snow", 0xFFFAFA),
("springgreen", 0x00FF7F),
("steelblue", 0x4682B4),
("tan", 0xD2B48C),
("teal", 0x008080),
("thistle", 0xD8BFD8),
("tomato", 0xFF6347),
("turquoise", 0x40E0D0),
("violet", 0xEE82EE),
("wheat", 0xF5DEB3),
("white", 0xFFFFFF),
("whitesmoke", 0xF5F5F5),
("yellow", 0xFFFF00),
("yellowgreen", 0x9ACD32),
];
/// Parse a CSS colour into RGBA 0..1 (`transparent` is RGBA 0). `None` for anything else
/// (`none`, `currentColor` and `url()` are handled by the caller).
pub fn parse_color(s: &str) -> Option<[f64; 4]> {
let s = s.trim();
let lower = s.to_ascii_lowercase();
if lower == "transparent" {
return Some([0.0; 4]);
}
if let Some(h) = lower.strip_prefix('#') {
let d: Vec<u32> = h.chars().map(|c| c.to_digit(16)).collect::<Option<_>>()?;
let v = |i: usize| d[i] as f64 / 15.0;
let w = |i: usize| (d[i] * 16 + d[i + 1]) as f64 / 255.0;
return Some(match d.len() {
3 => [v(0), v(1), v(2), 1.0],
4 => [v(0), v(1), v(2), v(3)],
6 => [w(0), w(2), w(4), 1.0],
8 => [w(0), w(2), w(4), w(6)],
_ => return None,
});
}
if let Some(args) = func(&lower, "rgba").or_else(|| func(&lower, "rgb")) {
let a: Vec<&str> = split_args(args);
if a.len() < 3 {
return None;
}
let comp = |t: &str| -> Option<f64> {
Some(if let Some(p) = t.strip_suffix('%') { p.trim().parse::<f64>().ok()? / 100.0 } else { t.parse::<f64>().ok()? / 255.0 })
};
let alpha = a.get(3).map_or(Some(1.0), |t| alpha(t))?;
return Some([comp(a[0])?.clamp(0.0, 1.0), comp(a[1])?.clamp(0.0, 1.0), comp(a[2])?.clamp(0.0, 1.0), alpha]);
}
if let Some(args) = func(&lower, "hsla").or_else(|| func(&lower, "hsl")) {
let a: Vec<&str> = split_args(args);
if a.len() < 3 {
return None;
}
let h = a[0].trim_end_matches("deg").parse::<f64>().ok()?.rem_euclid(360.0) / 360.0;
let s = a[1].trim_end_matches('%').parse::<f64>().ok()?.clamp(0.0, 100.0) / 100.0;
let l = a[2].trim_end_matches('%').parse::<f64>().ok()?.clamp(0.0, 100.0) / 100.0;
let al = a.get(3).map_or(Some(1.0), |t| alpha(t))?;
let [r, g, b] = hsl(h, s, l);
return Some([r, g, b, al]);
}
NAMED.iter().find(|(n, _)| *n == lower).map(|(_, v)| [((v >> 16) & 255) as f64 / 255.0, ((v >> 8) & 255) as f64 / 255.0, (v & 255) as f64 / 255.0, 1.0])
}
fn func<'a>(s: &'a str, name: &str) -> Option<&'a str> {
s.strip_prefix(name)?.trim_start().strip_prefix('(')?.strip_suffix(')')
}
fn split_args(s: &str) -> Vec<&str> {
s.split(|c: char| c == ',' || c == '/' || c.is_whitespace()).filter(|t| !t.is_empty()).collect()
}
fn alpha(t: &str) -> Option<f64> {
Some(if let Some(p) = t.strip_suffix('%') { p.parse::<f64>().ok()? / 100.0 } else { t.parse::<f64>().ok()? }.clamp(0.0, 1.0))
}
/// CSS HSL → RGB (CSS Color 3 §4.2.4).
fn hsl(h: f64, s: f64, l: f64) -> [f64; 3] {
let m2 = if l <= 0.5 { l * (s + 1.0) } else { l + s - l * s };
let m1 = l * 2.0 - m2;
let hue = |mut h: f64| {
if h < 0.0 {
h += 1.0;
}
if h > 1.0 {
h -= 1.0;
}
if h * 6.0 < 1.0 {
m1 + (m2 - m1) * h * 6.0
} else if h * 2.0 < 1.0 {
m2
} else if h * 3.0 < 2.0 {
m1 + (m2 - m1) * (2.0 / 3.0 - h) * 6.0
} else {
m1
}
};
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
}
#[cfg(test)]
mod tests {
use super::parse_color;
#[test]
fn colors() {
assert_eq!(parse_color("#f00"), Some([1.0, 0.0, 0.0, 1.0]));
assert_eq!(parse_color("#00ff0080").map(|c| (c[1], (c[3] * 255.0).round())), Some((1.0, 128.0)));
assert_eq!(parse_color("rgb(255, 0, 0)"), Some([1.0, 0.0, 0.0, 1.0]));
assert_eq!(parse_color("rgba(0,0,255,0.5)"), Some([0.0, 0.0, 1.0, 0.5]));
assert_eq!(parse_color("rgb(100%, 50%, 0%)"), Some([1.0, 0.5, 0.0, 1.0]));
assert_eq!(parse_color("hsl(120, 100%, 50%)"), Some([0.0, 1.0, 0.0, 1.0]));
assert_eq!(parse_color("CornflowerBlue"), Some([100.0 / 255.0, 149.0 / 255.0, 237.0 / 255.0, 1.0]));
assert_eq!(parse_color("transparent"), Some([0.0; 4]));
assert_eq!(parse_color("nonsense"), None);
}
}

949
crates/svg/src/lib.rs Normal file
View File

@ -0,0 +1,949 @@
//! SVG import (see README.md for the supported subset and the specifications used).
//!
//! [`parse`] resolves styles, `use` references and gradients into a small render tree
//! ([`Doc`]): groups with transforms and opacity, and shapes with fills and strokes in their own
//! user space. [`rasterize`] draws it at any scale; the engine converts it to shape layers.
mod color;
mod pathdata;
mod raster;
use std::collections::HashMap;
pub use color::parse_color;
pub use kurbo::{Affine, BezPath};
pub use pathdata::parse_path_data;
pub use raster::{rasterize, rasterize_with};
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
Xml(String),
NotSvg,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Xml(e) => write!(f, "invalid SVG: {e}"),
Error::NotSvg => write!(f, "not an SVG document"),
}
}
}
impl std::error::Error for Error {}
/// Quick sniff: an XML document whose root looks like `<svg`.
pub fn looks_like_svg(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(2048)];
let s = String::from_utf8_lossy(head);
s.contains("<svg") && (s.trim_start().starts_with('<') || s.starts_with('\u{feff}'))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FillRule {
#[default]
NonZero,
EvenOdd,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Cap {
#[default]
Butt,
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Join {
#[default]
Miter,
Round,
Bevel,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Spread {
#[default]
Pad,
Reflect,
Repeat,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GradientKind {
Linear { x1: f64, y1: f64, x2: f64, y2: f64 },
Radial { cx: f64, cy: f64, r: f64, fx: f64, fy: f64 },
}
#[derive(Clone, Debug, PartialEq)]
pub struct Gradient {
pub kind: GradientKind,
/// (offset 0..1, straight RGBA 0..1), sorted.
pub stops: Vec<(f64, [f64; 4])>,
/// `gradientUnits="objectBoundingBox"` (the default): coordinates are fractions of the
/// shape's bounding box.
pub bbox_units: bool,
pub transform: Affine,
pub spread: Spread,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Paint {
Color([f64; 3]),
Gradient(Gradient),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Stroke {
pub paint: Paint,
pub opacity: f64,
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter: f64,
/// Dash array and offset.
pub dash: Option<(Vec<f64>, f64)>,
}
/// Shape geometry in its user space (basic shapes are kept so they can become parametric shape
/// layer items).
#[derive(Clone, Debug, PartialEq)]
pub enum Geom {
Rect { x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64 },
Ellipse { cx: f64, cy: f64, rx: f64, ry: f64 },
Path(BezPath),
}
impl Geom {
pub fn to_path(&self) -> BezPath {
use kurbo::Shape as _;
match self {
Geom::Rect { x, y, w, h, rx, ry } => {
let r = kurbo::Rect::new(*x, *y, x + w, y + h);
if *rx > 0.0 || *ry > 0.0 {
if (rx - ry).abs() < 1e-9 { kurbo::RoundedRect::from_rect(r, *rx).to_path(0.01) } else { elliptic_round_rect(*x, *y, *w, *h, *rx, *ry) }
} else {
r.to_path(0.01)
}
}
Geom::Ellipse { cx, cy, rx, ry } => kurbo::Ellipse::new((*cx, *cy), (*rx, *ry), 0.0).to_path(0.01),
Geom::Path(p) => p.clone(),
}
}
}
fn elliptic_round_rect(x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64) -> BezPath {
const K: f64 = 0.552_284_749_830_793_6;
let (kx, ky) = (rx * K, ry * K);
let mut p = BezPath::new();
p.move_to((x + rx, y));
p.line_to((x + w - rx, y));
p.curve_to((x + w - rx + kx, y), (x + w, y + ry - ky), (x + w, y + ry));
p.line_to((x + w, y + h - ry));
p.curve_to((x + w, y + h - ry + ky), (x + w - rx + kx, y + h), (x + w - rx, y + h));
p.line_to((x + rx, y + h));
p.curve_to((x + rx - kx, y + h), (x, y + h - ry + ky), (x, y + h - ry));
p.line_to((x, y + ry));
p.curve_to((x, y + ry - ky), (x + rx - kx, y), (x + rx, y));
p.close_path();
p
}
#[derive(Clone, Debug, PartialEq)]
pub struct Shape {
/// Element id, or the element name (`rect`, `path`…).
pub name: String,
pub geom: Geom,
/// User space → parent space.
pub transform: Affine,
pub fill: Option<Paint>,
pub fill_opacity: f64,
pub fill_rule: FillRule,
pub stroke: Option<Stroke>,
pub opacity: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Group {
pub name: String,
pub transform: Affine,
pub opacity: f64,
pub children: Vec<Node>,
}
#[derive(Clone, Debug, PartialEq)]
#[allow(clippy::large_enum_variant)]
pub enum Node {
Group(Group),
Shape(Shape),
}
/// A parsed SVG document. `root.transform` maps the viewBox to `width`×`height` pixels.
#[derive(Clone, Debug, PartialEq)]
pub struct Doc {
pub width: f64,
pub height: f64,
pub root: Group,
/// Elements that were skipped (text, image, filter…), by name.
pub skipped: Vec<String>,
}
impl Doc {
/// Pixel size (rounded up, at least 1×1).
pub fn pixel_size(&self) -> (u32, u32) {
(self.width.ceil().clamp(1.0, 30000.0) as u32, self.height.ceil().clamp(1.0, 30000.0) as u32)
}
/// Every shape with its accumulated transform (to document pixels) and opacity, in paint
/// order.
pub fn flatten(&self) -> Vec<(Affine, f64, &Shape)> {
fn walk<'a>(g: &'a Group, m: Affine, o: f64, out: &mut Vec<(Affine, f64, &'a Shape)>) {
let m = m * g.transform;
let o = o * g.opacity;
for c in &g.children {
match c {
Node::Group(sub) => walk(sub, m, o, out),
Node::Shape(s) => out.push((m * s.transform, o * s.opacity, s)),
}
}
}
let mut out = vec![];
walk(&self.root, Affine::IDENTITY, 1.0, &mut out);
out
}
}
// ---------------------------------------------------------------- styles
/// Inherited style state.
#[derive(Clone, Debug)]
struct Style {
fill: Option<PaintRef>,
fill_opacity: f64,
fill_rule: FillRule,
stroke: Option<PaintRef>,
stroke_opacity: f64,
stroke_width: f64,
cap: Cap,
join: Join,
miter: f64,
dash: Option<Vec<f64>>,
dash_offset: f64,
color: [f64; 4],
visible: bool,
font_size: f64,
}
impl Default for Style {
fn default() -> Self {
Style {
fill: Some(PaintRef::Color([0.0, 0.0, 0.0, 1.0])),
fill_opacity: 1.0,
fill_rule: FillRule::NonZero,
stroke: None,
stroke_opacity: 1.0,
stroke_width: 1.0,
cap: Cap::Butt,
join: Join::Miter,
miter: 4.0,
dash: None,
dash_offset: 0.0,
color: [0.0, 0.0, 0.0, 1.0],
visible: true,
font_size: 16.0,
}
}
}
#[derive(Clone, Debug)]
enum PaintRef {
Color([f64; 4]),
Url(String, Option<[f64; 4]>),
Current,
}
/// A CSS rule: selector (type, class, id; `*`) with its specificity and declarations.
#[derive(Clone, Debug)]
struct Rule {
tag: Option<String>,
classes: Vec<String>,
id: Option<String>,
specificity: u32,
order: usize,
decls: Vec<(String, String)>,
}
fn parse_decls(s: &str) -> Vec<(String, String)> {
s.split(';')
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
let v = v.trim().trim_end_matches("!important").trim();
Some((k.trim().to_ascii_lowercase(), v.to_string()))
})
.filter(|(k, _)| !k.is_empty())
.collect()
}
fn strip_comments(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(i) = rest.find("/*") {
out.push_str(&rest[..i]);
match rest[i + 2..].find("*/") {
Some(j) => rest = &rest[i + 2 + j + 2..],
None => return out,
}
}
out.push_str(rest);
out
}
fn parse_css(css: &str, rules: &mut Vec<Rule>) {
let css = strip_comments(css);
let mut rest = css.as_str();
while let Some(open) = rest.find('{') {
let sel = rest[..open].trim();
let Some(close) = rest[open..].find('}') else { break };
let body = &rest[open + 1..open + close];
rest = &rest[open + close + 1..];
if sel.starts_with('@') {
continue;
}
let decls = parse_decls(body);
for one in sel.split(',') {
let one = one.trim();
// Only simple selectors (no combinators / pseudo-classes / attributes).
if one.is_empty() || one.contains([' ', '>', '+', '~', ':', '[']) {
continue;
}
let mut tag = None;
let mut classes = vec![];
let mut id = None;
let mut cur = String::new();
let mut kind = 't';
let flush = |kind: char, cur: &mut String, tag: &mut Option<String>, classes: &mut Vec<String>, id: &mut Option<String>| {
if !cur.is_empty() {
match kind {
'.' => classes.push(std::mem::take(cur)),
'#' => *id = Some(std::mem::take(cur)),
_ => {
if cur != "*" {
*tag = Some(std::mem::take(cur));
}
cur.clear();
}
}
}
};
for c in one.chars() {
if c == '.' || c == '#' {
flush(kind, &mut cur, &mut tag, &mut classes, &mut id);
kind = c;
} else {
cur.push(c);
}
}
flush(kind, &mut cur, &mut tag, &mut classes, &mut id);
let specificity = id.is_some() as u32 * 100 + classes.len() as u32 * 10 + tag.is_some() as u32;
let order = rules.len();
rules.push(Rule { tag, classes, id, specificity, order, decls: decls.clone() });
}
}
}
const PROPS: &[&str] = &[
"fill",
"fill-opacity",
"fill-rule",
"stroke",
"stroke-width",
"stroke-opacity",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-dasharray",
"stroke-dashoffset",
"opacity",
"display",
"visibility",
"color",
"font-size",
"transform",
"stop-color",
"stop-opacity",
];
struct Parser<'a, 'i> {
ids: HashMap<String, roxmltree::Node<'a, 'i>>,
rules: Vec<Rule>,
vw: f64,
vh: f64,
skipped: Vec<String>,
}
fn is_svg_ns(n: &roxmltree::Node) -> bool {
n.is_element()
}
impl<'a, 'i> Parser<'a, 'i> {
/// The declared properties of an element: presentation attributes, then CSS rules (by
/// specificity), then the `style` attribute.
fn declared(&self, n: roxmltree::Node) -> HashMap<String, String> {
let mut out = HashMap::new();
for a in n.attributes() {
if PROPS.contains(&a.name()) && a.name() != "transform" {
out.insert(a.name().to_string(), a.value().to_string());
}
}
let tag = n.tag_name().name();
let classes: Vec<&str> = n.attribute("class").map(|c| c.split_whitespace().collect()).unwrap_or_default();
let id = n.attribute("id");
let mut matching: Vec<&Rule> = self
.rules
.iter()
.filter(|r| {
r.tag.as_deref().is_none_or(|t| t == tag)
&& r.id.as_deref().is_none_or(|i| Some(i) == id)
&& r.classes.iter().all(|c| classes.contains(&c.as_str()))
})
.collect();
matching.sort_by_key(|r| (r.specificity, r.order));
for r in matching {
for (k, v) in &r.decls {
out.insert(k.clone(), v.clone());
}
}
if let Some(st) = n.attribute("style") {
for (k, v) in parse_decls(st) {
out.insert(k, v);
}
}
out
}
fn length(&self, s: &str, axis: char, font: f64) -> Option<f64> {
let s = s.trim();
let num_end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))).unwrap_or(s.len());
// "e" also starts "em"/"ex": back off if the number does not parse.
let (mut num, mut unit) = (&s[..num_end], &s[num_end..]);
if num.parse::<f64>().is_err() && (num.ends_with('e') || num.ends_with('E')) {
num = &s[..num_end - 1];
unit = &s[num_end - 1..];
}
let v: f64 = num.parse().ok()?;
Some(match unit.trim() {
"" | "px" => v,
"pt" => v * 4.0 / 3.0,
"pc" => v * 16.0,
"mm" => v * 96.0 / 25.4,
"cm" => v * 96.0 / 2.54,
"in" => v * 96.0,
"em" => v * font,
"ex" => v * font / 2.0,
"%" => {
let base = match axis {
'x' => self.vw,
'y' => self.vh,
_ => ((self.vw * self.vw + self.vh * self.vh) / 2.0).sqrt(),
};
v / 100.0 * base
}
_ => v,
})
}
fn attr_len(&self, n: roxmltree::Node, name: &str, axis: char, st: &Style) -> f64 {
n.attribute(name).and_then(|v| self.length(v, axis, st.font_size)).unwrap_or(0.0)
}
fn paint_ref(&self, v: &str) -> Option<Option<PaintRef>> {
let v = v.trim();
if v == "none" {
return Some(None);
}
if v == "currentColor" || v == "currentcolor" {
return Some(Some(PaintRef::Current));
}
if let Some(rest) = v.strip_prefix("url(") {
let (url, fallback) = rest.split_once(')').unwrap_or((rest, ""));
let id = url.trim().trim_matches(['"', '\'']).trim_start_matches('#').to_string();
let fb = fallback.trim();
let fb = if fb.is_empty() || fb == "none" { None } else { parse_color(fb) };
return Some(Some(PaintRef::Url(id, fb)));
}
if v == "inherit" {
return None;
}
parse_color(v).map(|c| Some(PaintRef::Color(c)))
}
/// Apply an element's declared inherited properties to `st`. Returns (opacity, display).
fn cascade(&self, n: roxmltree::Node, st: &mut Style) -> (f64, bool) {
let d = self.declared(n);
let num =
|k: &str| d.get(k).and_then(|v| v.trim().trim_end_matches('%').parse::<f64>().ok().map(|x| if v.trim().ends_with('%') { x / 100.0 } else { x }));
if let Some(c) = d.get("color").and_then(|v| parse_color(v)) {
st.color = c;
}
if let Some(v) = d.get("font-size").and_then(|v| self.length(v, 'o', st.font_size)) {
st.font_size = v;
}
if let Some(p) = d.get("fill").and_then(|v| self.paint_ref(v)) {
st.fill = p;
}
if let Some(p) = d.get("stroke").and_then(|v| self.paint_ref(v)) {
st.stroke = p;
}
if let Some(v) = num("fill-opacity") {
st.fill_opacity = v.clamp(0.0, 1.0);
}
if let Some(v) = num("stroke-opacity") {
st.stroke_opacity = v.clamp(0.0, 1.0);
}
if let Some(v) = d.get("stroke-width").and_then(|v| self.length(v, 'o', st.font_size)) {
st.stroke_width = v.max(0.0);
}
if let Some(v) = num("stroke-miterlimit") {
st.miter = v.max(1.0);
}
if let Some(v) = d.get("fill-rule") {
st.fill_rule = if v.trim() == "evenodd" { FillRule::EvenOdd } else { FillRule::NonZero };
}
if let Some(v) = d.get("stroke-linecap") {
st.cap = match v.trim() {
"round" => Cap::Round,
"square" => Cap::Square,
_ => Cap::Butt,
};
}
if let Some(v) = d.get("stroke-linejoin") {
st.join = match v.trim() {
"round" => Join::Round,
"bevel" => Join::Bevel,
_ => Join::Miter,
};
}
if let Some(v) = d.get("stroke-dasharray") {
st.dash = if v.trim() == "none" {
None
} else {
let a: Vec<f64> =
v.split(|c: char| c == ',' || c.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| self.length(t, 'o', st.font_size)).collect();
if a.iter().all(|x| *x >= 0.0) && a.iter().any(|x| *x > 0.0) { Some(a) } else { None }
};
}
if let Some(v) = d.get("stroke-dashoffset").and_then(|v| self.length(v, 'o', st.font_size)) {
st.dash_offset = v;
}
if let Some(v) = d.get("visibility") {
st.visible = !matches!(v.trim(), "hidden" | "collapse");
}
let display = d.get("display").is_none_or(|v| v.trim() != "none");
(num("opacity").unwrap_or(1.0).clamp(0.0, 1.0), display)
}
fn resolve_paint(&self, p: &PaintRef, st: &Style) -> Option<(Paint, f64)> {
match p {
PaintRef::Color(c) => Some((Paint::Color([c[0], c[1], c[2]]), c[3])),
PaintRef::Current => Some((Paint::Color([st.color[0], st.color[1], st.color[2]]), st.color[3])),
PaintRef::Url(id, fb) => match self.gradient(id, 0) {
Some(g) if g.stops.len() == 1 => {
let c = g.stops[0].1;
Some((Paint::Color([c[0], c[1], c[2]]), c[3]))
}
Some(g) if !g.stops.is_empty() => Some((Paint::Gradient(g), 1.0)),
Some(_) => None,
None => fb.map(|c| (Paint::Color([c[0], c[1], c[2]]), c[3])),
},
}
}
fn href(&self, n: roxmltree::Node<'a, 'i>) -> Option<roxmltree::Node<'a, 'i>> {
let h = n.attributes().find(|a| a.name() == "href").map(|a| a.value())?;
self.ids.get(h.trim().trim_start_matches('#')).copied()
}
/// A gradient by id, with `href` inheritance of attributes and stops.
fn gradient(&self, id: &str, depth: usize) -> Option<Gradient> {
let n = *self.ids.get(id)?;
let tag = n.tag_name().name();
if !matches!(tag, "linearGradient" | "radialGradient") || depth > 16 {
return None;
}
// Chain: this element, then what it references.
let mut chain = vec![n];
let mut cur = n;
while let Some(next) = self.href(cur) {
if chain.len() > 16 || chain.contains(&next) {
break;
}
chain.push(next);
cur = next;
}
let attr = |name: &str| chain.iter().find_map(|e| e.attribute(name));
let bbox_units = attr("gradientUnits") != Some("userSpaceOnUse");
let st = Style::default();
let len = |name: &str, axis: char, def: &str| -> f64 {
let v = attr(name).unwrap_or(def);
if bbox_units {
let v = v.trim();
if let Some(p) = v.strip_suffix('%') { p.parse::<f64>().unwrap_or(0.0) / 100.0 } else { v.parse::<f64>().unwrap_or(0.0) }
} else {
self.length(v, axis, st.font_size).unwrap_or(0.0)
}
};
let kind = if tag == "linearGradient" {
GradientKind::Linear { x1: len("x1", 'x', "0%"), y1: len("y1", 'y', "0%"), x2: len("x2", 'x', "100%"), y2: len("y2", 'y', "0%") }
} else {
let cx = len("cx", 'x', "50%");
let cy = len("cy", 'y', "50%");
let r = len("r", 'o', "50%");
let fx = if attr("fx").is_some() { len("fx", 'x', "50%") } else { cx };
let fy = if attr("fy").is_some() { len("fy", 'y', "50%") } else { cy };
GradientKind::Radial { cx, cy, r, fx, fy }
};
let transform = attr("gradientTransform").map(parse_transform).unwrap_or(Affine::IDENTITY);
let spread = match attr("spreadMethod") {
Some("reflect") => Spread::Reflect,
Some("repeat") => Spread::Repeat,
_ => Spread::Pad,
};
// Stops: from the first element in the chain that has any.
let mut stops = vec![];
if let Some(src) = chain.iter().find(|e| e.children().any(|c| c.is_element() && c.tag_name().name() == "stop")) {
let mut last = 0.0f64;
for s in src.children().filter(|c| c.is_element() && c.tag_name().name() == "stop") {
let d = self.declared(s);
let off = s
.attribute("offset")
.map(|o| {
let o = o.trim();
if let Some(p) = o.strip_suffix('%') { p.parse::<f64>().unwrap_or(0.0) / 100.0 } else { o.parse::<f64>().unwrap_or(0.0) }
})
.unwrap_or(0.0)
.clamp(0.0, 1.0)
.max(last);
last = off;
let c = d
.get("stop-color")
.and_then(|c| if c.trim() == "currentColor" { d.get("color").and_then(|x| parse_color(x)) } else { parse_color(c) })
.unwrap_or([0.0, 0.0, 0.0, 1.0]);
let o = d.get("stop-opacity").and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(1.0).clamp(0.0, 1.0);
stops.push((off, [c[0], c[1], c[2], c[3] * o]));
}
}
Some(Gradient { kind, stops, bbox_units, transform, spread })
}
fn element_name(n: roxmltree::Node) -> String {
n.attribute("id").map(str::to_string).unwrap_or_else(|| n.tag_name().name().to_string())
}
/// Convert an element (and its subtree) into nodes appended to `out`.
fn node(&mut self, n: roxmltree::Node<'a, 'i>, parent: &Style, out: &mut Vec<Node>, depth: usize) {
if depth > 64 || !is_svg_ns(&n) {
return;
}
let tag = n.tag_name().name();
match tag {
"defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker"
| "script" => return,
"text" | "image" | "filter" | "foreignObject" | "switch" => {
self.skipped.push(tag.to_string());
return;
}
_ => {}
}
let mut st = parent.clone();
let (opacity, display) = self.cascade(n, &mut st);
if !display {
return;
}
let mut transform = n.attribute("transform").map(parse_transform).unwrap_or(Affine::IDENTITY);
match tag {
"g" | "a" => {
let mut children = vec![];
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
}
"svg" => {
// Nested viewport.
let x = self.attr_len(n, "x", 'x', &st);
let y = self.attr_len(n, "y", 'y', &st);
let w = n.attribute("width").and_then(|v| self.length(v, 'x', st.font_size)).unwrap_or(self.vw);
let h = n.attribute("height").and_then(|v| self.length(v, 'y', st.font_size)).unwrap_or(self.vh);
let vb = viewbox_transform(n, w, h);
let mut children = vec![];
let saved = (self.vw, self.vh);
if let Some((_, vw, vh)) = viewbox(n) {
self.vw = vw;
self.vh = vh;
}
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
(self.vw, self.vh) = saved;
out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children }));
}
"use" => {
let Some(target) = self.href(n) else { return };
if target == n || n.ancestors().any(|a| a == target) {
return;
}
let x = self.attr_len(n, "x", 'x', &st);
let y = self.attr_len(n, "y", 'y', &st);
transform *= Affine::translate((x, y));
let mut children = vec![];
if target.tag_name().name() == "symbol" {
let w = n.attribute("width").and_then(|v| self.length(v, 'x', st.font_size)).unwrap_or(self.vw);
let h = n.attribute("height").and_then(|v| self.length(v, 'y', st.font_size)).unwrap_or(self.vh);
let vb = viewbox_transform(target, w, h);
let mut inner = vec![];
let mut sst = st.clone();
let (o, _) = self.cascade(target, &mut sst);
for c in target.children() {
self.node(c, &sst, &mut inner, depth + 1);
}
children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner }));
} else {
self.node(target, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
}
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
if !st.visible {
return;
}
let Some(geom) = self.geometry(n, &st) else { return };
let fill = if matches!(tag, "line") { None } else { st.fill.as_ref().and_then(|p| self.resolve_paint(p, &st)) };
let (fill, fill_alpha) = match fill {
Some((p, a)) => (Some(p), a),
None => (None, 1.0),
};
let stroke = st.stroke.as_ref().and_then(|p| self.resolve_paint(p, &st)).filter(|_| st.stroke_width > 0.0).map(|(paint, a)| Stroke {
paint,
opacity: st.stroke_opacity * a,
width: st.stroke_width,
cap: st.cap,
join: st.join,
miter: st.miter,
dash: st.dash.clone().map(|d| (d, st.dash_offset)),
});
out.push(Node::Shape(Shape {
name: Self::element_name(n),
geom,
transform,
fill,
fill_opacity: st.fill_opacity * fill_alpha,
fill_rule: st.fill_rule,
stroke,
opacity,
}));
}
_ => {
// Unknown container-ish elements: render their children.
if n.has_children() {
let mut children = vec![];
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
if !children.is_empty() {
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
}
}
}
}
}
fn geometry(&self, n: roxmltree::Node, st: &Style) -> Option<Geom> {
let l = |name: &str, axis: char| self.attr_len(n, name, axis, st);
Some(match n.tag_name().name() {
"path" => {
let p = parse_path_data(n.attribute("d")?);
if p.elements().is_empty() {
return None;
}
Geom::Path(p)
}
"rect" => {
let (w, h) = (l("width", 'x'), l("height", 'y'));
if w <= 0.0 || h <= 0.0 {
return None;
}
let rx_a = n.attribute("rx").and_then(|v| self.length(v, 'x', st.font_size));
let ry_a = n.attribute("ry").and_then(|v| self.length(v, 'y', st.font_size));
let (rx, ry) = match (rx_a, ry_a) {
(Some(x), Some(y)) => (x, y),
(Some(x), None) => (x, x),
(None, Some(y)) => (y, y),
(None, None) => (0.0, 0.0),
};
Geom::Rect { x: l("x", 'x'), y: l("y", 'y'), w, h, rx: rx.clamp(0.0, w / 2.0), ry: ry.clamp(0.0, h / 2.0) }
}
"circle" => {
let r = l("r", 'o');
if r <= 0.0 {
return None;
}
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx: r, ry: r }
}
"ellipse" => {
let (rx, ry) = (l("rx", 'x'), l("ry", 'y'));
if rx <= 0.0 || ry <= 0.0 {
return None;
}
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx, ry }
}
"line" => {
let mut p = BezPath::new();
p.move_to((l("x1", 'x'), l("y1", 'y')));
p.line_to((l("x2", 'x'), l("y2", 'y')));
Geom::Path(p)
}
"polyline" | "polygon" => {
let nums = pathdata::numbers(n.attribute("points")?);
if nums.len() < 4 {
return None;
}
let mut p = BezPath::new();
p.move_to((nums[0], nums[1]));
for c in nums[2..].chunks_exact(2) {
p.line_to((c[0], c[1]));
}
if n.tag_name().name() == "polygon" {
p.close_path();
}
Geom::Path(p)
}
_ => return None,
})
}
}
fn viewbox(n: roxmltree::Node) -> Option<([f64; 2], f64, f64)> {
let v = pathdata::numbers(n.attribute("viewBox")?);
(v.len() == 4 && v[2] > 0.0 && v[3] > 0.0).then(|| ([v[0], v[1]], v[2], v[3]))
}
/// The viewBox → viewport transform (`preserveAspectRatio`, default `xMidYMid meet`).
fn viewbox_transform(n: roxmltree::Node, w: f64, h: f64) -> Affine {
let Some(([x, y], vw, vh)) = viewbox(n) else { return Affine::IDENTITY };
let par = n.attribute("preserveAspectRatio").unwrap_or("xMidYMid meet");
let mut parts = par.split_whitespace();
let mut align = parts.next().unwrap_or("xMidYMid");
if align == "defer" {
align = parts.next().unwrap_or("xMidYMid");
}
let slice = parts.next() == Some("slice");
let (sx, sy) = (w / vw, h / vh);
if align == "none" {
return Affine::scale_non_uniform(sx, sy) * Affine::translate((-x, -y));
}
let s = if slice { sx.max(sy) } else { sx.min(sy) };
let fx = if align.contains("xMid") {
0.5
} else if align.contains("xMax") {
1.0
} else {
0.0
};
let fy = if align.contains("YMid") {
0.5
} else if align.contains("YMax") {
1.0
} else {
0.0
};
let tx = (w - vw * s) * fx;
let ty = (h - vh * s) * fy;
Affine::translate((tx, ty)) * Affine::scale(s) * Affine::translate((-x, -y))
}
/// Parse a `transform` list.
pub fn parse_transform(s: &str) -> Affine {
let mut m = Affine::IDENTITY;
let mut rest = s;
while let Some(open) = rest.find('(') {
let name = rest[..open].trim().trim_start_matches(',').trim();
let Some(close) = rest[open..].find(')') else { break };
let a = pathdata::numbers(&rest[open + 1..open + close]);
rest = &rest[open + close + 1..];
let t = match (name, a.len()) {
("matrix", 6) => Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]]),
("translate", 1) => Affine::translate((a[0], 0.0)),
("translate", 2) => Affine::translate((a[0], a[1])),
("scale", 1) => Affine::scale(a[0]),
("scale", 2) => Affine::scale_non_uniform(a[0], a[1]),
("rotate", 1) => Affine::rotate(a[0].to_radians()),
("rotate", 3) => Affine::translate((a[1], a[2])) * Affine::rotate(a[0].to_radians()) * Affine::translate((-a[1], -a[2])),
("skewX", 1) => Affine::new([1.0, 0.0, a[0].to_radians().tan(), 1.0, 0.0, 0.0]),
("skewY", 1) => Affine::new([1.0, a[0].to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),
_ => Affine::IDENTITY,
};
m *= t;
}
m
}
/// Parse an SVG document.
pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
let text = String::from_utf8_lossy(bytes);
let text = text.trim_start_matches('\u{feff}');
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let xml = roxmltree::Document::parse_with_options(text, opts).map_err(|e| Error::Xml(e.to_string()))?;
let root = xml.root_element();
if root.tag_name().name() != "svg" {
return Err(Error::NotSvg);
}
let mut ids = HashMap::new();
let mut rules = vec![];
for n in root.descendants().filter(|n| n.is_element()) {
if let Some(id) = n.attribute("id") {
ids.entry(id.to_string()).or_insert(n);
}
if n.tag_name().name() == "style" {
let css: String = n.children().filter_map(|c| c.text()).collect();
parse_css(&css, &mut rules);
}
}
let vb = viewbox(root);
let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![] };
let st = Style::default();
let pct_or = |name: &str, axis: char, def: f64| -> f64 {
match root.attribute(name) {
Some(v) if v.trim().ends_with('%') => {
let f = v.trim().trim_end_matches('%').parse::<f64>().unwrap_or(100.0) / 100.0;
def * f
}
Some(v) => p.length(v, axis, st.font_size).filter(|x| *x > 0.0).unwrap_or(def),
None => def,
}
};
// Without width/height the viewBox size is used (or 300×150).
let (dw, dh) = match vb {
Some((_, w, h)) => {
let w2 = pct_or("width", 'x', w);
let h2 = match (root.attribute("width"), root.attribute("height")) {
// Only width given: keep the aspect ratio.
(Some(_), None) => w2 * h / w,
_ => pct_or("height", 'y', h),
};
let w2 = if root.attribute("width").is_none() && root.attribute("height").is_some() { h2 * w / h } else { w2 };
(w2, h2)
}
None => (pct_or("width", 'x', 300.0), pct_or("height", 'y', 150.0)),
};
let mut rst = st.clone();
let (opacity, _) = p.cascade(root, &mut rst);
let mut children = vec![];
for c in root.children() {
p.node(c, &rst, &mut children, 1);
}
let transform = viewbox_transform(root, dw, dh);
Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children }, skipped: std::mem::take(&mut p.skipped) })
}
#[cfg(test)]
mod tests;

293
crates/svg/src/pathdata.rs Normal file
View File

@ -0,0 +1,293 @@
//! SVG path data (SVG 1.1 §8.3 / SVG 2 §9.3): every command, implicit repeats, relative
//! coordinates, smooth curves and elliptical arcs (converted to cubics through kurbo's `SvgArc`).
//! Parsing stops at the first error, keeping what was read (as the specification asks).
use kurbo::{BezPath, Point, SvgArc, Vec2};
/// Tokenizer over numbers and command letters.
struct Lex<'a> {
b: &'a [u8],
i: usize,
}
impl Lex<'_> {
fn skip_sep(&mut self) {
while self.i < self.b.len() && (self.b[self.i].is_ascii_whitespace() || self.b[self.i] == b',') {
self.i += 1;
}
}
fn command(&mut self) -> Option<u8> {
self.skip_sep();
let c = *self.b.get(self.i)?;
if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
self.i += 1;
Some(c)
} else {
None
}
}
fn at_number(&mut self) -> bool {
self.skip_sep();
self.b.get(self.i).is_some_and(|c| c.is_ascii_digit() || matches!(c, b'-' | b'+' | b'.'))
}
fn number(&mut self) -> Option<f64> {
self.skip_sep();
let start = self.i;
let b = self.b;
let mut i = self.i;
if i < b.len() && matches!(b[i], b'-' | b'+') {
i += 1;
}
let mut digits = false;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
digits = true;
}
if i < b.len() && b[i] == b'.' {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
digits = true;
}
}
if !digits {
return None;
}
if i < b.len() && matches!(b[i], b'e' | b'E') {
let mut j = i + 1;
if j < b.len() && matches!(b[j], b'-' | b'+') {
j += 1;
}
if j < b.len() && b[j].is_ascii_digit() {
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
i = j;
}
}
self.i = i;
std::str::from_utf8(&b[start..i]).ok()?.parse().ok()
}
/// Arc flags may be written without separators ("a1 1 0 00 1 1").
fn flag(&mut self) -> Option<bool> {
self.skip_sep();
let c = *self.b.get(self.i)?;
self.i += 1;
match c {
b'0' => Some(false),
b'1' => Some(true),
_ => None,
}
}
}
/// All numbers in a list (`points`, `viewBox`, transform arguments).
pub fn numbers(s: &str) -> Vec<f64> {
let mut l = Lex { b: s.as_bytes(), i: 0 };
let mut v = vec![];
while l.at_number() {
match l.number() {
Some(x) => v.push(x),
None => break,
}
}
v
}
/// Parse path data into a kurbo path.
pub fn parse_path_data(d: &str) -> BezPath {
let mut l = Lex { b: d.as_bytes(), i: 0 };
let mut p = BezPath::new();
let mut cur = Point::ZERO;
let mut start = Point::ZERO;
// Last control point of the previous C/S (cubic) or Q/T (quad) command.
let mut last_cubic: Option<Point> = None;
let mut last_quad: Option<Point> = None;
let mut cmd: Option<u8> = None;
let mut open = false;
loop {
let c = match l.command() {
Some(c) => c,
None => match cmd {
// Implicit repeat (a moveto repeats as lineto).
Some(c) if l.at_number() => match c {
b'M' => b'L',
b'm' => b'l',
other => other,
},
_ => break,
},
};
let rel = c.is_ascii_lowercase();
let base = if rel { cur.to_vec2() } else { Vec2::ZERO };
let pt = |l: &mut Lex| -> Option<Point> { Some(Point::new(l.number()?, l.number()?) + base) };
let ok = (|| -> Option<()> {
match c.to_ascii_uppercase() {
b'M' => {
cur = pt(&mut l)?;
p.move_to(cur);
start = cur;
open = true;
last_cubic = None;
last_quad = None;
}
b'Z' => {
if open {
p.close_path();
}
cur = start;
last_cubic = None;
last_quad = None;
}
_ if !open => {
// A segment without a preceding moveto starts at the current point.
p.move_to(cur);
open = true;
return segment(&mut l, c, base, &mut p, &mut cur, &mut last_cubic, &mut last_quad);
}
_ => return segment(&mut l, c, base, &mut p, &mut cur, &mut last_cubic, &mut last_quad),
}
Some(())
})();
if ok.is_none() {
break;
}
cmd = Some(c);
if c.eq_ignore_ascii_case(&b'z') {
// After Z a new segment starts from the subpath start (an unused move is dropped
// below).
p.move_to(start);
open = true;
}
}
strip_trailing_moves(p)
}
fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, last_cubic: &mut Option<Point>, last_quad: &mut Option<Point>) -> Option<()> {
let num = |l: &mut Lex| l.number();
let pt = |l: &mut Lex| -> Option<Point> { Some(Point::new(l.number()?, l.number()?) + base) };
match c.to_ascii_uppercase() {
b'L' => {
*cur = pt(l)?;
p.line_to(*cur);
*last_cubic = None;
*last_quad = None;
}
b'H' => {
let x = num(l)? + base.x;
*cur = Point::new(x, cur.y);
p.line_to(*cur);
*last_cubic = None;
*last_quad = None;
}
b'V' => {
let y = num(l)? + base.y;
*cur = Point::new(cur.x, y);
p.line_to(*cur);
*last_cubic = None;
*last_quad = None;
}
b'C' => {
let (c1, c2, e) = (pt(l)?, pt(l)?, pt(l)?);
p.curve_to(c1, c2, e);
*last_cubic = Some(c2);
*last_quad = None;
*cur = e;
}
b'S' => {
let c1 = last_cubic.map_or(*cur, |c| *cur + (*cur - c));
let (c2, e) = (pt(l)?, pt(l)?);
p.curve_to(c1, c2, e);
*last_cubic = Some(c2);
*last_quad = None;
*cur = e;
}
b'Q' => {
let (q, e) = (pt(l)?, pt(l)?);
p.quad_to(q, e);
*last_quad = Some(q);
*last_cubic = None;
*cur = e;
}
b'T' => {
let q = last_quad.map_or(*cur, |c| *cur + (*cur - c));
let e = pt(l)?;
p.quad_to(q, e);
*last_quad = Some(q);
*last_cubic = None;
*cur = e;
}
b'A' => {
let rx = num(l)?.abs();
let ry = num(l)?.abs();
let rot = num(l)?;
let large = l.flag()?;
let sweep = l.flag()?;
let e = pt(l)?;
let arc = SvgArc { from: *cur, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep };
match kurbo::Arc::from_svg_arc(&arc) {
Some(a) => a.to_cubic_beziers(0.1, |c1, c2, end| p.curve_to(c1, c2, end)),
None => p.line_to(e),
}
*cur = e;
*last_cubic = None;
*last_quad = None;
}
_ => return None,
}
Some(())
}
/// Drop move-tos that start no segment (from closepath bookkeeping).
fn strip_trailing_moves(p: BezPath) -> BezPath {
use kurbo::PathEl;
let els = p.elements();
let mut out = BezPath::new();
for (i, e) in els.iter().enumerate() {
if let PathEl::MoveTo(_) = e {
let next_is_seg = els.get(i + 1).is_some_and(|n| !matches!(n, PathEl::MoveTo(_)));
if !next_is_seg {
continue;
}
}
out.push(*e);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::PathEl;
#[test]
fn commands() {
let p = parse_path_data("M10,10 L20,10 h10 v10 H10 z m5 5 l1-1.5.5.5e1 C 0 0 1 1 2 2 s3 3 4 4 Q1 1 2 2 t3 3 A5 5 0 0 1 20 20 a5 5 0 1010 0");
let els = p.elements();
assert_eq!(els[0], PathEl::MoveTo(Point::new(10.0, 10.0)));
assert_eq!(els[2], PathEl::LineTo(Point::new(30.0, 10.0)));
assert_eq!(els[3], PathEl::LineTo(Point::new(30.0, 20.0)));
assert!(els.iter().any(|e| matches!(e, PathEl::ClosePath)));
// Relative move after Z is relative to the subpath start (10,10).
assert!(els.contains(&PathEl::MoveTo(Point::new(15.0, 15.0))));
// "l1-1.5.5.5e1": two implicit linetos (1,-1.5) then (.5,5).
assert!(els.contains(&PathEl::LineTo(Point::new(16.0, 13.5))));
assert!(els.contains(&PathEl::LineTo(Point::new(16.5, 18.5))));
assert!(els.iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count() >= 4);
}
#[test]
fn errors_keep_prefix() {
let p = parse_path_data("M0 0 L10 0 L10 x10 L0 10");
assert_eq!(p.elements().len(), 2);
assert!(parse_path_data("garbage").elements().is_empty());
assert!(parse_path_data("").elements().is_empty());
}
#[test]
fn number_lists() {
assert_eq!(numbers("0 0,100 50"), vec![0.0, 0.0, 100.0, 50.0]);
assert_eq!(numbers("-1-2.5.5"), vec![-1.0, -2.5, 0.5]);
assert_eq!(numbers("1e2,2E-1"), vec![100.0, 0.2]);
}
}

220
crates/svg/src/raster.rs Normal file
View File

@ -0,0 +1,220 @@
//! Rasterisation of a parsed SVG into a premultiplied `f32` image (anti-aliased coverage from
//! `effectcraft-path`; group opacity composites the group as a whole, as SVG specifies).
use effectcraft_geom::Mat3;
use effectcraft_raster::{Image, Mask};
use kurbo::{Affine, Point, Shape as _};
use rayon::prelude::*;
use crate::{Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread};
fn mat3(a: Affine) -> Mat3 {
let c = a.as_coeffs();
Mat3([[c[0], c[2], c[4]], [c[1], c[3], c[5]], [0.0, 0.0, 1.0]])
}
/// Rasterise `doc` into a `w`×`h` image, scaled by `scale` (1 = the document's pixel size).
pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image {
rasterize_with(doc, w, h, Affine::scale(scale))
}
/// Rasterise `doc` with `m` mapping document pixels to image pixels.
pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
let mut img = Image::new(w, h);
draw_group(&doc.root, m, 1.0, &mut img);
img
}
fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
let m = m * g.transform;
let o = opacity * g.opacity;
if o <= 0.0 {
return;
}
if g.opacity < 1.0 && g.children.len() > 1 {
let mut tmp = Image::new(dst.width, dst.height);
for c in &g.children {
draw_node(c, m, 1.0, &mut tmp);
}
over(dst, &tmp, o as f32);
} else {
for c in &g.children {
draw_node(c, m, o, dst);
}
}
}
fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) {
match n {
Node::Group(g) => draw_group(g, m, opacity, dst),
Node::Shape(s) => {
if s.opacity < 1.0 && s.fill.is_some() && s.stroke.is_some() {
let mut tmp = Image::new(dst.width, dst.height);
draw_shape(s, m, 1.0, &mut tmp);
over(dst, &tmp, (opacity * s.opacity) as f32);
} else {
draw_shape(s, m, opacity * s.opacity, dst);
}
}
}
}
/// `dst = src·k over dst` (premultiplied).
fn over(dst: &mut Image, src: &Image, k: f32) {
dst.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(d, s)| {
let a = s[3] * k;
if a > 0.0 {
for c in 0..3 {
d[c] = s[c] * k + d[c] * (1.0 - a);
}
d[3] = a + d[3] * (1.0 - a);
}
});
}
fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
let m = m * s.transform;
let path = s.geom.to_path();
let bbox = path.bounding_box();
let (w, h) = (dst.width, dst.height);
if let Some(fill) = &s.fill {
let rule = match s.fill_rule {
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
};
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(m), w, h, rule);
paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox);
}
if let Some(st) = &s.stroke {
let style = effectcraft_path::StrokeStyle {
width: st.width,
cap: match st.cap {
crate::Cap::Butt => effectcraft_path::Cap::Butt,
crate::Cap::Round => effectcraft_path::Cap::Round,
crate::Cap::Square => effectcraft_path::Cap::Square,
},
join: match st.join {
crate::Join::Miter => effectcraft_path::Join::Miter,
crate::Join::Round => effectcraft_path::Join::Round,
crate::Join::Bevel => effectcraft_path::Join::Bevel,
},
miter: st.miter,
dash: st.dash.clone(),
};
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);
}
}
/// Composite paint through coverage.
fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) {
let w = dst.width as usize;
if w == 0 {
return;
}
let shader = match p {
Paint::Color(c) => Shader::Solid([c[0] as f32, c[1] as f32, c[2] as f32, 1.0]),
Paint::Gradient(g) => {
let unit = if g.bbox_units { Affine::new([bbox.width(), 0.0, 0.0, bbox.height(), bbox.x0, bbox.y0]) } else { Affine::IDENTITY };
let to_grad = (m * unit * g.transform).inverse();
if !to_grad.as_coeffs().iter().all(|v| v.is_finite()) {
return;
}
Shader::Gradient(g, to_grad)
}
};
dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let c = cov.data[y * w + x];
if c <= 0.0 {
continue;
}
let col = shader.at(x as f64 + 0.5, y as f64 + 0.5);
let a = col[3] * c * opacity;
if a <= 0.0 {
continue;
}
for k in 0..3 {
d[k] = col[k] * a + d[k] * (1.0 - a);
}
d[3] = a + d[3] * (1.0 - a);
}
});
}
enum Shader<'a> {
Solid([f32; 4]),
Gradient(&'a Gradient, Affine),
}
impl Shader<'_> {
/// Straight RGBA at a pixel centre.
fn at(&self, x: f64, y: f64) -> [f32; 4] {
match self {
Shader::Solid(c) => *c,
Shader::Gradient(g, inv) => {
let p = *inv * Point::new(x, y);
let t = match g.kind {
GradientKind::Linear { x1, y1, x2, y2 } => {
let (dx, dy) = (x2 - x1, y2 - y1);
let l2 = dx * dx + dy * dy;
if l2 <= 0.0 { 1.0 } else { ((p.x - x1) * dx + (p.y - y1) * dy) / l2 }
}
GradientKind::Radial { cx, cy, r, fx, fy } => radial_t(p, cx, cy, r, fx, fy),
};
eval_stops(&g.stops, spread(t, g.spread))
}
}
}
}
/// Radial gradient parameter with a focal point: the circle through `p` from the focus.
pub(crate) fn radial_t(p: Point, cx: f64, cy: f64, r: f64, fx: f64, fy: f64) -> f64 {
if r <= 0.0 {
return 1.0;
}
let (dx, dy) = (p.x - fx, p.y - fy);
let (ex, ey) = (fx - cx, fy - cy);
if ex * ex + ey * ey < 1e-12 {
return (dx * dx + dy * dy).sqrt() / r;
}
// Solve |e + u·d|² = r² for u > 0; t = 1/u.
let a = dx * dx + dy * dy;
if a < 1e-18 {
return 0.0;
}
let b = ex * dx + ey * dy;
let c = ex * ex + ey * ey - r * r;
let disc = b * b - a * c;
if disc < 0.0 {
return 1.0;
}
let u = (-b + disc.sqrt()) / a;
if u <= 0.0 { 1.0 } else { 1.0 / u }
}
fn spread(t: f64, s: Spread) -> f64 {
match s {
Spread::Pad => t.clamp(0.0, 1.0),
Spread::Repeat => t.rem_euclid(1.0),
Spread::Reflect => {
let r = t.rem_euclid(2.0);
if r > 1.0 { 2.0 - r } else { r }
}
}
}
pub(crate) fn eval_stops(stops: &[(f64, [f64; 4])], t: f64) -> [f32; 4] {
let Some(first) = stops.first() else { return [0.0; 4] };
if t <= first.0 {
return first.1.map(|v| v as f32);
}
for w in stops.windows(2) {
let (a, b) = (w[0], w[1]);
if t <= b.0 {
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
return [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32);
}
}
stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32))
}

108
crates/svg/src/tests.rs Normal file
View File

@ -0,0 +1,108 @@
use super::*;
const BASIC: &[u8] = include_bytes!("../tests/fixtures/basic-shapes.svg");
const PATHS: &[u8] = include_bytes!("../tests/fixtures/paths-gradients.svg");
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[img.idx(x, y)]
}
fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
(0..4).all(|i| (a[i] - b[i]).abs() <= tol)
}
#[test]
fn basic_shapes_structure() {
let d = parse(BASIC).unwrap();
assert_eq!((d.width, d.height), (200.0, 100.0));
assert_eq!(d.pixel_size(), (200, 100));
// viewBox 400×200 into 200×100.
assert!((d.root.transform.as_coeffs()[0] - 0.5).abs() < 1e-12);
let shapes = d.flatten();
assert_eq!(shapes.len(), 6);
let (m, o, s) = shapes[1];
assert_eq!(s.name, "box");
assert!(matches!(s.geom, Geom::Rect { w, h, rx, .. } if w == 120.0 && h == 80.0 && rx == 12.0));
assert!((o - 0.8).abs() < 1e-12);
// translate(20 20) then the viewBox scale.
let p = m * kurbo::Point::new(10.0, 10.0);
assert!((p.x - 15.0).abs() < 1e-9 && (p.y - 15.0).abs() < 1e-9);
let st = s.stroke.as_ref().unwrap();
assert_eq!(st.width, 6.0);
assert_eq!(st.paint, Paint::Color([1.0, 1.0, 1.0]));
assert!(matches!(shapes[2].2.geom, Geom::Ellipse { rx, ry, .. } if rx == 30.0 && ry == 30.0));
assert_eq!(shapes[3].2.fill_opacity, 0.75);
assert!(shapes[4].2.fill.is_none());
assert_eq!(shapes[4].2.stroke.as_ref().unwrap().join, Join::Round);
assert_eq!(shapes[5].2.stroke.as_ref().unwrap().cap, Cap::Round);
}
#[test]
fn basic_shapes_raster() {
let d = parse(BASIC).unwrap();
let img = rasterize(&d, 200, 100, 1.0);
let bg = [0x20 as f32 / 255.0, 0x28 as f32 / 255.0, 0x30 as f32 / 255.0, 1.0];
assert!(near(px(&img, 2, 2), bg, 1e-3));
// Inside the box: tomato at group opacity 0.8 over the background.
let tomato = [1.0, 99.0 / 255.0, 71.0 / 255.0];
let want = [0, 1, 2].map(|i| tomato[i] * 0.8 + bg[i] * 0.2);
let got = px(&img, 45, 35);
assert!(near(got, [want[0], want[1], want[2], 1.0], 2e-3), "{got:?} vs {want:?}");
// Scaled ×2: the same point lands at double the coordinates, edges stay sharp.
let big = rasterize(&d, 400, 200, 2.0);
assert!(near(px(&big, 90, 70), got, 2e-3));
}
#[test]
fn paths_gradients_css_use() {
let d = parse(PATHS).unwrap();
assert_eq!(d.pixel_size(), (160, 120));
let shapes = d.flatten();
assert_eq!(shapes.len(), 6, "{:?}", shapes.iter().map(|s| &s.2.name).collect::<Vec<_>>());
// CSS class → gradient fill.
let Some(Paint::Gradient(g)) = &shapes[0].2.fill else { panic!("gradient expected") };
assert!(g.bbox_units);
assert_eq!(g.stops.len(), 2);
assert_eq!(shapes[1].2.fill_rule, FillRule::EvenOdd);
// #wave: id selector beats nothing; stroke from CSS.
assert!(shapes[2].2.fill.is_none());
assert_eq!(shapes[2].2.stroke.as_ref().unwrap().width, 5.0);
// use → the referenced path with the use's fill, translated.
assert_eq!(shapes[4].2.fill, Some(Paint::Color([128.0 / 255.0, 0.0, 128.0 / 255.0])));
let p = shapes[4].0 * kurbo::Point::new(0.0, 0.0);
assert!((p.x - 100.0).abs() < 1e-9 && (p.y - 110.0).abs() < 1e-9);
assert_eq!(shapes[5].2.stroke.as_ref().unwrap().dash, Some((vec![4.0, 2.0], 0.0)));
let img = rasterize(&d, 160, 120, 1.0);
// Vertical gradient: yellow at the top, red at the bottom.
let top = px(&img, 40, 11);
let bottom = px(&img, 40, 48);
assert!(top[1] > 0.7 && bottom[1] < 0.3, "{top:?} {bottom:?}");
// Even-odd hole.
assert_eq!(px(&img, 120, 35)[3], 0.0);
assert!(px(&img, 95, 15)[3] > 0.99);
// Radial gradient: white centre.
let c = px(&img, 40, 90);
assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}");
}
#[test]
fn malformed_input() {
assert!(parse(b"<html/>").is_err());
assert!(parse(b"not xml").is_err());
let d = parse(b"<svg xmlns='http://www.w3.org/2000/svg'><path d='M0 0 L 10'/><rect width='-5' height='5'/><text>hi</text></svg>").unwrap();
assert_eq!(d.pixel_size(), (300, 150));
assert_eq!(d.skipped, vec!["text".to_string()]);
assert!(looks_like_svg(BASIC));
assert!(!looks_like_svg(b"\x89PNG"));
}
#[test]
fn transforms() {
let m = parse_transform("translate(10,20) rotate(90) scale(2 3)");
let p = m * kurbo::Point::new(1.0, 1.0);
assert!((p.x - 7.0).abs() < 1e-9 && (p.y - 22.0).abs() < 1e-9, "{p:?}");
let m = parse_transform("matrix(1 0 0 1 5 6) skewX(45)");
let p = m * kurbo::Point::new(0.0, 1.0);
assert!((p.x - 6.0).abs() < 1e-9 && (p.y - 7.0).abs() < 1e-9);
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- EffectCraft test fixture (original work, MIT OR Apache-2.0): basic shapes, groups, transforms. -->
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 400 200">
<title>Basic shapes</title>
<rect id="bg" x="0" y="0" width="400" height="200" fill="#202830"/>
<g id="left" transform="translate(20 20)" opacity="0.8">
<rect id="box" x="10" y="10" width="120" height="80" rx="12" fill="tomato" stroke="white" stroke-width="6"/>
<circle id="dot" cx="70" cy="130" r="30" fill="rgb(40, 180, 255)"/>
</g>
<g id="right" transform="translate(220 0) rotate(15 80 100)">
<ellipse cx="80" cy="60" rx="70" ry="30" fill="gold" fill-opacity="0.75"/>
<polygon points="20,180 80,110 140,180" fill="none" stroke="#7CFC00" stroke-width="8" stroke-linejoin="round"/>
<line x1="10" y1="100" x2="150" y2="100" stroke="white" stroke-width="4" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 957 B

View File

@ -0,0 +1,7 @@
asset: basic-shapes.svg
title: SVG test fixture: basic shapes, groups and transforms
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.

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- EffectCraft test fixture (original work, MIT OR Apache-2.0): path data, gradients, CSS, use. -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="160" height="120">
<defs>
<style>
.warm { fill: url(#sunset); }
#wave { stroke: #3366ff; stroke-width: 5; fill: none; }
path.hole { fill-rule: evenodd; }
</style>
<linearGradient id="sunset" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffcc00"/>
<stop offset="100%" stop-color="#ff3300"/>
</linearGradient>
<radialGradient id="glow" cx="40" cy="90" r="25" gradientUnits="userSpaceOnUse">
<stop offset="0%" style="stop-color: white; stop-opacity: 1"/>
<stop offset="100%" stop-color="#00aaff" stop-opacity="0.2"/>
</radialGradient>
<path id="tri" d="M0 0 L20 0 L10 -16 Z"/>
</defs>
<path class="warm" d="M10 10 h60 v40 h-60 z"/>
<path class="hole" fill="#44aa66" d="M90,10 L150,10 L150,60 L90,60 Z M105,25 L135,25 L135,45 L105,45 Z"/>
<path id="wave" d="M10 75 C 30 55, 50 95, 70 75 S 110 55, 130 75 Q 140 85 150 75"/>
<circle cx="40" cy="90" r="25" fill="url(#glow)"/>
<use xlink:href="#tri" x="100" y="110" fill="purple"/>
<path d="M120 100 a 15 10 0 1 0 30 0 a15 10 0 1 0 -30 0" fill="none" stroke="black" stroke-width="2" stroke-dasharray="4 2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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.

View File

@ -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<Arc<DiskCache>>,
}
/// Content keys of (project revision, comp), computed once per revision.
type ContentKeys = Arc<Mutex<HashMap<(u64, u64), u128>>>;
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<egui::Context>,
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
pub last_ms: Arc<Mutex<f64>>,
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<i64> {
let Some(dc) = &src.disk else { return vec![] };
let ram: HashSet<i64> = 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<Mutex<HashSet<FrameKey>>>,
ctx: Option<egui::Context>,
last: Arc<Mutex<f64>>,
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<u8> = 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 {

View File

@ -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 {

View File

@ -716,7 +716,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
let paths = f(&[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
"ogg", "opus", "svg",
"ogg", "opus", "svg", "psd", "psb",
]);
match (paths.is_empty(), key) {
(true, _) => None,
@ -739,6 +739,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
};
let Some(v) = picked else { return Some(Ok(Value::Null)) };
p.insert(key.to_string(), v);
// Photoshop files ask how to import them first.
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &Value::Object(p.clone())) {
return Some(Ok(json!({"dialog": id})));
}
// Save (untitled) becomes Save As.
let id = if id == "file.save" { "file.saveAs" } else { id };
let r = app.session.execute(id, Value::Object(p)).map_err(|e| e.to_string());

View File

@ -108,6 +108,16 @@ fn has(p: &Value, keys: &[&str]) -> 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<Field>) = 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));

View File

@ -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;

View File

@ -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),

View File

@ -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),