mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 15:18:39 +00:00
Merge branch 'worktree-agent-ae879257b39393c25'
# Conflicts: # docs/parity.md
This commit is contained in:
commit
0631ef29dc
92 files changed
+12217
-56
No files matched your search
@@ -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 |
|
||||
| `crates/model/tests/fixtures/quad.gltf` | EffectCraft contributors | Original work: glTF 2.0 test fixture (textured quad, node hierarchy, translation animation; data URIs) | MIT OR Apache-2.0 |
|
||||
| `crates/model/tests/fixtures/quad.glb` | EffectCraft contributors | Original work: binary glTF test fixture (the same quad with its texture in the BIN chunk) | MIT OR Apache-2.0 |
|
||||
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
|
||||
|
||||
Generated
+56
@@ -1214,12 +1214,15 @@ dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
"effectcraft-track",
|
||||
"image",
|
||||
"kurbo",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
@@ -1234,14 +1237,18 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-media",
|
||||
"effectcraft-opusenc",
|
||||
"effectcraft-project",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"effectcraft-vp9enc",
|
||||
"filmcraft-aac",
|
||||
"filmcraft-h264enc",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-opus",
|
||||
"filmcraft-prores",
|
||||
"filmcraft-vp9",
|
||||
"gif",
|
||||
"image",
|
||||
"log",
|
||||
@@ -1303,7 +1310,11 @@ dependencies = [
|
||||
"effectcraft-expr",
|
||||
"effectcraft-media",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-script",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -1342,8 +1353,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"exr",
|
||||
"filmcraft-codecs",
|
||||
@@ -1369,6 +1382,10 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.1.0"
|
||||
@@ -1394,6 +1411,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"
|
||||
@@ -1419,6 +1443,7 @@ dependencies = [
|
||||
"effectcraft-time",
|
||||
"kurbo",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"rayon",
|
||||
"serde",
|
||||
"web-time",
|
||||
@@ -1439,6 +1464,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -1497,6 +1534,10 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.1.0"
|
||||
@@ -2807,6 +2848,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"
|
||||
@@ -4047,6 +4094,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"
|
||||
|
||||
+13
@@ -29,10 +29,14 @@ 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-vp9enc = { path = "crates/vp9enc" }
|
||||
effectcraft-svg = { path = "crates/svg" }
|
||||
effectcraft-engine = { path = "crates/engine" }
|
||||
effectcraft-host = { path = "crates/host" }
|
||||
effectcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
effectcraft-automation = { path = "crates/automation" }
|
||||
effectcraft-opusenc = { path = "crates/opusenc" }
|
||||
effectcraft-script = { path = "crates/script" }
|
||||
|
||||
# foundation
|
||||
@@ -69,6 +73,9 @@ filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "89c
|
||||
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
|
||||
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
|
||||
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
|
||||
# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
@@ -132,9 +139,15 @@ opt-level = 3
|
||||
[profile.dev.package.effectcraft-media]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.effectcraft-opusenc]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.effectcraft-export]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.effectcraft-vp9enc]
|
||||
opt-level = 3
|
||||
|
||||
# FilmCraft codec hot loops (same settings as FilmCraft's own workspace).
|
||||
[profile.dev.package.filmcraft-h264]
|
||||
opt-level = 3
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|prores|png|jpeg|tiff|exr|gif] [--start S] [--end S]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
|
||||
missing: true,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ impl Importer for ModelImporter {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ impl Importer for MockImporter {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "effectcraft-export"
|
||||
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes MOV (+PCM) via FilmCraft's pure-Rust encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF"
|
||||
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes MOV (+PCM) via FilmCraft's pure-Rust encoders, WebM (VP9 + Opus), PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -18,6 +18,8 @@ filmcraft-h264enc = { workspace = true }
|
||||
filmcraft-prores = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-isobmff = { workspace = true }
|
||||
effectcraft-opusenc = { workspace = true }
|
||||
effectcraft-vp9enc = { workspace = true }
|
||||
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] }
|
||||
gif = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
@@ -28,3 +30,5 @@ log = { workspace = true }
|
||||
[dev-dependencies]
|
||||
effectcraft-media = { workspace = true }
|
||||
effectcraft-color = { workspace = true }
|
||||
filmcraft-opus = { workspace = true }
|
||||
filmcraft-vp9 = { workspace = true }
|
||||
@@ -7,6 +7,8 @@
|
||||
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
|
||||
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
|
||||
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
|
||||
//! | WebM | WebM (Matroska) | VP9 profile 0 intra frames (`effectcraft-vp9enc`), 8-bit 4:2:0 | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`), 48 kHz stereo |
|
||||
//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | 16-bit PCM stereo (audio only) |
|
||||
//!
|
||||
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
|
||||
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
|
||||
@@ -18,6 +20,7 @@
|
||||
|
||||
mod encode;
|
||||
mod out;
|
||||
mod webm;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
@@ -131,6 +134,9 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<
|
||||
let mut report = match job.output.format {
|
||||
OutputFormat::H264 | OutputFormat::ProRes => encode::movie(job, comp, w, h, &mut st)?,
|
||||
OutputFormat::Gif => gif_export(job, comp, w, h, &mut st)?,
|
||||
OutputFormat::WebM => webm::webm(job, comp, w, h, &mut st)?,
|
||||
OutputFormat::Wav => webm::audio_file(job, comp, false, &mut st)?,
|
||||
OutputFormat::Aiff => webm::audio_file(job, comp, true, &mut st)?,
|
||||
f if f.is_sequence() => sequence(job, comp, w, h, &mut st)?,
|
||||
f => return Err(ExportError::Unsupported(f.label().into())),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! WebM output (VP9 video, optional VP9 alpha, Opus audio) and audio-only WAV / AIFF.
|
||||
//!
|
||||
//! The WebM container is written from the Matroska / WebM specifications (IETF RFC 8794 EBML,
|
||||
//! the Matroska element registry and the WebM container guidelines): EBML header, Segment
|
||||
//! (size patched at the end), Info, Tracks and one Cluster per render batch. Video frames are
|
||||
//! SimpleBlocks; with alpha (Channels: RGB + Alpha) each frame is a BlockGroup whose
|
||||
//! BlockAdditional (BlockAddID 1) carries a second VP9 frame coding the alpha channel as luma,
|
||||
//! with `AlphaMode` 1 on the track, as WebM defines. VP9 comes from `effectcraft-vp9enc` (every
|
||||
//! frame a key frame), Opus from `effectcraft-opusenc` (CELT, 48 kHz stereo, 20 ms packets,
|
||||
//! `CodecDelay` = pre-skip). WAV (RIFF, 16-bit PCM) and AIFF (big-endian 16-bit, 80-bit
|
||||
//! extended sample rate) follow their published file layouts.
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_project::render_queue::Channels;
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{Job, Report, Result, State, batch_size, io, render_frame, rgba8, wants_audio};
|
||||
|
||||
const OPUS_RATE: u32 = 48_000;
|
||||
const OPUS_BITRATE: u32 = 192_000;
|
||||
|
||||
// Element ids (with their length markers).
|
||||
const EBML: u32 = 0x1A45_DFA3;
|
||||
const SEGMENT: u32 = 0x1853_8067;
|
||||
const INFO: u32 = 0x1549_A966;
|
||||
const TRACKS: u32 = 0x1654_AE6B;
|
||||
const TRACK_ENTRY: u32 = 0xAE;
|
||||
const CLUSTER: u32 = 0x1F43_B675;
|
||||
|
||||
fn id_bytes(id: u32) -> Vec<u8> {
|
||||
let b = id.to_be_bytes();
|
||||
let skip = b.iter().position(|x| *x != 0).unwrap_or(3);
|
||||
b[skip..].to_vec()
|
||||
}
|
||||
|
||||
/// EBML variable-size integer for an element data size (shortest form).
|
||||
fn vint(n: u64) -> Vec<u8> {
|
||||
for len in 1..=8u32 {
|
||||
let max = (1u64 << (7 * len)) - 2;
|
||||
if n <= max {
|
||||
let marked = n | (1u64 << (7 * len));
|
||||
return marked.to_be_bytes()[8 - len as usize..].to_vec();
|
||||
}
|
||||
}
|
||||
let mut v = vec![0x01];
|
||||
v.extend_from_slice(&n.to_be_bytes()[1..]);
|
||||
v
|
||||
}
|
||||
|
||||
fn el(out: &mut Vec<u8>, id: u32, data: &[u8]) {
|
||||
out.extend(id_bytes(id));
|
||||
out.extend(vint(data.len() as u64));
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
fn el_uint(out: &mut Vec<u8>, id: u32, v: u64) {
|
||||
let b = v.to_be_bytes();
|
||||
let skip = b.iter().position(|x| *x != 0).unwrap_or(7);
|
||||
el(out, id, &b[skip..]);
|
||||
}
|
||||
|
||||
fn el_float(out: &mut Vec<u8>, id: u32, v: f64) {
|
||||
el(out, id, &v.to_be_bytes());
|
||||
}
|
||||
|
||||
fn el_str(out: &mut Vec<u8>, id: u32, s: &str) {
|
||||
el(out, id, s.as_bytes());
|
||||
}
|
||||
|
||||
/// A block payload: track number, timestamp relative to the cluster, flags, frame.
|
||||
fn block(track: u64, rel: i16, flags: u8, data: &[u8]) -> Vec<u8> {
|
||||
let mut b = vint(track);
|
||||
b.extend_from_slice(&rel.to_be_bytes());
|
||||
b.push(flags);
|
||||
b.extend_from_slice(data);
|
||||
b
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
ms: i64,
|
||||
track: u64,
|
||||
data: Vec<u8>,
|
||||
alpha: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn cluster(frames: &mut [Frame]) -> Vec<u8> {
|
||||
frames.sort_by_key(|f| (f.ms, f.track));
|
||||
let base = frames.first().map_or(0, |f| f.ms);
|
||||
let mut body = vec![];
|
||||
el_uint(&mut body, 0xE7, base.max(0) as u64);
|
||||
for f in frames.iter() {
|
||||
let rel = (f.ms - base).clamp(i16::MIN as i64, i16::MAX as i64) as i16;
|
||||
match &f.alpha {
|
||||
None => el(&mut body, 0xA3, &block(f.track, rel, 0x80, &f.data)),
|
||||
Some(a) => {
|
||||
let mut g = vec![];
|
||||
el(&mut g, 0xA1, &block(f.track, rel, 0x00, &f.data));
|
||||
let mut more = vec![];
|
||||
el_uint(&mut more, 0xEE, 1);
|
||||
el(&mut more, 0xA5, a);
|
||||
let mut adds = vec![];
|
||||
el(&mut adds, 0xA6, &more);
|
||||
el(&mut g, 0x75A1, &adds);
|
||||
el(&mut body, 0xA0, &g);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
el(&mut out, CLUSTER, &body);
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn webm(job: &Job, comp: &Comp, w: u32, h: u32, st: &mut State) -> Result<Report> {
|
||||
let rate = job.settings.rate(comp);
|
||||
let alpha = job.output.channels == Channels::Rgba;
|
||||
let quality = job.output.quality.clamp(1, 100);
|
||||
let cfg = effectcraft_vp9enc::EncoderConfig { width: w, height: h, quality, full_range: false };
|
||||
let with_audio = wants_audio(job);
|
||||
let mut opus = with_audio.then(|| effectcraft_opusenc::OpusEncoder::new(2, OPUS_BITRATE));
|
||||
let total = st.total;
|
||||
let frame_ms = |k: u64| -> i64 { ((k as i128 * 1000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128) as i64 };
|
||||
let duration_ms = frame_ms(total) as f64;
|
||||
|
||||
let mut file = crate::out::create(job.sink, job.path)?;
|
||||
// EBML header.
|
||||
let mut head = vec![];
|
||||
let mut eb = vec![];
|
||||
el_uint(&mut eb, 0x4286, 1);
|
||||
el_uint(&mut eb, 0x42F7, 1);
|
||||
el_uint(&mut eb, 0x42F2, 4);
|
||||
el_uint(&mut eb, 0x42F3, 8);
|
||||
el_str(&mut eb, 0x4282, "webm");
|
||||
el_uint(&mut eb, 0x4287, 4);
|
||||
el_uint(&mut eb, 0x4285, 2);
|
||||
el(&mut head, EBML, &eb);
|
||||
// Segment with an 8-byte size patched at the end.
|
||||
head.extend(id_bytes(SEGMENT));
|
||||
let size_pos = head.len() as u64;
|
||||
head.extend_from_slice(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
let seg_start = head.len() as u64;
|
||||
let mut info = vec![];
|
||||
el_uint(&mut info, 0x2AD7B1, 1_000_000);
|
||||
el_float(&mut info, 0x4489, duration_ms);
|
||||
el_str(&mut info, 0x4D80, "EffectCraft");
|
||||
el_str(&mut info, 0x5741, "EffectCraft");
|
||||
el(&mut head, INFO, &info);
|
||||
let mut tracks = vec![];
|
||||
let mut v = vec![];
|
||||
el_uint(&mut v, 0xD7, 1);
|
||||
el_uint(&mut v, 0x73C5, 1);
|
||||
el_uint(&mut v, 0x83, 1);
|
||||
el_uint(&mut v, 0x9C, 0);
|
||||
el_str(&mut v, 0x86, "V_VP9");
|
||||
el_uint(&mut v, 0x23E383, (1_000_000_000u128 * rate.den as u128 / rate.num.max(1) as u128) as u64);
|
||||
if alpha {
|
||||
el_uint(&mut v, 0x55EE, 1);
|
||||
}
|
||||
let mut video = vec![];
|
||||
el_uint(&mut video, 0xB0, w as u64);
|
||||
el_uint(&mut video, 0xBA, h as u64);
|
||||
if alpha {
|
||||
el_uint(&mut video, 0x53C0, 1);
|
||||
}
|
||||
el(&mut v, 0xE0, &video);
|
||||
el(&mut tracks, TRACK_ENTRY, &v);
|
||||
if let Some(o) = &opus {
|
||||
let mut a = vec![];
|
||||
el_uint(&mut a, 0xD7, 2);
|
||||
el_uint(&mut a, 0x73C5, 2);
|
||||
el_uint(&mut a, 0x83, 2);
|
||||
el_uint(&mut a, 0x9C, 0);
|
||||
el_str(&mut a, 0x86, "A_OPUS");
|
||||
el(&mut a, 0x63A2, &o.opus_head());
|
||||
el_uint(&mut a, 0x56AA, o.pre_skip() as u64 * 1_000_000_000 / OPUS_RATE as u64);
|
||||
el_uint(&mut a, 0x56BB, 80_000_000);
|
||||
let mut au = vec![];
|
||||
el_float(&mut au, 0xB5, OPUS_RATE as f64);
|
||||
el_uint(&mut au, 0x9F, 2);
|
||||
el(&mut a, 0xE1, &au);
|
||||
el(&mut tracks, TRACK_ENTRY, &a);
|
||||
}
|
||||
el(&mut head, TRACKS, &tracks);
|
||||
file.write_all(&head).map_err(io)?;
|
||||
let mut seg_len = head.len() as u64 - seg_start;
|
||||
|
||||
// Audio: mixed in blocks, encoded in 20 ms packets.
|
||||
let (span_start, span_end) = job.settings.span(comp);
|
||||
let mut cursor = span_start.to_units_floor(OPUS_RATE as i64);
|
||||
let mut pcm_left: Vec<f32> = vec![];
|
||||
let mut packets = 0u64;
|
||||
let mut audio_until = |until: Tick, frames: &mut Vec<Frame>, finish: bool| {
|
||||
let Some(enc) = opus.as_mut() else { return };
|
||||
let end = until.to_units_floor(OPUS_RATE as i64);
|
||||
if end > cursor {
|
||||
let n = (end - cursor) as usize;
|
||||
let start = Tick(((cursor as i128 * TICKS_PER_SECOND as i128) / OPUS_RATE as i128) as i64);
|
||||
pcm_left.extend(effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, start, n, OPUS_RATE));
|
||||
cursor = end;
|
||||
}
|
||||
let fs = effectcraft_opusenc::OpusEncoder::FRAME_SIZE * 2;
|
||||
if finish && !pcm_left.is_empty() {
|
||||
// Pad the last packet (pre-skip + padding cover the encoder delay).
|
||||
let pad = (fs - pcm_left.len() % fs) % fs + fs;
|
||||
pcm_left.extend(std::iter::repeat_n(0.0, pad));
|
||||
}
|
||||
while pcm_left.len() >= fs {
|
||||
let pkt = enc.encode_float(&pcm_left[..fs]);
|
||||
pcm_left.drain(..fs);
|
||||
frames.push(Frame { ms: packets as i64 * 20, track: 2, data: pkt, alpha: None });
|
||||
packets += 1;
|
||||
}
|
||||
};
|
||||
|
||||
let batch = batch_size();
|
||||
let mut i = 0;
|
||||
while i < total {
|
||||
let end = (i + batch).min(total);
|
||||
let encoded: Vec<(Vec<u8>, Option<Vec<u8>>)> = (i..end)
|
||||
.into_par_iter()
|
||||
.map(|k| {
|
||||
let px = rgba8(&render_frame(job, comp, k), comp, if alpha { Channels::Rgba } else { Channels::Rgb }, w, h);
|
||||
let mut enc = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
|
||||
let (y, u, vv) = effectcraft_vp9enc::rgba_to_yuv420(&px, w, h, false);
|
||||
let color = enc.encode_yuv420(&y, &u, &vv);
|
||||
let a = alpha.then(|| {
|
||||
let mut ea = effectcraft_vp9enc::Vp9Encoder::new(cfg.clone());
|
||||
let (y, u, vv) = effectcraft_vp9enc::alpha_to_yuv420(&px, w, h);
|
||||
ea.encode_yuv420(&y, &u, &vv)
|
||||
});
|
||||
(color, a)
|
||||
})
|
||||
.collect();
|
||||
let mut frames: Vec<Frame> =
|
||||
encoded.into_iter().enumerate().map(|(j, (data, alpha))| Frame { ms: frame_ms(i + j as u64), track: 1, data, alpha }).collect();
|
||||
let t_end = if end >= total { span_end } else { job.settings.frame_time(comp, end) };
|
||||
audio_until(t_end, &mut frames, end >= total);
|
||||
let c = cluster(&mut frames);
|
||||
file.write_all(&c).map_err(io)?;
|
||||
seg_len += c.len() as u64;
|
||||
st.advance(end - i)?;
|
||||
i = end;
|
||||
}
|
||||
if total == 0 {
|
||||
let mut frames = vec![];
|
||||
audio_until(span_end, &mut frames, true);
|
||||
if !frames.is_empty() {
|
||||
let c = cluster(&mut frames);
|
||||
file.write_all(&c).map_err(io)?;
|
||||
seg_len += c.len() as u64;
|
||||
}
|
||||
}
|
||||
// Patch the Segment size.
|
||||
let mut size = [0u8; 8];
|
||||
size[0] = 0x01;
|
||||
size[1..].copy_from_slice(&seg_len.to_be_bytes()[1..]);
|
||||
file.seek(SeekFrom::Start(size_pos)).map_err(io)?;
|
||||
file.write_all(&size).map_err(io)?;
|
||||
file.seek(SeekFrom::End(0)).map_err(io)?;
|
||||
let bytes = file.finish()?;
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: with_audio })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- audio-only outputs
|
||||
|
||||
fn ieee_extended(v: f64) -> [u8; 10] {
|
||||
// 80-bit IEEE 754 extended precision (AIFF COMM sample rate).
|
||||
let mut out = [0u8; 10];
|
||||
if v <= 0.0 {
|
||||
return out;
|
||||
}
|
||||
let e = v.log2().floor() as i32;
|
||||
let mant = (v / 2f64.powi(e) * (1u64 << 63) as f64) as u64;
|
||||
out[..2].copy_from_slice(&((e + 16383) as u16).to_be_bytes());
|
||||
out[2..].copy_from_slice(&mant.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// WAV or AIFF: the comp's audio over the render span, 16-bit stereo.
|
||||
pub(crate) fn audio_file(job: &Job, comp: &Comp, aiff: bool, st: &mut State) -> Result<Report> {
|
||||
let sr = job.output.audio_sample_rate.clamp(8_000, 192_000);
|
||||
let (span_start, span_end) = job.settings.span(comp);
|
||||
let start = span_start.to_units_floor(sr as i64);
|
||||
let end = span_end.to_units_floor(sr as i64);
|
||||
let n = (end - start).max(0) as usize;
|
||||
let mut pcm = Vec::with_capacity(n * 4);
|
||||
// Mix in one-second blocks, reporting progress in frames.
|
||||
let block = sr as usize;
|
||||
let mut done = 0usize;
|
||||
let total_frames = st.total.max(1);
|
||||
let mut reported = 0u64;
|
||||
while done < n {
|
||||
let k = block.min(n - done);
|
||||
let t = Tick((((start + done as i64) as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
|
||||
for s in effectcraft_render::audio::mix_comp(job.project, job.footage, job.expr, job.comp, t, k, sr) {
|
||||
let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16;
|
||||
pcm.extend_from_slice(&if aiff { v.to_be_bytes() } else { v.to_le_bytes() });
|
||||
}
|
||||
done += k;
|
||||
let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames);
|
||||
if now > reported && st.total > 0 {
|
||||
st.advance(now - reported)?;
|
||||
reported = now;
|
||||
}
|
||||
}
|
||||
if reported < st.total {
|
||||
st.advance(st.total - reported)?;
|
||||
}
|
||||
let mut out = vec![];
|
||||
let frames = (pcm.len() / 4) as u32;
|
||||
if aiff {
|
||||
let mut comm = vec![];
|
||||
comm.extend_from_slice(&2u16.to_be_bytes());
|
||||
comm.extend_from_slice(&frames.to_be_bytes());
|
||||
comm.extend_from_slice(&16u16.to_be_bytes());
|
||||
comm.extend_from_slice(&ieee_extended(sr as f64));
|
||||
let ssnd_len = 8 + pcm.len();
|
||||
out.extend_from_slice(b"FORM");
|
||||
out.extend_from_slice(&((4 + 8 + comm.len() + 8 + ssnd_len + ssnd_len % 2) as u32).to_be_bytes());
|
||||
out.extend_from_slice(b"AIFF");
|
||||
out.extend_from_slice(b"COMM");
|
||||
out.extend_from_slice(&(comm.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&comm);
|
||||
out.extend_from_slice(b"SSND");
|
||||
out.extend_from_slice(&(ssnd_len as u32).to_be_bytes());
|
||||
out.extend_from_slice(&[0; 8]);
|
||||
out.extend_from_slice(&pcm);
|
||||
if ssnd_len % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
} else {
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&((36 + pcm.len()) as u32).to_le_bytes());
|
||||
out.extend_from_slice(b"WAVEfmt ");
|
||||
out.extend_from_slice(&16u32.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&2u16.to_le_bytes());
|
||||
out.extend_from_slice(&sr.to_le_bytes());
|
||||
out.extend_from_slice(&(sr * 4).to_le_bytes());
|
||||
out.extend_from_slice(&4u16.to_le_bytes());
|
||||
out.extend_from_slice(&16u16.to_le_bytes());
|
||||
out.extend_from_slice(b"data");
|
||||
out.extend_from_slice(&(pcm.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&pcm);
|
||||
}
|
||||
let mut file = crate::out::create(job.sink, job.path)?;
|
||||
file.write_all(&out).map_err(io)?;
|
||||
let bytes = file.finish()?;
|
||||
Ok(Report { path: job.path.to_string(), frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vints_and_ids() {
|
||||
assert_eq!(vint(0), vec![0x80]);
|
||||
assert_eq!(vint(126), vec![0xFE]);
|
||||
assert_eq!(vint(127), vec![0x40, 0x7F]);
|
||||
assert_eq!(id_bytes(0xA3), vec![0xA3]);
|
||||
assert_eq!(id_bytes(EBML), vec![0x1A, 0x45, 0xDF, 0xA3]);
|
||||
assert_eq!(ieee_extended(44100.0), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(ieee_extended(48000.0)[..4], [0x40, 0x0E, 0xBB, 0x80]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Round trip of `effectcraft-opusenc` through FilmCraft's spec-derived Opus decoder, plus an
|
||||
//! ffmpeg oracle (skipped when ffmpeg is not installed) reading an Ogg Opus file written here.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_opusenc::OpusEncoder;
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
const FRAME: usize = OpusEncoder::FRAME_SIZE;
|
||||
|
||||
/// Encodes interleaved `pcm`, padding the tail so the encoder's look-ahead is flushed.
|
||||
fn encode(pcm: &[f32], channels: u8, bitrate: u32) -> (OpusEncoder, Vec<Vec<u8>>) {
|
||||
let c = channels as usize;
|
||||
let mut enc = OpusEncoder::new(channels, bitrate);
|
||||
let total = pcm.len() / c + enc.pre_skip() as usize;
|
||||
let frames = total.div_ceil(FRAME);
|
||||
let mut padded = pcm.to_vec();
|
||||
padded.resize(frames * FRAME * c, 0.0);
|
||||
let packets = padded.chunks(FRAME * c).map(|f| enc.encode_float(f)).collect();
|
||||
(enc, packets)
|
||||
}
|
||||
|
||||
/// Decodes with filmcraft-opus, drops the pre-skip and returns planar channels of `len` samples.
|
||||
fn decode(head: &[u8], packets: &[Vec<u8>], len: usize) -> Vec<Vec<f32>> {
|
||||
let mut dec = filmcraft_opus::Decoder::new(head).expect("OpusHead");
|
||||
dec.set_trim_pre_skip(true);
|
||||
let mut out: Vec<Vec<f32>> = vec![Vec::new(); dec.channels()];
|
||||
for p in packets {
|
||||
let planes = dec.decode(Some(p)).expect("decode");
|
||||
for (o, p) in out.iter_mut().zip(planes) {
|
||||
o.extend_from_slice(&p);
|
||||
}
|
||||
}
|
||||
for o in &mut out {
|
||||
assert!(o.len() >= len, "decoded {} < {len}", o.len());
|
||||
o.truncate(len);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn roundtrip(planar: &[Vec<f32>], bitrate: u32) -> (Vec<Vec<u8>>, Vec<Vec<f32>>) {
|
||||
let c = planar.len();
|
||||
let len = planar[0].len();
|
||||
let mut pcm = vec![0f32; len * c];
|
||||
for (ch, p) in planar.iter().enumerate() {
|
||||
for (i, &v) in p.iter().enumerate() {
|
||||
pcm[i * c + ch] = v;
|
||||
}
|
||||
}
|
||||
let (enc, packets) = encode(&pcm, c as u8, bitrate);
|
||||
let decoded = decode(&enc.opus_head(), &packets, len);
|
||||
(packets, decoded)
|
||||
}
|
||||
|
||||
/// SNR in dB over the steady part (the first and last 20 ms are excluded).
|
||||
fn snr(orig: &[f32], dec: &[f32]) -> f64 {
|
||||
let (a, b) = (FRAME, orig.len() - FRAME);
|
||||
let mut s = 0f64;
|
||||
let mut e = 0f64;
|
||||
for i in a..b {
|
||||
s += (orig[i] as f64).powi(2);
|
||||
e += (orig[i] as f64 - dec[i] as f64).powi(2);
|
||||
}
|
||||
10.0 * (s / e.max(1e-30)).log10()
|
||||
}
|
||||
|
||||
fn sine(freq: f32, amp: f32, len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| amp * (2.0 * std::f32::consts::PI * freq * i as f32 / SR).sin()).collect()
|
||||
}
|
||||
|
||||
fn chirp(f0: f32, f1: f32, amp: f32, len: usize) -> Vec<f32> {
|
||||
let dur = len as f32 / SR;
|
||||
let k = (f1 - f0) / dur;
|
||||
(0..len)
|
||||
.map(|i| {
|
||||
let t = i as f32 / SR;
|
||||
amp * (2.0 * std::f32::consts::PI * (f0 * t + 0.5 * k * t * t)).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn noise(amp: f32, len: usize, seed: u32) -> Vec<f32> {
|
||||
let mut s = seed;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
amp * ((s >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Energy (dB) of `x` in each of a few octave bands up to 16 kHz, from a Hann-windowed FFT
|
||||
/// averaged over 2048-sample blocks.
|
||||
fn band_energies(x: &[f32]) -> Vec<f64> {
|
||||
const N: usize = 2048;
|
||||
let edges = [100.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
let mut acc = vec![0f64; edges.len() - 1];
|
||||
let mut start = N;
|
||||
while start + N <= x.len() - N {
|
||||
let mut re: Vec<f64> = (0..N).map(|i| x[start + i] as f64 * (0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / N as f64).cos())).collect();
|
||||
let mut im = vec![0f64; N];
|
||||
fft(&mut re, &mut im);
|
||||
for k in 1..N / 2 {
|
||||
let f = k as f64 * SR as f64 / N as f64;
|
||||
if let Some(b) = edges.windows(2).position(|w| f >= w[0] && f < w[1]) {
|
||||
acc[b] += re[k] * re[k] + im[k] * im[k];
|
||||
}
|
||||
}
|
||||
start += N;
|
||||
}
|
||||
acc.iter().map(|e| 10.0 * (e + 1e-20).log10()).collect()
|
||||
}
|
||||
|
||||
fn fft(re: &mut [f64], im: &mut [f64]) {
|
||||
let n = re.len();
|
||||
let mut j = 0;
|
||||
for i in 1..n {
|
||||
let mut bit = n >> 1;
|
||||
while j & bit != 0 {
|
||||
j ^= bit;
|
||||
bit >>= 1;
|
||||
}
|
||||
j |= bit;
|
||||
if i < j {
|
||||
re.swap(i, j);
|
||||
im.swap(i, j);
|
||||
}
|
||||
}
|
||||
let mut len = 2;
|
||||
while len <= n {
|
||||
let ang = -2.0 * std::f64::consts::PI / len as f64;
|
||||
for s in (0..n).step_by(len) {
|
||||
for k in 0..len / 2 {
|
||||
let (wr, wi) = ((ang * k as f64).cos(), (ang * k as f64).sin());
|
||||
let (a, b) = (s + k, s + k + len / 2);
|
||||
let tr = re[b] * wr - im[b] * wi;
|
||||
let ti = re[b] * wi + im[b] * wr;
|
||||
re[b] = re[a] - tr;
|
||||
im[b] = im[a] - ti;
|
||||
re[a] += tr;
|
||||
im[a] += ti;
|
||||
}
|
||||
}
|
||||
len <<= 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sine_440_mono() {
|
||||
let x = sine(440.0, 0.5, 48_000);
|
||||
for rate in [64_000, 128_000] {
|
||||
let (packets, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
assert!(packets.iter().all(|p| p.len() == rate as usize / 400));
|
||||
let s = snr(&x, &d[0]);
|
||||
eprintln!("sine 440 Hz mono @ {} kb/s: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 20.0, "sine SNR {s:.1} dB at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chirp_mono() {
|
||||
let x = chirp(100.0, 10_000.0, 0.4, 96_000);
|
||||
for rate in [64_000, 128_000] {
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
let s = snr(&x, &d[0]);
|
||||
eprintln!("chirp 100 Hz-10 kHz mono @ {} kb/s: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 15.0, "chirp SNR {s:.1} dB at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_noise_mono() {
|
||||
let x = noise(0.3, 48_000, 12345);
|
||||
for rate in [64_000, 128_000] {
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
let s = snr(&x, &d[0]);
|
||||
let (eo, ed) = (band_energies(&x), band_energies(&d[0]));
|
||||
let err: Vec<f32> = x.iter().zip(&d[0]).map(|(a, b)| a - b).collect();
|
||||
let ee = band_energies(&err);
|
||||
let octave_snr: Vec<f64> = eo.iter().zip(&ee).map(|(a, b)| a - b).collect();
|
||||
let worst = eo.iter().zip(&ed).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
|
||||
let min_octave = octave_snr.iter().copied().fold(f64::MAX, f64::min);
|
||||
eprintln!(
|
||||
"white noise mono @ {} kb/s: full-band SNR {s:.1} dB, octave SNRs 100 Hz-16 kHz {:.1?} dB, worst octave energy error {worst:.2} dB",
|
||||
rate / 1000,
|
||||
octave_snr
|
||||
);
|
||||
// Full-band noise loses everything above 20 kHz (CELT's last band ends there) and spends
|
||||
// few bits per coefficient on 16-20 kHz, so the full-band waveform SNR is low; the band
|
||||
// energies must match closely and the octaves below 16 kHz must be waveform-coded.
|
||||
assert!(worst < 1.5, "band energy error {worst:.2} dB at {rate}: {eo:?} vs {ed:?}");
|
||||
assert!(s > 0.5, "noise SNR {s:.1} dB at {rate}");
|
||||
assert!(min_octave > if rate >= 128_000 { 10.0 } else { 3.0 }, "octave SNRs {octave_snr:?} at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowpass_noise_mono() {
|
||||
// White noise through a 4-pole low-pass at about 2 kHz: a dense spectrum the encoder can
|
||||
// afford to code as a waveform.
|
||||
let w = noise(0.8, 48_000, 999);
|
||||
let a = (-2.0 * std::f32::consts::PI * 2_000.0 / SR).exp();
|
||||
let mut st = [0f32; 4];
|
||||
let x: Vec<f32> = w
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
let mut y = v;
|
||||
for s in st.iter_mut() {
|
||||
*s = (1.0 - a) * y + a * *s;
|
||||
y = *s;
|
||||
}
|
||||
y
|
||||
})
|
||||
.collect();
|
||||
for rate in [64_000, 128_000] {
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), rate);
|
||||
let s = snr(&x, &d[0]);
|
||||
eprintln!("low-pass noise mono @ {} kb/s: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 15.0, "low-pass noise SNR {s:.1} dB at {rate}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_stays_silent() {
|
||||
let x = vec![0f32; 48_000];
|
||||
let (packets, d) = roundtrip(&[x.clone(), x], 128_000);
|
||||
assert!(packets.iter().all(|p| p.len() == 3), "silence frames are 3-byte packets");
|
||||
for ch in &d {
|
||||
let peak = ch.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(peak < 1e-6, "peak {peak}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_distinct_channels() {
|
||||
let l = sine(440.0, 0.5, 48_000);
|
||||
let r = chirp(200.0, 6_000.0, 0.3, 48_000);
|
||||
let (packets, d) = roundtrip(&[l.clone(), r.clone()], 256_000);
|
||||
assert!(packets.iter().all(|p| p[0] == 0xFC && p.len() == 640));
|
||||
let (sl, sr) = (snr(&l, &d[0]), snr(&r, &d[1]));
|
||||
eprintln!("stereo @ 256 kb/s: L (sine) SNR {sl:.1} dB, R (chirp) SNR {sr:.1} dB");
|
||||
assert!(sl > 20.0 && sr > 15.0, "stereo SNR L {sl:.1} R {sr:.1}");
|
||||
// No leakage of the chirp into the left channel (2-4 kHz octave: chirp only).
|
||||
let (el, er) = (band_energies(&d[0]), band_energies(&r));
|
||||
assert!(el[4] < er[4] - 30.0, "chirp energy leaked into L: {} vs {}", el[4], er[4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_128k_total() {
|
||||
let l = sine(1000.0, 0.4, 48_000);
|
||||
let r = sine(330.0, 0.4, 48_000);
|
||||
let (_, d) = roundtrip(&[l.clone(), r.clone()], 128_000);
|
||||
let (sl, sr) = (snr(&l, &d[0]), snr(&r, &d[1]));
|
||||
eprintln!("stereo @ 128 kb/s: L SNR {sl:.1} dB, R SNR {sr:.1} dB");
|
||||
assert!(sl > 15.0 && sr > 15.0, "stereo SNR L {sl:.1} R {sr:.1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_after_silence_and_loud_transient() {
|
||||
// Silence, then a burst at full scale, then silence again: energy prediction must recover.
|
||||
let mut x = vec![0f32; 24_000];
|
||||
x.extend(sine(2_000.0, 0.99, 24_000));
|
||||
x.extend(vec![0f32; 24_000]);
|
||||
let (_, d) = roundtrip(std::slice::from_ref(&x), 96_000);
|
||||
let s = snr(&x[24_000 + 2 * FRAME..48_000], &d[0][24_000 + 2 * FRAME..48_000]);
|
||||
eprintln!("burst after silence @ 96 kb/s: SNR {s:.1} dB");
|
||||
assert!(s > 15.0, "burst SNR {s:.1}");
|
||||
let tail_peak = d[0][48_000 + 2 * FRAME..].iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(tail_peak < 1e-3, "tail peak {tail_peak}");
|
||||
}
|
||||
|
||||
// ---- Ogg Opus (RFC 3533 + RFC 7845) for the ffmpeg oracle ----
|
||||
|
||||
fn ogg_crc(data: &[u8]) -> u32 {
|
||||
let mut crc = 0u32;
|
||||
for &b in data {
|
||||
crc ^= (b as u32) << 24;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
fn ogg_page(out: &mut Vec<u8>, packet: &[u8], header_type: u8, granule: u64, seq: u32) {
|
||||
let mut lacing = vec![255u8; packet.len() / 255];
|
||||
lacing.push((packet.len() % 255) as u8);
|
||||
let start = out.len();
|
||||
out.extend_from_slice(b"OggS");
|
||||
out.push(0);
|
||||
out.push(header_type);
|
||||
out.extend_from_slice(&granule.to_le_bytes());
|
||||
out.extend_from_slice(&0x4543_4F50u32.to_le_bytes()); // serial
|
||||
out.extend_from_slice(&seq.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // CRC placeholder
|
||||
out.push(lacing.len() as u8);
|
||||
out.extend_from_slice(&lacing);
|
||||
out.extend_from_slice(packet);
|
||||
let crc = ogg_crc(&out[start..]);
|
||||
out[start + 22..start + 26].copy_from_slice(&crc.to_le_bytes());
|
||||
}
|
||||
|
||||
fn ogg_opus(enc: &OpusEncoder, packets: &[Vec<u8>], len: usize) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
ogg_page(&mut out, &enc.opus_head(), 0x02, 0, 0);
|
||||
let vendor = b"effectcraft-opusenc";
|
||||
let mut tags = b"OpusTags".to_vec();
|
||||
tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
|
||||
tags.extend_from_slice(vendor);
|
||||
tags.extend_from_slice(&0u32.to_le_bytes());
|
||||
ogg_page(&mut out, &tags, 0, 0, 1);
|
||||
let pre_skip = enc.pre_skip() as u64;
|
||||
for (i, p) in packets.iter().enumerate() {
|
||||
let last = i + 1 == packets.len();
|
||||
let granule = if last { pre_skip + len as u64 } else { ((i + 1) * FRAME) as u64 };
|
||||
ogg_page(&mut out, p, if last { 0x04 } else { 0 }, granule, i as u32 + 2);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ogg_crc_known_value() {
|
||||
// CRC-32 (poly 0x04C11DB7, no reflection, init 0) of "123456789".
|
||||
assert_eq!(ogg_crc(b"123456789"), 0x89A1_897F);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_oracle_decodes_ogg_opus() {
|
||||
if Command::new("ffmpeg").arg("-version").output().is_err() {
|
||||
eprintln!("ffmpeg not installed; skipping the oracle test");
|
||||
return;
|
||||
}
|
||||
for (channels, rate) in [(1u8, 96_000u32), (2, 192_000)] {
|
||||
let len = 48_000;
|
||||
let planar: Vec<Vec<f32>> =
|
||||
if channels == 1 { vec![chirp(150.0, 9_000.0, 0.4, len)] } else { vec![sine(440.0, 0.5, len), chirp(150.0, 9_000.0, 0.3, len)] };
|
||||
let c = channels as usize;
|
||||
let mut pcm = vec![0f32; len * c];
|
||||
for (ch, p) in planar.iter().enumerate() {
|
||||
for (i, &v) in p.iter().enumerate() {
|
||||
pcm[i * c + ch] = v;
|
||||
}
|
||||
}
|
||||
let (enc, packets) = encode(&pcm, channels, rate);
|
||||
let path = std::env::temp_dir().join(format!("effectcraft-opusenc-{}-{channels}.opus", std::process::id()));
|
||||
std::fs::write(&path, ogg_opus(&enc, &packets, len)).unwrap();
|
||||
let out = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "f32le", "-ac", &channels.to_string(), "-ar", "48000", "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let dec: Vec<f32> = out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
assert_eq!(dec.len(), len * c, "ffmpeg must honour pre-skip and the end granule");
|
||||
for ch in 0..c {
|
||||
let d: Vec<f32> = (0..len).map(|i| dec[i * c + ch]).collect();
|
||||
let s = snr(&planar[ch], &d);
|
||||
eprintln!("ffmpeg {channels} ch @ {} kb/s, channel {ch}: SNR {s:.1} dB", rate / 1000);
|
||||
assert!(s > 15.0, "ffmpeg-decoded SNR {s:.1} dB (channel {ch})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,3 +363,95 @@ fn movies_carry_audio() {
|
||||
assert!(!effectcraft_media::probe(&path).expect("probe").has_audio);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webm_vp9_opus_roundtrip() {
|
||||
let d = out_dir("webm");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
let om = OutputModule { quality: 90, audio: AudioOutput::Auto, ..OutputModule::for_format(OutputFormat::WebM) };
|
||||
let path = d.join("comp.webm");
|
||||
let r = run(&p, cid, &pool, &om, &path);
|
||||
assert!(r.audio && r.bytes > 200, "{r:?}");
|
||||
// Decoded back through FilmCraft (Matroska demuxer, VP9 and Opus decoders).
|
||||
let f = decode_movie(&path, FRAMES, 0.08);
|
||||
assert!(f.has_audio);
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
let l = rms(s.iter().step_by(2).copied());
|
||||
assert!((l - 0.3536).abs() < 0.06, "left RMS {l}");
|
||||
ffmpeg_check(&path, 0.08);
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.contains("vp9|64|48|yuv420p"), "{s}");
|
||||
assert!(s.contains("opus"), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webm_with_alpha() {
|
||||
let d = out_dir("webm-alpha");
|
||||
let (p, cid) = project(None);
|
||||
// Transparent outside the red solid.
|
||||
let om = OutputModule { channels: Channels::Rgba, ..OutputModule::for_format(OutputFormat::WebM) };
|
||||
let path = d.join("alpha.webm");
|
||||
run(&p, cid, &NoFootage, &om, &path);
|
||||
let f = effectcraft_media::probe(&path).expect("probe our output");
|
||||
assert_eq!((f.width, f.height), (W, H));
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
// AlphaMode element (0x53C0) and BlockAdditions (0x75A1).
|
||||
assert!(bytes.windows(3).any(|w| w == [0x53, 0xC0, 0x81]), "AlphaMode 1");
|
||||
assert!(bytes.windows(2).any(|w| w == [0x75, 0xA1]), "BlockAdditions");
|
||||
// ffmpeg's libvpx decoder reads the alpha channel.
|
||||
let png = path.with_extension("alpha.png");
|
||||
let ok = Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-c:v", "libvpx-vp9", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-vf", "select=eq(n\\,8)", "-frames:v", "1", "-pix_fmt", "rgba"])
|
||||
.arg(&png)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
let img = image::open(&png).unwrap().to_rgba8();
|
||||
assert!(img.get_pixel(2, 2)[3] < 20, "corner transparent");
|
||||
assert!(img.get_pixel(W / 2, H / 2)[3] > 235, "centre opaque");
|
||||
} else {
|
||||
eprintln!("ffmpeg with libvpx not available: skipping alpha oracle");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_audio_only() {
|
||||
let d = out_dir("audio-only");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 2.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
for (fmt, name, magic) in [(OutputFormat::Wav, "out.wav", &b"RIFF"[..]), (OutputFormat::Aiff, "out.aif", &b"FORM"[..])] {
|
||||
let om = OutputModule::for_format(fmt);
|
||||
let path = d.join(name);
|
||||
let r = run(&p, cid, &pool, &om, &path);
|
||||
assert!(r.audio);
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(&bytes[..4], magic);
|
||||
// 1.2 s of 48 kHz 16-bit stereo.
|
||||
assert!((bytes.len() as i64 - 57_600 * 4).abs() < 200, "{name}: {}", bytes.len());
|
||||
let l = if fmt == OutputFormat::Wav {
|
||||
// Decoded by FilmCraft.
|
||||
let f = effectcraft_media::probe(&path).expect("probe audio");
|
||||
assert!(f.has_audio && !f.has_video, "{name}");
|
||||
let s = MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.3), 24_000, 48_000);
|
||||
rms(s.iter().step_by(2).copied())
|
||||
} else {
|
||||
// AIFF: big-endian samples after the SSND chunk's offset/block size.
|
||||
let at = bytes.windows(4).position(|w| w == b"SSND").expect("SSND") + 16;
|
||||
let left = bytes[at..].chunks_exact(4).skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0);
|
||||
rms(left)
|
||||
};
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
if let Some(s) = ffprobe(&path) {
|
||||
assert!(s.contains("pcm_s16"), "{s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! The VP9 intra encoder (`effectcraft-vp9enc`) round-trips through FilmCraft's VP9 decoder (and
|
||||
//! ffmpeg as an external oracle when installed): sizes, lossless exactness, PSNR.
|
||||
|
||||
use effectcraft_vp9enc::{EncoderConfig, Vp9Encoder};
|
||||
use filmcraft_vp9::{Decoder, Plane};
|
||||
|
||||
fn plane_u8(p: &Plane) -> Vec<u8> {
|
||||
match p {
|
||||
Plane::U8(v) => v.clone(),
|
||||
Plane::U16(v) => v.iter().map(|x| *x as u8).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthetic planes: gradients, noise and sharp edges.
|
||||
fn source(w: usize, h: usize, kind: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut s = 99u32 + kind;
|
||||
let mut rnd = || {
|
||||
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(s >> 24) as u8
|
||||
};
|
||||
let y: Vec<u8> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, yy) = (i % w, i / w);
|
||||
match kind {
|
||||
0 => ((x * 255) / w.max(1)) as u8 / 2 + ((yy * 255) / h.max(1)) as u8 / 2,
|
||||
1 => rnd(),
|
||||
_ => {
|
||||
if (x / 7 + yy / 5) % 2 == 0 {
|
||||
30
|
||||
} else {
|
||||
220
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let u: Vec<u8> = (0..cw * ch).map(|i| (64 + (i % cw) * 128 / cw.max(1)) as u8).collect();
|
||||
let v: Vec<u8> = (0..cw * ch).map(|i| (200 - (i / cw) * 100 / ch.max(1)) as u8).collect();
|
||||
(y, u, v)
|
||||
}
|
||||
|
||||
fn decode(frame: &[u8]) -> filmcraft_vp9::Picture {
|
||||
let mut d = Decoder::new();
|
||||
let mut pics = d.decode(frame, 0).expect("decode");
|
||||
assert_eq!(pics.len(), 1);
|
||||
pics.remove(0)
|
||||
}
|
||||
|
||||
fn crop(p: &[u8], stride: usize, w: usize, h: usize) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| p[y * stride..y * stride + w].to_vec()).collect()
|
||||
}
|
||||
|
||||
fn psnr(a: &[u8], b: &[u8]) -> f64 {
|
||||
let mse: f64 = a.iter().zip(b).map(|(x, y)| (*x as f64 - *y as f64).powi(2)).sum::<f64>() / a.len() as f64;
|
||||
if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossless_is_exact_for_odd_sizes() {
|
||||
for (w, h) in [(1, 1), (33, 17), (64, 64), (200, 120), (7, 70)] {
|
||||
for kind in 0..3 {
|
||||
let (y, u, v) = source(w, h, kind);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
|
||||
let f = e.encode_yuv420(&y, &u, &v);
|
||||
let p = decode(&f);
|
||||
assert_eq!((p.width, p.height), (w as u32, h as u32));
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
assert_eq!(crop(&plane_u8(&p.y), p.y_stride, w, h), y, "{w}x{h} kind {kind}: luma");
|
||||
assert_eq!(crop(&plane_u8(&p.u), p.uv_stride, cw, ch), u, "{w}x{h} kind {kind}: u");
|
||||
assert_eq!(crop(&plane_u8(&p.v), p.uv_stride, cw, ch), v, "{w}x{h} kind {kind}: v");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_quality_and_size() {
|
||||
let (w, h) = (200, 120);
|
||||
for kind in [0, 2] {
|
||||
let (y, u, v) = source(w, h, kind);
|
||||
let mut last = 0usize;
|
||||
for q in [30u8, 60, 80, 95] {
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: q, full_range: false });
|
||||
let f = e.encode_yuv420(&y, &u, &v);
|
||||
let p = decode(&f);
|
||||
let got = crop(&plane_u8(&p.y), p.y_stride, w, h);
|
||||
let db = psnr(&got, &y);
|
||||
let min = match q {
|
||||
30 => 20.0,
|
||||
60 => 28.0,
|
||||
80 => 32.0,
|
||||
_ => 38.0,
|
||||
};
|
||||
assert!(db > min, "kind {kind} q{q}: PSNR {db:.1} dB, {} bytes", f.len());
|
||||
assert!(f.len() >= last, "higher quality, more bytes");
|
||||
last = f.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_frames_use_tile_columns() {
|
||||
// 4160 px > 64 superblocks: two tile columns are required.
|
||||
let (w, h) = (4160, 16);
|
||||
let (y, u, v) = source(w, h, 0);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 90, full_range: false });
|
||||
let f = e.encode_yuv420(&y, &u, &v);
|
||||
let p = decode(&f);
|
||||
assert!(psnr(&crop(&plane_u8(&p.y), p.y_stride, w, h), &y) > 35.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_decodes_our_stream() {
|
||||
let (w, h) = (96, 64);
|
||||
let (y, u, v) = source(w, h, 0);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 100, full_range: false });
|
||||
let frame = e.encode_yuv420(&y, &u, &v);
|
||||
// IVF container (one frame).
|
||||
let mut ivf = Vec::new();
|
||||
ivf.extend_from_slice(b"DKIF");
|
||||
ivf.extend_from_slice(&0u16.to_le_bytes());
|
||||
ivf.extend_from_slice(&32u16.to_le_bytes());
|
||||
ivf.extend_from_slice(b"VP90");
|
||||
ivf.extend_from_slice(&(w as u16).to_le_bytes());
|
||||
ivf.extend_from_slice(&(h as u16).to_le_bytes());
|
||||
ivf.extend_from_slice(&30u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&1u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&1u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&0u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&(frame.len() as u32).to_le_bytes());
|
||||
ivf.extend_from_slice(&0u64.to_le_bytes());
|
||||
ivf.extend_from_slice(&frame);
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.ivf");
|
||||
std::fs::write(&path, &ivf).unwrap();
|
||||
let out = std::process::Command::new("ffmpeg")
|
||||
.args(["-hide_banner", "-loglevel", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
|
||||
.output();
|
||||
let Ok(out) = out else {
|
||||
eprintln!("ffmpeg not found: skipping oracle");
|
||||
return;
|
||||
};
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(&out.stdout[..w * h], &y[..], "ffmpeg luma matches (lossless)");
|
||||
assert_eq!(&out.stdout[w * h..w * h + u.len()], &u[..]);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -19,3 +19,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 }
|
||||
@@ -0,0 +1,186 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_footage_continuously_rasterizes() {
|
||||
let path = tmp("cr.svg");
|
||||
std::fs::write(&path, SVG).unwrap();
|
||||
let doc = effectcraft_svg::parse(SVG).unwrap();
|
||||
let (w, h) = doc.pixel_size();
|
||||
let mut s = effectcraft_host::session();
|
||||
let item = s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([300, 300]))] {
|
||||
s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap();
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
let want = effectcraft_svg::rasterize(&doc, w, h, 3.0);
|
||||
let soft = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
|
||||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap();
|
||||
let sharp = mean_diff(&s.render(cid, t, RenderOpts::default()), &want);
|
||||
assert!(sharp < 0.004, "continuously rasterised diff {sharp}");
|
||||
assert!(sharp < soft, "sharper than the upscaled pixels ({sharp} vs {soft})");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
effectcraft-model = { workspace = true }
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -10,10 +10,12 @@
|
||||
//! 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;
|
||||
pub mod exr_channels;
|
||||
mod layered;
|
||||
mod pool;
|
||||
mod probe;
|
||||
|
||||
@@ -45,7 +47,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.
|
||||
|
||||
@@ -386,6 +386,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 | FootageKind::Model => Loc { key: key(&footage.path, 0), media_t: None },
|
||||
FootageKind::Video | FootageKind::Audio => {
|
||||
let rate = footage.frame_rate;
|
||||
@@ -464,6 +469,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))
|
||||
}
|
||||
@@ -503,6 +511,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 aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
if footage.missing || !footage.has_video {
|
||||
return None;
|
||||
|
||||
@@ -64,11 +64,15 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Ve
|
||||
missing: false,
|
||||
sequence: Vec::new(),
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 crate::MODEL_EXTENSIONS.contains(&ext_of(Path::new(path)).as_str()) {
|
||||
return probe_model(path, &bytes, &|_| None);
|
||||
}
|
||||
@@ -111,6 +115,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +132,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) {
|
||||
@@ -153,6 +158,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "effectcraft-opusenc"
|
||||
description = "Clean-room pure-Rust Opus encoder (RFC 6716 CELT-only, fullband 48 kHz, 20 ms; RFC 7845 OpusHead) for WebM export audio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,33 @@
|
||||
# effectcraft-opusenc
|
||||
|
||||
Clean-room, pure-Rust Opus encoder for EffectCraft's WebM export audio (layer L0, `std` only,
|
||||
no `unsafe`).
|
||||
|
||||
- **CELT-only** packets (TOC configuration 31: fullband 20 kHz, 20 ms, one frame per packet),
|
||||
48 kHz input, mono or stereo (stereo coded as dual L/R stereo), constant packet size from the
|
||||
requested bitrate; all-zero frames become 3-byte silence packets.
|
||||
- Forward MDCT with the low-overlap window, band energies with inter/intra coarse prediction
|
||||
(cheaper of the two per frame), fine and final energy bits, the decoder's exact bit allocation,
|
||||
spreading and recursive band splitting, greedy PVQ search and codeword indexing.
|
||||
- Not implemented: SILK/hybrid modes, transient (short-block) frames, TF changes, pitch
|
||||
post-filter, intensity stereo, dynamic allocation boosts, VBR rate control.
|
||||
- `OpusEncoder::pre_skip()` is 120 samples (the MDCT overlap); `opus_head()` returns the RFC 7845
|
||||
identification header.
|
||||
|
||||
```rust
|
||||
let mut enc = effectcraft_opusenc::OpusEncoder::new(2, 128_000);
|
||||
let packet = enc.encode_float(&vec![0.0; effectcraft_opusenc::OpusEncoder::FRAME_SIZE * 2]);
|
||||
```
|
||||
|
||||
## Specifications
|
||||
|
||||
- RFC 6716, *Definition of the Opus Audio Codec* (September 2012), with the RFC 8251 updates
|
||||
(October 2017) as implemented by the decoder we round-trip against.
|
||||
- RFC 7845, *Ogg Encapsulation for the Opus Audio Codec* (April 2016), §5.1 `OpusHead` and
|
||||
§4.2 pre-skip.
|
||||
|
||||
Implemented from the RFC text. Constant tables (band edges, `eMeans`, the coarse-energy Laplace
|
||||
model, the allocation table, ICDFs) and the decoder-side allocation arithmetic are mirrored from
|
||||
FilmCraft's first-party, spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0), which is also
|
||||
the round-trip oracle in `crates/export/tests/opus_roundtrip.rs` (ffmpeg is a second, external
|
||||
oracle when installed). No third-party encoder or decoder source was consulted.
|
||||
@@ -0,0 +1,497 @@
|
||||
//! Band shape coding: PVQ search and codeword indexing, spreading, recursive band splitting
|
||||
//! (RFC 6716 §4.3.4, encoder direction).
|
||||
//!
|
||||
//! The encoder codes 20 ms long-block frames only (no transients, no TF changes), and stereo
|
||||
//! as dual (independent L/R) stereo, so a band is always coded by `quant_partition` with a single
|
||||
//! block. Every bit-budget computation mirrors the decoder so both sides take the same
|
||||
//! split/pulse decisions.
|
||||
|
||||
use crate::range::{BITRES, RangeEncoder, ilog};
|
||||
use crate::rate::{Mode, QTHETA_OFFSET, get_pulses};
|
||||
use crate::tables::*;
|
||||
|
||||
pub const SPREAD_NORMAL: usize = 2;
|
||||
|
||||
#[inline]
|
||||
fn frac_mul16(a: i32, b: i32) -> i32 {
|
||||
(16384 + (a as i16 as i32) * (b as i16 as i32)) >> 15
|
||||
}
|
||||
|
||||
fn bitexact_cos(x: i32) -> i32 {
|
||||
let tmp = (4096 + x * x) >> 13;
|
||||
let x2 = tmp;
|
||||
let x2 = (32767 - x2) + frac_mul16(x2, -7651 + frac_mul16(x2, 8277 + frac_mul16(-626, x2)));
|
||||
1 + x2
|
||||
}
|
||||
|
||||
fn bitexact_log2tan(isin: i32, icos: i32) -> i32 {
|
||||
let lc = ilog(icos as u32);
|
||||
let ls = ilog(isin as u32);
|
||||
let icos = icos << (15 - lc);
|
||||
let isin = isin << (15 - ls);
|
||||
(ls - lc) * (1 << 11) + frac_mul16(isin, frac_mul16(isin, -2597) + 7932) - frac_mul16(icos, frac_mul16(icos, -2597) + 7932)
|
||||
}
|
||||
|
||||
fn compute_qn(n: i32, b: i32, offset: i32, pulse_cap: i32, stereo: bool) -> i32 {
|
||||
const EXP2_TABLE8: [i32; 8] = [16384, 17866, 19483, 21247, 23170, 25267, 27554, 30048];
|
||||
let mut n2 = 2 * n - 1;
|
||||
if stereo && n == 2 {
|
||||
n2 -= 1;
|
||||
}
|
||||
let mut qb = (b + n2 * offset) / n2;
|
||||
qb = qb.min(b - pulse_cap - (4 << BITRES));
|
||||
qb = qb.min(8 << BITRES);
|
||||
if qb < (1 << BITRES >> 1) {
|
||||
1
|
||||
} else {
|
||||
let qn = EXP2_TABLE8[(qb & 0x7) as usize] >> (14 - (qb >> BITRES));
|
||||
(qn + 1) >> 1 << 1
|
||||
}
|
||||
}
|
||||
|
||||
/// One elementary pass of the spreading rotation, inverted (the decoder applies the forward
|
||||
/// pass; the encoder undoes it before the pulse search).
|
||||
fn exp_rotation1_inv(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) {
|
||||
if len <= stride {
|
||||
return;
|
||||
}
|
||||
if len >= 2 * stride + 1 {
|
||||
for i in 0..=len - 2 * stride - 1 {
|
||||
let x1 = x[i];
|
||||
let x2 = x[i + stride];
|
||||
x[i + stride] = c * x2 - s * x1;
|
||||
x[i] = c * x1 + s * x2;
|
||||
}
|
||||
}
|
||||
for i in (0..len - stride).rev() {
|
||||
let x1 = x[i];
|
||||
let x2 = x[i + stride];
|
||||
x[i + stride] = c * x2 - s * x1;
|
||||
x[i] = c * x1 + s * x2;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of the decoder's spreading rotation (§4.3.4.3) for one block of `len` samples.
|
||||
pub fn exp_rotation_inv(x: &mut [f32], len: usize, k: usize, spread: usize) {
|
||||
const SPREAD_FACTOR: [usize; 3] = [15, 10, 5];
|
||||
if 2 * k >= len || spread == 0 {
|
||||
return;
|
||||
}
|
||||
let factor = SPREAD_FACTOR[spread - 1];
|
||||
let gain = len as f32 / (len + factor * k) as f32;
|
||||
let theta = 0.5 * gain * gain;
|
||||
let c = (0.5 * std::f32::consts::PI * theta).cos();
|
||||
let s = (0.5 * std::f32::consts::PI * (1.0 - theta)).cos();
|
||||
let mut stride2 = 0;
|
||||
if len >= 8 {
|
||||
stride2 = 1;
|
||||
while stride2 * stride2 + stride2 < len {
|
||||
stride2 += 1;
|
||||
}
|
||||
}
|
||||
// The decoder applies (stride2: s, c) then (1: c, s); undo in reverse order.
|
||||
exp_rotation1_inv(x, len, 1, c, s);
|
||||
if stride2 != 0 {
|
||||
exp_rotation1_inv(x, len, stride2, s, c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedy PVQ search: the integer vector `y` with `sum |y| = k` closest in direction to `x`.
|
||||
pub fn pvq_search(x: &[f32], k: usize, y: &mut [i32]) {
|
||||
let n = x.len();
|
||||
y[..n].fill(0);
|
||||
let abs: Vec<f32> = x.iter().map(|v| v.abs()).collect();
|
||||
let sum: f32 = abs.iter().sum();
|
||||
if !sum.is_finite() || sum <= 1e-15 {
|
||||
y[0] = k as i32;
|
||||
return;
|
||||
}
|
||||
let mut placed = 0usize;
|
||||
let mut xy = 0f32;
|
||||
let mut yy = 0f32;
|
||||
if k > n / 2 {
|
||||
// Pre-project onto the pyramid (rounding down) and place the rest greedily.
|
||||
let r = (k as f32 - 1.0) / sum;
|
||||
for j in 0..n {
|
||||
let p = (abs[j] * r).floor() as i32;
|
||||
y[j] = p;
|
||||
placed += p as usize;
|
||||
xy += abs[j] * p as f32;
|
||||
yy += (p * p) as f32;
|
||||
}
|
||||
}
|
||||
while placed < k {
|
||||
let mut best = 0usize;
|
||||
let mut best_num = -1f32;
|
||||
let mut best_den = 1f32;
|
||||
for j in 0..n {
|
||||
let num = xy + abs[j];
|
||||
let num = num * num;
|
||||
let den = yy + 2.0 * y[j] as f32 + 1.0;
|
||||
if num * best_den > best_num * den {
|
||||
best_num = num;
|
||||
best_den = den;
|
||||
best = j;
|
||||
}
|
||||
}
|
||||
xy += abs[best];
|
||||
yy += 2.0 * y[best] as f32 + 1.0;
|
||||
y[best] += 1;
|
||||
placed += 1;
|
||||
}
|
||||
for j in 0..n {
|
||||
if x[j] < 0.0 {
|
||||
y[j] = -y[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the PVQ codeword `y` (`sum |y| = k`) as the index the RFC 6716 §4.3.4.2 decoder maps
|
||||
/// back to `y`.
|
||||
pub fn encode_pulses(y: &[i32], k: usize, enc: &mut RangeEncoder, v: &mut Vec<u64>) {
|
||||
let n = y.len();
|
||||
let w = k + 1;
|
||||
v.clear();
|
||||
v.resize((n + 1) * w, 0);
|
||||
v[0] = 1;
|
||||
for nn in 1..=n {
|
||||
v[nn * w] = 1;
|
||||
for kk in 1..=k {
|
||||
let a = v[(nn - 1) * w + kk];
|
||||
let b = v[nn * w + kk - 1];
|
||||
let c = v[(nn - 1) * w + kk - 1];
|
||||
v[nn * w + kk] = a.saturating_add(b).saturating_add(c);
|
||||
}
|
||||
}
|
||||
let total = v[n * w + k];
|
||||
let vv = |nn: usize, kx: usize| v[nn * w + kx];
|
||||
let mut index = 0u64;
|
||||
let mut kk = k;
|
||||
for j in 0..n {
|
||||
if kk == 0 {
|
||||
break;
|
||||
}
|
||||
let rem = n - j;
|
||||
let t = y[j].unsigned_abs() as usize;
|
||||
// Codewords with |y_j| = t start at sum_{m < kk - t} V(rem-1, m) within their sign half.
|
||||
let mut base: u64 = (0..kk - t).map(|m| vv(rem - 1, m)).sum();
|
||||
if y[j] < 0 {
|
||||
base += (vv(rem - 1, kk) + vv(rem, kk)) / 2;
|
||||
}
|
||||
index += base;
|
||||
kk -= t;
|
||||
}
|
||||
debug_assert!(total <= u32::MAX as u64);
|
||||
if total >= 2 {
|
||||
enc.uint(index as u32, total as u32);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BandEnc<'m, 'e> {
|
||||
pub m: &'m Mode,
|
||||
pub enc: &'e mut RangeEncoder,
|
||||
pub i: usize,
|
||||
pub spread: usize,
|
||||
pub remaining_bits: i32,
|
||||
pvq_v: Vec<u64>,
|
||||
iy: Vec<i32>,
|
||||
tmp: Vec<f32>,
|
||||
}
|
||||
|
||||
impl BandEnc<'_, '_> {
|
||||
/// `compute_theta` for a mono split (single block): measures the energy split between the
|
||||
/// two halves and codes the quantised angle with the triangular pdf.
|
||||
fn compute_theta_split(&mut self, xa: &[f32], xb: &[f32], b: &mut i32, lm: i32) -> (i32, i32, i32) {
|
||||
let n = xa.len() as i32;
|
||||
let i = self.i;
|
||||
let pulse_cap = self.m.log_n[i] + lm * (1 << BITRES);
|
||||
let offset = (pulse_cap >> 1) - QTHETA_OFFSET;
|
||||
let qn = compute_qn(n, *b, offset, pulse_cap, false);
|
||||
let tell = self.enc.tell_frac();
|
||||
let mut itheta = 0i32;
|
||||
if qn != 1 {
|
||||
let mid = xa.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
let side = xb.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
let ang = (side as f64).atan2(mid as f64) * std::f64::consts::FRAC_2_PI;
|
||||
let it = ((ang * qn as f64 + 0.5).floor() as i32).clamp(0, qn) as u32;
|
||||
let q = qn as u32;
|
||||
let half = q >> 1;
|
||||
let ft = (half + 1) * (half + 1);
|
||||
let (fl, fs) = if it <= half { (it * (it + 1) >> 1, it + 1) } else { (ft - ((q + 1 - it) * (q + 2 - it) >> 1), q + 1 - it) };
|
||||
self.enc.encode(fl, fl + fs, ft);
|
||||
itheta = ((it * 16384) / q) as i32;
|
||||
}
|
||||
let qalloc = self.enc.tell_frac() - tell;
|
||||
*b -= qalloc;
|
||||
let delta = if itheta == 0 {
|
||||
-16384
|
||||
} else if itheta == 16384 {
|
||||
16384
|
||||
} else {
|
||||
let imid = bitexact_cos(itheta);
|
||||
let iside = bitexact_cos(16384 - itheta);
|
||||
frac_mul16((n - 1) << 7, bitexact_log2tan(iside, imid))
|
||||
};
|
||||
(itheta, delta, qalloc)
|
||||
}
|
||||
|
||||
/// `quant_partition` (single block, encoder side).
|
||||
pub fn quant_partition(&mut self, x: &[f32], mut b: i32, mut lm: i32) {
|
||||
let n = x.len();
|
||||
let i = self.i;
|
||||
let cache = self.m.cache(lm, i);
|
||||
let cache_max = cache[cache[0] as usize] as i32;
|
||||
if lm != -1 && b > cache_max + 12 && n > 2 {
|
||||
let half = n >> 1;
|
||||
let (xa, xb) = x.split_at(half);
|
||||
lm -= 1;
|
||||
let (itheta, delta, qalloc) = self.compute_theta_split(xa, xb, &mut b, lm);
|
||||
let mut mbits = 0.max(b.min((b - delta) / 2));
|
||||
let mut sbits = b - mbits;
|
||||
self.remaining_bits -= qalloc;
|
||||
let mut rebalance = self.remaining_bits;
|
||||
if mbits >= sbits {
|
||||
self.quant_partition(xa, mbits, lm);
|
||||
rebalance = mbits - (rebalance - self.remaining_bits);
|
||||
if rebalance > 3 << BITRES && itheta != 0 {
|
||||
sbits += rebalance - (3 << BITRES);
|
||||
}
|
||||
self.quant_partition(xb, sbits, lm);
|
||||
} else {
|
||||
self.quant_partition(xb, sbits, lm);
|
||||
rebalance = sbits - (rebalance - self.remaining_bits);
|
||||
if rebalance > 3 << BITRES && itheta != 16384 {
|
||||
mbits += rebalance - (3 << BITRES);
|
||||
}
|
||||
self.quant_partition(xa, mbits, lm);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let m = self.m;
|
||||
let mut q = m.bits2pulses(i, lm, b);
|
||||
let mut curr_bits = m.pulses2bits(i, lm, q);
|
||||
self.remaining_bits -= curr_bits;
|
||||
while self.remaining_bits < 0 && q > 0 {
|
||||
self.remaining_bits += curr_bits;
|
||||
q -= 1;
|
||||
curr_bits = m.pulses2bits(i, lm, q);
|
||||
self.remaining_bits -= curr_bits;
|
||||
}
|
||||
if q != 0 {
|
||||
let k = get_pulses(q) as usize;
|
||||
let mut tmp = std::mem::take(&mut self.tmp);
|
||||
tmp.clear();
|
||||
tmp.extend_from_slice(x);
|
||||
exp_rotation_inv(&mut tmp, n, k, self.spread);
|
||||
let mut iy = std::mem::take(&mut self.iy);
|
||||
iy.clear();
|
||||
iy.resize(n, 0);
|
||||
pvq_search(&tmp, k, &mut iy);
|
||||
encode_pulses(&iy, k, self.enc, &mut self.pvq_v);
|
||||
self.iy = iy;
|
||||
self.tmp = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codes all band shapes (`quant_all_bands`, encoder side, long blocks). `x` holds `c` channels
|
||||
/// of `n` normalised MDCT coefficients each.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn quant_all_bands(
|
||||
m: &Mode,
|
||||
start: usize,
|
||||
end: usize,
|
||||
x: &[f32],
|
||||
n_total: usize,
|
||||
c: usize,
|
||||
pulses: &[i32; NB_EBANDS],
|
||||
spread: usize,
|
||||
dual_stereo: bool,
|
||||
intensity: usize,
|
||||
total_bits: i32,
|
||||
mut balance: i32,
|
||||
enc: &mut RangeEncoder,
|
||||
lm: usize,
|
||||
coded_bands: usize,
|
||||
) {
|
||||
let mm = 1usize << lm;
|
||||
debug_assert!(c == 1 || dual_stereo || intensity <= start, "stereo frames are coded as dual stereo");
|
||||
let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, pvq_v: Vec::new(), iy: Vec::new(), tmp: Vec::new() };
|
||||
for i in start..end {
|
||||
ctx.i = i;
|
||||
let xo = mm * EBANDS[i] as usize;
|
||||
let n = mm * EBANDS[i + 1] as usize - xo;
|
||||
let tell = ctx.enc.tell_frac();
|
||||
if i != start {
|
||||
balance -= tell;
|
||||
}
|
||||
let remaining_bits = total_bits - tell - 1;
|
||||
ctx.remaining_bits = remaining_bits;
|
||||
let b = if i < coded_bands {
|
||||
let curr_balance = balance / 3.min(coded_bands as i32 - i as i32);
|
||||
0.max(16383.min((remaining_bits + 1).min(pulses[i] + curr_balance)))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Bands at and above `intensity` (= coded_bands) carry no bits, so they emit no symbols.
|
||||
if i < coded_bands || c == 1 {
|
||||
if c == 2 && dual_stereo && i < intensity {
|
||||
ctx.quant_partition(&x[xo..xo + n], b / 2, lm as i32);
|
||||
ctx.quant_partition(&x[n_total + xo..n_total + xo + n], b / 2, lm as i32);
|
||||
} else {
|
||||
ctx.quant_partition(&x[xo..xo + n], b, lm as i32);
|
||||
}
|
||||
}
|
||||
balance += pulses[i] + tell;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::range::dec::RangeDecoder;
|
||||
|
||||
fn exp_rotation1(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) {
|
||||
let ms = -s;
|
||||
if len <= stride {
|
||||
return;
|
||||
}
|
||||
for i in 0..len - stride {
|
||||
let x1 = x[i];
|
||||
let x2 = x[i + stride];
|
||||
x[i + stride] = c * x2 + s * x1;
|
||||
x[i] = c * x1 + ms * x2;
|
||||
}
|
||||
if len >= 2 * stride + 1 {
|
||||
for i in (0..=len - 2 * stride - 1).rev() {
|
||||
let x1 = x[i];
|
||||
let x2 = x[i + stride];
|
||||
x[i + stride] = c * x2 + s * x1;
|
||||
x[i] = c * x1 + ms * x2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoder's spreading rotation (single block).
|
||||
fn exp_rotation(x: &mut [f32], len: usize, k: usize, spread: usize) {
|
||||
const SPREAD_FACTOR: [usize; 3] = [15, 10, 5];
|
||||
if 2 * k >= len || spread == 0 {
|
||||
return;
|
||||
}
|
||||
let factor = SPREAD_FACTOR[spread - 1];
|
||||
let gain = len as f32 / (len + factor * k) as f32;
|
||||
let theta = 0.5 * gain * gain;
|
||||
let c = (0.5 * std::f32::consts::PI * theta).cos();
|
||||
let s = (0.5 * std::f32::consts::PI * (1.0 - theta)).cos();
|
||||
let mut stride2 = 0;
|
||||
if len >= 8 {
|
||||
stride2 = 1;
|
||||
while stride2 * stride2 + stride2 < len {
|
||||
stride2 += 1;
|
||||
}
|
||||
}
|
||||
if stride2 != 0 {
|
||||
exp_rotation1(x, len, stride2, s, c);
|
||||
}
|
||||
exp_rotation1(x, len, 1, c, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreading_inverse_is_exact() {
|
||||
for &len in &[8usize, 16, 24, 48, 96, 176] {
|
||||
for k in [1usize, 2, 3, 5] {
|
||||
let orig: Vec<f32> = (0..len).map(|i| ((i * 31 % 17) as f32 - 8.0) / 8.0).collect();
|
||||
let mut x = orig.clone();
|
||||
exp_rotation_inv(&mut x, len, k, SPREAD_NORMAL);
|
||||
exp_rotation(&mut x, len, k, SPREAD_NORMAL);
|
||||
for (a, b) in x.iter().zip(&orig) {
|
||||
assert!((a - b).abs() < 1e-4, "len={len} k={k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PVQ decoder of RFC 6716 §4.3.4.2 (as in FilmCraft's decoder).
|
||||
fn decode_pulses(n: usize, k: usize, dec: &mut RangeDecoder) -> Vec<i32> {
|
||||
let w = k + 1;
|
||||
let mut v = vec![0u64; (n + 1) * w];
|
||||
v[0] = 1;
|
||||
for nn in 1..=n {
|
||||
v[nn * w] = 1;
|
||||
for kk in 1..=k {
|
||||
v[nn * w + kk] = v[(nn - 1) * w + kk].saturating_add(v[nn * w + kk - 1]).saturating_add(v[(nn - 1) * w + kk - 1]);
|
||||
}
|
||||
}
|
||||
let total = v[n * w + k];
|
||||
let mut i = if total >= 2 { dec.uint(total as u32) as u64 } else { 0 };
|
||||
let mut y = vec![0i32; n];
|
||||
let mut kk = k;
|
||||
for j in 0..n {
|
||||
let rem = n - j;
|
||||
if kk == 0 {
|
||||
break;
|
||||
}
|
||||
let vv = |nn: usize, kx: usize| v[nn * w + kx];
|
||||
let mut p = (vv(rem - 1, kk) + vv(rem, kk)) / 2;
|
||||
let neg = i >= p;
|
||||
if neg {
|
||||
i -= p;
|
||||
}
|
||||
let k0 = kk;
|
||||
p -= vv(rem - 1, kk);
|
||||
while p > i && kk > 0 {
|
||||
kk -= 1;
|
||||
p -= vv(rem - 1, kk);
|
||||
}
|
||||
let mag = (k0 - kk) as i32;
|
||||
y[j] = if neg { -mag } else { mag };
|
||||
i -= p.min(i);
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
fn all_codewords(n: usize, k: usize) -> Vec<Vec<i32>> {
|
||||
if n == 0 {
|
||||
return if k == 0 { vec![vec![]] } else { vec![] };
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for t in -(k as i32)..=(k as i32) {
|
||||
for mut rest in all_codewords(n - 1, k - t.unsigned_abs() as usize) {
|
||||
rest.insert(0, t);
|
||||
out.push(rest);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvq_index_roundtrip_exhaustive() {
|
||||
let mut v = Vec::new();
|
||||
for n in 1..6usize {
|
||||
for k in 1..6usize {
|
||||
for y in all_codewords(n, k) {
|
||||
let mut e = RangeEncoder::new();
|
||||
encode_pulses(&y, k, &mut e, &mut v);
|
||||
let buf = e.done(16);
|
||||
let mut d = RangeDecoder::new(&buf);
|
||||
assert_eq!(decode_pulses(n, k, &mut d), y, "n={n} k={k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvq_search_finds_good_direction() {
|
||||
let x: Vec<f32> = (0..32).map(|i| ((i as f32) * 0.7).sin()).collect();
|
||||
let mut y = vec![0i32; 32];
|
||||
for k in [1usize, 4, 16, 64] {
|
||||
pvq_search(&x, k, &mut y);
|
||||
assert_eq!(y.iter().map(|v| v.unsigned_abs() as usize).sum::<usize>(), k);
|
||||
let xy: f32 = x.iter().zip(&y).map(|(a, &b)| a * b as f32).sum();
|
||||
let yy: f32 = y.iter().map(|&b| (b * b) as f32).sum();
|
||||
let xx: f32 = x.iter().map(|a| a * a).sum();
|
||||
let corr = xy / (xx * yy).sqrt();
|
||||
assert!(corr > if k >= 16 { 0.9 } else { 0.2 }, "k={k} corr={corr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Band energy quantisation: Laplace-coded coarse energy with inter/intra prediction, fine
|
||||
//! energy and the final refinement bits (RFC 6716 §4.3.2, encoder direction).
|
||||
//!
|
||||
//! The quantised energies are updated with exactly the decoder's arithmetic so the prediction
|
||||
//! state never drifts between encoder and decoder.
|
||||
|
||||
use crate::range::RangeEncoder;
|
||||
use crate::rate::MAX_FINE_BITS;
|
||||
use crate::tables::*;
|
||||
|
||||
const LAPLACE_MINP: u32 = 1;
|
||||
const LAPLACE_NMIN: u32 = 16;
|
||||
|
||||
fn laplace_get_freq1(fs0: u32, decay: u32) -> u32 {
|
||||
let ft = 32768 - LAPLACE_MINP * (2 * LAPLACE_NMIN) - fs0;
|
||||
(ft * (16384 - decay)) >> 15
|
||||
}
|
||||
|
||||
/// Encodes a Laplace-distributed integer (`fs` = probability of 0 in Q15, `decay` in Q14).
|
||||
/// Returns the value actually coded (very large magnitudes are clamped to the representable
|
||||
/// range).
|
||||
pub fn laplace_encode(enc: &mut RangeEncoder, value: i32, mut fs: u32, decay: u32) -> i32 {
|
||||
let mut fl = 0u32;
|
||||
let mut coded = value;
|
||||
if value != 0 {
|
||||
let s: i32 = if value < 0 { -1 } else { 0 };
|
||||
let val = value.abs();
|
||||
fl = fs;
|
||||
fs = laplace_get_freq1(fs, decay);
|
||||
let mut i = 1;
|
||||
while fs > 0 && i < val {
|
||||
fs *= 2;
|
||||
fl += fs + 2 * LAPLACE_MINP;
|
||||
fs = (fs * decay) >> 15;
|
||||
i += 1;
|
||||
}
|
||||
if fs == 0 {
|
||||
let ndi_max = (32768 - fl + LAPLACE_MINP - 1) as i32;
|
||||
let ndi_max = (ndi_max - s) >> 1;
|
||||
let di = (val - i).min(ndi_max - 1);
|
||||
fl = (fl as i32 + (2 * di + 1 + s) * LAPLACE_MINP as i32) as u32;
|
||||
fs = LAPLACE_MINP.min(32768 - fl);
|
||||
let mag = i + di;
|
||||
coded = if s < 0 { -mag } else { mag };
|
||||
} else {
|
||||
fs += LAPLACE_MINP;
|
||||
if s == 0 {
|
||||
fl += fs;
|
||||
}
|
||||
}
|
||||
}
|
||||
enc.encode_bin(fl, fl + fs, 15);
|
||||
coded
|
||||
}
|
||||
|
||||
/// `quant_coarse_energy` for one prediction mode. `e` holds the target log2 energies (relative
|
||||
/// to `E_MEANS`), `old_e` the quantised energies, updated in place. `budget` is the frame size in
|
||||
/// bits.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn quant_coarse_energy(
|
||||
start: usize,
|
||||
end: usize,
|
||||
e: &[f32; 2 * NB_EBANDS],
|
||||
old_e: &mut [f32; 2 * NB_EBANDS],
|
||||
intra: bool,
|
||||
enc: &mut RangeEncoder,
|
||||
c: usize,
|
||||
lm: usize,
|
||||
budget: i32,
|
||||
) {
|
||||
let prob = &E_PROB_MODEL[lm][intra as usize];
|
||||
let (coef, beta) = if intra { (0.0, BETA_INTRA) } else { (PRED_COEF[lm], BETA_COEF[lm]) };
|
||||
let mut prev = [0f32; 2];
|
||||
for i in start..end {
|
||||
for ch in 0..c {
|
||||
let idx = i + ch * NB_EBANDS;
|
||||
let oldc = old_e[idx].max(-9.0);
|
||||
let x = e[idx];
|
||||
let f = x - coef * oldc - prev[ch];
|
||||
let mut qi = (f + 0.5).floor().clamp(-64.0, 64.0) as i32;
|
||||
let tell = enc.tell();
|
||||
// Keep a few bits per remaining band in reserve when the budget runs out.
|
||||
let bits_left = budget - tell - 3 * c as i32 * (end - i) as i32;
|
||||
if i != start && bits_left < 30 {
|
||||
if bits_left < 24 {
|
||||
qi = qi.min(1);
|
||||
}
|
||||
if bits_left < 16 {
|
||||
qi = qi.max(-1);
|
||||
}
|
||||
}
|
||||
if budget - tell >= 15 {
|
||||
let pi = 2 * i.min(20);
|
||||
qi = laplace_encode(enc, qi, (prob[pi] as u32) << 7, (prob[pi + 1] as u32) << 6);
|
||||
} else if budget - tell >= 2 {
|
||||
qi = qi.clamp(-1, 1);
|
||||
enc.icdf(if qi < 0 { (-2 * qi - 1) as usize } else { (2 * qi) as usize }, &SMALL_ENERGY_ICDF, 2);
|
||||
} else if budget - tell >= 1 {
|
||||
qi = qi.clamp(-1, 0);
|
||||
enc.bit_logp(qi == -1, 1);
|
||||
} else {
|
||||
qi = -1;
|
||||
}
|
||||
let q = qi as f32;
|
||||
old_e[idx] = oldc;
|
||||
let tmp = coef * old_e[idx] + prev[ch] + q;
|
||||
old_e[idx] = tmp;
|
||||
prev[ch] = prev[ch] + q - beta * q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `quant_fine_energy`: raw bits refining each band's quantised energy.
|
||||
pub fn quant_fine_energy(
|
||||
start: usize,
|
||||
end: usize,
|
||||
e: &[f32; 2 * NB_EBANDS],
|
||||
old_e: &mut [f32; 2 * NB_EBANDS],
|
||||
fine_quant: &[i32; NB_EBANDS],
|
||||
enc: &mut RangeEncoder,
|
||||
c: usize,
|
||||
) {
|
||||
for i in start..end {
|
||||
let fq = fine_quant[i];
|
||||
if fq <= 0 {
|
||||
continue;
|
||||
}
|
||||
for ch in 0..c {
|
||||
let idx = i + ch * NB_EBANDS;
|
||||
let err = e[idx] - old_e[idx];
|
||||
let levels = 1i32 << fq;
|
||||
let q2 = (((err + 0.5) * levels as f32).floor() as i32).clamp(0, levels - 1);
|
||||
enc.bits(q2 as u32, fq as u32);
|
||||
let offset = (q2 as f32 + 0.5) * (1 << (14 - fq)) as f32 / 16384.0 - 0.5;
|
||||
old_e[idx] += offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `quant_energy_finalise`: spends the bits left at the end of the frame on one more bit of
|
||||
/// energy resolution per band, in priority order.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn quant_energy_finalise(
|
||||
start: usize,
|
||||
end: usize,
|
||||
e: &[f32; 2 * NB_EBANDS],
|
||||
old_e: &mut [f32; 2 * NB_EBANDS],
|
||||
fine_quant: &[i32; NB_EBANDS],
|
||||
fine_priority: &[i32; NB_EBANDS],
|
||||
mut bits_left: i32,
|
||||
enc: &mut RangeEncoder,
|
||||
c: usize,
|
||||
) {
|
||||
for prio in 0..2 {
|
||||
let mut i = start;
|
||||
while i < end && bits_left >= c as i32 {
|
||||
if fine_quant[i] >= MAX_FINE_BITS || fine_priority[i] != prio {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
for ch in 0..c {
|
||||
let idx = i + ch * NB_EBANDS;
|
||||
let q2 = (e[idx] - old_e[idx] >= 0.0) as u32;
|
||||
enc.bits(q2, 1);
|
||||
let offset = (q2 as f32 - 0.5) * (1 << (14 - fine_quant[i] - 1)) as f32 / 16384.0;
|
||||
old_e[idx] += offset;
|
||||
bits_left -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::range::dec::RangeDecoder;
|
||||
|
||||
/// Laplace decoder of RFC 6716 §4.3.2.1 (as in FilmCraft's decoder).
|
||||
fn laplace_decode(dec: &mut RangeDecoder, mut fs: u32, decay: u32) -> i32 {
|
||||
let mut val = 0i32;
|
||||
let fm = dec.decode(32768);
|
||||
let mut fl = 0u32;
|
||||
if fm >= fs {
|
||||
val += 1;
|
||||
fl = fs;
|
||||
fs = laplace_get_freq1(fs, decay) + LAPLACE_MINP;
|
||||
while fs > LAPLACE_MINP && fm >= fl + 2 * fs {
|
||||
fs *= 2;
|
||||
fl += fs;
|
||||
fs = (((fs - 2 * LAPLACE_MINP) * decay) >> 15) + LAPLACE_MINP;
|
||||
val += 1;
|
||||
}
|
||||
if fs <= LAPLACE_MINP {
|
||||
let di = (fm - fl) >> 1;
|
||||
val += di as i32;
|
||||
fl += 2 * di * LAPLACE_MINP;
|
||||
}
|
||||
if fm < fl + fs {
|
||||
val = -val;
|
||||
} else {
|
||||
fl += fs;
|
||||
}
|
||||
}
|
||||
dec.update(fl, (fl + fs).min(32768), 32768);
|
||||
val
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laplace_roundtrip_all_models() {
|
||||
for lm in 0..4 {
|
||||
for intra in 0..2 {
|
||||
for band in 0..21 {
|
||||
let prob = &E_PROB_MODEL[lm][intra];
|
||||
let (fs, decay) = ((prob[2 * band] as u32) << 7, (prob[2 * band + 1] as u32) << 6);
|
||||
let vals: Vec<i32> = (-64..=64).collect();
|
||||
let mut e = RangeEncoder::new();
|
||||
let coded: Vec<i32> = vals.iter().map(|&v| laplace_encode(&mut e, v, fs, decay)).collect();
|
||||
let size = (e.tell() as usize).div_ceil(8);
|
||||
let buf = e.done(size);
|
||||
let mut d = RangeDecoder::new(&buf);
|
||||
for (&v, &c) in vals.iter().zip(&coded) {
|
||||
assert!(c == v || (c.signum() == v.signum() && c.abs() < v.abs()), "clamp {v} -> {c}");
|
||||
assert_eq!(laplace_decode(&mut d, fs, decay), c, "lm={lm} intra={intra} band={band} v={v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Clean-room pure-Rust Opus encoder (RFC 6716 CELT-only mode; RFC 7845 `OpusHead`).
|
||||
//!
|
||||
//! Produces fullband (20 kHz) 48 kHz Opus packets of 20 ms, mono or stereo, at a constant
|
||||
//! packet size derived from the requested bitrate. Each packet is a single CELT frame (TOC
|
||||
//! configuration 31, frame-count code 0). Stereo is coded as dual (independent L/R) stereo.
|
||||
//!
|
||||
//! Layer L0: no dependencies beyond `std`; no `unsafe`; builds for `wasm32-unknown-unknown`.
|
||||
//!
|
||||
//! ```
|
||||
//! use effectcraft_opusenc::OpusEncoder;
|
||||
//! let mut enc = OpusEncoder::new(2, 128_000);
|
||||
//! let pcm = vec![0.0f32; OpusEncoder::FRAME_SIZE * 2];
|
||||
//! let packet = enc.encode_float(&pcm);
|
||||
//! assert_eq!(packet[0] >> 3, 31); // CELT-only, fullband, 20 ms
|
||||
//! assert_eq!(enc.opus_head().len(), 19);
|
||||
//! ```
|
||||
|
||||
// The bit-allocation code mirrors the normative integer arithmetic; keep C-like shift expressions.
|
||||
#![allow(clippy::precedence, clippy::int_plus_one)]
|
||||
|
||||
mod bands;
|
||||
mod energy;
|
||||
mod mdct;
|
||||
mod range;
|
||||
mod rate;
|
||||
mod tables;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use bands::{SPREAD_NORMAL, quant_all_bands};
|
||||
use energy::{quant_coarse_energy, quant_energy_finalise, quant_fine_energy};
|
||||
use mdct::Mdct;
|
||||
use range::{BITRES, RangeEncoder};
|
||||
use rate::{Mode, compute_allocation};
|
||||
use tables::*;
|
||||
|
||||
/// Samples per channel in one 20 ms frame at 48 kHz.
|
||||
const N: usize = 960;
|
||||
/// LM of a 20 ms frame (8 short blocks).
|
||||
const LM: usize = 3;
|
||||
/// Smallest CELT payload the encoder produces (bytes).
|
||||
const MIN_FRAME_BYTES: usize = 16;
|
||||
/// Largest CELT payload allowed in one frame (RFC 6716 §3.4, R2).
|
||||
const MAX_FRAME_BYTES: usize = 1275;
|
||||
|
||||
fn mode() -> &'static Mode {
|
||||
static MODE: OnceLock<Mode> = OnceLock::new();
|
||||
MODE.get_or_init(Mode::new)
|
||||
}
|
||||
|
||||
/// Opus encoder for one mono or stereo stream at 48 kHz.
|
||||
pub struct OpusEncoder {
|
||||
channels: usize,
|
||||
/// CELT payload bytes per packet (the packet is one byte longer: the TOC).
|
||||
frame_bytes: usize,
|
||||
mdct: Mdct,
|
||||
/// Last input sample of each channel (pre-emphasis filter memory, ×32768).
|
||||
preemph_mem: [f32; 2],
|
||||
/// Last `OVERLAP` pre-emphasised samples of each channel.
|
||||
hist: Vec<f32>,
|
||||
/// Quantised band energies, exactly as the decoder holds them.
|
||||
old_band_e: [f32; 2 * NB_EBANDS],
|
||||
block: Vec<f32>,
|
||||
freq: Vec<f32>,
|
||||
}
|
||||
|
||||
impl OpusEncoder {
|
||||
/// Samples per channel per packet (20 ms at 48 kHz).
|
||||
pub const FRAME_SIZE: usize = N;
|
||||
|
||||
/// Creates an encoder for `channels` (1 or 2) channels of 48 kHz audio at `bitrate_bps`
|
||||
/// (total for all channels; packets are constant-size, clamped to 6.8–510 kb/s).
|
||||
pub fn new(channels: u8, bitrate_bps: u32) -> Self {
|
||||
assert!(channels == 1 || channels == 2, "Opus family-0 streams have 1 or 2 channels");
|
||||
let c = channels as usize;
|
||||
let packet_bytes = (bitrate_bps as usize).div_ceil(400);
|
||||
let frame_bytes = packet_bytes.saturating_sub(1).clamp(MIN_FRAME_BYTES, MAX_FRAME_BYTES);
|
||||
OpusEncoder {
|
||||
channels: c,
|
||||
frame_bytes,
|
||||
mdct: Mdct::new(N),
|
||||
preemph_mem: [0.0; 2],
|
||||
hist: vec![0.0; c * OVERLAP],
|
||||
old_band_e: [-28.0; 2 * NB_EBANDS],
|
||||
block: vec![0.0; N + OVERLAP],
|
||||
freq: vec![0.0; c * N],
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of channels.
|
||||
pub fn channels(&self) -> u8 {
|
||||
self.channels as u8
|
||||
}
|
||||
|
||||
/// Actual bitrate in bits per second (packet size × 50 packets per second).
|
||||
pub fn bitrate(&self) -> u32 {
|
||||
((self.frame_bytes + 1) * 8 * 50) as u32
|
||||
}
|
||||
|
||||
/// Samples at 48 kHz the decoder must discard from the start of the stream (the MDCT
|
||||
/// overlap); this is the `OpusHead` pre-skip.
|
||||
pub fn pre_skip(&self) -> u16 {
|
||||
OVERLAP as u16
|
||||
}
|
||||
|
||||
/// The 19-byte `OpusHead` identification header (RFC 7845 §5.1): version 1, channel count,
|
||||
/// pre-skip, input sample rate 48000, output gain 0, channel mapping family 0.
|
||||
pub fn opus_head(&self) -> Vec<u8> {
|
||||
let mut h = Vec::with_capacity(19);
|
||||
h.extend_from_slice(b"OpusHead");
|
||||
h.push(1);
|
||||
h.push(self.channels as u8);
|
||||
h.extend_from_slice(&self.pre_skip().to_le_bytes());
|
||||
h.extend_from_slice(&48_000u32.to_le_bytes());
|
||||
h.extend_from_slice(&0i16.to_le_bytes());
|
||||
h.push(0);
|
||||
h
|
||||
}
|
||||
|
||||
/// Encodes one 20 ms frame of interleaved f32 samples in [-1, 1] (exactly
|
||||
/// `FRAME_SIZE * channels` values) and returns one Opus packet (TOC byte + CELT frame).
|
||||
pub fn encode_float(&mut self, pcm: &[f32]) -> Vec<u8> {
|
||||
let c = self.channels;
|
||||
assert_eq!(pcm.len(), N * c, "encode_float takes exactly FRAME_SIZE samples per channel");
|
||||
|
||||
// Pre-emphasis, the MDCT and the band energies.
|
||||
let mut band_e = [0f32; 2 * NB_EBANDS];
|
||||
let mut log_e = [0f32; 2 * NB_EBANDS];
|
||||
let mut peak = 0f32;
|
||||
for ch in 0..c {
|
||||
self.block[..OVERLAP].copy_from_slice(&self.hist[ch * OVERLAP..(ch + 1) * OVERLAP]);
|
||||
let mut mem = self.preemph_mem[ch];
|
||||
for j in 0..N {
|
||||
let s = pcm[j * c + ch];
|
||||
let s = if s.is_finite() { s.clamp(-2.0, 2.0) * 32768.0 } else { 0.0 };
|
||||
let v = s - PREEMPH * mem;
|
||||
mem = s;
|
||||
self.block[OVERLAP + j] = v;
|
||||
}
|
||||
self.preemph_mem[ch] = mem;
|
||||
self.hist[ch * OVERLAP..(ch + 1) * OVERLAP].copy_from_slice(&self.block[N..]);
|
||||
peak = self.block.iter().fold(peak, |p, v| p.max(v.abs()));
|
||||
let freq = &mut self.freq[ch * N..(ch + 1) * N];
|
||||
self.mdct.forward(&self.block, freq);
|
||||
for i in 0..NB_EBANDS {
|
||||
let lo = (EBANDS[i] as usize) << LM;
|
||||
let hi = (EBANDS[i + 1] as usize) << LM;
|
||||
let e = (1e-27f32 + freq[lo..hi].iter().map(|v| v * v).sum::<f32>()).sqrt();
|
||||
band_e[ch * NB_EBANDS + i] = e;
|
||||
log_e[ch * NB_EBANDS + i] = e.log2() - E_MEANS[i];
|
||||
let g = 1.0 / e;
|
||||
for v in freq[lo..hi].iter_mut() {
|
||||
*v *= g;
|
||||
}
|
||||
}
|
||||
}
|
||||
let silence = peak < 1e-4;
|
||||
let toc = (31u8 << 3) | if c == 2 { 4 } else { 0 };
|
||||
let mut packet = vec![toc];
|
||||
packet.extend_from_slice(&self.encode_celt(&log_e, silence));
|
||||
packet
|
||||
}
|
||||
|
||||
/// Codes one CELT frame (RFC 6716 §4.3, in bitstream order).
|
||||
fn encode_celt(&mut self, log_e: &[f32; 2 * NB_EBANDS], silence: bool) -> Vec<u8> {
|
||||
let m = mode();
|
||||
let c = self.channels;
|
||||
let (start, end) = (0usize, NB_EBANDS);
|
||||
let mut enc = RangeEncoder::new();
|
||||
if c == 1 {
|
||||
for i in 0..NB_EBANDS {
|
||||
self.old_band_e[i] = self.old_band_e[i].max(self.old_band_e[NB_EBANDS + i]);
|
||||
}
|
||||
}
|
||||
if silence {
|
||||
// The silence flag; the decoder treats every remaining bit as consumed.
|
||||
enc.bit_logp(true, 15);
|
||||
self.old_band_e = [-28.0; 2 * NB_EBANDS];
|
||||
return enc.done(2);
|
||||
}
|
||||
let len = self.frame_bytes;
|
||||
let total_bits_raw = len as i32 * 8;
|
||||
enc.bit_logp(false, 15);
|
||||
// No pitch post-filter.
|
||||
if enc.tell() + 16 <= total_bits_raw {
|
||||
enc.bit_logp(false, 1);
|
||||
}
|
||||
// Long blocks only.
|
||||
if enc.tell() + 3 <= total_bits_raw {
|
||||
enc.bit_logp(false, 3);
|
||||
}
|
||||
// Coarse energy, choosing intra or inter prediction by trial (whichever is cheaper).
|
||||
if enc.tell() + 3 <= total_bits_raw {
|
||||
let mut enc_intra = enc.clone();
|
||||
let mut old_intra = self.old_band_e;
|
||||
enc_intra.bit_logp(true, 3);
|
||||
quant_coarse_energy(start, end, log_e, &mut old_intra, true, &mut enc_intra, c, LM, total_bits_raw);
|
||||
enc.bit_logp(false, 3);
|
||||
let mut old_inter = self.old_band_e;
|
||||
quant_coarse_energy(start, end, log_e, &mut old_inter, false, &mut enc, c, LM, total_bits_raw);
|
||||
if enc_intra.tell_frac() < enc.tell_frac() {
|
||||
enc = enc_intra;
|
||||
self.old_band_e = old_intra;
|
||||
} else {
|
||||
self.old_band_e = old_inter;
|
||||
}
|
||||
} else {
|
||||
quant_coarse_energy(start, end, log_e, &mut self.old_band_e, false, &mut enc, c, LM, total_bits_raw);
|
||||
}
|
||||
// TF resolution: no changes.
|
||||
{
|
||||
let mut budget = total_bits_raw;
|
||||
let mut tell = enc.tell();
|
||||
let mut logp = 4;
|
||||
let tf_select_rsv = LM > 0 && tell + logp + 1 <= budget;
|
||||
budget -= tf_select_rsv as i32;
|
||||
for _ in start..end {
|
||||
if tell + logp <= budget {
|
||||
enc.bit_logp(false, logp as u32);
|
||||
tell = enc.tell();
|
||||
}
|
||||
logp = 5;
|
||||
}
|
||||
if tf_select_rsv && TF_SELECT_TABLE[LM][0] != TF_SELECT_TABLE[LM][2] {
|
||||
enc.bit_logp(false, 1);
|
||||
}
|
||||
}
|
||||
if enc.tell() + 4 <= total_bits_raw {
|
||||
enc.icdf(SPREAD_NORMAL, &SPREAD_ICDF, 5);
|
||||
}
|
||||
// Dynamic allocation: no boosts.
|
||||
let cap = m.init_caps(LM, c);
|
||||
let offsets = [0i32; NB_EBANDS];
|
||||
let total_bits = total_bits_raw << BITRES;
|
||||
let mut tellf = enc.tell_frac();
|
||||
for &cp in cap.iter().take(end).skip(start) {
|
||||
if tellf + (6 << BITRES) < total_bits && 0 < cp {
|
||||
enc.bit_logp(false, 6);
|
||||
tellf = enc.tell_frac();
|
||||
}
|
||||
}
|
||||
let alloc_trim = 5;
|
||||
if tellf + (6 << BITRES) <= total_bits {
|
||||
enc.icdf(alloc_trim as usize, &TRIM_ICDF, 7);
|
||||
}
|
||||
let bits = (total_bits_raw << BITRES) - enc.tell_frac() - 1;
|
||||
let alloc = compute_allocation(m, start, end, &offsets, &cap, alloc_trim, bits, c, LM, &mut enc);
|
||||
quant_fine_energy(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &mut enc, c);
|
||||
quant_all_bands(
|
||||
m,
|
||||
start,
|
||||
end,
|
||||
&self.freq,
|
||||
N,
|
||||
c,
|
||||
&alloc.pulses,
|
||||
SPREAD_NORMAL,
|
||||
alloc.dual_stereo,
|
||||
alloc.intensity,
|
||||
total_bits_raw << BITRES,
|
||||
alloc.balance,
|
||||
&mut enc,
|
||||
LM,
|
||||
alloc.coded_bands,
|
||||
);
|
||||
let bits_left = total_bits_raw - enc.tell();
|
||||
quant_energy_finalise(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &alloc.fine_priority, bits_left, &mut enc, c);
|
||||
debug_assert!(enc.tell() <= total_bits_raw, "frame overflow: {} > {}", enc.tell(), total_bits_raw);
|
||||
if c == 1 {
|
||||
let (a, b) = self.old_band_e.split_at_mut(NB_EBANDS);
|
||||
b.copy_from_slice(a);
|
||||
}
|
||||
enc.done(len)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn opus_head_layout() {
|
||||
let e = OpusEncoder::new(2, 96_000);
|
||||
let h = e.opus_head();
|
||||
assert_eq!(&h[..8], b"OpusHead");
|
||||
assert_eq!(h[8], 1);
|
||||
assert_eq!(h[9], 2);
|
||||
assert_eq!(u16::from_le_bytes([h[10], h[11]]), e.pre_skip());
|
||||
assert_eq!(u32::from_le_bytes([h[12], h[13], h[14], h[15]]), 48_000);
|
||||
assert_eq!(&h[16..], &[0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_sizes_follow_bitrate() {
|
||||
for (ch, rate) in [(1u8, 64_000u32), (2, 128_000), (2, 256_000)] {
|
||||
let mut e = OpusEncoder::new(ch, rate);
|
||||
let pcm: Vec<f32> = (0..N * ch as usize).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
|
||||
let p = e.encode_float(&pcm);
|
||||
assert_eq!(p.len(), rate as usize / 400);
|
||||
assert_eq!(p[0], 0xF8 | if ch == 2 { 4 } else { 0 });
|
||||
assert_eq!(e.bitrate(), rate);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_is_a_tiny_packet() {
|
||||
let mut e = OpusEncoder::new(1, 64_000);
|
||||
let p = e.encode_float(&vec![0.0; N]);
|
||||
assert_eq!(p.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Forward MDCT with the CELT low-overlap window (RFC 6716 §4.3.7, run in the analysis
|
||||
//! direction).
|
||||
//!
|
||||
//! A 20 ms frame is a 1920-point MDCT (960 coefficients) whose window is zero for 420 samples,
|
||||
//! rises over the 120-sample overlap, is flat for 840 samples, falls over the next 120 samples
|
||||
//! and is zero again for the last 420. The input block is therefore the 120 overlap samples kept
|
||||
//! from the previous frame followed by the 960 new samples. The MDCT is folded into a DCT-IV,
|
||||
//! which is computed with an N/2-point complex FFT.
|
||||
|
||||
use crate::tables::OVERLAP;
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
pub struct Cpx {
|
||||
pub re: f32,
|
||||
pub im: f32,
|
||||
}
|
||||
|
||||
impl Cpx {
|
||||
#[inline]
|
||||
fn mul(self, o: Cpx) -> Cpx {
|
||||
Cpx { re: self.re * o.re - self.im * o.im, im: self.re * o.im + self.im * o.re }
|
||||
}
|
||||
#[inline]
|
||||
fn add(self, o: Cpx) -> Cpx {
|
||||
Cpx { re: self.re + o.re, im: self.im + o.im }
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixed-radix (2, 3, 4, 5) forward complex FFT, unscaled (decimation in time, recursive).
|
||||
pub struct Fft {
|
||||
n: usize,
|
||||
factors: Vec<usize>,
|
||||
twiddle: Vec<Cpx>,
|
||||
}
|
||||
|
||||
impl Fft {
|
||||
pub fn new(n: usize) -> Fft {
|
||||
let mut factors = Vec::new();
|
||||
let mut m = n;
|
||||
for p in [4usize, 2, 3, 5] {
|
||||
while m.is_multiple_of(p) {
|
||||
factors.push(p);
|
||||
m /= p;
|
||||
}
|
||||
}
|
||||
assert_eq!(m, 1, "unsupported FFT size {n}");
|
||||
let twiddle = (0..n)
|
||||
.map(|k| {
|
||||
let ph = -2.0 * std::f64::consts::PI * k as f64 / n as f64;
|
||||
Cpx { re: ph.cos() as f32, im: ph.sin() as f32 }
|
||||
})
|
||||
.collect();
|
||||
Fft { n, factors, twiddle }
|
||||
}
|
||||
|
||||
pub fn process(&self, data: &mut [Cpx], scratch: &mut Vec<Cpx>) {
|
||||
scratch.clear();
|
||||
scratch.extend_from_slice(&data[..self.n]);
|
||||
self.rec(&mut data[..self.n], scratch, 1, self.n, 0, 1);
|
||||
}
|
||||
|
||||
fn rec(&self, out: &mut [Cpx], inp: &[Cpx], in_stride: usize, n: usize, fi: usize, tw_stride: usize) {
|
||||
let p = self.factors[fi];
|
||||
let m = n / p;
|
||||
if m == 1 {
|
||||
for (i, o) in out.iter_mut().enumerate().take(p) {
|
||||
*o = inp[i * in_stride];
|
||||
}
|
||||
} else {
|
||||
for q in 0..p {
|
||||
self.rec(&mut out[q * m..(q + 1) * m], &inp[q * in_stride..], in_stride * p, m, fi + 1, tw_stride * p);
|
||||
}
|
||||
}
|
||||
let big = self.n;
|
||||
let mut t = [Cpx::default(); 5];
|
||||
for k in 0..m {
|
||||
for (q, tq) in t.iter_mut().enumerate().take(p) {
|
||||
*tq = out[q * m + k].mul(self.twiddle[(q * k * tw_stride) % big]);
|
||||
}
|
||||
for q2 in 0..p {
|
||||
let mut acc = Cpx::default();
|
||||
for (q, tq) in t.iter().enumerate().take(p) {
|
||||
acc = acc.add(tq.mul(self.twiddle[((q * q2) % p) * (big / p)]));
|
||||
}
|
||||
out[q2 * m + k] = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward MDCT producing `n` coefficients from a block of `n + OVERLAP` samples.
|
||||
pub struct Mdct {
|
||||
/// Number of coefficients (half the MDCT length).
|
||||
n: usize,
|
||||
fft: Fft,
|
||||
pre: Vec<Cpx>,
|
||||
post: Vec<Cpx>,
|
||||
/// Rising half of the low-overlap window (RFC 6716 §4.3.7).
|
||||
pub window: [f32; OVERLAP],
|
||||
scale: f32,
|
||||
z: Vec<Cpx>,
|
||||
scratch: Vec<Cpx>,
|
||||
v: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Mdct {
|
||||
pub fn new(n: usize) -> Mdct {
|
||||
let n2 = n / 2;
|
||||
let pi = std::f64::consts::PI;
|
||||
let pre = (0..n2)
|
||||
.map(|m| {
|
||||
let ph = -pi * m as f64 / n as f64;
|
||||
Cpx { re: ph.cos() as f32, im: ph.sin() as f32 }
|
||||
})
|
||||
.collect();
|
||||
let post = (0..n2)
|
||||
.map(|p| {
|
||||
let ph = -pi * (p as f64 + 0.25) / n as f64;
|
||||
Cpx { re: ph.cos() as f32, im: ph.sin() as f32 }
|
||||
})
|
||||
.collect();
|
||||
let mut window = [0f32; OVERLAP];
|
||||
for (i, w) in window.iter_mut().enumerate() {
|
||||
let x = std::f64::consts::FRAC_PI_2 * (i as f64 + 0.5) / OVERLAP as f64;
|
||||
let s = x.sin();
|
||||
*w = (std::f64::consts::FRAC_PI_2 * s * s).sin() as f32;
|
||||
}
|
||||
Mdct { n, fft: Fft::new(n2), pre, post, window, scale: 2.0 / n as f32, z: Vec::new(), scratch: Vec::new(), v: vec![0.0; n] }
|
||||
}
|
||||
|
||||
/// Transforms `block` (`n + OVERLAP` samples: the previous frame's last `OVERLAP` samples, then
|
||||
/// `n` new ones) into `out[..n]`, scaled so that the RFC 6716 decoder's inverse MDCT and
|
||||
/// overlap-add reproduce the input.
|
||||
pub fn forward(&mut self, block: &[f32], out: &mut [f32]) {
|
||||
let n = self.n;
|
||||
let n2 = n / 2;
|
||||
debug_assert_eq!(block.len(), n + OVERLAP);
|
||||
// Place the windowed block into the (virtual) 2n-sample MDCT input u at offset `pad`.
|
||||
let pad = (n - OVERLAP) / 2;
|
||||
let w = &self.window;
|
||||
let u = |t: usize| -> f32 {
|
||||
if t < pad || t >= pad + n + OVERLAP {
|
||||
return 0.0;
|
||||
}
|
||||
let j = t - pad;
|
||||
let g = if j < OVERLAP {
|
||||
w[j]
|
||||
} else if j >= n {
|
||||
w[OVERLAP - 1 - (j - n)]
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
block[j] * g
|
||||
};
|
||||
// Fold to a DCT-IV input: v = (-c_r - d, a - b_r) over the four quarters of u.
|
||||
for m in 0..n2 {
|
||||
self.v[m] = -u(n + n2 - 1 - m) - u(n + n2 + m);
|
||||
self.v[n2 + m] = u(m) - u(n2 + n2 - 1 - m);
|
||||
}
|
||||
// DCT-IV via an n/2-point complex FFT.
|
||||
self.z.clear();
|
||||
for m in 0..n2 {
|
||||
let wv = Cpx { re: self.v[2 * m], im: self.v[n - 1 - 2 * m] };
|
||||
self.z.push(wv.mul(self.pre[m]));
|
||||
}
|
||||
self.fft.process(&mut self.z, &mut self.scratch);
|
||||
for p in 0..n2 {
|
||||
let y = self.z[p].mul(self.post[p]);
|
||||
out[2 * p] = y.re * self.scale;
|
||||
out[n - 1 - 2 * p] = -y.im * self.scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Direct MDCT definition X[k] = sum u[t] cos(pi/N (t + 1/2 + N/2)(k + 1/2)).
|
||||
fn direct(block: &[f32], n: usize, window: &[f32; OVERLAP]) -> Vec<f64> {
|
||||
let pad = (n - OVERLAP) / 2;
|
||||
let mut u = vec![0f64; 2 * n];
|
||||
for (j, &b) in block.iter().enumerate() {
|
||||
let g = if j < OVERLAP {
|
||||
window[j]
|
||||
} else if j >= n {
|
||||
window[OVERLAP - 1 - (j - n)]
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
u[pad + j] = (b * g) as f64;
|
||||
}
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
(0..2 * n).map(|t| u[t] * (std::f64::consts::PI / n as f64 * (t as f64 + 0.5 + n as f64 / 2.0) * (k as f64 + 0.5)).cos()).sum::<f64>() * 2.0
|
||||
/ n as f64
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_direct_formula() {
|
||||
for &n in &[240usize, 960] {
|
||||
let block: Vec<f32> = (0..n + OVERLAP).map(|i| ((i * 37 % 101) as f32 - 50.0) / 7.0).collect();
|
||||
let mut m = Mdct::new(n);
|
||||
let mut out = vec![0f32; n];
|
||||
m.forward(&block, &mut out);
|
||||
let d = direct(&block, n, &m.window);
|
||||
for k in 0..n {
|
||||
assert!((out[k] as f64 - d[k]).abs() < 1e-2, "n={n} k={k}: {} vs {}", out[k], d[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Replica of the RFC 6716 decoder inverse MDCT (as in FilmCraft's decoder) ----
|
||||
|
||||
fn imdct_backward(n_full: usize, input: &[f32], out: &mut [f32], window: &[f32]) {
|
||||
let n2 = n_full >> 1;
|
||||
let n4 = n_full >> 2;
|
||||
let t: Vec<f32> = (0..n2).map(|i| (2.0 * std::f64::consts::PI * (i as f64 + 0.125) / n_full as f64).cos() as f32).collect();
|
||||
let fft = Fft::new(n4);
|
||||
let mut z = Vec::new();
|
||||
for i in 0..n4 {
|
||||
let x1 = input[2 * i];
|
||||
let x2 = input[n2 - 1 - 2 * i];
|
||||
let yr = x2 * t[i] + x1 * t[n4 + i];
|
||||
let yi = x1 * t[i] - x2 * t[n4 + i];
|
||||
z.push(Cpx { re: yi, im: yr });
|
||||
}
|
||||
fft.process(&mut z, &mut Vec::new());
|
||||
let overlap = OVERLAP;
|
||||
let base = overlap >> 1;
|
||||
let y = &mut out[base..base + n2];
|
||||
for i in 0..n4.div_ceil(2) {
|
||||
let front = z[i];
|
||||
let back = z[n4 - 1 - i];
|
||||
let (re, im) = (front.im, front.re);
|
||||
let (t0, t1) = (t[i], t[n4 + i]);
|
||||
let yr0 = re * t0 + im * t1;
|
||||
let yi0 = re * t1 - im * t0;
|
||||
let (re, im) = (back.im, back.re);
|
||||
let (t0, t1) = (t[n4 - i - 1], t[n2 - i - 1]);
|
||||
let yr1 = re * t0 + im * t1;
|
||||
let yi1 = re * t1 - im * t0;
|
||||
y[2 * i] = yr0;
|
||||
y[n2 - 1 - 2 * i] = yi0;
|
||||
y[n2 - 2 - 2 * i] = yr1;
|
||||
y[2 * i + 1] = yi1;
|
||||
}
|
||||
for i in 0..overlap / 2 {
|
||||
let x1 = out[overlap - 1 - i];
|
||||
let x2 = out[i];
|
||||
let w1 = window[i];
|
||||
let w2 = window[overlap - 1 - i];
|
||||
out[i] = x2 * w2 - x1 * w1;
|
||||
out[overlap - 1 - i] = x2 * w1 + x1 * w2;
|
||||
}
|
||||
}
|
||||
|
||||
/// MDCT analysis followed by the decoder's IMDCT + TDAC overlap-add reproduces the input
|
||||
/// delayed by exactly `OVERLAP` samples.
|
||||
#[test]
|
||||
fn perfect_reconstruction_through_decoder_imdct() {
|
||||
let n = 960;
|
||||
let frames = 5;
|
||||
let sig: Vec<f32> = (0..n * frames).map(|i| (i as f32 * 0.013).sin() * 1000.0 + ((i * 7919) % 211) as f32 - 105.0).collect();
|
||||
let mut m = Mdct::new(n);
|
||||
let mut hist = vec![0f32; OVERLAP];
|
||||
// Decoder memory: [frame output n][folded tail OVERLAP/2] laid out like decode_mem.
|
||||
let mut mem = vec![0f32; n + OVERLAP];
|
||||
let mut decoded = Vec::new();
|
||||
for f in 0..frames {
|
||||
let mut block = hist.clone();
|
||||
block.extend_from_slice(&sig[f * n..(f + 1) * n]);
|
||||
hist.copy_from_slice(&block[n..]);
|
||||
let mut coefs = vec![0f32; n];
|
||||
m.forward(&block, &mut coefs);
|
||||
// Shift: the previous folded tail (at n..n+OVERLAP/2) moves to 0..OVERLAP/2.
|
||||
mem.copy_within(n..n + OVERLAP / 2, 0);
|
||||
imdct_backward(2 * n, &coefs, &mut mem, &m.window);
|
||||
decoded.extend_from_slice(&mem[..n]);
|
||||
}
|
||||
let mut err = 0f64;
|
||||
let mut sum = 0f64;
|
||||
for t in OVERLAP..n * frames {
|
||||
let d = decoded[t] as f64 - sig[t - OVERLAP] as f64;
|
||||
err += d * d;
|
||||
sum += (sig[t - OVERLAP] as f64).powi(2);
|
||||
}
|
||||
let snr = 10.0 * (sum / err).log10();
|
||||
assert!(snr > 80.0, "reconstruction SNR {snr:.1} dB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Range encoder (RFC 6716 §5.1), the exact inverse of the §4.1 range decoder.
|
||||
//!
|
||||
//! Range-coded symbols are written from the front of the buffer and raw bits from the back; the
|
||||
//! bit accounting ([`RangeEncoder::tell`], [`RangeEncoder::tell_frac`]) matches the decoder's, so
|
||||
//! every budget decision the decoder takes can be mirrored exactly.
|
||||
|
||||
const SYM_BITS: u32 = 8;
|
||||
const CODE_BITS: u32 = 32;
|
||||
const SYM_MAX: u32 = (1 << SYM_BITS) - 1;
|
||||
const CODE_SHIFT: u32 = CODE_BITS - SYM_BITS - 1;
|
||||
const CODE_TOP: u32 = 1 << (CODE_BITS - 1);
|
||||
const CODE_BOT: u32 = CODE_TOP >> SYM_BITS;
|
||||
/// Bit resolution of [`RangeEncoder::tell_frac`] (1/8 bit).
|
||||
pub const BITRES: u32 = 3;
|
||||
|
||||
/// Number of bits needed to represent `x` (0 for 0).
|
||||
#[inline]
|
||||
pub fn ilog(x: u32) -> i32 {
|
||||
(32 - x.leading_zeros()) as i32
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RangeEncoder {
|
||||
out: Vec<u8>,
|
||||
end: Vec<u8>,
|
||||
end_window: u32,
|
||||
nend_bits: i32,
|
||||
nbits_total: i32,
|
||||
rng: u32,
|
||||
low: u32,
|
||||
rem: i32,
|
||||
ext: u32,
|
||||
}
|
||||
|
||||
impl Default for RangeEncoder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RangeEncoder {
|
||||
pub fn new() -> Self {
|
||||
RangeEncoder {
|
||||
out: Vec::new(),
|
||||
end: Vec::new(),
|
||||
end_window: 0,
|
||||
nend_bits: 0,
|
||||
nbits_total: (CODE_BITS + 1) as i32,
|
||||
rng: CODE_TOP,
|
||||
low: 0,
|
||||
rem: -1,
|
||||
ext: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current range (equals the decoder's range after the same symbols).
|
||||
#[cfg(test)]
|
||||
pub fn rng(&self) -> u32 {
|
||||
self.rng
|
||||
}
|
||||
|
||||
fn carry_out(&mut self, c: u32) {
|
||||
if c != SYM_MAX {
|
||||
let carry = c >> SYM_BITS;
|
||||
if self.rem >= 0 {
|
||||
self.out.push((self.rem as u32 + carry) as u8);
|
||||
}
|
||||
if self.ext > 0 {
|
||||
let sym = (SYM_MAX + carry) & SYM_MAX;
|
||||
for _ in 0..self.ext {
|
||||
self.out.push(sym as u8);
|
||||
}
|
||||
self.ext = 0;
|
||||
}
|
||||
self.rem = (c & SYM_MAX) as i32;
|
||||
} else {
|
||||
self.ext += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn normalize(&mut self) {
|
||||
while self.rng <= CODE_BOT {
|
||||
self.carry_out(self.low >> CODE_SHIFT);
|
||||
self.low = (self.low << SYM_BITS) & (CODE_TOP - 1);
|
||||
self.rng <<= SYM_BITS;
|
||||
self.nbits_total += SYM_BITS as i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the symbol with cumulative range `[fl, fh)` of total `ft`.
|
||||
pub fn encode(&mut self, fl: u32, fh: u32, ft: u32) {
|
||||
let r = self.rng / ft;
|
||||
if fl > 0 {
|
||||
self.low += self.rng - r * (ft - fl);
|
||||
self.rng = r * (fh - fl);
|
||||
} else {
|
||||
self.rng -= r * (ft - fh);
|
||||
}
|
||||
self.normalize();
|
||||
}
|
||||
|
||||
/// Like [`Self::encode`] with `ft = 1 << bits`.
|
||||
pub fn encode_bin(&mut self, fl: u32, fh: u32, bits: u32) {
|
||||
let r = self.rng >> bits;
|
||||
let ft = 1u32 << bits;
|
||||
if fl > 0 {
|
||||
self.low += self.rng - r * (ft - fl);
|
||||
self.rng = r * (fh - fl);
|
||||
} else {
|
||||
self.rng -= r * (ft - fh);
|
||||
}
|
||||
self.normalize();
|
||||
}
|
||||
|
||||
/// Encodes a binary symbol whose probability of being 1 is `1/2^logp`.
|
||||
pub fn bit_logp(&mut self, val: bool, logp: u32) {
|
||||
let r = self.rng;
|
||||
let s = r >> logp;
|
||||
let r2 = r - s;
|
||||
if val {
|
||||
self.low += r2;
|
||||
self.rng = s;
|
||||
} else {
|
||||
self.rng = r2;
|
||||
}
|
||||
self.normalize();
|
||||
}
|
||||
|
||||
/// Encodes symbol `s` with an "inverse CDF" table (`ft = 1 << ftb`).
|
||||
pub fn icdf(&mut self, s: usize, icdf: &[u8], ftb: u32) {
|
||||
let r = self.rng >> ftb;
|
||||
if s > 0 {
|
||||
self.low += self.rng - r * icdf[s - 1] as u32;
|
||||
self.rng = r * (icdf[s - 1] as u32 - icdf[s] as u32);
|
||||
} else {
|
||||
self.rng -= r * icdf[s] as u32;
|
||||
}
|
||||
self.normalize();
|
||||
}
|
||||
|
||||
/// Encodes a uniformly distributed integer `fl` in `[0, ft)` (`ft >= 2`).
|
||||
pub fn uint(&mut self, fl: u32, ft: u32) {
|
||||
debug_assert!(ft > 1 && fl < ft);
|
||||
let ft1 = ft - 1;
|
||||
let ftb = ilog(ft1);
|
||||
if ftb > 8 {
|
||||
let ftb = (ftb - 8) as u32;
|
||||
let top = (ft1 >> ftb) + 1;
|
||||
let v = fl >> ftb;
|
||||
self.encode(v, v + 1, top);
|
||||
self.bits(fl & ((1 << ftb) - 1), ftb);
|
||||
} else {
|
||||
self.encode(fl, fl + 1, ft);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `bits` raw bits (0..=25) to the end of the buffer.
|
||||
pub fn bits(&mut self, val: u32, bits: u32) {
|
||||
if bits == 0 {
|
||||
return;
|
||||
}
|
||||
let mut window = self.end_window;
|
||||
let mut used = self.nend_bits;
|
||||
if used as u32 + bits > CODE_BITS {
|
||||
while used >= SYM_BITS as i32 {
|
||||
self.end.push((window & SYM_MAX) as u8);
|
||||
window >>= SYM_BITS;
|
||||
used -= SYM_BITS as i32;
|
||||
}
|
||||
}
|
||||
window |= val << used;
|
||||
used += bits as i32;
|
||||
self.end_window = window;
|
||||
self.nend_bits = used;
|
||||
self.nbits_total += bits as i32;
|
||||
}
|
||||
|
||||
/// Number of bits written so far (rounded up), identical to the decoder's `tell`.
|
||||
#[inline]
|
||||
pub fn tell(&self) -> i32 {
|
||||
self.nbits_total - ilog(self.rng)
|
||||
}
|
||||
|
||||
/// Number of 1/8 bits written so far (rounded up), identical to the decoder's `tell_frac`.
|
||||
pub fn tell_frac(&self) -> i32 {
|
||||
let nbits = (self.nbits_total as u32) << BITRES;
|
||||
let mut l = ilog(self.rng) as u32;
|
||||
let mut r = self.rng >> (l - 16);
|
||||
for _ in 0..BITRES {
|
||||
r = (r * r) >> 15;
|
||||
let b = r >> 16;
|
||||
l = (l << 1) | b;
|
||||
r >>= b;
|
||||
}
|
||||
nbits.wrapping_sub(l) as i32
|
||||
}
|
||||
|
||||
/// Finalises the stream into a buffer of exactly `size` bytes (range data at the front, raw
|
||||
/// bits at the back, zero padding in between; a partial raw-bit byte is merged into the byte
|
||||
/// just before the full raw-bit bytes).
|
||||
pub fn done(mut self, size: usize) -> Vec<u8> {
|
||||
let mut l = CODE_BITS as i32 - ilog(self.rng);
|
||||
let mut msk = (CODE_TOP - 1) >> l;
|
||||
let mut end = (self.low.wrapping_add(msk)) & !msk;
|
||||
if (end | msk) >= self.low.wrapping_add(self.rng) {
|
||||
l += 1;
|
||||
msk >>= 1;
|
||||
end = (self.low.wrapping_add(msk)) & !msk;
|
||||
}
|
||||
while l > 0 {
|
||||
self.carry_out(end >> CODE_SHIFT);
|
||||
end = (end << SYM_BITS) & (CODE_TOP - 1);
|
||||
l -= SYM_BITS as i32;
|
||||
}
|
||||
if self.rem >= 0 || self.ext > 0 {
|
||||
self.carry_out(0);
|
||||
}
|
||||
let mut window = self.end_window;
|
||||
let mut used = self.nend_bits;
|
||||
while used >= SYM_BITS as i32 {
|
||||
self.end.push((window & SYM_MAX) as u8);
|
||||
window >>= SYM_BITS;
|
||||
used -= SYM_BITS as i32;
|
||||
}
|
||||
let mut buf = vec![0u8; size];
|
||||
let full = self.end.len().min(size);
|
||||
let room = size - full;
|
||||
let n = self.out.len().min(room);
|
||||
buf[..n].copy_from_slice(&self.out[..n]);
|
||||
for (k, &b) in self.end.iter().take(full).enumerate() {
|
||||
buf[size - 1 - k] = b;
|
||||
}
|
||||
if used > 0 && room > 0 {
|
||||
buf[room - 1] |= window as u8;
|
||||
}
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod dec {
|
||||
//! Minimal range decoder (RFC 6716 §4.1) used to verify the encoder.
|
||||
use super::*;
|
||||
const CODE_EXTRA: u32 = (CODE_BITS - 2) % SYM_BITS + 1;
|
||||
|
||||
pub struct RangeDecoder<'a> {
|
||||
buf: &'a [u8],
|
||||
offs: usize,
|
||||
end_offs: usize,
|
||||
end_window: u32,
|
||||
nend_bits: i32,
|
||||
nbits_total: i32,
|
||||
rng: u32,
|
||||
val: u32,
|
||||
rem: u32,
|
||||
}
|
||||
|
||||
impl<'a> RangeDecoder<'a> {
|
||||
pub fn new(buf: &'a [u8]) -> Self {
|
||||
let mut d = RangeDecoder {
|
||||
buf,
|
||||
offs: 0,
|
||||
end_offs: 0,
|
||||
end_window: 0,
|
||||
nend_bits: 0,
|
||||
nbits_total: (CODE_BITS + 1 - ((CODE_BITS - CODE_EXTRA) / SYM_BITS) * SYM_BITS) as i32,
|
||||
rng: 1 << CODE_EXTRA,
|
||||
val: 0,
|
||||
rem: 0,
|
||||
};
|
||||
d.rem = d.read_byte();
|
||||
d.val = d.rng - 1 - (d.rem >> (SYM_BITS - CODE_EXTRA));
|
||||
d.normalize();
|
||||
d
|
||||
}
|
||||
fn read_byte(&mut self) -> u32 {
|
||||
if self.offs < self.buf.len() {
|
||||
self.offs += 1;
|
||||
self.buf[self.offs - 1] as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn read_byte_from_end(&mut self) -> u32 {
|
||||
if self.end_offs < self.buf.len() {
|
||||
self.end_offs += 1;
|
||||
self.buf[self.buf.len() - self.end_offs] as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn normalize(&mut self) {
|
||||
while self.rng <= CODE_BOT {
|
||||
self.nbits_total += SYM_BITS as i32;
|
||||
self.rng <<= SYM_BITS;
|
||||
let sym = self.rem;
|
||||
self.rem = self.read_byte();
|
||||
let sym = ((sym << SYM_BITS) | self.rem) >> (SYM_BITS - CODE_EXTRA);
|
||||
self.val = ((self.val << SYM_BITS).wrapping_add(SYM_MAX & !sym)) & (CODE_TOP - 1);
|
||||
}
|
||||
}
|
||||
pub fn rng(&self) -> u32 {
|
||||
self.rng
|
||||
}
|
||||
pub fn decode(&mut self, ft: u32) -> u32 {
|
||||
let ext = self.rng / ft;
|
||||
let s = self.val / ext;
|
||||
ft - (s + 1).min(ft)
|
||||
}
|
||||
pub fn update(&mut self, fl: u32, fh: u32, ft: u32) {
|
||||
let ext = self.rng / ft;
|
||||
let s = ext * (ft - fh);
|
||||
self.val = self.val.wrapping_sub(s);
|
||||
self.rng = if fl > 0 { ext * (fh - fl) } else { self.rng - s };
|
||||
self.normalize();
|
||||
}
|
||||
pub fn bit_logp(&mut self, logp: u32) -> bool {
|
||||
let r = self.rng;
|
||||
let d = self.val;
|
||||
let s = r >> logp;
|
||||
let ret = d < s;
|
||||
if !ret {
|
||||
self.val = d - s;
|
||||
}
|
||||
self.rng = if ret { s } else { r - s };
|
||||
self.normalize();
|
||||
ret
|
||||
}
|
||||
pub fn icdf(&mut self, icdf: &[u8], ftb: u32) -> usize {
|
||||
let mut s = self.rng;
|
||||
let d = self.val;
|
||||
let r = s >> ftb;
|
||||
let mut ret = 0usize;
|
||||
let mut t;
|
||||
loop {
|
||||
t = s;
|
||||
s = r * icdf[ret] as u32;
|
||||
if d >= s {
|
||||
break;
|
||||
}
|
||||
ret += 1;
|
||||
}
|
||||
self.val = d - s;
|
||||
self.rng = t - s;
|
||||
self.normalize();
|
||||
ret
|
||||
}
|
||||
pub fn uint(&mut self, ft: u32) -> u32 {
|
||||
let ft1 = ft - 1;
|
||||
let ftb = ilog(ft1);
|
||||
if ftb > 8 {
|
||||
let ftb = (ftb - 8) as u32;
|
||||
let top = (ft1 >> ftb) + 1;
|
||||
let s = self.decode(top);
|
||||
self.update(s, s + 1, top);
|
||||
(s << ftb) | self.bits(ftb)
|
||||
} else {
|
||||
let s = self.decode(ft);
|
||||
self.update(s, s + 1, ft);
|
||||
s
|
||||
}
|
||||
}
|
||||
pub fn bits(&mut self, bits: u32) -> u32 {
|
||||
let mut window = self.end_window;
|
||||
let mut available = self.nend_bits;
|
||||
if (available as u32) < bits {
|
||||
loop {
|
||||
window |= self.read_byte_from_end() << available;
|
||||
available += SYM_BITS as i32;
|
||||
if available > (CODE_BITS - SYM_BITS) as i32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let ret = window & ((1u32 << bits) - 1);
|
||||
self.end_window = window >> bits;
|
||||
self.nend_bits = available - bits as i32;
|
||||
self.nbits_total += bits as i32;
|
||||
ret
|
||||
}
|
||||
pub fn tell(&self) -> i32 {
|
||||
self.nbits_total - ilog(self.rng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::dec::RangeDecoder;
|
||||
use super::*;
|
||||
|
||||
struct Lcg(u32);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u32 {
|
||||
self.0 = self.0.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
self.0 >> 8
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_at_one_bit() {
|
||||
let e = RangeEncoder::new();
|
||||
assert_eq!(e.tell(), 1);
|
||||
assert_eq!(e.tell_frac(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_mixed_symbols_and_tell_matches() {
|
||||
let mut rng = Lcg(7);
|
||||
let icdf: [u8; 4] = [200, 120, 30, 0];
|
||||
for trial in 0..300 {
|
||||
let n = 1 + (rng.next() % 300) as usize;
|
||||
let mut ops = Vec::new();
|
||||
for _ in 0..n {
|
||||
match rng.next() % 5 {
|
||||
0 => {
|
||||
let ft = 2 + rng.next() % 1000;
|
||||
ops.push((0u8, rng.next() % ft, ft));
|
||||
}
|
||||
1 => {
|
||||
let logp = 1 + rng.next() % 15;
|
||||
ops.push((1, rng.next().is_multiple_of(5) as u32, logp));
|
||||
}
|
||||
2 => {
|
||||
let bits = 1 + rng.next() % 24;
|
||||
ops.push((2, rng.next() & ((1 << bits) - 1), bits));
|
||||
}
|
||||
3 => {
|
||||
let ft = 2 + rng.next() % 100_000_000;
|
||||
ops.push((3, rng.next() % ft, ft));
|
||||
}
|
||||
_ => ops.push((4, rng.next() % 4, 0)),
|
||||
}
|
||||
}
|
||||
let mut e = RangeEncoder::new();
|
||||
let mut tells = Vec::new();
|
||||
for &(k, v, p) in &ops {
|
||||
match k {
|
||||
0 => e.encode(v, v + 1, p),
|
||||
1 => e.bit_logp(v != 0, p),
|
||||
2 => e.bits(v, p),
|
||||
3 => e.uint(v, p),
|
||||
_ => e.icdf(v as usize, &icdf, 8),
|
||||
}
|
||||
tells.push(e.tell());
|
||||
}
|
||||
// Exactly the number of bytes `tell` says were used must suffice.
|
||||
let size = (e.tell() as usize).div_ceil(8);
|
||||
let final_rng = e.rng();
|
||||
let buf = e.done(size);
|
||||
assert_eq!(buf.len(), size);
|
||||
let mut d = RangeDecoder::new(&buf);
|
||||
for (i, &(k, v, p)) in ops.iter().enumerate() {
|
||||
let got = match k {
|
||||
0 => {
|
||||
let s = d.decode(p);
|
||||
d.update(s, s + 1, p);
|
||||
s
|
||||
}
|
||||
1 => d.bit_logp(p) as u32,
|
||||
2 => d.bits(p),
|
||||
3 => d.uint(p),
|
||||
_ => d.icdf(&icdf, 8) as u32,
|
||||
};
|
||||
assert_eq!(got, v, "trial {trial} op {i} kind {k}");
|
||||
assert_eq!(d.tell(), tells[i], "tell mismatch trial {trial} op {i}");
|
||||
}
|
||||
assert_eq!(d.rng(), final_rng);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Pulse cache and bit allocation (RFC 6716 §4.3.3, §4.3.4.1).
|
||||
//!
|
||||
//! The allocation must be bit-identical to the decoder's, so this mirrors FilmCraft's
|
||||
//! spec-derived `filmcraft-opus` decoder (first-party, MIT OR Apache-2.0) step by step; the only
|
||||
//! difference is that the encoder *writes* the band-skip, intensity and dual-stereo symbols.
|
||||
|
||||
use crate::range::{BITRES, RangeEncoder, ilog};
|
||||
use crate::tables::*;
|
||||
|
||||
const MAX_PSEUDO: i32 = 40;
|
||||
const LOG_MAX_PSEUDO: i32 = 6;
|
||||
pub const MAX_FINE_BITS: i32 = 8;
|
||||
const FINE_OFFSET: i32 = 21;
|
||||
pub const QTHETA_OFFSET: i32 = 4;
|
||||
pub const QTHETA_OFFSET_TWOPHASE: i32 = 16;
|
||||
const ALLOC_STEPS: i32 = 6;
|
||||
|
||||
/// Number of pulses represented by pseudo-pulse index `i`.
|
||||
pub fn get_pulses(i: i32) -> i32 {
|
||||
if i < 8 { i } else { (8 + (i & 7)) << ((i >> 3) - 1) }
|
||||
}
|
||||
|
||||
/// Conservative (rounded up) log2 of `val` with `frac` fractional bits.
|
||||
pub fn log2_frac(mut val: u32, mut frac: u32) -> i32 {
|
||||
let mut l = ilog(val);
|
||||
if val & val.wrapping_sub(1) != 0 {
|
||||
if l > 16 {
|
||||
val = ((val - 1) >> (l - 16)) + 1;
|
||||
} else {
|
||||
val <<= 16 - l;
|
||||
}
|
||||
let mut l = (l - 1) << frac;
|
||||
loop {
|
||||
let b = (val >> 16) as i32;
|
||||
l += b << frac;
|
||||
val = (val + b as u32) >> b;
|
||||
val = (val * val + 0x7FFF) >> 15;
|
||||
if frac == 0 {
|
||||
break;
|
||||
}
|
||||
frac -= 1;
|
||||
}
|
||||
l + (val > 0x8000) as i32
|
||||
} else {
|
||||
l -= 1;
|
||||
l << frac
|
||||
}
|
||||
}
|
||||
|
||||
/// PVQ codebook size V(N, K) (saturating far above 2^32).
|
||||
pub fn pvq_v(n: usize, k: usize) -> u128 {
|
||||
if k == 0 {
|
||||
return 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut row = vec![0u128; k + 1];
|
||||
row[0] = 1;
|
||||
for _ in 0..n {
|
||||
let mut prev_diag = row[0];
|
||||
for kk in 1..=k {
|
||||
let up = row[kk];
|
||||
let v = up.saturating_add(row[kk - 1]).saturating_add(prev_diag);
|
||||
prev_diag = up;
|
||||
row[kk] = v.min(u128::MAX >> 8);
|
||||
}
|
||||
row[0] = 1;
|
||||
}
|
||||
row[k]
|
||||
}
|
||||
|
||||
fn fits_in32(n: usize, k: i32) -> bool {
|
||||
pvq_v(n, k as usize) <= u32::MAX as u128
|
||||
}
|
||||
|
||||
/// Precomputed per-mode data.
|
||||
pub struct Mode {
|
||||
/// log2 of band width in 1/8 bits.
|
||||
pub log_n: [i32; NB_EBANDS],
|
||||
cache_index: Vec<i16>,
|
||||
cache_bits: Vec<u8>,
|
||||
caps: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn new() -> Mode {
|
||||
let mut log_n = [0i32; NB_EBANDS];
|
||||
for (i, l) in log_n.iter_mut().enumerate() {
|
||||
*l = log2_frac((EBANDS[i + 1] - EBANDS[i]) as u32, BITRES);
|
||||
}
|
||||
let (cache_index, cache_bits) = compute_pulse_cache(MAX_LM as i32);
|
||||
let mut m = Mode { log_n, cache_index, cache_bits, caps: Vec::new() };
|
||||
m.caps = compute_caps(&m);
|
||||
m
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn cache(&self, lm: i32, band: usize) -> &[u8] {
|
||||
let idx = self.cache_index[((lm + 1) as usize) * NB_EBANDS + band];
|
||||
&self.cache_bits[idx.max(0) as usize..]
|
||||
}
|
||||
|
||||
pub fn bits2pulses(&self, band: usize, lm: i32, bits: i32) -> i32 {
|
||||
let cache = self.cache(lm, band);
|
||||
let mut lo = 0i32;
|
||||
let mut hi = cache[0] as i32;
|
||||
let bits = bits - 1;
|
||||
for _ in 0..LOG_MAX_PSEUDO {
|
||||
let mid = (lo + hi + 1) >> 1;
|
||||
if cache[mid as usize] as i32 >= bits {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
let lo_bits = if lo == 0 { -1 } else { cache[lo as usize] as i32 };
|
||||
if bits - lo_bits <= cache[hi as usize] as i32 - bits { lo } else { hi }
|
||||
}
|
||||
|
||||
pub fn pulses2bits(&self, band: usize, lm: i32, pulses: i32) -> i32 {
|
||||
if pulses == 0 { 0 } else { self.cache(lm, band)[pulses as usize] as i32 + 1 }
|
||||
}
|
||||
|
||||
/// Per-band allocation caps for this frame (`init_caps`).
|
||||
pub fn init_caps(&self, lm: usize, c: usize) -> [i32; NB_EBANDS] {
|
||||
let mut cap = [0i32; NB_EBANDS];
|
||||
for (i, cp) in cap.iter_mut().enumerate() {
|
||||
let n = (EBANDS[i + 1] - EBANDS[i]) << lm;
|
||||
*cp = (self.caps[NB_EBANDS * (2 * lm + c - 1) + i] as i32 + 64) * c as i32 * n >> 2;
|
||||
}
|
||||
cap
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_pulse_cache(lm_max: i32) -> (Vec<i16>, Vec<u8>) {
|
||||
let nb = NB_EBANDS;
|
||||
let rows = (lm_max + 2) as usize;
|
||||
let mut index = vec![-1i16; nb * rows];
|
||||
let mut entries: Vec<(usize, i32, usize)> = Vec::new();
|
||||
let mut curr = 0usize;
|
||||
for i in 0..rows {
|
||||
for j in 0..nb {
|
||||
let n = (((EBANDS[j + 1] - EBANDS[j]) << i) >> 1) as usize;
|
||||
index[i * nb + j] = -1;
|
||||
'search: for k in 0..=i {
|
||||
for nn in 0..nb {
|
||||
if k == i && nn >= j {
|
||||
break;
|
||||
}
|
||||
if n == (((EBANDS[nn + 1] - EBANDS[nn]) << k) >> 1) as usize {
|
||||
index[i * nb + j] = index[k * nb + nn];
|
||||
break 'search;
|
||||
}
|
||||
}
|
||||
}
|
||||
if index[i * nb + j] == -1 && n != 0 {
|
||||
let mut k = 0;
|
||||
while k < MAX_PSEUDO && fits_in32(n, get_pulses(k + 1)) {
|
||||
k += 1;
|
||||
}
|
||||
index[i * nb + j] = curr as i16;
|
||||
entries.push((n, k, curr));
|
||||
curr += (k + 1) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut bits = vec![0u8; curr];
|
||||
for &(n, k, off) in &entries {
|
||||
let maxk = get_pulses(k) as usize;
|
||||
let tmp = required_bits(n, maxk, BITRES);
|
||||
for j in 1..=k {
|
||||
bits[off + j as usize] = (tmp[get_pulses(j) as usize] - 1) as u8;
|
||||
}
|
||||
bits[off] = k as u8;
|
||||
}
|
||||
(index, bits)
|
||||
}
|
||||
|
||||
fn required_bits(n: usize, maxk: usize, frac: u32) -> Vec<i32> {
|
||||
let mut out = vec![0i32; maxk + 1];
|
||||
if n == 1 {
|
||||
for o in out.iter_mut().skip(1) {
|
||||
*o = 1 << frac;
|
||||
}
|
||||
} else {
|
||||
for (k, o) in out.iter_mut().enumerate().skip(1) {
|
||||
*o = log2_frac(pvq_v(n, k) as u32, frac);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn compute_caps(m: &Mode) -> Vec<u8> {
|
||||
let nb = NB_EBANDS;
|
||||
let mut caps = vec![0u8; (MAX_LM + 1) * 2 * nb];
|
||||
for i in 0..=MAX_LM as i32 {
|
||||
for c in 1..=2i32 {
|
||||
for j in 0..nb {
|
||||
let mut n0 = EBANDS[j + 1] - EBANDS[j];
|
||||
let max_bits;
|
||||
if n0 << i == 1 {
|
||||
max_bits = c * (1 + MAX_FINE_BITS) << BITRES;
|
||||
} else {
|
||||
let mut lm0 = 0;
|
||||
if n0 > 2 {
|
||||
n0 >>= 1;
|
||||
lm0 -= 1;
|
||||
} else if n0 <= 1 {
|
||||
lm0 = i.min(1);
|
||||
n0 <<= lm0;
|
||||
}
|
||||
let pcache = m.cache(lm0, j);
|
||||
let mut mb = pcache[pcache[0] as usize] as i32 + 1;
|
||||
let mut n = n0;
|
||||
for k in 0..(i - lm0) {
|
||||
mb <<= 1;
|
||||
let offset = ((m.log_n[j] + ((lm0 + k) << BITRES)) >> 1) - QTHETA_OFFSET;
|
||||
let num = 459 * ((2 * n - 1) * offset + mb);
|
||||
let den = ((2 * n - 1) << 9) - 459;
|
||||
let qb = ((num + (den >> 1)) / den).min(57);
|
||||
mb += qb;
|
||||
n <<= 1;
|
||||
}
|
||||
if c == 2 {
|
||||
mb <<= 1;
|
||||
let offset = ((m.log_n[j] + (i << BITRES)) >> 1) - if n == 2 { QTHETA_OFFSET_TWOPHASE } else { QTHETA_OFFSET };
|
||||
let ndof = 2 * n - 1 - (n == 2) as i32;
|
||||
let p = if n == 2 { 512 } else { 487 };
|
||||
let num = p * (mb + ndof * offset);
|
||||
let den = (ndof << 9) - p;
|
||||
let qb = ((num + (den >> 1)) / den).min(if n == 2 { 64 } else { 61 });
|
||||
mb += qb;
|
||||
}
|
||||
let ndof = c * n + if c == 2 && n > 2 { 1 } else { 0 };
|
||||
let mut offset = ((m.log_n[j] + (i << BITRES)) >> 1) - FINE_OFFSET;
|
||||
if n == 2 {
|
||||
offset += (1 << BITRES) >> 2;
|
||||
}
|
||||
let num = mb + ndof * offset;
|
||||
let den = (ndof - 1) << BITRES;
|
||||
let qb = ((num + (den >> 1)) / den).min(MAX_FINE_BITS);
|
||||
mb += c * qb << BITRES;
|
||||
max_bits = mb;
|
||||
}
|
||||
let w = c * ((EBANDS[j + 1] - EBANDS[j]) << i);
|
||||
let v = (4 * max_bits / w) - 64;
|
||||
caps[((i * 2 + c - 1) as usize) * nb + j] = v.clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
caps
|
||||
}
|
||||
|
||||
/// Result of the allocation process.
|
||||
pub struct Allocation {
|
||||
pub coded_bands: usize,
|
||||
pub intensity: usize,
|
||||
pub dual_stereo: bool,
|
||||
pub balance: i32,
|
||||
pub pulses: [i32; NB_EBANDS],
|
||||
pub fine_quant: [i32; NB_EBANDS],
|
||||
pub fine_priority: [i32; NB_EBANDS],
|
||||
}
|
||||
|
||||
/// `clt_compute_allocation`, encoder side. Stereo frames are always coded as dual (L/R) stereo
|
||||
/// with no intensity bands, and no band is skipped voluntarily.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_allocation(
|
||||
m: &Mode,
|
||||
start: usize,
|
||||
end: usize,
|
||||
offsets: &[i32; NB_EBANDS],
|
||||
cap: &[i32; NB_EBANDS],
|
||||
alloc_trim: i32,
|
||||
total: i32,
|
||||
c: usize,
|
||||
lm: usize,
|
||||
enc: &mut RangeEncoder,
|
||||
) -> Allocation {
|
||||
let ci = c as i32;
|
||||
let lmi = lm as i32;
|
||||
let mut total = total.max(0);
|
||||
let mut skip_start = start;
|
||||
let skip_rsv = if total >= 1 << BITRES { 1 << BITRES } else { 0 };
|
||||
total -= skip_rsv;
|
||||
let mut intensity_rsv = 0;
|
||||
let mut dual_stereo_rsv = 0;
|
||||
if c == 2 {
|
||||
intensity_rsv = LOG2_FRAC_TABLE[end - start];
|
||||
if intensity_rsv > total {
|
||||
intensity_rsv = 0;
|
||||
} else {
|
||||
total -= intensity_rsv;
|
||||
dual_stereo_rsv = if total >= 1 << BITRES { 1 << BITRES } else { 0 };
|
||||
total -= dual_stereo_rsv;
|
||||
}
|
||||
}
|
||||
let mut bits1 = [0i32; NB_EBANDS];
|
||||
let mut bits2 = [0i32; NB_EBANDS];
|
||||
let mut thresh = [0i32; NB_EBANDS];
|
||||
let mut trim_offset = [0i32; NB_EBANDS];
|
||||
for j in start..end {
|
||||
let w = EBANDS[j + 1] - EBANDS[j];
|
||||
thresh[j] = (ci << BITRES).max((3 * w << lmi << BITRES) >> 4);
|
||||
trim_offset[j] = ci * w * (alloc_trim - 5 - lmi) * (end as i32 - j as i32 - 1) * (1 << (lmi + BITRES as i32)) >> 6;
|
||||
if w << lmi == 1 {
|
||||
trim_offset[j] -= ci << BITRES;
|
||||
}
|
||||
}
|
||||
let nb_alloc = ALLOC.len() as i32;
|
||||
let mut lo = 1;
|
||||
let mut hi = nb_alloc - 1;
|
||||
loop {
|
||||
let mut done = false;
|
||||
let mut psum = 0;
|
||||
let mid = (lo + hi) >> 1;
|
||||
for j in (start..end).rev() {
|
||||
let n = EBANDS[j + 1] - EBANDS[j];
|
||||
let mut bitsj = ci * n * (ALLOC[mid as usize][j] as i32) << lmi >> 2;
|
||||
if bitsj > 0 {
|
||||
bitsj = (bitsj + trim_offset[j]).max(0);
|
||||
}
|
||||
bitsj += offsets[j];
|
||||
if bitsj >= thresh[j] || done {
|
||||
done = true;
|
||||
psum += bitsj.min(cap[j]);
|
||||
} else if bitsj >= ci << BITRES {
|
||||
psum += ci << BITRES;
|
||||
}
|
||||
}
|
||||
if psum > total {
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
if lo > hi {
|
||||
break;
|
||||
}
|
||||
}
|
||||
hi = lo;
|
||||
lo -= 1;
|
||||
for j in start..end {
|
||||
let n = EBANDS[j + 1] - EBANDS[j];
|
||||
let mut bits1j = ci * n * (ALLOC[lo as usize][j] as i32) << lmi >> 2;
|
||||
let mut bits2j = if hi >= nb_alloc { cap[j] } else { ci * n * (ALLOC[hi as usize][j] as i32) << lmi >> 2 };
|
||||
if bits1j > 0 {
|
||||
bits1j = (bits1j + trim_offset[j]).max(0);
|
||||
}
|
||||
if bits2j > 0 {
|
||||
bits2j = (bits2j + trim_offset[j]).max(0);
|
||||
}
|
||||
if lo > 0 {
|
||||
bits1j += offsets[j];
|
||||
}
|
||||
bits2j += offsets[j];
|
||||
if offsets[j] > 0 {
|
||||
skip_start = j;
|
||||
}
|
||||
bits2j = (bits2j - bits1j).max(0);
|
||||
bits1[j] = bits1j;
|
||||
bits2[j] = bits2j;
|
||||
}
|
||||
interp_bits2pulses(m, start, end, skip_start, &bits1, &bits2, &thresh, cap, total, skip_rsv, intensity_rsv, dual_stereo_rsv, c, lm, enc)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn interp_bits2pulses(
|
||||
m: &Mode,
|
||||
start: usize,
|
||||
end: usize,
|
||||
skip_start: usize,
|
||||
bits1: &[i32; NB_EBANDS],
|
||||
bits2: &[i32; NB_EBANDS],
|
||||
thresh: &[i32; NB_EBANDS],
|
||||
cap: &[i32; NB_EBANDS],
|
||||
mut total: i32,
|
||||
skip_rsv: i32,
|
||||
mut intensity_rsv: i32,
|
||||
mut dual_stereo_rsv: i32,
|
||||
c: usize,
|
||||
lm: usize,
|
||||
enc: &mut RangeEncoder,
|
||||
) -> Allocation {
|
||||
let ci = c as i32;
|
||||
let alloc_floor = ci << BITRES;
|
||||
let stereo = (c > 1) as i32;
|
||||
let log_m = (lm as i32) << BITRES;
|
||||
let mut lo = 0;
|
||||
let mut hi = 1 << ALLOC_STEPS;
|
||||
for _ in 0..ALLOC_STEPS {
|
||||
let mid = (lo + hi) >> 1;
|
||||
let mut psum = 0;
|
||||
let mut done = false;
|
||||
for j in (start..end).rev() {
|
||||
let tmp = bits1[j] + (mid * bits2[j] >> ALLOC_STEPS);
|
||||
if tmp >= thresh[j] || done {
|
||||
done = true;
|
||||
psum += tmp.min(cap[j]);
|
||||
} else if tmp >= alloc_floor {
|
||||
psum += alloc_floor;
|
||||
}
|
||||
}
|
||||
if psum > total {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
let mut bits = [0i32; NB_EBANDS];
|
||||
let mut psum = 0;
|
||||
let mut done = false;
|
||||
for j in (start..end).rev() {
|
||||
let mut tmp = bits1[j] + (lo * bits2[j] >> ALLOC_STEPS);
|
||||
if tmp < thresh[j] && !done {
|
||||
tmp = if tmp >= alloc_floor { alloc_floor } else { 0 };
|
||||
} else {
|
||||
done = true;
|
||||
}
|
||||
tmp = tmp.min(cap[j]);
|
||||
bits[j] = tmp;
|
||||
psum += tmp;
|
||||
}
|
||||
let s = start;
|
||||
let mut coded_bands = end;
|
||||
loop {
|
||||
let j = coded_bands - 1;
|
||||
if j <= skip_start {
|
||||
total += skip_rsv;
|
||||
break;
|
||||
}
|
||||
let mut left = total - psum;
|
||||
let span = EBANDS[coded_bands] - EBANDS[s];
|
||||
let percoeff = left / span;
|
||||
left -= span * percoeff;
|
||||
let rem = (left - (EBANDS[j] - EBANDS[s])).max(0);
|
||||
let band_width = EBANDS[coded_bands] - EBANDS[j];
|
||||
let band_bits = bits[j] + percoeff * band_width + rem;
|
||||
if band_bits >= thresh[j].max(alloc_floor + (1 << BITRES)) {
|
||||
// Keep this band (and every band below it).
|
||||
enc.bit_logp(true, 1);
|
||||
break;
|
||||
}
|
||||
psum -= bits[j] + intensity_rsv;
|
||||
if intensity_rsv > 0 {
|
||||
intensity_rsv = LOG2_FRAC_TABLE[j - s];
|
||||
}
|
||||
psum += intensity_rsv;
|
||||
if band_bits >= alloc_floor {
|
||||
psum += alloc_floor;
|
||||
bits[j] = alloc_floor;
|
||||
} else {
|
||||
bits[j] = 0;
|
||||
}
|
||||
coded_bands -= 1;
|
||||
}
|
||||
let intensity = if intensity_rsv > 0 {
|
||||
// No intensity stereo: every coded band is coded in full.
|
||||
enc.uint((coded_bands - s) as u32, (coded_bands + 1 - s) as u32);
|
||||
coded_bands
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if intensity <= s {
|
||||
total += dual_stereo_rsv;
|
||||
dual_stereo_rsv = 0;
|
||||
}
|
||||
let dual_stereo = if dual_stereo_rsv > 0 {
|
||||
enc.bit_logp(true, 1);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut left = total - psum;
|
||||
let span = EBANDS[coded_bands] - EBANDS[s];
|
||||
let percoeff = left / span;
|
||||
left -= span * percoeff;
|
||||
for j in s..coded_bands {
|
||||
bits[j] += percoeff * (EBANDS[j + 1] - EBANDS[j]);
|
||||
}
|
||||
for j in s..coded_bands {
|
||||
let tmp = left.min(EBANDS[j + 1] - EBANDS[j]);
|
||||
bits[j] += tmp;
|
||||
left -= tmp;
|
||||
}
|
||||
let mut ebits = [0i32; NB_EBANDS];
|
||||
let mut fine_priority = [0i32; NB_EBANDS];
|
||||
let mut balance = 0;
|
||||
for j in s..coded_bands {
|
||||
let n0 = EBANDS[j + 1] - EBANDS[j];
|
||||
let n = n0 << lm;
|
||||
let bit = bits[j] + balance;
|
||||
let mut excess;
|
||||
if n > 1 {
|
||||
excess = (bit - cap[j]).max(0);
|
||||
bits[j] = bit - excess;
|
||||
let den = ci * n + if c == 2 && n > 2 && !dual_stereo && j < intensity { 1 } else { 0 };
|
||||
let nclogn = den * (m.log_n[j] + log_m);
|
||||
let mut offset = (nclogn >> 1) - den * FINE_OFFSET;
|
||||
if n == 2 {
|
||||
offset += (den << BITRES) >> 2;
|
||||
}
|
||||
if bits[j] + offset < den * 2 << BITRES {
|
||||
offset += nclogn >> 2;
|
||||
} else if bits[j] + offset < den * 3 << BITRES {
|
||||
offset += nclogn >> 3;
|
||||
}
|
||||
let mut eb = (bits[j] + offset + (den << (BITRES - 1))).max(0);
|
||||
eb = (eb / den) >> BITRES;
|
||||
if ci * eb > (bits[j] >> BITRES) {
|
||||
eb = bits[j] >> stereo >> BITRES;
|
||||
}
|
||||
eb = eb.min(MAX_FINE_BITS);
|
||||
ebits[j] = eb;
|
||||
fine_priority[j] = (eb * (den << BITRES) >= bits[j] + offset) as i32;
|
||||
bits[j] -= ci * eb << BITRES;
|
||||
} else {
|
||||
excess = (bit - (ci << BITRES)).max(0);
|
||||
bits[j] = bit - excess;
|
||||
ebits[j] = 0;
|
||||
fine_priority[j] = 1;
|
||||
}
|
||||
if excess > 0 {
|
||||
let extra_fine = (excess >> (stereo + BITRES as i32)).min(MAX_FINE_BITS - ebits[j]);
|
||||
ebits[j] += extra_fine;
|
||||
let extra_bits = extra_fine * ci << BITRES;
|
||||
fine_priority[j] = (extra_bits >= excess - balance) as i32;
|
||||
excess -= extra_bits;
|
||||
}
|
||||
balance = excess;
|
||||
}
|
||||
for j in coded_bands..end {
|
||||
ebits[j] = bits[j] >> stereo >> BITRES;
|
||||
bits[j] = 0;
|
||||
fine_priority[j] = (ebits[j] < 1) as i32;
|
||||
}
|
||||
Allocation { coded_bands, intensity, dual_stereo, balance, pulses: bits, fine_quant: ebits, fine_priority }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn caps_match_decoder_reference() {
|
||||
let m = Mode::new();
|
||||
let first_rows: [u8; 42] = [
|
||||
224, 224, 224, 224, 224, 224, 224, 224, 160, 160, 160, 160, 185, 185, 185, 178, 178, 168, 134, 61, 37, 224, 224, 224, 224, 224, 224, 224, 224, 240,
|
||||
240, 240, 240, 207, 207, 207, 198, 198, 183, 144, 66, 40,
|
||||
];
|
||||
assert_eq!(&m.caps[..42], &first_rows[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pvq_counts() {
|
||||
assert_eq!(pvq_v(5, 1), 10);
|
||||
assert_eq!(pvq_v(1, 7), 2);
|
||||
assert_eq!(pvq_v(2, 9), 36);
|
||||
assert_eq!(pvq_v(3, 2), 18);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Constant tables of the 48 kHz CELT mode (RFC 6716 §4.3, Tables 55–58 and §4.3.2).
|
||||
//!
|
||||
//! Transcribed from RFC 6716; copied from FilmCraft's spec-derived `filmcraft-opus` decoder
|
||||
//! (first-party, MIT OR Apache-2.0) so both sides share the exact same values.
|
||||
|
||||
/// Number of energy bands.
|
||||
pub const NB_EBANDS: usize = 21;
|
||||
/// Band edges in units of 2.5 ms MDCT bins (Table 55).
|
||||
pub const EBANDS: [i32; NB_EBANDS + 1] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24, 28, 34, 40, 48, 60, 78, 100];
|
||||
/// Overlap of the low-overlap window (samples at 48 kHz).
|
||||
pub const OVERLAP: usize = 120;
|
||||
/// Largest LM (20 ms frames).
|
||||
pub const MAX_LM: usize = 3;
|
||||
/// Pre-emphasis coefficient.
|
||||
pub const PREEMPH: f32 = 0.850_006_1;
|
||||
|
||||
/// Static allocation table (Table 57), indexed `[quality][band]`, in 1/32 bit per MDCT bin.
|
||||
pub const ALLOC: [[u8; NB_EBANDS]; 11] = transpose([
|
||||
[0, 90, 110, 118, 126, 134, 144, 152, 162, 172, 200],
|
||||
[0, 80, 100, 110, 119, 127, 137, 145, 155, 165, 200],
|
||||
[0, 75, 90, 103, 112, 120, 130, 138, 148, 158, 200],
|
||||
[0, 69, 84, 93, 104, 114, 124, 132, 142, 152, 200],
|
||||
[0, 63, 78, 86, 95, 103, 113, 123, 133, 143, 200],
|
||||
[0, 56, 71, 80, 89, 97, 107, 117, 127, 137, 200],
|
||||
[0, 49, 65, 75, 83, 91, 101, 111, 121, 131, 200],
|
||||
[0, 40, 58, 70, 78, 85, 95, 105, 115, 125, 200],
|
||||
[0, 34, 51, 65, 72, 78, 88, 98, 108, 118, 198],
|
||||
[0, 29, 45, 59, 66, 72, 82, 92, 102, 112, 193],
|
||||
[0, 20, 39, 53, 60, 66, 76, 86, 96, 106, 188],
|
||||
[0, 18, 32, 47, 54, 60, 70, 80, 90, 100, 183],
|
||||
[0, 10, 26, 40, 47, 54, 64, 74, 84, 94, 178],
|
||||
[0, 0, 20, 31, 39, 47, 57, 67, 77, 87, 173],
|
||||
[0, 0, 12, 23, 32, 41, 51, 61, 71, 81, 168],
|
||||
[0, 0, 0, 15, 25, 35, 45, 55, 65, 75, 163],
|
||||
[0, 0, 0, 4, 17, 29, 39, 49, 59, 69, 158],
|
||||
[0, 0, 0, 0, 12, 23, 33, 43, 53, 63, 153],
|
||||
[0, 0, 0, 0, 1, 16, 26, 36, 46, 56, 148],
|
||||
[0, 0, 0, 0, 0, 10, 15, 20, 30, 45, 129],
|
||||
[0, 0, 0, 0, 0, 1, 1, 1, 1, 20, 104],
|
||||
]);
|
||||
|
||||
const fn transpose(t: [[u8; 11]; NB_EBANDS]) -> [[u8; NB_EBANDS]; 11] {
|
||||
let mut out = [[0u8; NB_EBANDS]; 11];
|
||||
let mut b = 0;
|
||||
while b < NB_EBANDS {
|
||||
let mut q = 0;
|
||||
while q < 11 {
|
||||
out[q][b] = t[b][q];
|
||||
q += 1;
|
||||
}
|
||||
b += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mean band energy (log2 domain) removed before coarse quantisation.
|
||||
pub const E_MEANS: [f32; 25] = [
|
||||
6.4375, 6.25, 5.75, 5.3125, 5.0625, 4.8125, 4.5, 4.375, 4.875, 4.6875, 4.5625, 4.4375, 4.875, 4.625, 4.3125, 4.5, 4.375, 4.625, 4.75, 4.4375, 3.75, 3.75,
|
||||
3.75, 3.75, 3.75,
|
||||
];
|
||||
|
||||
/// Inter-frame prediction coefficient (alpha) per LM, Q15.
|
||||
pub const PRED_COEF: [f32; 4] = [29440.0 / 32768.0, 26112.0 / 32768.0, 21248.0 / 32768.0, 16384.0 / 32768.0];
|
||||
/// Inter-band prediction coefficient (beta) per LM for inter frames, Q15.
|
||||
pub const BETA_COEF: [f32; 4] = [30147.0 / 32768.0, 22282.0 / 32768.0, 12124.0 / 32768.0, 6554.0 / 32768.0];
|
||||
/// Beta for intra frames.
|
||||
pub const BETA_INTRA: f32 = 4915.0 / 32768.0;
|
||||
|
||||
/// Laplace model parameters for coarse energy: `[LM][intra][2*band + {0: prob, 1: decay}]`.
|
||||
pub const E_PROB_MODEL: [[[u8; 42]; 2]; 4] = [
|
||||
[
|
||||
[
|
||||
72, 127, 65, 129, 66, 128, 65, 128, 64, 128, 62, 128, 64, 128, 64, 128, 92, 78, 92, 79, 92, 78, 90, 79, 116, 41, 115, 40, 114, 40, 132, 26, 132,
|
||||
26, 145, 17, 161, 12, 176, 10, 177, 11,
|
||||
],
|
||||
[
|
||||
24, 179, 48, 138, 54, 135, 54, 132, 53, 134, 56, 133, 55, 132, 55, 132, 61, 114, 70, 96, 74, 88, 75, 88, 87, 74, 89, 66, 91, 67, 100, 59, 108, 50,
|
||||
120, 40, 122, 37, 97, 43, 78, 50,
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
83, 78, 84, 81, 88, 75, 86, 74, 87, 71, 90, 73, 93, 74, 93, 74, 109, 40, 114, 36, 117, 34, 117, 34, 143, 17, 145, 18, 146, 19, 162, 12, 165, 10,
|
||||
178, 7, 189, 6, 190, 8, 177, 9,
|
||||
],
|
||||
[
|
||||
23, 178, 54, 115, 63, 102, 66, 98, 69, 99, 74, 89, 71, 91, 73, 91, 78, 89, 86, 80, 92, 66, 93, 64, 102, 59, 103, 60, 104, 60, 117, 52, 123, 44,
|
||||
138, 35, 133, 31, 97, 38, 77, 45,
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
61, 90, 93, 60, 105, 42, 107, 41, 110, 45, 116, 38, 113, 38, 112, 38, 124, 26, 132, 27, 136, 19, 140, 20, 155, 14, 159, 16, 158, 18, 170, 13, 177,
|
||||
10, 187, 8, 192, 6, 175, 9, 159, 10,
|
||||
],
|
||||
[
|
||||
21, 178, 59, 110, 71, 86, 75, 85, 84, 83, 91, 66, 88, 73, 87, 72, 92, 75, 98, 72, 105, 58, 107, 54, 115, 52, 114, 55, 112, 56, 129, 51, 132, 40,
|
||||
150, 33, 140, 29, 98, 35, 77, 42,
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
42, 121, 96, 66, 108, 43, 111, 40, 117, 44, 123, 32, 120, 36, 119, 33, 127, 33, 134, 34, 139, 21, 147, 23, 152, 20, 158, 25, 154, 26, 166, 21, 173,
|
||||
16, 184, 13, 184, 10, 150, 13, 139, 15,
|
||||
],
|
||||
[
|
||||
22, 178, 63, 114, 74, 82, 84, 83, 92, 82, 103, 62, 96, 72, 96, 67, 101, 73, 107, 72, 113, 55, 118, 52, 125, 52, 118, 52, 117, 55, 135, 49, 137, 39,
|
||||
157, 32, 145, 29, 97, 33, 77, 40,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/// Inverse CDF for coarse energy when only a few bits remain.
|
||||
pub const SMALL_ENERGY_ICDF: [u8; 3] = [2, 1, 0];
|
||||
/// Conservative log2 in 1/8 bits of the number of coded bands (intensity reservation).
|
||||
pub const LOG2_FRAC_TABLE: [i32; 24] = [0, 8, 13, 16, 19, 21, 23, 24, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 34, 35, 36, 36, 37, 37];
|
||||
/// TF resolution changes: `[LM][4*transient + 2*tf_select + tf_flag]`.
|
||||
pub const TF_SELECT_TABLE: [[i8; 8]; 4] = [[0, -1, 0, -1, 0, -1, 0, -1], [0, -1, 0, -2, 1, 0, 1, -1], [0, -2, 0, -3, 2, 0, 1, -1], [0, -2, 0, -3, 3, 0, 1, -1]];
|
||||
/// Spreading decision `{7, 2, 21, 2}/32`.
|
||||
pub const SPREAD_ICDF: [u8; 4] = [25, 23, 2, 0];
|
||||
/// Allocation trim (Table 58).
|
||||
pub const TRIM_ICDF: [u8; 11] = [126, 124, 119, 109, 87, 41, 19, 9, 4, 2, 0];
|
||||
@@ -629,6 +629,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 {
|
||||
|
||||
@@ -136,17 +136,26 @@ pub enum OutputFormat {
|
||||
ExrSequence,
|
||||
/// Animated GIF.
|
||||
Gif,
|
||||
/// WebM: VP9 video (alpha with RGB + Alpha) + Opus audio.
|
||||
WebM,
|
||||
/// Audio only: WAV (16-bit PCM).
|
||||
Wav,
|
||||
/// Audio only: AIFF (16-bit PCM).
|
||||
Aiff,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
pub const ALL: [OutputFormat; 7] = [
|
||||
pub const ALL: [OutputFormat; 10] = [
|
||||
OutputFormat::H264,
|
||||
OutputFormat::ProRes,
|
||||
OutputFormat::WebM,
|
||||
OutputFormat::PngSequence,
|
||||
OutputFormat::JpegSequence,
|
||||
OutputFormat::TiffSequence,
|
||||
OutputFormat::ExrSequence,
|
||||
OutputFormat::Gif,
|
||||
OutputFormat::Wav,
|
||||
OutputFormat::Aiff,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -158,6 +167,9 @@ impl OutputFormat {
|
||||
OutputFormat::TiffSequence => "TIFF Sequence",
|
||||
OutputFormat::ExrSequence => "OpenEXR Sequence",
|
||||
OutputFormat::Gif => "Animated GIF",
|
||||
OutputFormat::WebM => "WebM (VP9 + Opus)",
|
||||
OutputFormat::Wav => "WAV",
|
||||
OutputFormat::Aiff => "AIFF",
|
||||
}
|
||||
}
|
||||
pub fn extension(self) -> &'static str {
|
||||
@@ -169,19 +181,26 @@ impl OutputFormat {
|
||||
OutputFormat::TiffSequence => "tif",
|
||||
OutputFormat::ExrSequence => "exr",
|
||||
OutputFormat::Gif => "gif",
|
||||
OutputFormat::WebM => "webm",
|
||||
OutputFormat::Wav => "wav",
|
||||
OutputFormat::Aiff => "aif",
|
||||
}
|
||||
}
|
||||
pub fn is_sequence(self) -> bool {
|
||||
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
|
||||
}
|
||||
pub fn is_movie(self) -> bool {
|
||||
matches!(self, OutputFormat::H264 | OutputFormat::ProRes)
|
||||
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::WebM)
|
||||
}
|
||||
/// Audio-only outputs (no video is rendered).
|
||||
pub fn is_audio_only(self) -> bool {
|
||||
matches!(self, OutputFormat::Wav | OutputFormat::Aiff)
|
||||
}
|
||||
pub fn supports_alpha(self) -> bool {
|
||||
!matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence)
|
||||
!matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence | OutputFormat::Wav | OutputFormat::Aiff)
|
||||
}
|
||||
pub fn supports_audio(self) -> bool {
|
||||
self.is_movie()
|
||||
self.is_movie() || self.is_audio_only()
|
||||
}
|
||||
/// The written frame size for a rendered size: H.264 needs even dimensions (rounded down,
|
||||
/// at least 2).
|
||||
@@ -201,6 +220,9 @@ impl OutputFormat {
|
||||
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
|
||||
"exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence,
|
||||
"gif" | "animatedgif" => OutputFormat::Gif,
|
||||
"webm" | "vp9" | "webmvp9opus" => OutputFormat::WebM,
|
||||
"wav" | "wave" => OutputFormat::Wav,
|
||||
"aif" | "aiff" | "aifc" => OutputFormat::Aiff,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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)).
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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('?')
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"] }
|
||||
effectcraft-model = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
//! 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.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
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,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -56,6 +57,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`]).
|
||||
@@ -387,7 +398,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);
|
||||
@@ -677,6 +693,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 };
|
||||
|
||||
@@ -51,6 +51,7 @@ fn audio_footage() -> Footage {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
layer: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ fn footage(profile: Option<ColorSpace>) -> Footage {
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: profile,
|
||||
layer: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
@@ -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`).
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+15
@@ -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 |
@@ -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.
|
||||
+26
@@ -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 |
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -324,6 +324,7 @@ impl EffectcraftApp {
|
||||
layer_cache: self.session.layer_cache.clone(),
|
||||
gpu: self.gpu.clone(),
|
||||
gpu_display: false,
|
||||
disk: self.session.disk_cache.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,13 +422,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,
|
||||
@@ -435,7 +434,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 {
|
||||
|
||||
@@ -802,7 +802,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", "gltf", "glb", "obj",
|
||||
"ogg", "opus", "svg", "psd", "psb", "gltf", "glb", "obj",
|
||||
]);
|
||||
match (paths.is_empty(), key) {
|
||||
(true, _) => None,
|
||||
@@ -825,6 +825,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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -108,7 +108,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat]) -> Vec<(String,
|
||||
v.push((mark(o.bitrate_kbps == kbps, &format!("Bitrate: {} Mbps", kbps / 1000)), json!({"bitrate": kbps})));
|
||||
}
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
OutputFormat::JpegSequence | OutputFormat::WebM => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for q in [60u8, 80, 90, 100] {
|
||||
v.push((mark(o.quality == q, &format!("Quality: {q}")), json!({"quality": q})));
|
||||
|
||||
@@ -849,6 +849,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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "effectcraft-vp9enc"
|
||||
description = "VP9 intra-frame encoder (profile 0, 8-bit 4:2:0) for WebM export, from the VP9 Bitstream & Decoding Process Specification"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,15 @@
|
||||
# effectcraft-vp9enc
|
||||
|
||||
A small VP9 encoder for WebM export: every frame is a key frame (profile 0, 8-bit 4:2:0,
|
||||
BT.709), coded with 8×8 intra blocks (DC / V / H / TM prediction chosen per block), 4×4
|
||||
DCT/ADST transforms with default probabilities, or the Walsh–Hadamard transform at quality 100
|
||||
(mathematically lossless).
|
||||
|
||||
Written from the **VP9 Bitstream & Decoding Process Specification, version 0.6 (31 March
|
||||
2016)**: the encoder emits the uncompressed and compressed headers (§6.2, §6.3), partition, mode
|
||||
info and token syntax (§6.4) and reconstructs with the specification's intra prediction (§8.5.1)
|
||||
and integer inverse transforms (§8.7) so it stays in step with any conformant decoder. The
|
||||
boolean encoder is the inverse of the §9.2 decoder in the form given in RFC 6386 §7.3. Spec
|
||||
tables (§10) were transcribed from FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR
|
||||
Apache-2.0), which is also the decoder the tests round-trip through; ffmpeg is used only as an
|
||||
external test oracle. No libvpx or other third-party encoder/decoder source was consulted.
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Boolean (arithmetic) encoder: the inverse of the VP9 boolean decoder (spec §9.2), written as
|
||||
//! the classic carry-propagating range coder described in RFC 6386 §7.3 (VP8 and VP9 share the
|
||||
//! bool coder).
|
||||
|
||||
pub struct BoolEncoder {
|
||||
out: Vec<u8>,
|
||||
range: u32,
|
||||
bottom: u32,
|
||||
bit_count: i32,
|
||||
}
|
||||
|
||||
impl Default for BoolEncoder {
|
||||
fn default() -> Self {
|
||||
BoolEncoder::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BoolEncoder {
|
||||
pub fn new() -> Self {
|
||||
BoolEncoder { out: Vec::new(), range: 255, bottom: 0, bit_count: 24 }
|
||||
}
|
||||
|
||||
fn add_one(&mut self) {
|
||||
let mut i = self.out.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
if self.out[i] == 255 {
|
||||
self.out[i] = 0;
|
||||
} else {
|
||||
self.out[i] += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bit` with probability `prob`/256 of it being 0.
|
||||
#[inline]
|
||||
pub fn write(&mut self, bit: bool, prob: u8) {
|
||||
let split = 1 + (((self.range - 1) * prob as u32) >> 8);
|
||||
if bit {
|
||||
self.bottom = self.bottom.wrapping_add(split);
|
||||
self.range -= split;
|
||||
} else {
|
||||
self.range = split;
|
||||
}
|
||||
while self.range < 128 {
|
||||
self.range <<= 1;
|
||||
if self.bottom & (1 << 31) != 0 {
|
||||
self.add_one();
|
||||
}
|
||||
self.bottom <<= 1;
|
||||
self.bit_count -= 1;
|
||||
if self.bit_count == 0 {
|
||||
self.out.push((self.bottom >> 24) as u8);
|
||||
self.bottom &= (1 << 24) - 1;
|
||||
self.bit_count = 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn literal(&mut self, v: u32, bits: u32) {
|
||||
for i in (0..bits).rev() {
|
||||
self.write((v >> i) & 1 != 0, 128);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `value` with a tree (leaves stored negated, as in the decoder's tables).
|
||||
pub fn tree(&mut self, tree: &[i8], probs: &[u8], value: u8) {
|
||||
// Find the path to the leaf.
|
||||
fn path(tree: &[i8], node: usize, value: u8, out: &mut Vec<(usize, bool)>) -> bool {
|
||||
for b in 0..2 {
|
||||
let t = tree[node + b];
|
||||
out.push((node >> 1, b == 1));
|
||||
if t <= 0 {
|
||||
if (-t) as u8 == value {
|
||||
return true;
|
||||
}
|
||||
} else if path(tree, t as usize, value, out) {
|
||||
return true;
|
||||
}
|
||||
out.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut p = Vec::with_capacity(8);
|
||||
path(tree, 0, value, &mut p);
|
||||
for (i, bit) in p {
|
||||
self.write(bit, probs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush (RFC 6386 §7.3) and return the bytes.
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
let mut c = self.bit_count;
|
||||
let mut v = self.bottom;
|
||||
if v & (1u32 << (32 - c)) != 0 {
|
||||
self.add_one();
|
||||
}
|
||||
v <<= c & 7;
|
||||
c >>= 3;
|
||||
while {
|
||||
c -= 1;
|
||||
c >= 0
|
||||
} {
|
||||
v <<= 8;
|
||||
}
|
||||
for _ in 0..4 {
|
||||
self.out.push((v >> 24) as u8);
|
||||
v <<= 8;
|
||||
}
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A straightforward decoder per VP9 spec §9.2.
|
||||
struct Dec<'a> {
|
||||
d: &'a [u8],
|
||||
pos: usize,
|
||||
value: u64,
|
||||
bits: i32,
|
||||
range: u32,
|
||||
}
|
||||
impl<'a> Dec<'a> {
|
||||
fn new(d: &'a [u8]) -> Self {
|
||||
let mut x = Dec { d, pos: 0, value: 0, bits: 0, range: 255 };
|
||||
x.fill();
|
||||
x
|
||||
}
|
||||
fn fill(&mut self) {
|
||||
while self.bits <= 48 {
|
||||
let b = if self.pos < self.d.len() { self.d[self.pos] } else { 0 };
|
||||
self.pos += 1;
|
||||
self.value |= (b as u64) << (56 - self.bits);
|
||||
self.bits += 8;
|
||||
}
|
||||
}
|
||||
fn read(&mut self, p: u8) -> bool {
|
||||
let split = 1 + (((self.range - 1) * p as u32) >> 8);
|
||||
let big = (split as u64) << 56;
|
||||
let bit = if self.value >= big {
|
||||
self.range -= split;
|
||||
self.value -= big;
|
||||
true
|
||||
} else {
|
||||
self.range = split;
|
||||
false
|
||||
};
|
||||
while self.range < 128 {
|
||||
self.range <<= 1;
|
||||
self.value <<= 1;
|
||||
self.bits -= 1;
|
||||
}
|
||||
self.fill();
|
||||
bit
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let mut e = BoolEncoder::new();
|
||||
let mut seq = vec![];
|
||||
let mut s = 12345u32;
|
||||
for i in 0..20000 {
|
||||
s = s.wrapping_mul(1103515245).wrapping_add(12345);
|
||||
let p = ((s >> 8) % 255 + 1) as u8;
|
||||
let bit = (s >> 20) % 256 >= p as u32 || i % 97 == 0;
|
||||
seq.push((bit, p));
|
||||
e.write(bit, p);
|
||||
}
|
||||
let d = e.finish();
|
||||
let mut dec = Dec::new(&d);
|
||||
for (i, (b, p)) in seq.into_iter().enumerate() {
|
||||
assert_eq!(dec.read(p), b, "bit {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_ones_carry() {
|
||||
let mut e = BoolEncoder::new();
|
||||
for _ in 0..5000 {
|
||||
e.write(true, 1);
|
||||
}
|
||||
let d = e.finish();
|
||||
let mut dec = Dec::new(&d);
|
||||
for _ in 0..5000 {
|
||||
assert!(dec.read(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
//! A VP9 intra-frame encoder (profile 0, 8-bit 4:2:0), written from the *VP9 Bitstream &
|
||||
//! Decoding Process Specification* v0.6 by mirroring its decoding process in reverse (see
|
||||
//! README.md). Every frame is a key frame: one tile row (tile columns as the frame width
|
||||
//! requires), 64×64 superblocks split to 8×8 blocks, 4×4 transforms (the Walsh–Hadamard
|
||||
//! transform at quality 100, which is lossless), intra modes DC / V / H / TM chosen per block by
|
||||
//! prediction error, default probabilities (no probability updates). The encoder reconstructs
|
||||
//! exactly what a decoder reconstructs (the specification's integer inverse transforms and
|
||||
//! intra edge rules), so prediction never drifts.
|
||||
|
||||
mod bool;
|
||||
mod tables;
|
||||
|
||||
use bool::BoolEncoder;
|
||||
use tables::*;
|
||||
|
||||
const DC_PRED: u8 = 0;
|
||||
const V_PRED: u8 = 1;
|
||||
const H_PRED: u8 = 2;
|
||||
const TM_PRED: u8 = 9;
|
||||
const MODES: [u8; 4] = [DC_PRED, V_PRED, H_PRED, TM_PRED];
|
||||
|
||||
const DCT_DCT: u8 = 0;
|
||||
const ADST_DCT: u8 = 1;
|
||||
const DCT_ADST: u8 = 2;
|
||||
const ADST_ADST: u8 = 3;
|
||||
|
||||
/// mode2txfm_map for the modes used here.
|
||||
fn mode_tx_type(mode: u8) -> u8 {
|
||||
match mode {
|
||||
V_PRED => ADST_DCT,
|
||||
H_PRED => DCT_ADST,
|
||||
TM_PRED => ADST_ADST,
|
||||
_ => DCT_DCT,
|
||||
}
|
||||
}
|
||||
|
||||
const PARTITION_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3];
|
||||
const INTRA_MODE_TREE: [i8; 18] = [0, 2, -9, 4, -1, 6, 8, 12, -2, 10, -4, -5, -3, 14, -8, 16, -6, -7];
|
||||
|
||||
/// extra_bits[token] = (cat, numExtra, base).
|
||||
const EXTRA_BITS: [(u8, u8, u16); 11] =
|
||||
[(0, 0, 0), (0, 0, 1), (0, 0, 2), (0, 0, 3), (0, 0, 4), (1, 1, 5), (2, 2, 7), (3, 3, 11), (4, 4, 19), (5, 5, 35), (6, 14, 67)];
|
||||
const CAT_PROBS: [&[u8]; 7] = [
|
||||
&[],
|
||||
&[159],
|
||||
&[165, 145],
|
||||
&[173, 148, 140],
|
||||
&[176, 155, 140, 135],
|
||||
&[180, 157, 141, 134, 130],
|
||||
&[254, 254, 254, 252, 249, 243, 230, 196, 177, 153, 140, 133, 130, 129],
|
||||
];
|
||||
|
||||
/// Encoder settings.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EncoderConfig {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// 1..=100; 100 is lossless.
|
||||
pub quality: u8,
|
||||
/// Full-range (0–255) rather than limited (16–235) YUV; only signalled.
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
/// Probabilities of the full token tree (9.3.2) for model probability `p`.
|
||||
fn pareto(p: u8) -> [u8; 8] {
|
||||
let p = p.max(1) as usize;
|
||||
let x = (p - 1) / 2;
|
||||
let mut t = [0u8; 8];
|
||||
for n in 0..8 {
|
||||
t[n] = if p & 1 == 1 { PARETO_TABLE[x * 8 + n] } else { ((PARETO_TABLE[x * 8 + n] as u32 + PARETO_TABLE[(x + 1) * 8 + n] as u32) >> 1) as u8 };
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn scan(tx_type: u8) -> &'static [u16; 16] {
|
||||
match tx_type {
|
||||
ADST_DCT => &ROW_SCAN_4X4,
|
||||
DCT_ADST => &COL_SCAN_4X4,
|
||||
_ => &DEFAULT_SCAN_4X4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Coefficient context neighbours (9.3.2) of each scan position.
|
||||
fn neighbors(tx_type: u8) -> [(usize, usize); 16] {
|
||||
let sc = scan(tx_type);
|
||||
let mut nb = [(0, 0); 16];
|
||||
for (c, &pos) in sc.iter().enumerate().skip(1) {
|
||||
let pos = pos as usize;
|
||||
let (i, j) = (pos / 4, pos % 4);
|
||||
nb[c] = if i > 0 && j > 0 {
|
||||
let a = (i - 1) * 4 + j;
|
||||
let b = i * 4 + j - 1;
|
||||
match tx_type {
|
||||
DCT_ADST => (a, a),
|
||||
ADST_DCT => (b, b),
|
||||
_ => (a, b),
|
||||
}
|
||||
} else if i > 0 {
|
||||
((i - 1) * 4 + j, (i - 1) * 4 + j)
|
||||
} else {
|
||||
(j - 1, j - 1)
|
||||
};
|
||||
}
|
||||
nb
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- transforms (8.7)
|
||||
|
||||
fn r14(v: i32) -> i32 {
|
||||
(v + (1 << 13)) >> 14
|
||||
}
|
||||
|
||||
fn idct4(x: [i32; 4]) -> [i32; 4] {
|
||||
let v0 = r14(x[0] * 11585 - x[2] * 11585);
|
||||
let v1 = r14(x[0] * 11585 + x[2] * 11585);
|
||||
let v2 = r14(x[1] * 6270 - x[3] * 15137);
|
||||
let v3 = r14(x[1] * 15137 + x[3] * 6270);
|
||||
[v1 + v3, v0 + v2, v0 - v2, v1 - v3]
|
||||
}
|
||||
|
||||
fn iadst4(x: [i32; 4]) -> [i32; 4] {
|
||||
let (s19, s29, s39, s49) = (5283i64, 9929i64, 13377i64, 15212i64);
|
||||
let x: [i64; 4] = x.map(|v| v as i64);
|
||||
let s0 = s19 * x[0];
|
||||
let s1 = s29 * x[0];
|
||||
let s2 = s39 * x[1];
|
||||
let s3 = s49 * x[2];
|
||||
let s4 = s19 * x[2];
|
||||
let s5 = s29 * x[3];
|
||||
let s6 = s49 * x[3];
|
||||
let s7 = s39 * (x[0] - x[2] + x[3]);
|
||||
let x0 = s0 + s3 + s5;
|
||||
let x1 = s1 - s4 - s6;
|
||||
let x2 = s7;
|
||||
let x3 = s2;
|
||||
let r = |v: i64| ((v + (1 << 13)) >> 14) as i32;
|
||||
[r(x0 + x3), r(x1 + x3), r(x2), r(x0 + x1 - x3)]
|
||||
}
|
||||
|
||||
/// The decoder's 4×4 inverse transform of dequantised coefficients (row-major) to a residual.
|
||||
fn inverse4(coefs: &[i32; 16], tx_type: u8) -> [i32; 16] {
|
||||
let row_adst = matches!(tx_type, DCT_ADST | ADST_ADST);
|
||||
let col_adst = matches!(tx_type, ADST_DCT | ADST_ADST);
|
||||
let mut t = [[0i32; 4]; 4];
|
||||
for i in 0..4 {
|
||||
let r = [coefs[i * 4], coefs[i * 4 + 1], coefs[i * 4 + 2], coefs[i * 4 + 3]];
|
||||
t[i] = if row_adst { iadst4(r) } else { idct4(r) };
|
||||
}
|
||||
let mut out = [0i32; 16];
|
||||
for j in 0..4 {
|
||||
let c = [t[0][j], t[1][j], t[2][j], t[3][j]];
|
||||
let c = if col_adst { iadst4(c) } else { idct4(c) };
|
||||
for i in 0..4 {
|
||||
out[i * 4 + j] = (c[i] + 8) >> 4;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse Walsh–Hadamard (8.7.1.10) on one line.
|
||||
fn iwht(x: [i32; 4], shift: u32) -> [i32; 4] {
|
||||
let mut a = x[0] >> shift;
|
||||
let mut c = x[1] >> shift;
|
||||
let mut d = x[2] >> shift;
|
||||
let mut b = x[3] >> shift;
|
||||
a += c;
|
||||
d -= b;
|
||||
let e = (a - d) >> 1;
|
||||
b = e - b;
|
||||
c = e - c;
|
||||
a -= b;
|
||||
d += c;
|
||||
[a, b, c, d]
|
||||
}
|
||||
|
||||
/// The exact inverse of [`iwht`] (with shift 0): inputs that produce outputs `y`.
|
||||
fn fwht(y: [i32; 4]) -> [i32; 4] {
|
||||
let (aa, bb, cc, dd) = (y[0], y[1], y[2], y[3]);
|
||||
let d1 = dd - cc;
|
||||
let a1 = aa + bb;
|
||||
let e = (a1 - d1) >> 1;
|
||||
let b0 = e - bb;
|
||||
let c0 = e - cc;
|
||||
let a0 = a1 - c0;
|
||||
let d0 = d1 + b0;
|
||||
[a0, c0, d0, b0]
|
||||
}
|
||||
|
||||
fn inverse_wht(coefs: &[i32; 16]) -> [i32; 16] {
|
||||
let mut t = [[0i32; 4]; 4];
|
||||
for i in 0..4 {
|
||||
t[i] = iwht([coefs[i * 4], coefs[i * 4 + 1], coefs[i * 4 + 2], coefs[i * 4 + 3]], 2);
|
||||
}
|
||||
let mut out = [0i32; 16];
|
||||
for j in 0..4 {
|
||||
let r = iwht([t[0][j], t[1][j], t[2][j], t[3][j]], 0);
|
||||
for i in 0..4 {
|
||||
out[i * 4 + j] = r[i];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lossless forward transform: levels (dequantised by 4) whose inverse is exactly `res`.
|
||||
fn forward_wht(res: &[i32; 16]) -> [i32; 16] {
|
||||
let mut t = [[0i32; 4]; 4];
|
||||
for j in 0..4 {
|
||||
let col = fwht([res[j], res[4 + j], res[8 + j], res[12 + j]]);
|
||||
for i in 0..4 {
|
||||
t[i][j] = col[i];
|
||||
}
|
||||
}
|
||||
let mut lv = [0i32; 16];
|
||||
for i in 0..4 {
|
||||
let r = fwht(t[i]);
|
||||
lv[i * 4..i * 4 + 4].copy_from_slice(&r);
|
||||
}
|
||||
lv
|
||||
}
|
||||
|
||||
/// Forward transform matrices: the inverses of the decoder's (linear part of the) inverse
|
||||
/// transforms, so `inverse4(forward(res)) ≈ res`.
|
||||
fn forward_matrices() -> &'static [[[f64; 16]; 16]; 4] {
|
||||
use std::sync::OnceLock;
|
||||
static M: OnceLock<[[[f64; 16]; 16]; 4]> = OnceLock::new();
|
||||
M.get_or_init(|| {
|
||||
let mut out = [[[0.0; 16]; 16]; 4];
|
||||
for (ty, o) in out.iter_mut().enumerate() {
|
||||
// Columns of the inverse: response to a scaled unit coefficient.
|
||||
let k = 1 << 16;
|
||||
let mut inv = [[0.0f64; 16]; 16];
|
||||
for c in 0..16 {
|
||||
let mut e = [0i32; 16];
|
||||
e[c] = k;
|
||||
let r = inverse4(&e, ty as u8);
|
||||
for p in 0..16 {
|
||||
inv[p][c] = r[p] as f64 / k as f64;
|
||||
}
|
||||
}
|
||||
*o = invert16(inv);
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
fn invert16(m: [[f64; 16]; 16]) -> [[f64; 16]; 16] {
|
||||
let mut a = m;
|
||||
let mut inv = [[0.0; 16]; 16];
|
||||
for (i, r) in inv.iter_mut().enumerate() {
|
||||
r[i] = 1.0;
|
||||
}
|
||||
for col in 0..16 {
|
||||
let piv = (col..16).max_by(|&x, &y| a[x][col].abs().total_cmp(&a[y][col].abs())).unwrap_or(col);
|
||||
a.swap(col, piv);
|
||||
inv.swap(col, piv);
|
||||
let d = a[col][col];
|
||||
for j in 0..16 {
|
||||
a[col][j] /= d;
|
||||
inv[col][j] /= d;
|
||||
}
|
||||
for r in 0..16 {
|
||||
if r != col {
|
||||
let f = a[r][col];
|
||||
if f != 0.0 {
|
||||
for j in 0..16 {
|
||||
a[r][j] -= f * a[col][j];
|
||||
inv[r][j] -= f * inv[col][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inv
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- planes and prediction
|
||||
|
||||
struct Plane {
|
||||
w: usize,
|
||||
/// Last valid column / row for prediction edges (the 8-aligned mode-info grid).
|
||||
max_x: usize,
|
||||
max_y: usize,
|
||||
src: Vec<u8>,
|
||||
rec: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
fn new(data: &[u8], vw: usize, vh: usize, w: usize, h: usize, max_x: usize, max_y: usize) -> Plane {
|
||||
let mut src = vec![0u8; w * h];
|
||||
for y in 0..h {
|
||||
let sy = y.min(vh.saturating_sub(1));
|
||||
for x in 0..w {
|
||||
let sx = x.min(vw.saturating_sub(1));
|
||||
src[y * w + x] = data.get(sy * vw + sx).copied().unwrap_or(128);
|
||||
}
|
||||
}
|
||||
Plane { w, max_x, max_y, src, rec: vec![0; w * h] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Intra prediction (8.5.1) of a square block for DC / V / H / TM.
|
||||
fn predict(p: &Plane, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> [i32; 64] {
|
||||
let size = 1usize << log2;
|
||||
let base = 128i32;
|
||||
let buf = &p.rec;
|
||||
let w = p.w;
|
||||
let mut above = [0i32; 8];
|
||||
let mut left = [0i32; 8];
|
||||
if have_above {
|
||||
for i in 0..size {
|
||||
above[i] = buf[(y - 1) * w + (x + i).min(p.max_x)] as i32;
|
||||
}
|
||||
} else {
|
||||
above[..size].fill(base - 1);
|
||||
}
|
||||
let top_left = if have_above && have_left {
|
||||
buf[(y - 1) * w + (x - 1).min(p.max_x)] as i32
|
||||
} else if have_above {
|
||||
base + 1
|
||||
} else {
|
||||
base - 1
|
||||
};
|
||||
if have_left {
|
||||
for i in 0..size {
|
||||
left[i] = buf[(y + i).min(p.max_y) * w + x - 1] as i32;
|
||||
}
|
||||
} else {
|
||||
left[..size].fill(base + 1);
|
||||
}
|
||||
let mut out = [0i32; 64];
|
||||
for i in 0..size {
|
||||
for j in 0..size {
|
||||
out[i * size + j] = match mode {
|
||||
V_PRED => above[j],
|
||||
H_PRED => left[i],
|
||||
TM_PRED => (above[j] + left[i] - top_left).clamp(0, 255),
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
if mode == DC_PRED {
|
||||
let v = match (have_left, have_above) {
|
||||
(true, true) => (left[..size].iter().sum::<i32>() + above[..size].iter().sum::<i32>() + size as i32) >> (log2 + 1),
|
||||
(true, false) => (left[..size].iter().sum::<i32>() + (1 << (log2 - 1))) >> log2,
|
||||
(false, true) => (above[..size].iter().sum::<i32>() + (1 << (log2 - 1))) >> log2,
|
||||
(false, false) => base,
|
||||
};
|
||||
out[..size * size].fill(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the encoder
|
||||
|
||||
pub struct Vp9Encoder {
|
||||
cfg: EncoderConfig,
|
||||
qidx: u8,
|
||||
lossless: bool,
|
||||
}
|
||||
|
||||
/// Per-tile coding state.
|
||||
struct Tile<'a> {
|
||||
enc: &'a Vp9Encoder,
|
||||
bc: BoolEncoder,
|
||||
planes: &'a mut [Plane; 3],
|
||||
mi_cols: usize,
|
||||
mi_rows: usize,
|
||||
col_start: usize,
|
||||
above_nz: [Vec<u8>; 3],
|
||||
left_nz: [Vec<u8>; 3],
|
||||
above_part: Vec<u8>,
|
||||
left_part: Vec<u8>,
|
||||
/// y mode of every 8×8 block (for mode contexts).
|
||||
modes: &'a mut [u8],
|
||||
}
|
||||
|
||||
impl Vp9Encoder {
|
||||
pub fn new(cfg: EncoderConfig) -> Self {
|
||||
let q = cfg.quality.clamp(1, 100);
|
||||
let lossless = q >= 100;
|
||||
// Quality → base_q_idx: 99 ≈ 3, 90 ≈ 30, 50 ≈ 150, 1 ≈ 255.
|
||||
let qidx = if lossless { 0 } else { ((100 - q as i32) * 255 / 85).clamp(1, 255) as u8 };
|
||||
Vp9Encoder { cfg, qidx, lossless }
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &EncoderConfig {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
/// Encode one key frame from planar 8-bit 4:2:0 (`y`: width×height, `u`/`v`:
|
||||
/// ((w+1)/2)×((h+1)/2), tightly packed).
|
||||
pub fn encode_yuv420(&mut self, y: &[u8], u: &[u8], v: &[u8]) -> Vec<u8> {
|
||||
let (w, h) = (self.cfg.width.max(1) as usize, self.cfg.height.max(1) as usize);
|
||||
let mi_cols = w.div_ceil(8);
|
||||
let mi_rows = h.div_ceil(8);
|
||||
let sb_cols = mi_cols.div_ceil(8);
|
||||
let sb_rows = mi_rows.div_ceil(8);
|
||||
let (pw, ph) = (sb_cols * 64, sb_rows * 64);
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut planes = [
|
||||
Plane::new(y, w, h, pw, ph, mi_cols * 8 - 1, mi_rows * 8 - 1),
|
||||
Plane::new(u, cw, ch, pw / 2, ph / 2, mi_cols * 4 - 1, mi_rows * 4 - 1),
|
||||
Plane::new(v, cw, ch, pw / 2, ph / 2, mi_cols * 4 - 1, mi_rows * 4 - 1),
|
||||
];
|
||||
// Tile columns: as few as the width allows (tile_info, 6.2.13).
|
||||
let mut min_log2 = 0;
|
||||
while (64usize << min_log2) < sb_cols {
|
||||
min_log2 += 1;
|
||||
}
|
||||
let mut max_log2 = 1;
|
||||
while (sb_cols >> max_log2) >= 4 {
|
||||
max_log2 += 1;
|
||||
}
|
||||
max_log2 -= 1;
|
||||
let tile_cols = 1usize << min_log2;
|
||||
let mut modes = vec![0u8; mi_cols * mi_rows];
|
||||
let mut tiles = vec![];
|
||||
for t in 0..tile_cols {
|
||||
let start = (((t * sb_cols) >> min_log2) * 8).min(mi_cols);
|
||||
let end = ((((t + 1) * sb_cols) >> min_log2) * 8).min(mi_cols);
|
||||
let mut tile = Tile {
|
||||
enc: self,
|
||||
bc: BoolEncoder::new(),
|
||||
planes: &mut planes,
|
||||
mi_cols,
|
||||
mi_rows,
|
||||
col_start: start,
|
||||
above_nz: [vec![0; mi_cols * 2 + 16], vec![0; mi_cols * 2 + 16], vec![0; mi_cols * 2 + 16]],
|
||||
left_nz: [vec![0; 16], vec![0; 16], vec![0; 16]],
|
||||
above_part: vec![0; mi_cols + 8],
|
||||
left_part: vec![0; 8],
|
||||
modes: &mut modes,
|
||||
};
|
||||
tile.bc.write(false, 128);
|
||||
for r in (0..mi_rows).step_by(8) {
|
||||
for p in 0..3 {
|
||||
tile.left_nz[p].fill(0);
|
||||
}
|
||||
tile.left_part.fill(0);
|
||||
for c in (start..end).step_by(8) {
|
||||
tile.partition(r, c, 12);
|
||||
}
|
||||
}
|
||||
tiles.push(tile.bc.finish());
|
||||
}
|
||||
// Compressed header.
|
||||
let mut ch_enc = BoolEncoder::new();
|
||||
ch_enc.write(false, 128);
|
||||
if !self.lossless {
|
||||
ch_enc.literal(0, 2); // tx_mode ONLY_4X4
|
||||
}
|
||||
ch_enc.literal(0, 1); // no coefficient probability updates (TX_4X4)
|
||||
for _ in 0..3 {
|
||||
ch_enc.write(false, 252); // skip probabilities unchanged
|
||||
}
|
||||
let compressed = ch_enc.finish();
|
||||
// Uncompressed header.
|
||||
let mut bw = BitWriter::default();
|
||||
bw.put(2, 2); // frame_marker
|
||||
bw.put(0, 1); // profile low bit
|
||||
bw.put(0, 1); // profile high bit
|
||||
bw.put(0, 1); // show_existing_frame
|
||||
bw.put(0, 1); // frame_type = KEY_FRAME
|
||||
bw.put(1, 1); // show_frame
|
||||
bw.put(0, 1); // error_resilient_mode
|
||||
bw.put(0x49, 8);
|
||||
bw.put(0x83, 8);
|
||||
bw.put(0x42, 8);
|
||||
bw.put(2, 3); // color_space CS_BT_709
|
||||
bw.put(self.cfg.full_range as u32, 1);
|
||||
bw.put(w as u32 - 1, 16);
|
||||
bw.put(h as u32 - 1, 16);
|
||||
bw.put(0, 1); // render_and_frame_size_different
|
||||
bw.put(0, 1); // refresh_frame_context
|
||||
bw.put(1, 1); // frame_parallel_decoding_mode
|
||||
bw.put(0, 2); // frame_context_idx
|
||||
bw.put(0, 6); // loop_filter_level
|
||||
bw.put(0, 3); // sharpness
|
||||
bw.put(0, 1); // mode_ref_delta_enabled
|
||||
bw.put(self.qidx as u32, 8);
|
||||
bw.put(0, 1); // delta_q_y_dc
|
||||
bw.put(0, 1); // delta_q_uv_dc
|
||||
bw.put(0, 1); // delta_q_uv_ac
|
||||
bw.put(0, 1); // segmentation_enabled
|
||||
if min_log2 < max_log2 {
|
||||
bw.put(0, 1); // no extra tile columns
|
||||
}
|
||||
bw.put(0, 1); // tile_rows_log2 = 0
|
||||
bw.put(compressed.len() as u32, 16);
|
||||
let mut out = bw.finish();
|
||||
out.extend_from_slice(&compressed);
|
||||
let n = tiles.len();
|
||||
for (i, t) in tiles.into_iter().enumerate() {
|
||||
if i + 1 < n {
|
||||
out.extend_from_slice(&(t.len() as u32).to_be_bytes());
|
||||
}
|
||||
out.extend_from_slice(&t);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BitWriter {
|
||||
out: Vec<u8>,
|
||||
acc: u32,
|
||||
n: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
fn put(&mut self, v: u32, bits: u32) {
|
||||
for i in (0..bits).rev() {
|
||||
self.acc = (self.acc << 1) | ((v >> i) & 1);
|
||||
self.n += 1;
|
||||
if self.n == 8 {
|
||||
self.out.push(self.acc as u8);
|
||||
self.acc = 0;
|
||||
self.n = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
if self.n > 0 {
|
||||
self.out.push((self.acc << (8 - self.n)) as u8);
|
||||
}
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCK_8X8: u8 = 3;
|
||||
const NUM_8X8: [usize; 13] = [1, 1, 1, 1, 1, 2, 2, 2, 4, 4, 4, 8, 8];
|
||||
const MI_WIDTH_LOG2: [usize; 13] = [0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3];
|
||||
|
||||
impl Tile<'_> {
|
||||
fn partition(&mut self, r: usize, c: usize, bsize: u8) {
|
||||
if r >= self.mi_rows || c >= self.mi_cols {
|
||||
return;
|
||||
}
|
||||
let num8 = NUM_8X8[bsize as usize];
|
||||
let half = num8 >> 1;
|
||||
let has_rows = (r + half) < self.mi_rows;
|
||||
let has_cols = (c + half) < self.mi_cols;
|
||||
let bsl = MI_WIDTH_LOG2[bsize as usize];
|
||||
let boffset = 3 - bsl;
|
||||
let mut above = 0u8;
|
||||
let mut left = 0u8;
|
||||
for i in 0..num8 {
|
||||
above |= self.above_part[c + i];
|
||||
left |= self.left_part[(r & 7) + i];
|
||||
}
|
||||
let above = (above >> boffset) & 1;
|
||||
let left = (left >> boffset) & 1;
|
||||
let ctx = bsl * 4 + left as usize * 2 + above as usize;
|
||||
let probs = &KF_PARTITION_PROBS[ctx * 3..ctx * 3 + 3];
|
||||
if bsize == BLOCK_8X8 {
|
||||
// PARTITION_NONE.
|
||||
self.bc.write(false, probs[0]);
|
||||
self.block(r, c);
|
||||
self.above_part[c] = 15 >> 1;
|
||||
self.left_part[r & 7] = 15 >> 1;
|
||||
return;
|
||||
}
|
||||
// PARTITION_SPLIT.
|
||||
if has_rows && has_cols {
|
||||
self.bc.tree(&PARTITION_TREE, probs, 3);
|
||||
} else if has_cols {
|
||||
self.bc.write(true, probs[1]);
|
||||
} else if has_rows {
|
||||
self.bc.write(true, probs[2]);
|
||||
}
|
||||
let sub = bsize - 3;
|
||||
self.partition(r, c, sub);
|
||||
self.partition(r, c + half, sub);
|
||||
self.partition(r + half, c, sub);
|
||||
self.partition(r + half, c + half, sub);
|
||||
}
|
||||
|
||||
fn block(&mut self, r: usize, c: usize) {
|
||||
let avail_u = r > 0;
|
||||
let avail_l = c > self.col_start;
|
||||
// Mode decision on the 8×8 luma block and the 4×4 chroma blocks.
|
||||
let y_mode = self.choose(0, c * 8, r * 8, 3, avail_l, avail_u);
|
||||
let uv_mode = {
|
||||
let mut best = (i64::MAX, DC_PRED);
|
||||
for m in MODES {
|
||||
let cost: i64 = (1..3).map(|p| self.cost(p, c * 4, r * 4, 2, m, avail_l, avail_u)).sum();
|
||||
if cost < best.0 {
|
||||
best = (cost, m);
|
||||
}
|
||||
}
|
||||
best.1
|
||||
};
|
||||
// skip = 0 (context: neighbours' skip, always 0 here); tx_size implied (4×4).
|
||||
self.bc.write(false, DEFAULT_SKIP_PROB[0]);
|
||||
let above_mode = if avail_u { self.modes[(r - 1) * self.mi_cols + c] } else { DC_PRED };
|
||||
let left_mode = if avail_l { self.modes[r * self.mi_cols + c - 1] } else { DC_PRED };
|
||||
let o = (above_mode as usize * 10 + left_mode as usize) * 9;
|
||||
self.bc.tree(&INTRA_MODE_TREE, &KF_Y_MODE_PROBS[o..o + 9], y_mode);
|
||||
let o = y_mode as usize * 9;
|
||||
self.bc.tree(&INTRA_MODE_TREE, &KF_UV_MODE_PROBS[o..o + 9], uv_mode);
|
||||
self.modes[r * self.mi_cols + c] = y_mode;
|
||||
// Residual: luma 2×2 transform blocks, then one 4×4 block per chroma plane.
|
||||
for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||
let tx = if self.enc.lossless { DCT_DCT } else { mode_tx_type(y_mode) };
|
||||
self.tx_block(0, c * 8 + dx * 4, r * 8 + dy * 4, y_mode, tx, avail_l || dx > 0, avail_u || dy > 0);
|
||||
}
|
||||
for p in 1..3 {
|
||||
self.tx_block(p, c * 4, r * 4, uv_mode, DCT_DCT, avail_l, avail_u);
|
||||
}
|
||||
}
|
||||
|
||||
fn cost(&self, plane: usize, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> i64 {
|
||||
let p = &self.planes[plane];
|
||||
let pred = predict(p, x, y, log2, mode, have_left, have_above);
|
||||
let size = 1 << log2;
|
||||
let mut s = 0i64;
|
||||
for i in 0..size {
|
||||
for j in 0..size {
|
||||
s += (p.src[(y + i) * p.w + x + j] as i32 - pred[i * size + j]).abs() as i64;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn choose(&self, plane: usize, x: usize, y: usize, log2: usize, have_left: bool, have_above: bool) -> u8 {
|
||||
let mut best = (i64::MAX, DC_PRED);
|
||||
for m in MODES {
|
||||
let cost = self.cost(plane, x, y, log2, m, have_left, have_above);
|
||||
if cost < best.0 {
|
||||
best = (cost, m);
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tx_block(&mut self, plane: usize, x: usize, y: usize, mode: u8, tx_type: u8, have_left: bool, have_above: bool) {
|
||||
let ss = (plane > 0) as usize;
|
||||
let max_x = (self.mi_cols * 8) >> ss;
|
||||
let max_y = (self.mi_rows * 8) >> ss;
|
||||
let (x4, y4) = (x >> 2, y >> 2);
|
||||
let ly = y4 & (15 >> ss);
|
||||
if x >= max_x || y >= max_y {
|
||||
self.above_nz[plane][x4] = 0;
|
||||
self.left_nz[plane][ly] = 0;
|
||||
return;
|
||||
}
|
||||
let p = &self.planes[plane];
|
||||
let pred = predict(p, x, y, 2, mode, have_left, have_above);
|
||||
let mut res = [0i32; 16];
|
||||
for i in 0..4 {
|
||||
for j in 0..4 {
|
||||
res[i * 4 + j] = p.src[(y + i) * p.w + x + j] as i32 - pred[i * 4 + j];
|
||||
}
|
||||
}
|
||||
let (dcq, acq) = (DC_QLOOKUP[self.enc.qidx as usize], AC_QLOOKUP[self.enc.qidx as usize]);
|
||||
// Quantised levels (raster order).
|
||||
let levels: [i32; 16] = if self.enc.lossless {
|
||||
forward_wht(&res)
|
||||
} else {
|
||||
let f = &forward_matrices()[tx_type as usize];
|
||||
let mut lv = [0i32; 16];
|
||||
for k in 0..16 {
|
||||
let coef: f64 = (0..16).map(|q| f[k][q] * res[q] as f64).sum();
|
||||
let qs = if k == 0 { dcq } else { acq } as f64;
|
||||
let a = coef.abs() / qs;
|
||||
// Rounding with a small dead zone for AC coefficients.
|
||||
let l = if k == 0 { (a + 0.5).floor() } else { (a + 0.38).floor() };
|
||||
lv[k] = if coef < 0.0 { -(l as i32) } else { l as i32 };
|
||||
}
|
||||
lv
|
||||
};
|
||||
let sc = scan(tx_type);
|
||||
let eob = (0..16).rev().find(|&i| levels[sc[i] as usize] != 0).map_or(0, |i| i + 1);
|
||||
// Tokens.
|
||||
let ctx0 = (self.above_nz[plane][x4] + self.left_nz[plane][ly]) as usize;
|
||||
self.tokens(plane, tx_type, &levels, eob, ctx0);
|
||||
// Reconstruct exactly as the decoder does.
|
||||
let mut deq = [0i32; 16];
|
||||
for k in 0..16 {
|
||||
deq[k] = levels[k] * if k == 0 { dcq } else { acq };
|
||||
}
|
||||
let resid = if eob == 0 {
|
||||
[0i32; 16]
|
||||
} else if self.enc.lossless {
|
||||
inverse_wht(&deq)
|
||||
} else {
|
||||
inverse4(&deq, tx_type)
|
||||
};
|
||||
let p = &mut self.planes[plane];
|
||||
for i in 0..4 {
|
||||
for j in 0..4 {
|
||||
p.rec[(y + i) * p.w + x + j] = (pred[i * 4 + j] + resid[i * 4 + j]).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
let nz = (eob > 0) as u8;
|
||||
self.above_nz[plane][x4] = nz;
|
||||
self.left_nz[plane][ly] = nz;
|
||||
}
|
||||
|
||||
fn tokens(&mut self, plane: usize, tx_type: u8, levels: &[i32; 16], eob: usize, ctx0: usize) {
|
||||
let sc = scan(tx_type);
|
||||
let nb = neighbors(tx_type);
|
||||
let ptype = (plane > 0) as usize;
|
||||
// coef_probs[TX_4X4][ptype][intra]: 6 bands × 6 contexts × 3.
|
||||
let base = (ptype * 2) * 6 * 6 * 3;
|
||||
let mut tc = [0u8; 16];
|
||||
let mut check_eob = true;
|
||||
let mut c = 0;
|
||||
while c < 16 {
|
||||
let pos = sc[c] as usize;
|
||||
let band = COEFBAND_4X4[c] as usize;
|
||||
let ctx = if c == 0 { ctx0 } else { ((1 + tc[nb[c].0] + tc[nb[c].1]) >> 1) as usize };
|
||||
let o = base + (band * 6 + ctx) * 3;
|
||||
let p = [DEFAULT_COEF_PROBS[o], DEFAULT_COEF_PROBS[o + 1], DEFAULT_COEF_PROBS[o + 2]];
|
||||
if check_eob {
|
||||
let more = c < eob;
|
||||
self.bc.write(more, p[0]);
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let v = levels[pos].unsigned_abs();
|
||||
if v == 0 {
|
||||
self.bc.write(false, p[1]);
|
||||
tc[pos] = 0;
|
||||
check_eob = false;
|
||||
c += 1;
|
||||
continue;
|
||||
}
|
||||
self.bc.write(true, p[1]);
|
||||
check_eob = true;
|
||||
let token = match v {
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
3 => 3,
|
||||
4 => 4,
|
||||
5..=6 => 5,
|
||||
7..=10 => 6,
|
||||
11..=18 => 7,
|
||||
19..=34 => 8,
|
||||
35..=66 => 9,
|
||||
_ => 10,
|
||||
};
|
||||
if token == 1 {
|
||||
self.bc.write(false, p[2]);
|
||||
} else {
|
||||
self.bc.write(true, p[2]);
|
||||
let pp = pareto(p[2]);
|
||||
let path: &[(usize, bool)] = match token {
|
||||
2 => &[(0, false), (1, false)],
|
||||
3 => &[(0, false), (1, true), (2, false)],
|
||||
4 => &[(0, false), (1, true), (2, true)],
|
||||
5 => &[(0, true), (3, false), (4, false)],
|
||||
6 => &[(0, true), (3, false), (4, true)],
|
||||
7 => &[(0, true), (3, true), (5, false), (6, false)],
|
||||
8 => &[(0, true), (3, true), (5, false), (6, true)],
|
||||
9 => &[(0, true), (3, true), (5, true), (7, false)],
|
||||
_ => &[(0, true), (3, true), (5, true), (7, true)],
|
||||
};
|
||||
for &(n, b) in path {
|
||||
self.bc.write(b, pp[n]);
|
||||
}
|
||||
let (cat, num_extra, base_v) = EXTRA_BITS[token];
|
||||
let extra = (v - base_v as u32).min((1 << num_extra) - 1);
|
||||
let cp = CAT_PROBS[cat as usize];
|
||||
for e in 0..num_extra as usize {
|
||||
self.bc.write((extra >> (num_extra as usize - 1 - e)) & 1 != 0, cp[e]);
|
||||
}
|
||||
}
|
||||
tc[pos] = ENERGY_CLASS[token];
|
||||
self.bc.write(levels[pos] < 0, 128);
|
||||
c += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- colour conversion
|
||||
|
||||
/// BT.709 RGB(A) 8-bit → planar YUV 4:2:0 (limited or full range).
|
||||
pub fn rgba_to_yuv420(rgba: &[u8], w: u32, h: u32, full_range: bool) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut y = vec![0u8; w * h];
|
||||
let mut u = vec![0u8; cw * ch];
|
||||
let mut v = vec![0u8; cw * ch];
|
||||
let (ys, yo, cs) = if full_range { (255.0, 0.0, 255.0) } else { (219.0, 16.0, 224.0) };
|
||||
let px = |x: usize, yy: usize| -> [f32; 3] {
|
||||
let i = (yy.min(h - 1) * w + x.min(w - 1)) * 4;
|
||||
[rgba[i] as f32 / 255.0, rgba[i + 1] as f32 / 255.0, rgba[i + 2] as f32 / 255.0]
|
||||
};
|
||||
let luma = |c: [f32; 3]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
||||
for yy in 0..h {
|
||||
for x in 0..w {
|
||||
y[yy * w + x] = (luma(px(x, yy)) * ys + yo).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let mut acc = [0.0f32; 3];
|
||||
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let c = px(cx * 2 + dx, cy * 2 + dy);
|
||||
for k in 0..3 {
|
||||
acc[k] += c[k] / 4.0;
|
||||
}
|
||||
}
|
||||
let l = luma(acc);
|
||||
let cb = (acc[2] - l) / 1.8556;
|
||||
let cr = (acc[0] - l) / 1.5748;
|
||||
u[cy * cw + cx] = (cb * cs + 128.0).round().clamp(0.0, 255.0) as u8;
|
||||
v[cy * cw + cx] = (cr * cs + 128.0).round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
(y, u, v)
|
||||
}
|
||||
|
||||
/// The alpha channel as a luma plane (full range) with neutral chroma — the second VP9 stream of
|
||||
/// WebM alpha.
|
||||
pub fn alpha_to_yuv420(rgba: &[u8], w: u32, h: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let y: Vec<u8> = rgba.chunks_exact(4).take(w * h).map(|p| p[3]).collect();
|
||||
(y, vec![128; cw * ch], vec![128; cw * ch])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wht_is_exactly_invertible() {
|
||||
let mut s = 7u32;
|
||||
for _ in 0..2000 {
|
||||
let mut r = [0i32; 16];
|
||||
for v in &mut r {
|
||||
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
*v = (s >> 16) as i32 % 511 - 255;
|
||||
}
|
||||
let lv = forward_wht(&r);
|
||||
let deq = lv.map(|l| l * 4);
|
||||
assert_eq!(inverse_wht(&deq), r);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_transforms_invert_the_decoder() {
|
||||
for ty in 0..4u8 {
|
||||
let f = &forward_matrices()[ty as usize];
|
||||
let r: [i32; 16] = std::array::from_fn(|i| (i as i32 * 37 % 61) - 30);
|
||||
let c: [i32; 16] = std::array::from_fn(|k| (0..16).map(|q| f[k][q] * r[q] as f64).sum::<f64>().round() as i32);
|
||||
let back = inverse4(&c, ty);
|
||||
for i in 0..16 {
|
||||
assert!((back[i] - r[i]).abs() <= 1, "type {ty}: {back:?} vs {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Tables from the VP9 Bitstream & Decoding Process Specification v0.6 (section 10), as
|
||||
//! transcribed in FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR Apache-2.0, the same
|
||||
//! licence as this crate).
|
||||
|
||||
#![allow(clippy::unreadable_literal)]
|
||||
|
||||
pub const DC_QLOOKUP: [i32; 256] = [
|
||||
4, 8, 8, 9, 10, 11, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23, 24, 25, 26, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 35, 36, 37, 38, 38, 39, 40,
|
||||
41, 42, 43, 43, 44, 45, 46, 47, 48, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 57, 58, 59, 60, 61, 62, 62, 63, 64, 65, 66, 66, 67, 68, 69, 70, 70, 71, 72,
|
||||
73, 74, 74, 75, 76, 77, 78, 78, 79, 80, 81, 81, 82, 83, 84, 85, 85, 87, 88, 90, 92, 93, 95, 96, 98, 99, 101, 102, 104, 105, 107, 108, 110, 111, 113, 114,
|
||||
116, 117, 118, 120, 121, 123, 125, 127, 129, 131, 134, 136, 138, 140, 142, 144, 146, 148, 150, 152, 154, 156, 158, 161, 164, 166, 169, 172, 174, 177, 180,
|
||||
182, 185, 187, 190, 192, 195, 199, 202, 205, 208, 211, 214, 217, 220, 223, 226, 230, 233, 237, 240, 243, 247, 250, 253, 257, 261, 265, 269, 272, 276, 280,
|
||||
284, 288, 292, 296, 300, 304, 309, 313, 317, 322, 326, 330, 335, 340, 344, 349, 354, 359, 364, 369, 374, 379, 384, 389, 395, 400, 406, 411, 417, 423, 429,
|
||||
435, 441, 447, 454, 461, 467, 475, 482, 489, 497, 505, 513, 522, 530, 539, 549, 559, 569, 579, 590, 602, 614, 626, 640, 654, 668, 684, 700, 717, 736, 755,
|
||||
775, 796, 819, 843, 869, 896, 925, 955, 988, 1022, 1058, 1098, 1139, 1184, 1232, 1282, 1336,
|
||||
];
|
||||
|
||||
pub const AC_QLOOKUP: [i32; 256] = [
|
||||
4, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45,
|
||||
46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84,
|
||||
85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 124, 126, 128, 130, 132, 134,
|
||||
136, 138, 140, 142, 144, 146, 148, 150, 152, 155, 158, 161, 164, 167, 170, 173, 176, 179, 182, 185, 188, 191, 194, 197, 200, 203, 207, 211, 215, 219, 223,
|
||||
227, 231, 235, 239, 243, 247, 251, 255, 260, 265, 270, 275, 280, 285, 290, 295, 300, 305, 311, 317, 323, 329, 335, 341, 347, 353, 359, 366, 373, 380, 387,
|
||||
394, 401, 408, 416, 424, 432, 440, 448, 456, 465, 474, 483, 492, 501, 510, 520, 530, 540, 550, 560, 571, 582, 593, 604, 615, 627, 639, 651, 663, 676, 689,
|
||||
702, 715, 729, 743, 757, 771, 786, 801, 816, 832, 848, 864, 881, 898, 915, 933, 951, 969, 988, 1007, 1026, 1046, 1066, 1087, 1108, 1129, 1151, 1173, 1196,
|
||||
1219, 1243, 1267, 1292, 1317, 1343, 1369, 1396, 1423, 1451, 1479, 1508, 1537, 1567, 1597, 1628, 1660, 1692, 1725, 1759, 1793, 1828,
|
||||
];
|
||||
|
||||
pub const DEFAULT_SCAN_4X4: [u16; 16] = [0, 4, 1, 5, 8, 2, 12, 9, 3, 6, 13, 10, 7, 14, 11, 15];
|
||||
|
||||
pub const COL_SCAN_4X4: [u16; 16] = [0, 4, 8, 1, 12, 5, 9, 2, 13, 6, 10, 3, 7, 14, 11, 15];
|
||||
|
||||
pub const ROW_SCAN_4X4: [u16; 16] = [0, 1, 4, 2, 5, 3, 6, 8, 9, 7, 12, 10, 13, 11, 14, 15];
|
||||
|
||||
pub const COEFBAND_4X4: [u8; 16] = [0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5, 5, 5];
|
||||
|
||||
pub const ENERGY_CLASS: [u8; 12] = [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 5, 5];
|
||||
|
||||
pub const PARETO_TABLE: [u8; 1024] = [
|
||||
3, 86, 128, 6, 86, 23, 88, 29, 9, 86, 129, 17, 88, 61, 94, 76, 15, 87, 129, 28, 89, 93, 100, 110, 20, 88, 130, 38, 91, 118, 106, 136, 26, 89, 131, 48, 92,
|
||||
139, 111, 156, 31, 90, 131, 58, 94, 156, 117, 171, 37, 90, 132, 66, 95, 171, 122, 184, 42, 91, 132, 75, 97, 183, 127, 194, 47, 92, 133, 83, 98, 193, 132,
|
||||
202, 52, 93, 133, 90, 100, 201, 137, 208, 57, 94, 134, 98, 101, 208, 142, 214, 62, 94, 135, 105, 103, 214, 146, 218, 66, 95, 135, 111, 104, 219, 151, 222,
|
||||
71, 96, 136, 117, 106, 224, 155, 225, 76, 97, 136, 123, 107, 227, 159, 228, 80, 98, 137, 129, 109, 231, 162, 231, 84, 98, 138, 134, 110, 234, 166, 233, 89,
|
||||
99, 138, 140, 112, 236, 170, 235, 93, 100, 139, 145, 113, 238, 173, 236, 97, 101, 140, 149, 115, 240, 176, 238, 101, 102, 140, 154, 116, 242, 179, 239,
|
||||
105, 103, 141, 158, 118, 243, 182, 240, 109, 104, 141, 162, 119, 244, 185, 241, 113, 104, 142, 166, 120, 245, 187, 242, 116, 105, 143, 170, 122, 246, 190,
|
||||
243, 120, 106, 143, 173, 123, 247, 192, 244, 123, 107, 144, 177, 125, 248, 195, 244, 127, 108, 145, 180, 126, 249, 197, 245, 130, 109, 145, 183, 128, 249,
|
||||
199, 245, 134, 110, 146, 186, 129, 250, 201, 246, 137, 111, 147, 189, 131, 251, 203, 246, 140, 112, 147, 192, 132, 251, 205, 247, 143, 113, 148, 194, 133,
|
||||
251, 207, 247, 146, 114, 149, 197, 135, 252, 208, 248, 149, 115, 149, 199, 136, 252, 210, 248, 152, 115, 150, 201, 138, 252, 211, 248, 155, 116, 151, 204,
|
||||
139, 253, 213, 249, 158, 117, 151, 206, 140, 253, 214, 249, 161, 118, 152, 208, 142, 253, 216, 249, 163, 119, 153, 210, 143, 253, 217, 249, 166, 120, 153,
|
||||
212, 144, 254, 218, 250, 168, 121, 154, 213, 146, 254, 220, 250, 171, 122, 155, 215, 147, 254, 221, 250, 173, 123, 155, 217, 148, 254, 222, 250, 176, 124,
|
||||
156, 218, 150, 254, 223, 250, 178, 125, 157, 220, 151, 254, 224, 251, 180, 126, 157, 221, 152, 254, 225, 251, 183, 127, 158, 222, 153, 254, 226, 251, 185,
|
||||
128, 159, 224, 155, 255, 227, 251, 187, 129, 160, 225, 156, 255, 228, 251, 189, 131, 160, 226, 157, 255, 228, 251, 191, 132, 161, 227, 159, 255, 229, 251,
|
||||
193, 133, 162, 228, 160, 255, 230, 252, 195, 134, 163, 230, 161, 255, 231, 252, 197, 135, 163, 231, 162, 255, 231, 252, 199, 136, 164, 232, 163, 255, 232,
|
||||
252, 201, 137, 165, 233, 165, 255, 233, 252, 202, 138, 166, 233, 166, 255, 233, 252, 204, 139, 166, 234, 167, 255, 234, 252, 206, 140, 167, 235, 168, 255,
|
||||
235, 252, 207, 141, 168, 236, 169, 255, 235, 252, 209, 142, 169, 237, 171, 255, 236, 252, 210, 144, 169, 237, 172, 255, 236, 252, 212, 145, 170, 238, 173,
|
||||
255, 237, 252, 214, 146, 171, 239, 174, 255, 237, 253, 215, 147, 172, 240, 175, 255, 238, 253, 216, 148, 173, 240, 176, 255, 238, 253, 218, 149, 173, 241,
|
||||
177, 255, 239, 253, 219, 150, 174, 241, 179, 255, 239, 253, 220, 152, 175, 242, 180, 255, 240, 253, 222, 153, 176, 242, 181, 255, 240, 253, 223, 154, 177,
|
||||
243, 182, 255, 240, 253, 224, 155, 178, 244, 183, 255, 241, 253, 225, 156, 178, 244, 184, 255, 241, 253, 226, 158, 179, 244, 185, 255, 242, 253, 228, 159,
|
||||
180, 245, 186, 255, 242, 253, 229, 160, 181, 245, 187, 255, 242, 253, 230, 161, 182, 246, 188, 255, 243, 253, 231, 163, 183, 246, 189, 255, 243, 253, 232,
|
||||
164, 184, 247, 190, 255, 243, 253, 233, 165, 185, 247, 191, 255, 244, 253, 234, 166, 185, 247, 192, 255, 244, 253, 235, 168, 186, 248, 193, 255, 244, 253,
|
||||
236, 169, 187, 248, 194, 255, 244, 253, 236, 170, 188, 248, 195, 255, 245, 253, 237, 171, 189, 249, 196, 255, 245, 254, 238, 173, 190, 249, 197, 255, 245,
|
||||
254, 239, 174, 191, 249, 198, 255, 245, 254, 240, 175, 192, 249, 199, 255, 246, 254, 240, 177, 193, 250, 200, 255, 246, 254, 241, 178, 194, 250, 201, 255,
|
||||
246, 254, 242, 179, 195, 250, 202, 255, 246, 254, 242, 181, 196, 250, 203, 255, 247, 254, 243, 182, 197, 251, 204, 255, 247, 254, 244, 184, 198, 251, 205,
|
||||
255, 247, 254, 244, 185, 199, 251, 206, 255, 247, 254, 245, 186, 200, 251, 207, 255, 247, 254, 246, 188, 201, 252, 207, 255, 248, 254, 246, 189, 202, 252,
|
||||
208, 255, 248, 254, 247, 191, 203, 252, 209, 255, 248, 254, 247, 192, 204, 252, 210, 255, 248, 254, 248, 194, 205, 252, 211, 255, 248, 254, 248, 195, 206,
|
||||
252, 212, 255, 249, 254, 249, 197, 207, 253, 213, 255, 249, 254, 249, 198, 208, 253, 214, 255, 249, 254, 250, 200, 210, 253, 215, 255, 249, 254, 250, 201,
|
||||
211, 253, 215, 255, 249, 254, 250, 203, 212, 253, 216, 255, 249, 254, 251, 204, 213, 253, 217, 255, 250, 254, 251, 206, 214, 254, 218, 255, 250, 254, 252,
|
||||
207, 216, 254, 219, 255, 250, 254, 252, 209, 217, 254, 220, 255, 250, 254, 252, 211, 218, 254, 221, 255, 250, 254, 253, 213, 219, 254, 222, 255, 250, 254,
|
||||
253, 214, 221, 254, 223, 255, 250, 254, 253, 216, 222, 254, 224, 255, 251, 254, 253, 218, 224, 254, 225, 255, 251, 254, 254, 220, 225, 254, 225, 255, 251,
|
||||
254, 254, 222, 227, 255, 226, 255, 251, 254, 254, 224, 228, 255, 227, 255, 251, 254, 254, 226, 230, 255, 228, 255, 251, 254, 255, 228, 231, 255, 230, 255,
|
||||
251, 254, 255, 230, 233, 255, 231, 255, 252, 254, 255, 232, 235, 255, 232, 255, 252, 254, 255, 235, 237, 255, 233, 255, 252, 254, 255, 238, 240, 255, 235,
|
||||
255, 252, 255, 255, 241, 243, 255, 236, 255, 252, 254, 255, 246, 247, 255, 239, 255, 253, 255,
|
||||
];
|
||||
|
||||
pub const KF_PARTITION_PROBS: [u8; 48] = [
|
||||
158, 97, 94, 93, 24, 99, 85, 119, 44, 62, 59, 67, 149, 53, 53, 94, 20, 48, 83, 53, 24, 52, 18, 18, 150, 40, 39, 78, 12, 26, 67, 33, 11, 24, 7, 5, 174, 35,
|
||||
49, 68, 11, 27, 57, 15, 9, 12, 3, 3,
|
||||
];
|
||||
|
||||
pub const KF_Y_MODE_PROBS: [u8; 900] = [
|
||||
137, 30, 42, 148, 151, 207, 70, 52, 91, 92, 45, 102, 136, 116, 180, 74, 90, 100, 73, 32, 19, 187, 222, 215, 46, 34, 100, 91, 30, 32, 116, 121, 186, 93, 86,
|
||||
94, 72, 35, 36, 149, 68, 206, 68, 63, 105, 73, 31, 28, 138, 57, 124, 55, 122, 151, 67, 23, 21, 140, 126, 197, 40, 37, 171, 86, 27, 28, 128, 154, 212, 45,
|
||||
43, 53, 74, 32, 27, 107, 86, 160, 63, 134, 102, 59, 67, 44, 140, 161, 202, 78, 67, 119, 63, 36, 126, 146, 123, 158, 60, 90, 96, 43, 46, 168, 134, 107, 128,
|
||||
69, 142, 92, 44, 29, 68, 159, 201, 177, 50, 57, 77, 58, 38, 76, 114, 97, 172, 78, 133, 92, 46, 41, 76, 140, 63, 184, 69, 112, 57, 38, 32, 85, 140, 46, 112,
|
||||
54, 151, 133, 39, 27, 61, 131, 110, 175, 44, 75, 136, 52, 30, 74, 113, 130, 175, 51, 64, 58, 47, 35, 80, 100, 74, 143, 64, 163, 74, 36, 61, 116, 114, 128,
|
||||
162, 80, 125, 82, 82, 26, 26, 171, 208, 204, 44, 32, 105, 55, 44, 68, 166, 179, 192, 57, 57, 108, 42, 26, 11, 199, 241, 228, 23, 15, 85, 68, 42, 19, 131,
|
||||
160, 199, 55, 52, 83, 58, 50, 25, 139, 115, 232, 39, 52, 118, 50, 35, 33, 153, 104, 162, 64, 59, 131, 44, 24, 16, 150, 177, 202, 33, 19, 156, 55, 27, 12,
|
||||
153, 203, 218, 26, 27, 49, 53, 49, 21, 110, 116, 168, 59, 80, 76, 38, 72, 19, 168, 203, 212, 50, 50, 107, 103, 26, 36, 129, 132, 201, 83, 80, 93, 59, 38,
|
||||
83, 112, 103, 162, 98, 136, 90, 62, 30, 23, 158, 200, 207, 59, 57, 50, 67, 30, 29, 84, 86, 191, 102, 91, 59, 60, 32, 33, 112, 71, 220, 64, 89, 104, 53, 26,
|
||||
34, 130, 56, 149, 84, 120, 103, 53, 21, 23, 133, 109, 210, 56, 77, 172, 77, 19, 29, 112, 142, 228, 55, 66, 36, 61, 29, 29, 93, 97, 165, 83, 175, 162, 47,
|
||||
47, 43, 114, 137, 181, 100, 99, 95, 69, 23, 29, 128, 83, 199, 46, 44, 101, 53, 40, 55, 139, 69, 183, 61, 80, 110, 40, 29, 19, 161, 180, 207, 43, 24, 91,
|
||||
60, 34, 19, 105, 61, 198, 53, 64, 89, 52, 31, 22, 158, 40, 209, 58, 62, 89, 44, 31, 29, 147, 46, 158, 56, 102, 198, 35, 19, 12, 135, 87, 209, 41, 45, 167,
|
||||
55, 25, 21, 118, 95, 215, 38, 39, 66, 51, 38, 25, 113, 58, 164, 70, 93, 97, 47, 54, 34, 146, 108, 203, 72, 103, 151, 64, 19, 37, 156, 66, 138, 49, 95, 133,
|
||||
46, 27, 80, 150, 55, 124, 55, 121, 135, 36, 23, 27, 165, 149, 166, 54, 64, 118, 53, 21, 36, 131, 63, 163, 60, 109, 81, 40, 26, 35, 154, 40, 185, 51, 97,
|
||||
123, 35, 19, 34, 179, 19, 97, 48, 129, 124, 36, 20, 26, 136, 62, 164, 33, 77, 154, 45, 18, 32, 130, 90, 157, 40, 79, 91, 45, 26, 28, 129, 45, 129, 49, 147,
|
||||
123, 38, 44, 51, 136, 74, 162, 57, 97, 121, 75, 17, 22, 136, 138, 185, 32, 34, 166, 56, 39, 58, 133, 117, 173, 48, 53, 187, 35, 21, 12, 161, 212, 207, 20,
|
||||
23, 145, 56, 29, 19, 117, 109, 181, 55, 68, 112, 47, 29, 17, 153, 64, 220, 59, 51, 114, 46, 16, 24, 136, 76, 147, 41, 64, 172, 34, 17, 11, 108, 152, 187,
|
||||
13, 15, 209, 51, 24, 14, 115, 133, 209, 32, 26, 104, 55, 30, 18, 122, 79, 179, 44, 88, 116, 37, 49, 25, 129, 168, 164, 41, 54, 148, 82, 22, 32, 127, 143,
|
||||
213, 39, 41, 70, 62, 44, 61, 123, 105, 189, 48, 57, 64, 47, 25, 17, 175, 222, 220, 24, 30, 86, 68, 36, 17, 106, 102, 206, 59, 74, 74, 57, 39, 23, 151, 68,
|
||||
216, 55, 63, 58, 49, 30, 35, 141, 70, 168, 82, 40, 115, 51, 25, 15, 136, 129, 202, 38, 35, 139, 68, 26, 16, 111, 141, 215, 29, 28, 28, 59, 39, 19, 114, 75,
|
||||
180, 77, 104, 42, 40, 61, 26, 126, 152, 206, 61, 59, 93, 78, 23, 39, 111, 117, 170, 74, 124, 94, 48, 34, 86, 101, 92, 146, 78, 179, 134, 47, 22, 24, 138,
|
||||
187, 178, 68, 69, 59, 56, 25, 33, 105, 112, 187, 95, 177, 129, 48, 31, 27, 114, 63, 183, 82, 116, 56, 43, 28, 37, 121, 63, 123, 61, 192, 169, 42, 17, 24,
|
||||
109, 97, 177, 56, 76, 122, 58, 18, 28, 105, 139, 182, 70, 92, 63, 46, 23, 32, 74, 86, 150, 67, 183, 88, 36, 38, 48, 92, 122, 165, 88, 137, 91, 65, 70, 60,
|
||||
155, 159, 199, 61, 60, 81, 44, 78, 115, 132, 119, 173, 71, 112, 93, 39, 38, 21, 184, 227, 206, 42, 32, 64, 58, 47, 36, 124, 137, 193, 80, 82, 78, 49, 50,
|
||||
35, 144, 95, 205, 63, 78, 59, 41, 53, 52, 148, 71, 142, 65, 128, 51, 40, 36, 28, 143, 143, 202, 40, 55, 137, 52, 34, 29, 129, 183, 227, 42, 35, 43, 42, 44,
|
||||
44, 104, 105, 164, 64, 130, 80, 43, 81, 53, 140, 169, 204, 68, 84, 72,
|
||||
];
|
||||
|
||||
pub const KF_UV_MODE_PROBS: [u8; 90] = [
|
||||
144, 11, 54, 157, 195, 130, 46, 58, 108, 118, 15, 123, 148, 131, 101, 44, 93, 131, 113, 12, 23, 188, 226, 142, 26, 32, 125, 120, 11, 50, 123, 163, 135, 64,
|
||||
77, 103, 113, 9, 36, 155, 111, 157, 32, 44, 161, 116, 9, 55, 176, 76, 96, 37, 61, 149, 115, 9, 28, 141, 161, 167, 21, 25, 193, 120, 12, 32, 145, 195, 142,
|
||||
32, 38, 86, 116, 12, 64, 120, 140, 125, 49, 115, 121, 102, 19, 66, 162, 182, 122, 35, 59, 128,
|
||||
];
|
||||
|
||||
pub const DEFAULT_SKIP_PROB: [u8; 3] = [192, 128, 64];
|
||||
|
||||
pub const DEFAULT_COEF_PROBS: [u8; 432] = [
|
||||
195, 29, 183, 84, 49, 136, 8, 42, 71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 31, 107, 169, 35, 99, 159, 17, 82, 140, 8, 66, 114, 2, 44, 76, 1, 19, 32, 40, 132, 201,
|
||||
29, 114, 187, 13, 91, 157, 7, 75, 127, 3, 58, 95, 1, 28, 47, 69, 142, 221, 42, 122, 201, 15, 91, 159, 6, 67, 121, 1, 42, 77, 1, 17, 31, 102, 148, 228, 67,
|
||||
117, 204, 17, 82, 154, 6, 59, 114, 2, 39, 75, 1, 15, 29, 156, 57, 233, 119, 57, 212, 58, 48, 163, 29, 40, 124, 12, 30, 81, 3, 12, 31, 191, 107, 226, 124,
|
||||
117, 204, 25, 99, 155, 0, 0, 0, 0, 0, 0, 0, 0, 0, 29, 148, 210, 37, 126, 194, 8, 93, 157, 2, 68, 118, 1, 39, 69, 1, 17, 33, 41, 151, 213, 27, 123, 193, 3,
|
||||
82, 144, 1, 58, 105, 1, 32, 60, 1, 13, 26, 59, 159, 220, 23, 126, 198, 4, 88, 151, 1, 66, 114, 1, 38, 71, 1, 18, 34, 114, 136, 232, 51, 114, 207, 11, 83,
|
||||
155, 3, 56, 105, 1, 33, 65, 1, 17, 34, 149, 65, 234, 121, 57, 215, 61, 49, 166, 28, 36, 114, 12, 25, 76, 3, 16, 42, 214, 49, 220, 132, 63, 188, 42, 65,
|
||||
137, 0, 0, 0, 0, 0, 0, 0, 0, 0, 85, 137, 221, 104, 131, 216, 49, 111, 192, 21, 87, 155, 2, 49, 87, 1, 16, 28, 89, 163, 230, 90, 137, 220, 29, 100, 183, 10,
|
||||
70, 135, 2, 42, 81, 1, 17, 33, 108, 167, 237, 55, 133, 222, 15, 97, 179, 4, 72, 135, 1, 45, 85, 1, 19, 38, 124, 146, 240, 66, 124, 224, 17, 88, 175, 4, 58,
|
||||
122, 1, 36, 75, 1, 18, 37, 141, 79, 241, 126, 70, 227, 66, 58, 182, 30, 44, 136, 12, 34, 96, 2, 20, 47, 229, 99, 249, 143, 111, 235, 46, 109, 192, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 82, 158, 236, 94, 146, 224, 25, 117, 191, 9, 87, 149, 3, 56, 99, 1, 33, 57, 83, 167, 237, 68, 145, 222, 10, 103, 177, 2, 72, 131, 1, 41,
|
||||
79, 1, 20, 39, 99, 167, 239, 47, 141, 224, 10, 104, 178, 2, 73, 133, 1, 44, 85, 1, 22, 47, 127, 145, 243, 71, 129, 228, 17, 93, 177, 3, 61, 124, 1, 41, 84,
|
||||
1, 21, 52, 157, 78, 244, 140, 72, 231, 69, 58, 184, 31, 44, 137, 14, 38, 105, 8, 23, 61,
|
||||
];
|
||||
+14
-2
@@ -16,18 +16,22 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
|
||||
| L0 | `time` | `Tick` (254 016 000 000 per second), rational frame rates incl. NTSC, SMPTE and drop-frame timecode |
|
||||
| L0 | `geom` | Vectors, matrices, quaternions, the layer transform (anchor, position, scale, orientation, rotation) |
|
||||
| L0 | `color` | sRGB and linear, HSL/HSV, luminance, the 38 blend modes, label colors |
|
||||
| L0 | `vp9enc` | VP9 intra-frame encoder (profile 0, 8-bit 4:2:0; lossless at quality 100) for WebM export, from the VP9 bitstream specification |
|
||||
| L0 | `opusenc` | Opus (CELT) encoder: RFC 6716 CELT-only fullband 48 kHz 20 ms packets, mono/stereo, and the RFC 7845 `OpusHead`, for WebM export audio |
|
||||
| L1 | `raster` | Premultiplied float images, sampling, affine and projective warps, blurs, compositing (parallel with rayon) |
|
||||
| L1 | `keyframe` | Animated values, keyframes with temporal ease and spatial Bezier, roving, hold, velocity |
|
||||
| L1 | `psd` | Photoshop PSD/PSB reader (layers, groups, masks, blend modes, text, layer effects, adjustment layers; 8/16/32-bit; RGB/CMYK/Gray/Lab) and a minimal writer, from Adobe's published format specification |
|
||||
| L1 | `path` | Bezier paths, path operators (trim, offset, round corners, zig zag, twist, merge…), stroking, coverage masks |
|
||||
| L2 | `project` | The document: items, compositions, layers, the property tree, render queue model, `.ecproj` serde |
|
||||
| L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors |
|
||||
| L2 | `effects` | The effect registry (241 effects) and their CPU implementations |
|
||||
| L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale |
|
||||
| L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation |
|
||||
| L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves |
|
||||
| L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown |
|
||||
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache |
|
||||
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, Photoshop and SVG stills, frame cache |
|
||||
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
|
||||
| L3 | `export` | Render queue encoding: H.264, ProRes, PNG/JPEG/TIFF/EXR sequences, GIF, audio |
|
||||
| L3 | `export` | Render queue encoding: H.264, ProRes, WebM (VP9 + alpha, Opus), PNG/JPEG/TIFF/EXR sequences, GIF, WAV/AIFF |
|
||||
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
|
||||
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
|
||||
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
|
||||
@@ -106,6 +110,14 @@ its evaluated inputs, excluding the transform. Static and transform-only layers
|
||||
editing one layer re-renders only that layer. Effects that read the clock directly are declared in
|
||||
`effects::TIME_DEPENDENT`, and a test checks every registered effect against that list.
|
||||
|
||||
A persistent **disk cache** (`render::disk_cache`, Settings ▸ Media & Disk Cache) backs both
|
||||
the layer cache and the viewer's RAM preview: layer buffers that were slow to render and every
|
||||
preview frame are written (LZ4, atomic rename, checksummed) under 128-bit content keys — the
|
||||
comp's content hash (the comp, what it uses, footage file size and time) plus frame, scale and
|
||||
view for frames; the layer key salted with the footage fingerprint for layers — so entries
|
||||
survive restarts and never go stale. LRU eviction keeps it under the size limit; Edit ▸ Purge
|
||||
empties it, `cache.diskStats` reports it, and the timeline draws disk-only frames in blue.
|
||||
|
||||
**Time effects** (Echo, Posterize Time, Timewarp…) read the layer at other times through
|
||||
`EffectHost::self_at`: the renderer renders the layer's source and masks (plus, optionally, the
|
||||
effects before it) at that layer time via `Renderer::layer_input`, cached under separate *input*
|
||||
|
||||
+9
-8
@@ -75,19 +75,20 @@ library groundwork for mask tracking and Warp Stabilizer.
|
||||
|
||||
What is left is concentrated in large systems: the 3D camera tracker,
|
||||
the Warp Stabilizer and mask-tracking UI on top of
|
||||
the new tracking library, SVG/PSD import, WebM, a disk cache, and wasm threads. (The missing
|
||||
effect categories, 3D Channel, Immersive Video and OCIO, landed in M9.11; the scripting object
|
||||
model in M14.4; Roto Brush and Refine Edge in M6.7; native macOS menus, the Composition Flowchart,
|
||||
Timeline column/search depth, the Home screen and every After Effects workspace in the UI-polish wave.)
|
||||
the new tracking library, and wasm threads. (The missing effect categories, 3D Channel, Immersive
|
||||
Video and OCIO, landed in M9.11; the scripting object model in M14.4; Roto Brush and Refine Edge in
|
||||
M6.7; native macOS menus, the Composition Flowchart, Timeline column/search depth, the Home screen
|
||||
and every After Effects workspace in the UI-polish wave; PSD/SVG import, WebM/WAV/AIFF output and
|
||||
the disk cache in the formats wave.)
|
||||
|
||||
## By area
|
||||
|
||||
| Area | Weighted parity | Remaining (agent-hours) | Biggest gaps |
|
||||
|---|---|---|---|
|
||||
| Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit |
|
||||
| Output | 85% | 4.2 | multiple output modules, pre-render, WebM, audio-only |
|
||||
| Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 is intra-only, Opus CELT-only) |
|
||||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
|
||||
| Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) |
|
||||
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
|
||||
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
|
||||
| Compositions | 72% | 6.0 | Essential Graphics panel (the marker dialog and Composition Flowchart landed) |
|
||||
@@ -99,7 +100,7 @@ Timeline column/search depth, the Home screen and every After Effects workspace
|
||||
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
|
||||
| Masks & roto | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | ≈ 70% | 4.0 | 3D camera tracker, face tracking and Subspace Warp's mesh warp (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking and Warp Stabilizer landed in M6.x / M12.5) |
|
||||
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
|
||||
|
||||
@@ -138,7 +139,7 @@ Mocha and Cineware are third-party and not counted.
|
||||
10. ~~Drag-to-dock and floating panels, saved workspaces, native macOS menus.~~
|
||||
11. Auto-save, crash recovery, recent projects (in progress).
|
||||
12. Expression gaps: `sampleImage`, `footage()` (the `sourceText` style API landed in M9.9).
|
||||
13. Lottie (in progress), WebM, SVG and PSD import.
|
||||
13. ~~Lottie, WebM, SVG and PSD import~~ (landed; a disk cache too).
|
||||
14. Puppet and paint tools (in progress).
|
||||
15. Preferences and a shortcut editor that can rebind (in progress); real Wiggler, Smoother and Motion Sketch; the marker dialog.
|
||||
|
||||
|
||||
@@ -23,14 +23,18 @@ const LAYERS: &[(&str, u8)] = &[
|
||||
("time", 0),
|
||||
("geom", 0),
|
||||
("color", 0),
|
||||
("vp9enc", 0),
|
||||
("testkit", 0),
|
||||
("opusenc", 0),
|
||||
("raster", 1),
|
||||
("keyframe", 1),
|
||||
("path", 1),
|
||||
("psd", 1),
|
||||
("project", 2),
|
||||
("text", 2),
|
||||
("effects", 2),
|
||||
("track", 2),
|
||||
("svg", 2),
|
||||
("model", 2),
|
||||
("render", 3),
|
||||
("media", 3),
|
||||
|
||||
Reference in new issue
Block a user