Merge branch 'worktree-agent-ae879257b39393c25'

# Conflicts:
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-02 22:56:18 -04:00
92 changed files with 12217 additions and 56 deletions
+3
View File
@@ -23,6 +23,9 @@ effectcraft-render = { workspace = true }
effectcraft-track = { workspace = true }
effectcraft-model = { 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 }
+139
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)]
}
+31 -1
View File
@@ -525,12 +525,41 @@ 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();
}
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
effectcraft_effects::roto::purge();
}
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.
@@ -599,6 +628,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),
]
}
+95 -3
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...",
+1
View File
@@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
missing: true,
sequence: vec![],
color_profile: None,
layer: None,
}
}
+2
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;
@@ -124,6 +125,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());
+4 -4
View File
@@ -131,7 +131,7 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
let mut any = false;
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om.set_format(f);
any = true;
}
@@ -256,7 +256,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
let mut it = RenderQueueItem::new(0, cid);
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
it.output = OutputModule::for_format(f);
}
apply_settings(&mut it.settings, p, "renderQueue.add")?;
@@ -434,7 +434,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
let n = it.extra_outputs.len() + 2;
let mut om = it.output.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om = OutputModule::for_format(f);
}
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
@@ -509,7 +509,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
can_add,
add
),
-1
View File
@@ -29,7 +29,6 @@ pub fn specs() -> Vec<CommandSpec> {
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
// Time (timeline milestone).
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
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).
+12
View File
@@ -16,11 +16,13 @@ pub mod links;
pub mod mask_track;
pub mod menus;
pub mod prefs;
pub mod psd_import;
pub mod render_queue;
pub mod roto;
mod session_settings;
pub mod shortcuts;
pub mod tracking;
pub mod vector;
pub mod viewer;
pub mod warp;
@@ -277,6 +279,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…).
@@ -339,6 +343,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,
@@ -428,6 +433,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 });
}
@@ -612,6 +620,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;
@@ -642,6 +652,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).
+634
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()));
}
}
+1 -1
View File
@@ -159,6 +159,6 @@ fn without_exporter() {
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 7);
assert_eq!(f["formats"].as_array().unwrap().len(), 10);
assert_eq!(f["formats"][0]["available"], false);
}
+37
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
+121
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);
}
+1
View File
@@ -52,6 +52,7 @@ impl Importer for ModelImporter {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
})
}
}
+1
View File
@@ -257,6 +257,7 @@ impl Importer for MockImporter {
missing: false,
sequence: vec![],
color_profile: None,
layer: None,
})
}
}
+2
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();
+237
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
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
}