Merge main into M13.5 long-tail polish
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a4f32f8df2
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -1467,6 +1504,7 @@ dependencies = [
|
|||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1497,6 +1535,10 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1511,10 +1553,12 @@ dependencies = [
|
|||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2807,6 +2851,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 +4097,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
Cargo.toml
13
Cargo.toml
|
|
@ -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
|
||||
|
|
|
|||
15
ROADMAP.md
15
ROADMAP.md
|
|
@ -20,18 +20,17 @@ once.
|
|||
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress |
|
||||
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
|
||||
| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, offline install ([docs/web.md](docs/web.md)) |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
|
||||
|
||||
## How far from full parity
|
||||
|
||||
Measured feature by feature in [docs/parity.md](docs/parity.md) (end of 2 October 2026):
|
||||
Measured feature by feature in [docs/parity.md](docs/parity.md) (late 2 October 2026):
|
||||
|
||||
- **≈ 79% of After Effects' features, weighted by importance** (87% of the essentials, 67% of
|
||||
professional daily-use features, 21% of the long tail); 257 of 298 effects, 16 of them on the GPU.
|
||||
- **≈ 100 agent-hours of work remain by the audit's estimate.** With five Claude Opus 5.5 agents in
|
||||
parallel that is **≈ 8–10 hours of wall-clock time at the pace measured so far, up to ≈ 25 hours**
|
||||
by the audit's conservative per-feature figures.
|
||||
- Hardest remaining pieces: the 3D camera tracker, Advanced 3D, a scripting object model (Roto Brush & Refine Edge landed in M6.7).
|
||||
- **≈ 93% of After Effects' features, weighted by importance** (96% of the essentials, 90% of
|
||||
professional daily-use features, 65% of the long tail); all 298 After Effects effects.
|
||||
- **≈ 50–55 agent-hours of work remain**: about **11–14 hours of wall-clock time** with five Claude
|
||||
Opus 5.5 agents in parallel, ≈ 6–8 hours for the essentials and daily-use features.
|
||||
- Hardest remaining pieces: the web app's depth, GPU 3D, Content-Aware Fill, a plug-in API.
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "effectcraft-web"
|
||||
description = "EffectCraft in the browser (wasm32): eframe web runner, in-memory files, browser file pickers and downloads, window.effectcraft API"
|
||||
description = "EffectCraft in the browser (wasm32): eframe web runner, persistent browser storage (OPFS / IndexedDB), Web Audio, Web Worker renders, PWA, window.effectcraft API"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -12,10 +12,14 @@ workspace = true
|
|||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# Everything is wasm32-only: on other targets this crate is empty (so `cargo test --workspace`
|
||||
# and clippy stay cheap). Build with `cargo xtask web` (see docs/web.md).
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser glue is wasm32-only; on other targets the crate holds just the portable storage
|
||||
# model (`store`), unit-tested natively. Build the app with `cargo xtask web` (see docs/web.md).
|
||||
[dependencies]
|
||||
effectcraft-engine = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
effectcraft-host = { workspace = true }
|
||||
effectcraft-media = { workspace = true }
|
||||
effectcraft-export = { workspace = true }
|
||||
|
|
@ -23,8 +27,8 @@ effectcraft-expr = { workspace = true }
|
|||
effectcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu"] }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
# The wasm-bindgen CLI used by `cargo xtask web` must be exactly this version.
|
||||
wasm-bindgen = "=0.2.129"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
|
@ -35,6 +39,7 @@ web-sys = { version = "0.3", features = [
|
|||
"CssStyleDeclaration",
|
||||
"Document",
|
||||
"Element",
|
||||
"DedicatedWorkerGlobalScope",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"File",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
// Browser services for the EffectCraft web app (imported by the wasm module through
|
||||
// wasm-bindgen; see src/persist.rs, src/audio.rs and src/worker.rs):
|
||||
//
|
||||
// - storage: the Origin Private File System, or IndexedDB where OPFS can't write;
|
||||
// - audio: a Web Audio output (AudioWorklet, ScriptProcessorNode fallback) fed stereo blocks;
|
||||
// - workers: a pool of Web Workers, each running its own instance of the engine.
|
||||
//
|
||||
// Plain functions only: the Rust side owns the logic.
|
||||
|
||||
// ------------------------------------------------------------------ storage
|
||||
|
||||
const DIR = "effectcraft";
|
||||
const DB = "effectcraft";
|
||||
const STORE = "entries";
|
||||
let backend = null;
|
||||
|
||||
// OPFS file names can't hold "/": keys are percent-encoded.
|
||||
const enc = (k) => encodeURIComponent(k);
|
||||
const dec = (n) => decodeURIComponent(n);
|
||||
|
||||
async function opfs() {
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle(DIR, { create: true });
|
||||
// Probe a write: some browsers expose OPFS but only write it from workers.
|
||||
const probe = await dir.getFileHandle(".probe", { create: true });
|
||||
if (!probe.createWritable) throw new Error("OPFS is read-only on this thread");
|
||||
const w = await probe.createWritable();
|
||||
await w.write(new Uint8Array([1]));
|
||||
await w.close();
|
||||
await dir.removeEntry(".probe");
|
||||
return {
|
||||
name: "opfs",
|
||||
async loadAll() {
|
||||
const out = [];
|
||||
for await (const [name, h] of dir.entries()) {
|
||||
if (h.kind !== "file" || name.startsWith(".") || name.endsWith(".crswap")) continue;
|
||||
const f = await h.getFile();
|
||||
out.push({ key: dec(name), data: new Uint8Array(await f.arrayBuffer()), modified: f.lastModified });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async put(key, data) {
|
||||
// createWritable writes a swap file and replaces the entry on close: atomic.
|
||||
const h = await dir.getFileHandle(enc(key), { create: true });
|
||||
const w = await h.createWritable();
|
||||
await w.write(data);
|
||||
await w.close();
|
||||
},
|
||||
async del(key) {
|
||||
try { await dir.removeEntry(enc(key)); } catch (e) { if (e.name !== "NotFoundError") throw e; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function req(r) {
|
||||
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
||||
}
|
||||
|
||||
async function idb() {
|
||||
if (!self.indexedDB) throw new Error("no IndexedDB");
|
||||
const open = indexedDB.open(DB, 1);
|
||||
open.onupgradeneeded = () => open.result.createObjectStore(STORE, { keyPath: "key" });
|
||||
const db = await req(open);
|
||||
const tx = (mode) => db.transaction(STORE, mode).objectStore(STORE);
|
||||
return {
|
||||
name: "indexeddb",
|
||||
async loadAll() {
|
||||
const all = await req(tx("readonly").getAll());
|
||||
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
|
||||
},
|
||||
async put(key, data, modified) {
|
||||
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
|
||||
},
|
||||
async del(key) {
|
||||
await req(tx("readwrite").delete(key));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function memory() {
|
||||
const m = new Map();
|
||||
return {
|
||||
name: "memory",
|
||||
async loadAll() { return [...m.values()]; },
|
||||
async put(key, data, modified) { m.set(key, { key, data: data.slice(), modified }); },
|
||||
async del(key) { m.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
/// Open the store: OPFS, else IndexedDB, else memory (nothing persists). Resolves with the
|
||||
/// backend's name. `prefer` ("opfs" | "indexeddb" | "memory") forces one (tests).
|
||||
export async function storeOpen(prefer) {
|
||||
const order = { opfs: [opfs, idb, memory], indexeddb: [idb, memory], memory: [memory] }[prefer] ?? [opfs, idb, memory];
|
||||
for (const make of order) {
|
||||
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
|
||||
}
|
||||
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
|
||||
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
|
||||
return backend.name;
|
||||
}
|
||||
|
||||
/// Every stored entry: [{key, data: Uint8Array, modified}].
|
||||
export async function storeLoadAll() { return backend.loadAll(); }
|
||||
export async function storePut(key, data, modified) { return backend.put(key, data, modified); }
|
||||
export async function storeDelete(key) { return backend.del(key); }
|
||||
|
||||
/// {usage, quota, persisted, backend}.
|
||||
export async function storeEstimate() {
|
||||
const out = { backend: backend ? backend.name : null };
|
||||
try {
|
||||
const e = await navigator.storage.estimate();
|
||||
out.usage = e.usage; out.quota = e.quota;
|
||||
out.persisted = await navigator.storage.persisted();
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audio
|
||||
|
||||
// One AudioContext for the page. Browsers start it suspended until the user interacts with the
|
||||
// page, so the first pointer/key event resumes it ("unlock").
|
||||
let ctx = null;
|
||||
let node = null;
|
||||
let kind = null;
|
||||
// Sample frames handed to the output since `audioStart`, and the context time it started at.
|
||||
let posted = 0;
|
||||
let startTime = 0;
|
||||
let underruns = 0;
|
||||
// ScriptProcessor fallback: the queue lives on this thread.
|
||||
let spQueue = [];
|
||||
let spOffset = 0;
|
||||
// Blocks pushed while the output node is being created.
|
||||
let starting = false;
|
||||
let pendingPush = [];
|
||||
// Bumped by every start/stop: a start superseded while it awaited the worklet gives up.
|
||||
let generation = 0;
|
||||
|
||||
function context() {
|
||||
if (!ctx) {
|
||||
const AC = self.AudioContext || self.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
ctx = new AC({ latencyHint: "interactive" });
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
const c = context();
|
||||
if (c && c.state !== "running") c.resume().catch(() => {});
|
||||
}
|
||||
|
||||
/// Install the user-gesture unlock (called once at startup).
|
||||
export function audioInstallUnlock() {
|
||||
for (const ev of ["pointerdown", "keydown", "touchend"]) {
|
||||
self.addEventListener(ev, unlock, { capture: true, passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// {state, sampleRate, backend, posted, played, underruns, baseLatency, outputLatency}.
|
||||
export function audioState() {
|
||||
const c = ctx;
|
||||
return {
|
||||
state: c ? c.state : "none",
|
||||
sampleRate: c ? c.sampleRate : 0,
|
||||
backend: kind,
|
||||
posted,
|
||||
played: audioPlayed(),
|
||||
underruns,
|
||||
baseLatency: c ? c.baseLatency || 0 : 0,
|
||||
outputLatency: c ? c.outputLatency || 0 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// The output's sample rate (creates the context; 0 without Web Audio).
|
||||
export function audioSampleRate() {
|
||||
const c = context();
|
||||
return c ? c.sampleRate : 0;
|
||||
}
|
||||
|
||||
/// Start an output node; resolves when it is ready. Samples pushed before then queue up.
|
||||
export async function audioStart(workletUrl) {
|
||||
audioStop();
|
||||
const c = context();
|
||||
if (!c) throw new Error("Web Audio is not available");
|
||||
unlock();
|
||||
posted = 0;
|
||||
underruns = 0;
|
||||
spQueue = [];
|
||||
spOffset = 0;
|
||||
starting = true;
|
||||
pendingPush = [];
|
||||
const gen = ++generation;
|
||||
if (c.audioWorklet && workletUrl) {
|
||||
try {
|
||||
if (!c.__ecWorklet) {
|
||||
await c.audioWorklet.addModule(workletUrl);
|
||||
c.__ecWorklet = true;
|
||||
}
|
||||
if (gen !== generation) return;
|
||||
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
|
||||
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
|
||||
kind = "worklet";
|
||||
} catch (e) {
|
||||
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
|
||||
node = null;
|
||||
}
|
||||
}
|
||||
if (!node) {
|
||||
node = c.createScriptProcessor(2048, 0, 2);
|
||||
node.onaudioprocess = (e) => {
|
||||
const l = e.outputBuffer.getChannelData(0);
|
||||
const r = e.outputBuffer.getChannelData(1);
|
||||
let i = 0;
|
||||
while (i < l.length && spQueue.length) {
|
||||
const b = spQueue[0];
|
||||
const n = Math.min(l.length - i, (b.length - spOffset) >> 1);
|
||||
for (let k = 0; k < n; k++) { l[i + k] = b[spOffset + 2 * k]; r[i + k] = b[spOffset + 2 * k + 1]; }
|
||||
i += n; spOffset += 2 * n;
|
||||
if (spOffset >= b.length) { spQueue.shift(); spOffset = 0; }
|
||||
}
|
||||
if (i < l.length) { underruns += l.length - i; l.fill(0, i); r.fill(0, i); }
|
||||
};
|
||||
kind = "scriptProcessor";
|
||||
}
|
||||
starting = false;
|
||||
if (!node) return;
|
||||
node.connect(c.destination);
|
||||
startTime = c.currentTime;
|
||||
const p = pendingPush;
|
||||
pendingPush = [];
|
||||
for (const b of p) send(b);
|
||||
}
|
||||
|
||||
function send(samples) {
|
||||
if (kind === "worklet") node.port.postMessage(samples, [samples.buffer]);
|
||||
else spQueue.push(samples);
|
||||
}
|
||||
|
||||
/// Queue interleaved stereo samples (Float32Array) for output.
|
||||
export function audioPush(samples) {
|
||||
posted += samples.length >> 1;
|
||||
if (node && !starting) send(samples);
|
||||
else if (starting) pendingPush.push(samples);
|
||||
}
|
||||
|
||||
/// Sample frames played since `audioStart`, by the context clock (AudioContext.currentTime).
|
||||
export function audioPlayed() {
|
||||
if (!ctx || !node || starting) return 0;
|
||||
return Math.max(0, Math.round((ctx.currentTime - startTime) * ctx.sampleRate));
|
||||
}
|
||||
|
||||
/// Output latency in sample frames (what's handed over but not audible yet comes on top).
|
||||
export function audioOutputLatency() {
|
||||
if (!ctx) return 0;
|
||||
return Math.round(((ctx.outputLatency || 0) + (ctx.baseLatency || 0)) * ctx.sampleRate);
|
||||
}
|
||||
|
||||
export function audioStop() {
|
||||
generation++;
|
||||
starting = false;
|
||||
pendingPush = [];
|
||||
if (node) {
|
||||
try { node.disconnect(); } catch {}
|
||||
if (kind === "worklet") node.port.postMessage("stop");
|
||||
}
|
||||
node = null;
|
||||
spQueue = [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ workers
|
||||
|
||||
// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a
|
||||
// worker once.
|
||||
const idle = [];
|
||||
const running = new Map(); // job id → {worker, done}
|
||||
|
||||
function spawn(base) {
|
||||
const worker = new Worker(new URL("worker.js", base), { type: "module", name: "effectcraft-worker" });
|
||||
const w = { worker, files: new Map(), ready: null };
|
||||
w.ready = new Promise((res, rej) => {
|
||||
worker.onmessage = (e) => { if (e.data && e.data.type === "ready") res(); };
|
||||
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
|
||||
});
|
||||
worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base });
|
||||
return w;
|
||||
}
|
||||
|
||||
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
|
||||
/// path, bytes)` receives "reply" (json), "file" (path, bytes) and "error" (json = message).
|
||||
export function workerRun(id, json, files, base, onMessage) {
|
||||
const w = idle.pop() ?? spawn(base);
|
||||
running.set(id, w);
|
||||
w.ready.then(() => {
|
||||
w.worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "reply") {
|
||||
onMessage("reply", m.json, null, null);
|
||||
if (m.done) {
|
||||
running.delete(id);
|
||||
idle.push(w);
|
||||
}
|
||||
} else if (m.type === "file") {
|
||||
onMessage("file", null, m.path, new Uint8Array(m.bytes));
|
||||
}
|
||||
};
|
||||
w.worker.onerror = (e) => {
|
||||
running.delete(id);
|
||||
onMessage("error", String(e.message || "worker error"), null, null);
|
||||
w.worker.terminate();
|
||||
};
|
||||
for (const [path, bytes] of files) {
|
||||
if (w.files.get(path) === bytes.length) continue;
|
||||
w.files.set(path, bytes.length);
|
||||
w.worker.postMessage({ type: "file", path, bytes });
|
||||
}
|
||||
w.worker.postMessage({ type: "job", json });
|
||||
}, (e) => {
|
||||
running.delete(id);
|
||||
onMessage("error", String(e.message || e), null, null);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop a job: its worker is terminated (it can't be interrupted mid-job).
|
||||
export function workerCancel(id) {
|
||||
const w = running.get(id);
|
||||
if (!w) return;
|
||||
running.delete(id);
|
||||
w.worker.terminate();
|
||||
}
|
||||
|
||||
/// {running, idle}.
|
||||
export function workerStats() {
|
||||
return { running: running.size, idle: idle.length };
|
||||
}
|
||||
|
|
@ -92,9 +92,68 @@ pub fn info() -> JsValue {
|
|||
}
|
||||
v["version"] = json!(env!("CARGO_PKG_VERSION"));
|
||||
v["threads"] = json!(false);
|
||||
v["workers"] = from_js(&crate::worker::stats());
|
||||
v["audio"] = from_js(&crate::audio::state());
|
||||
if let Some(e) = crate::persist::last_error() {
|
||||
v["storage"]["error"] = json!(e);
|
||||
}
|
||||
to_js(&v)
|
||||
}
|
||||
|
||||
/// Save the project to browser storage without downloading it: `path` (default: its current
|
||||
/// path, or `/<project name>.ecproj`). Resolves with `{path, bytes}`.
|
||||
#[wasm_bindgen(js_name = saveToBrowser)]
|
||||
pub fn save_to_browser(path: Option<String>) -> js_sys::Promise {
|
||||
js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let path = path.clone();
|
||||
crate::post(move |app| {
|
||||
let p = path.or_else(|| app.session.path.clone()).unwrap_or_else(|| format!("/{}.ecproj", app.session.project_name()));
|
||||
let p = if p.starts_with('/') { p } else { format!("/{p}") };
|
||||
crate::files::save_quietly(&p);
|
||||
match app.session.execute("file.saveAs", json!({"path": p})) {
|
||||
Ok(v) => {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str(&e.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// What browser storage holds: `{backend, usage, quota, persisted, files: [{path, size,
|
||||
/// modified}], config: [name]}` (render outputs, which are not stored, are left out).
|
||||
#[wasm_bindgen(js_name = listStored)]
|
||||
pub async fn list_stored() -> JsValue {
|
||||
let est = wasm_bindgen_futures::JsFuture::from(crate::persist::store_estimate()).await.map(|v| from_js(&v)).unwrap_or(json!({}));
|
||||
let files: Vec<Value> = crate::files::STORE
|
||||
.files()
|
||||
.into_iter()
|
||||
.filter(|f| f.3)
|
||||
.map(|(path, size, modified, _)| json!({"path": path, "size": size, "modified": modified}))
|
||||
.collect();
|
||||
let config: Vec<Value> =
|
||||
crate::files::STORE.lock().list(crate::store::CONFIG).into_iter().map(|(k, ..)| json!(k.trim_start_matches(crate::store::CONFIG))).collect();
|
||||
let mut v = est;
|
||||
v["files"] = json!(files);
|
||||
v["config"] = json!(config);
|
||||
v["pending"] = json!(crate::files::STORE.lock().has_pending());
|
||||
to_js(&v)
|
||||
}
|
||||
|
||||
/// Remove a stored file (media, project, auto-save). Resolves with whether it existed.
|
||||
#[wasm_bindgen(js_name = removeStored)]
|
||||
pub fn remove_stored(path: String) -> bool {
|
||||
crate::files::STORE.remove_file(&path)
|
||||
}
|
||||
|
||||
/// Resolves once every change is written to browser storage.
|
||||
#[wasm_bindgen]
|
||||
pub fn flush() -> js_sys::Promise {
|
||||
crate::persist::flushed()
|
||||
}
|
||||
|
||||
/// Settle the promises whose replies arrived (called every frame).
|
||||
pub(crate) fn poll_replies() {
|
||||
PENDING.with(|p| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
//! The app: startup (storage, session restore, hooks), the eframe app and the page glue.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::{api, audio, files, persist, worker};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
|
||||
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
|
||||
|
||||
static WORKER: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Running inside a Web Worker (no page, no downloads).
|
||||
pub fn is_worker() -> bool {
|
||||
WORKER.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn set_worker() {
|
||||
WORKER.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// `rel` resolved against the page's URL (`worker.js`, `audio-worklet.js`); `""` = the page's
|
||||
/// folder.
|
||||
pub fn page_url(rel: &str) -> String {
|
||||
let href = web_sys::window().and_then(|w| w.location().href().ok()).unwrap_or_default();
|
||||
web_sys::Url::new_with_base(if rel.is_empty() { "./" } else { rel }, &href).map(|u| u.href()).unwrap_or_else(|_| rel.to_string())
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIONS: RefCell<Vec<Action>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` on the UI thread at the next frame.
|
||||
pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) {
|
||||
ACTIONS.with(|a| a.borrow_mut().push(Box::new(f)));
|
||||
repaint();
|
||||
}
|
||||
|
||||
/// Ask for a UI frame.
|
||||
pub fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a project or import media from table paths (picked or dropped files).
|
||||
pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
|
||||
let (projects, media): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj"));
|
||||
if let Some(p) = projects.last()
|
||||
&& let Err(e) = app.session.execute("file.open", json!({"path": p}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if !media.is_empty() {
|
||||
match app.session.execute("file.import", json!({"paths": media})) {
|
||||
Ok(r) => {
|
||||
if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) {
|
||||
app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::<Vec<_>>().join("; ");
|
||||
}
|
||||
}
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
|
||||
pub struct WebApp {
|
||||
pub app: EffectcraftApp,
|
||||
/// Keep the session snapshot (off with `?empty`).
|
||||
pub persist: bool,
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
|
||||
for f in actions {
|
||||
f(&mut self.app);
|
||||
}
|
||||
// Files dropped on the page: read them, then open/import.
|
||||
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.logic(ctx, frame);
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame (here or by a worker) → downloads.
|
||||
files::flush_downloads();
|
||||
// What a reload comes back to.
|
||||
if self.persist {
|
||||
persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0);
|
||||
}
|
||||
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> Option<web_sys::UrlSearchParams> {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok())
|
||||
}
|
||||
|
||||
fn query_flag(name: &str) -> bool {
|
||||
query().is_some_and(|p| p.has(name))
|
||||
}
|
||||
|
||||
fn query_value(name: &str) -> Option<String> {
|
||||
query().and_then(|p| p.get(name))
|
||||
}
|
||||
|
||||
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
|
||||
/// Queue export to downloads, settings in browser storage, renders and analyses in workers.
|
||||
pub fn session() -> Session {
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
persist::register_media(&pool);
|
||||
Session {
|
||||
services: Arc::new(files::WebServices),
|
||||
footage: pool.clone(),
|
||||
importer: Some(Arc::new(files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
script: Some(effectcraft_host::script::runner),
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
|
||||
config: Some(Arc::new(persist::config())),
|
||||
offload: (!query_flag("noworkers")).then(|| Arc::new(worker::WorkerOffload) as Arc<dyn effectcraft_engine::offload::Offload>),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser file pickers in place of the desktop's file dialogs.
|
||||
fn install_hooks(app: &mut EffectcraftApp) {
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
// the import happens when the browser hands over the files
|
||||
Vec::new()
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
None
|
||||
}));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
|
||||
app.hooks.audio_device = Some(Box::new(audio::open));
|
||||
app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()]));
|
||||
}
|
||||
|
||||
/// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
let empty = query_flag("empty");
|
||||
let home = query_flag("home");
|
||||
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
|
||||
// the last session come back.
|
||||
let t0 = js_sys::Date::now();
|
||||
let stored = match persist::load(&query_value("storage").unwrap_or_default()).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
log::warn!("browser storage unavailable: {e}");
|
||||
0
|
||||
}
|
||||
};
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
|
||||
audio::install_unlock();
|
||||
let runner = eframe::WebRunner::new();
|
||||
runner
|
||||
.start(
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
let recovery = session.begin_recovery();
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's
|
||||
// project (the demo on a first visit).
|
||||
let restored = !empty && !query_flag("demo") && persist::restore(&mut session);
|
||||
if !restored && !empty {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
api::set_info("restored", json!(restored));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = home;
|
||||
if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) {
|
||||
app.offer_recovery(r);
|
||||
}
|
||||
install_hooks(&mut app);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
|
||||
api::set_info("backend", json!(backend));
|
||||
Ok(Box::new(WebApp { app, persist: !empty }))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
// Hide the loading message once the app runs.
|
||||
if let Some(el) = doc.get_element_by_id("loading") {
|
||||
el.remove();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
//! Audio preview through Web Audio (`js/host.js`): an AudioWorklet (ScriptProcessorNode where
|
||||
//! worklets are unavailable) plays the blocks the UI mixes ahead
|
||||
//! ([`effectcraft_ui_egui::audio::AudioPlayback::pump`]).
|
||||
//!
|
||||
//! Every UI frame [`WebAudioOut::pump`] moves samples from the feed to the output so it stays
|
||||
//! ~150 ms ahead of what has played. A/V sync follows `AudioContext.currentTime`: frames played
|
||||
//! = (currentTime − start) × rate, so the device latency the playback clock subtracts is
|
||||
//! "handed over but not played yet" plus the context's output latency.
|
||||
//!
|
||||
//! Browsers keep the AudioContext suspended until the user interacts with the page; the host
|
||||
//! resumes it on the first pointer or key event.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = audioInstallUnlock)]
|
||||
pub fn install_unlock();
|
||||
#[wasm_bindgen(js_name = audioState)]
|
||||
pub fn state() -> JsValue;
|
||||
#[wasm_bindgen(js_name = audioSampleRate)]
|
||||
fn sample_rate() -> f64;
|
||||
#[wasm_bindgen(js_name = audioStart)]
|
||||
fn audio_start(worklet_url: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = audioPush)]
|
||||
fn audio_push(samples: js_sys::Float32Array);
|
||||
#[wasm_bindgen(js_name = audioPlayed)]
|
||||
fn audio_played() -> f64;
|
||||
#[wasm_bindgen(js_name = audioOutputLatency)]
|
||||
fn audio_output_latency() -> f64;
|
||||
#[wasm_bindgen(js_name = audioStop)]
|
||||
fn audio_stop();
|
||||
}
|
||||
|
||||
/// How far ahead of the playhead the output is kept fed.
|
||||
const LEAD_SECONDS: f64 = 0.15;
|
||||
|
||||
pub struct WebAudioOut {
|
||||
rate: u32,
|
||||
feed: Option<Arc<AudioFeed>>,
|
||||
/// Sample frames handed to the output since start.
|
||||
posted: u64,
|
||||
buf: Vec<f32>,
|
||||
}
|
||||
|
||||
/// The Settings ▸ Audio factory: Web Audio's default output (`None` without Web Audio).
|
||||
pub fn open(_: &effectcraft_ui_egui::audio::AudioOutput) -> Option<Box<dyn AudioDevice>> {
|
||||
let rate = sample_rate();
|
||||
(rate > 0.0).then(|| Box::new(WebAudioOut { rate: rate as u32, feed: None, posted: 0, buf: Vec::new() }) as Box<dyn AudioDevice>)
|
||||
}
|
||||
|
||||
fn worklet_url() -> String {
|
||||
crate::page_url("audio-worklet.js")
|
||||
}
|
||||
|
||||
impl AudioDevice for WebAudioOut {
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.rate
|
||||
}
|
||||
|
||||
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
|
||||
self.feed = Some(feed);
|
||||
self.posted = 0;
|
||||
let p = audio_start(&worklet_url());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
log::warn!("audio output: {e:?}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.feed = None;
|
||||
audio_stop();
|
||||
}
|
||||
|
||||
fn latency_frames(&self) -> u64 {
|
||||
let played = audio_played() as u64;
|
||||
self.posted.saturating_sub(played) + audio_output_latency() as u64
|
||||
}
|
||||
|
||||
fn pump(&mut self) {
|
||||
let Some(feed) = &self.feed else { return };
|
||||
let played = audio_played() as u64;
|
||||
let lead = (LEAD_SECONDS * self.rate as f64) as u64;
|
||||
let want = (played + lead).saturating_sub(self.posted) as usize;
|
||||
// Only real samples: the output plays silence by itself when it runs dry.
|
||||
let n = want.min(feed.queued_frames());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.buf.resize(n * 2, 0.0);
|
||||
feed.pull(&mut self.buf);
|
||||
audio_push(js_sys::Float32Array::from(&self.buf[..]));
|
||||
self.posted += n as u64;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebAudioOut {
|
||||
fn drop(&mut self) {
|
||||
if self.feed.is_some() {
|
||||
audio_stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,55 @@
|
|||
//! Files in the browser: an in-memory file table in place of the file system, `<input type=file>`
|
||||
//! pickers for Open/Import, and downloads for everything the app writes (projects, renders).
|
||||
//! Files in the browser: a virtual file table in place of the file system, `<input type=file>`
|
||||
//! pickers for Open/Import, and downloads for what the user saves and renders.
|
||||
//!
|
||||
//! Picked or dropped files are read into the table under `/<name>`; the engine, the media pool and
|
||||
//! the exporter read and write through it ([`WebServices`], [`WebImporter`], [`export_sink`]).
|
||||
//! The table is the persistent [`Store`]'s file namespace: imported media, saved projects and
|
||||
//! auto-saves survive reloads ([`crate::persist`]); render outputs don't (they download).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use effectcraft_engine::Services;
|
||||
use effectcraft_engine::project::Footage;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
static FILES: Mutex<Option<HashMap<String, Arc<[u8]>>>> = Mutex::new(None);
|
||||
use crate::store::Store;
|
||||
|
||||
/// The page's store (each worker has its own, unpersisted).
|
||||
pub static STORE: LazyLock<Arc<Store>> = LazyLock::new(|| {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(js_sys::Date::now);
|
||||
s
|
||||
});
|
||||
/// Render outputs written since the last [`flush_downloads`].
|
||||
static OUTPUTS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// Saves that write to browser storage only (no download): `api.saveToBrowser`.
|
||||
static QUIET: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
fn with_files<R>(f: impl FnOnce(&mut HashMap<String, Arc<[u8]>>) -> R) -> R {
|
||||
f(FILES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_default())
|
||||
}
|
||||
|
||||
/// Store a file in the table.
|
||||
/// Store a file in the table (persisted).
|
||||
pub fn put(path: &str, data: Arc<[u8]>) {
|
||||
with_files(|m| m.insert(path.to_string(), data));
|
||||
STORE.put_file(path, data, true);
|
||||
}
|
||||
|
||||
pub fn get(path: &str) -> Option<Arc<[u8]>> {
|
||||
with_files(|m| m.get(path).cloned())
|
||||
STORE.file(path)
|
||||
}
|
||||
|
||||
/// `(path, size)` of every file in the table.
|
||||
pub fn list() -> Vec<(String, usize)> {
|
||||
let mut v: Vec<(String, usize)> = with_files(|m| m.iter().map(|(k, d)| (k.clone(), d.len())).collect());
|
||||
v.sort();
|
||||
v
|
||||
STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect()
|
||||
}
|
||||
|
||||
/// The next write of `path` goes to browser storage only (no download).
|
||||
pub fn save_quietly(path: &str) {
|
||||
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
}
|
||||
|
||||
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
|
||||
/// downloaded at the next [`flush_downloads`].
|
||||
pub fn add_output(path: &str, data: Arc<[u8]>) {
|
||||
STORE.put_file(path, data, false);
|
||||
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
}
|
||||
|
||||
/// The table path for a picked/dropped file name.
|
||||
|
|
@ -85,7 +101,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), JsValue> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Engine file access (projects): reads from the table; writes go to the table and download.
|
||||
/// Engine file access (projects): reads from the table; writes go to the table (persisted) and
|
||||
/// download, except [`save_quietly`] saves.
|
||||
pub struct WebServices;
|
||||
|
||||
impl Services for WebServices {
|
||||
|
|
@ -96,6 +113,15 @@ impl Services for WebServices {
|
|||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
put(path, data.into());
|
||||
let quiet = {
|
||||
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let n = q.len();
|
||||
q.retain(|p| p != path);
|
||||
q.len() != n
|
||||
};
|
||||
if quiet || crate::is_worker() {
|
||||
return Ok(());
|
||||
}
|
||||
download(file_name(path), data).map_err(|e| std::io::Error::other(format!("download failed: {e:?}")))
|
||||
}
|
||||
}
|
||||
|
|
@ -116,10 +142,7 @@ impl effectcraft_engine::Importer for WebImporter {
|
|||
/// Render Queue output: files land in the table and are downloaded after the render
|
||||
/// ([`flush_downloads`]; several files, e.g. an image sequence, as one `.zip`).
|
||||
pub fn export_sink() -> Arc<effectcraft_export::Sink> {
|
||||
Arc::new(|path: &str, data: Vec<u8>| {
|
||||
put(path, data.into());
|
||||
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
})
|
||||
Arc::new(|path: &str, data: Vec<u8>| add_output(path, data.into()))
|
||||
}
|
||||
|
||||
/// Download the render outputs written since the last call.
|
||||
|
|
|
|||
|
|
@ -6,175 +6,31 @@
|
|||
//!
|
||||
//! | Desktop | Web |
|
||||
//! |---|---|
|
||||
//! | file system (`FsServices`, media reads, export writes) | in-memory file table ([`files`]) |
|
||||
//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
|
||||
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
|
||||
//! | rfd file dialogs | `<input type=file>` pickers; drop files on the page |
|
||||
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
|
||||
//! | frame render threads | `Frames::pump` on the UI thread between egui frames |
|
||||
//! | Render Queue thread | the render runs inline (the page waits until it is done) |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
|
||||
//!
|
||||
//! Everything here is `wasm32`-only; on other targets the crate is empty.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod app;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod audio;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod persist;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod worker;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
|
||||
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
|
||||
|
||||
thread_local! {
|
||||
static ACTIONS: RefCell<Vec<Action>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` on the UI thread at the next frame.
|
||||
pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) {
|
||||
ACTIONS.with(|a| a.borrow_mut().push(Box::new(f)));
|
||||
repaint();
|
||||
}
|
||||
|
||||
/// Ask for a UI frame.
|
||||
pub fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a project or import media from table paths (picked or dropped files).
|
||||
fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
|
||||
let (projects, media): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj"));
|
||||
if let Some(p) = projects.last()
|
||||
&& let Err(e) = app.session.execute("file.open", json!({"path": p}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if !media.is_empty() {
|
||||
match app.session.execute("file.import", json!({"paths": media})) {
|
||||
Ok(r) => {
|
||||
if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) {
|
||||
app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::<Vec<_>>().join("; ");
|
||||
}
|
||||
}
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
|
||||
pub struct WebApp {
|
||||
pub app: EffectcraftApp,
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
|
||||
for f in actions {
|
||||
f(&mut self.app);
|
||||
}
|
||||
// Files dropped on the page: read them, then open/import.
|
||||
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.logic(ctx, frame);
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame → downloads.
|
||||
files::flush_downloads();
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn query_flag(name: &str) -> bool {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
|
||||
}
|
||||
|
||||
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
|
||||
/// Queue export to downloads.
|
||||
pub fn session() -> Session {
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
Session {
|
||||
services: Arc::new(files::WebServices),
|
||||
footage: pool.clone(),
|
||||
importer: Some(Arc::new(files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
script: Some(effectcraft_host::script::runner),
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser file pickers in place of the desktop's file dialogs.
|
||||
fn install_hooks(app: &mut EffectcraftApp) {
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
// the import happens when the browser hands over the files
|
||||
Vec::new()
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
None
|
||||
}));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
|
||||
}
|
||||
|
||||
/// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
let demo = !query_flag("empty");
|
||||
let home = query_flag("home");
|
||||
let runner = eframe::WebRunner::new();
|
||||
runner
|
||||
.start(
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let mut session = session();
|
||||
if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = home;
|
||||
install_hooks(&mut app);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
|
||||
api::set_info("backend", json!(backend));
|
||||
Ok(Box::new(WebApp { app }))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
// Hide the loading message once the app runs.
|
||||
if let Some(el) = doc.get_element_by_id("loading") {
|
||||
el.remove();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use app::*;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
//! Persistence: the [`crate::store::Store`] mirrored to the browser's storage (the Origin
|
||||
//! Private File System, IndexedDB where OPFS can't write, memory as a last resort; `js/host.js`).
|
||||
//!
|
||||
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
|
||||
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
|
||||
//! one write per changed key, in order, with nothing written twice concurrently.
|
||||
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
|
||||
//! or so while it changes, so a reload (or a crash) comes back to where the user was
|
||||
//! ([`restore`]).
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::config::ConfigStore;
|
||||
use effectcraft_engine::project::Project;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::files::STORE;
|
||||
use crate::store::{SNAPSHOT, SNAPSHOT_META, SnapshotMeta, WebConfig};
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = storeOpen)]
|
||||
fn store_open(prefer: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeLoadAll)]
|
||||
fn store_load_all() -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storePut)]
|
||||
fn store_put(key: &str, data: js_sys::Uint8Array, modified: f64) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeDelete)]
|
||||
fn store_delete(key: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeEstimate)]
|
||||
pub(crate) fn store_estimate() -> js_sys::Promise;
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FLUSHING: Cell<bool> = const { Cell::new(false) };
|
||||
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
static FLUSH_WAITERS: std::cell::RefCell<Vec<js_sys::Function>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
|
||||
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
}
|
||||
|
||||
/// The storage in use: `opfs`, `indexeddb` or `memory`.
|
||||
pub fn backend() -> String {
|
||||
BACKEND.with(|b| b.borrow().clone())
|
||||
}
|
||||
|
||||
/// The last storage error (quota exceeded…), if any.
|
||||
pub fn last_error() -> Option<String> {
|
||||
LAST_ERROR.with(|e| e.borrow().clone())
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// Open the browser storage and read everything into the store. `prefer` forces a backend
|
||||
/// (`?storage=indexeddb` / `memory`; empty = OPFS, then IndexedDB). Returns the entry count.
|
||||
pub async fn load(prefer: &str) -> Result<usize, String> {
|
||||
let name = JsFuture::from(store_open(prefer)).await.map_err(js_err)?;
|
||||
BACKEND.with(|b| *b.borrow_mut() = name.as_string().unwrap_or_default());
|
||||
let all = JsFuture::from(store_load_all()).await.map_err(js_err)?;
|
||||
let all: js_sys::Array = all.dyn_into().map_err(js_err)?;
|
||||
let n = all.length() as usize;
|
||||
{
|
||||
let mut m = STORE.lock();
|
||||
for e in all.iter() {
|
||||
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let Some(key) = get("key").as_string() else { continue };
|
||||
let data: js_sys::Uint8Array = match get("data").dyn_into() {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
m.load(&key, data.to_vec().into(), get("modified").as_f64().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
let _ = STORE.on_change.set(schedule_flush);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write pending changes to the browser storage (in the background; one flush at a time).
|
||||
pub fn schedule_flush() {
|
||||
if FLUSHING.with(|f| f.replace(true)) {
|
||||
return; // the running flush picks the new changes up
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
loop {
|
||||
let pending = STORE.lock().take_pending();
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
for p in pending {
|
||||
let r = match &p.data {
|
||||
Some(d) => JsFuture::from(store_put(&p.key, js_sys::Uint8Array::from(&d[..]), p.modified)).await,
|
||||
None => JsFuture::from(store_delete(&p.key)).await,
|
||||
};
|
||||
if let Err(e) = r {
|
||||
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
|
||||
log::warn!("{msg}");
|
||||
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
FLUSHING.with(|f| f.set(false));
|
||||
for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) {
|
||||
let _ = w.call0(&JsValue::NULL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A promise that resolves once every change so far is in the browser storage.
|
||||
pub fn flushed() -> js_sys::Promise {
|
||||
js_sys::Promise::new(&mut |resolve, _| {
|
||||
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
|
||||
let _ = resolve.call0(&JsValue::NULL);
|
||||
} else {
|
||||
FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve));
|
||||
schedule_flush();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The config store for the session.
|
||||
pub fn config() -> WebConfig {
|
||||
WebConfig::new(STORE.clone())
|
||||
}
|
||||
|
||||
/// Record the open project and editor state when they changed (checked at most every
|
||||
/// `min_secs`).
|
||||
pub fn snapshot(s: &Session, now: f64, min_secs: f64) {
|
||||
let (rev, at) = SNAP.with(|c| c.get());
|
||||
if now - at < min_secs {
|
||||
return;
|
||||
}
|
||||
SNAP.with(|c| c.set((rev, now)));
|
||||
let meta = SnapshotMeta { path: s.path.clone(), dirty: s.is_dirty(), state: serde_json::to_value(&s.state).ok(), saved: 0.0 };
|
||||
let key = serde_json::to_string(&meta).unwrap_or_default();
|
||||
if rev == s.revision && LAST_META.with(|m| *m.borrow() == key) {
|
||||
return;
|
||||
}
|
||||
let c = config();
|
||||
if rev != s.revision {
|
||||
let _ = c.write(SNAPSHOT, &s.project.to_json());
|
||||
}
|
||||
let _ = c.write(SNAPSHOT_META, &serde_json::to_string(&SnapshotMeta { saved: js_sys::Date::now(), ..meta }).unwrap_or_default());
|
||||
SNAP.with(|c| c.set((s.revision, now)));
|
||||
LAST_META.with(|m| *m.borrow_mut() = key);
|
||||
}
|
||||
|
||||
/// Reopen the last visit's project and editor state. Returns whether there was one.
|
||||
pub fn restore(s: &mut Session) -> bool {
|
||||
let c = config();
|
||||
let (Some(json), Some(meta)) = (c.read(SNAPSHOT), c.read(SNAPSHOT_META)) else { return false };
|
||||
let meta: SnapshotMeta = serde_json::from_str(&meta).unwrap_or_default();
|
||||
let p = match Project::from_json(&json) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!("restoring the last session: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
s.replace_project(p, meta.path.clone());
|
||||
if let Some(st) = meta.state.and_then(|v| serde_json::from_value(v).ok()) {
|
||||
s.state = st;
|
||||
}
|
||||
if meta.dirty {
|
||||
// Unsaved changes stay unsaved.
|
||||
s.bump();
|
||||
}
|
||||
SNAP.with(|c| c.set((s.revision, 0.0)));
|
||||
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));
|
||||
true
|
||||
}
|
||||
|
||||
/// Register persisted media with the media pool (so restored projects find their footage).
|
||||
pub fn register_media(pool: &effectcraft_media::MediaPool) -> usize {
|
||||
let mut n = 0;
|
||||
for (path, _, _, persisted) in STORE.files() {
|
||||
if persisted
|
||||
&& !path.to_ascii_lowercase().ends_with(".ecproj")
|
||||
&& let Some(d) = STORE.file(&path)
|
||||
{
|
||||
pool.add_bytes(&path, d);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
//! What the browser keeps between visits: the in-memory side of the persistent store.
|
||||
//!
|
||||
//! The engine's storage traits are synchronous ([`ConfigStore`], [`FileOps`],
|
||||
//! [`effectcraft_engine::Services`]); the browser's storage (the Origin Private File System, or
|
||||
//! IndexedDB where OPFS can't write) is asynchronous. So the app works on a [`Mirror`]: every
|
||||
//! stored entry is loaded into memory before the app starts, reads and writes hit the mirror
|
||||
//! synchronously, and writes queue up as pending operations that the web host flushes to the
|
||||
//! browser in the background (coalesced per key, in order).
|
||||
//!
|
||||
//! Keys are namespaced:
|
||||
//!
|
||||
//! | Key | What |
|
||||
//! |---|---|
|
||||
//! | `config/<name>` | settings (`prefs.json`), shortcut presets, the recovery sentinel, the session snapshot |
|
||||
//! | `files<path>` | the virtual file table: imported media, saved projects, auto-saves (`/EffectCraft Auto-Save/…`) |
|
||||
//!
|
||||
//! Render outputs go into the file table unpersisted (they download instead).
|
||||
//!
|
||||
//! This module is plain Rust (no browser APIs) so it is unit-tested natively.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_engine::config::{ConfigStore, FileOps};
|
||||
|
||||
/// Prefix of configuration entries.
|
||||
pub const CONFIG: &str = "config/";
|
||||
/// Prefix of file-table entries (followed by the absolute path, `files/clip.mp4`).
|
||||
pub const FILES: &str = "files";
|
||||
/// The session snapshot (project + editor state), written while the app runs and restored
|
||||
/// on the next visit.
|
||||
pub const SNAPSHOT: &str = "session.ecproj";
|
||||
/// Its metadata ([`SnapshotMeta`]).
|
||||
pub const SNAPSHOT_META: &str = "session.json";
|
||||
|
||||
/// One stored blob.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Entry {
|
||||
pub data: Arc<[u8]>,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub modified: f64,
|
||||
/// Written to the browser's storage (render outputs are not).
|
||||
pub persist: bool,
|
||||
}
|
||||
|
||||
/// A queued write for the browser storage: `data` = `None` deletes the key.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pending {
|
||||
pub key: String,
|
||||
pub data: Option<Arc<[u8]>>,
|
||||
pub modified: f64,
|
||||
}
|
||||
|
||||
/// The in-memory copy of the store plus the writes not yet flushed.
|
||||
#[derive(Default)]
|
||||
pub struct Mirror {
|
||||
entries: BTreeMap<String, Entry>,
|
||||
/// Keys changed since the last [`Mirror::take_pending`], in first-change order.
|
||||
dirty: Vec<String>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
/// An entry read from the browser's storage at startup (nothing to flush).
|
||||
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true });
|
||||
}
|
||||
|
||||
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
|
||||
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist });
|
||||
if persist || was_persisted {
|
||||
self.touch(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
match self.entries.remove(key) {
|
||||
Some(e) => {
|
||||
if e.persist {
|
||||
self.touch(key);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(&mut self, key: &str) {
|
||||
if !self.dirty.iter().any(|k| k == key) {
|
||||
self.dirty.push(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Entry> {
|
||||
self.entries.get(key)
|
||||
}
|
||||
|
||||
/// Entries under `prefix`: (key, size, modified, persisted).
|
||||
pub fn list(&self, prefix: &str) -> Vec<(String, usize, f64, bool)> {
|
||||
self.entries
|
||||
.range(prefix.to_string()..)
|
||||
.take_while(|(k, _)| k.starts_with(prefix))
|
||||
.map(|(k, e)| (k.clone(), e.data.len(), e.modified, e.persist))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The writes to flush, one per changed key with its latest state (a put, or a delete when
|
||||
/// the key is gone or no longer persisted).
|
||||
pub fn take_pending(&mut self) -> Vec<Pending> {
|
||||
std::mem::take(&mut self.dirty)
|
||||
.into_iter()
|
||||
.map(|key| match self.entries.get(&key) {
|
||||
Some(e) if e.persist => Pending { data: Some(e.data.clone()), modified: e.modified, key },
|
||||
_ => Pending { key, data: None, modified: 0.0 },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
!self.dirty.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of a file-table path (`/a/b.mov` → `files/a/b.mov`).
|
||||
pub fn file_key(path: &str) -> String {
|
||||
if path.starts_with('/') { format!("{FILES}{path}") } else { format!("{FILES}/{path}") }
|
||||
}
|
||||
|
||||
/// The path of a file-table key.
|
||||
pub fn key_path(key: &str) -> Option<&str> {
|
||||
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
pub mirror: Mutex<Mirror>,
|
||||
/// Called after every change that needs flushing (the web host schedules a flush).
|
||||
pub on_change: OnceLock<fn()>,
|
||||
/// Clock in milliseconds since the Unix epoch (tests pin it).
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
|
||||
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
self.clock.get().map_or(0.0, |f| f())
|
||||
}
|
||||
|
||||
fn changed(&self) {
|
||||
if let Some(f) = self.on_change.get() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- file table
|
||||
|
||||
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
self.lock().put(&file_key(path), data, now, persist);
|
||||
if persist {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().get(&file_key(path)).map(|e| e.data.clone())
|
||||
}
|
||||
|
||||
pub fn remove_file(&self, path: &str) -> bool {
|
||||
let r = self.lock().remove(&file_key(path));
|
||||
self.changed();
|
||||
r
|
||||
}
|
||||
|
||||
/// Every file: (path, size, modified ms, persisted).
|
||||
pub fn files(&self) -> Vec<(String, usize, f64, bool)> {
|
||||
self.lock().list(FILES).into_iter().filter_map(|(k, n, m, p)| Some((key_path(&k)?.to_string(), n, m, p))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings and friends ([`ConfigStore`]) in the store; auto-saves ([`FileOps`]) in its file
|
||||
/// table, under `/` (untitled projects auto-save to `/EffectCraft Auto-Save/`).
|
||||
pub struct WebConfig {
|
||||
store: Arc<Store>,
|
||||
files: WebFiles,
|
||||
}
|
||||
|
||||
impl WebConfig {
|
||||
pub fn new(store: Arc<Store>) -> WebConfig {
|
||||
WebConfig { files: WebFiles(store.clone()), store }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`FileOps`] on the store's file table.
|
||||
pub struct WebFiles(pub Arc<Store>);
|
||||
|
||||
impl ConfigStore for WebConfig {
|
||||
fn read(&self, name: &str) -> Option<String> {
|
||||
let m = self.store.lock();
|
||||
m.get(&format!("{CONFIG}{name}")).map(|e| String::from_utf8_lossy(&e.data).into_owned())
|
||||
}
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
|
||||
let now = self.store.now();
|
||||
self.store.lock().put(&format!("{CONFIG}{name}"), data.as_bytes().into(), now, true);
|
||||
self.store.changed();
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
self.store.lock().remove(&format!("{CONFIG}{name}"));
|
||||
self.store.changed();
|
||||
Ok(())
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(PathBuf::from("/"))
|
||||
}
|
||||
fn files(&self) -> &dyn FileOps {
|
||||
&self.files
|
||||
}
|
||||
}
|
||||
|
||||
fn path_str(p: &Path) -> String {
|
||||
p.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
impl FileOps for WebFiles {
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
|
||||
let dir = path_str(dir);
|
||||
let dir = dir.trim_end_matches('/');
|
||||
self.0
|
||||
.files()
|
||||
.into_iter()
|
||||
.filter_map(|(p, _, m, _)| {
|
||||
let (parent, name) = p.rsplit_once('/')?;
|
||||
(parent == dir).then(|| (name.to_string(), std::time::UNIX_EPOCH + std::time::Duration::from_secs_f64(m.max(0.0) / 1000.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.put_file(&path_str(path), data.into(), true);
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
if self.0.remove_file(&path_str(path)) { Ok(()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path_str(path))) }
|
||||
}
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
self.0.file(&path_str(path)).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the session snapshot records besides the project.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SnapshotMeta {
|
||||
/// The project's file (None = untitled).
|
||||
pub path: Option<String>,
|
||||
/// Unsaved changes when the snapshot was taken.
|
||||
pub dirty: bool,
|
||||
/// Viewer, timeline and selection state.
|
||||
pub state: Option<serde_json::Value>,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub saved: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> Arc<Store> {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(|| 1_000.0);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_coalesce_per_key_in_order() {
|
||||
let mut m = Mirror::default();
|
||||
m.put("config/a", Arc::from(&b"1"[..]), 1.0, true);
|
||||
m.put("files/x.png", Arc::from(&b"px"[..]), 2.0, true);
|
||||
m.put("config/a", Arc::from(&b"2"[..]), 3.0, true);
|
||||
let p = m.take_pending();
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!((p[0].key.as_str(), p[0].data.as_deref()), ("config/a", Some(&b"2"[..])));
|
||||
assert_eq!(p[0].modified, 3.0);
|
||||
assert_eq!(p[1].key, "files/x.png");
|
||||
assert!(!m.has_pending() && m.take_pending().is_empty());
|
||||
// Delete after put: one delete.
|
||||
m.put("files/y", Arc::from(&b"y"[..]), 4.0, true);
|
||||
m.remove("files/y");
|
||||
assert_eq!(m.take_pending(), vec![Pending { key: "files/y".into(), data: None, modified: 0.0 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpersisted_entries_never_flush_and_loaded_ones_are_clean() {
|
||||
let mut m = Mirror::default();
|
||||
m.load("files/old.mov", Arc::from(&b"m"[..]), 5.0);
|
||||
m.put("files/render.gif", Arc::from(&b"g"[..]), 6.0, false);
|
||||
assert!(!m.has_pending());
|
||||
assert!(!m.remove("files/none"));
|
||||
m.remove("files/render.gif");
|
||||
assert!(!m.has_pending());
|
||||
// Overwriting a persisted file with an unpersisted one deletes the stored copy.
|
||||
m.put("files/old.mov", Arc::from(&b"n"[..]), 7.0, false);
|
||||
assert_eq!(m.take_pending()[0].data, None);
|
||||
assert_eq!(m.list("files").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_paths_round_trip() {
|
||||
assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov");
|
||||
assert_eq!(file_key("c.mov"), "files/c.mov");
|
||||
assert_eq!(key_path("files/a b/c.mov"), Some("/a b/c.mov"));
|
||||
assert_eq!(key_path("config/prefs.json"), None);
|
||||
let s = store();
|
||||
s.put_file("/clip.mp4", Arc::from(&b"abc"[..]), true);
|
||||
s.put_file("/out.gif", Arc::from(&b"g"[..]), false);
|
||||
let f = s.files();
|
||||
assert_eq!(f, vec![("/clip.mp4".to_string(), 3, 1000.0, true), ("/out.gif".to_string(), 1, 1000.0, false)]);
|
||||
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_and_file_ops() {
|
||||
let s = store();
|
||||
let c = WebConfig::new(s.clone());
|
||||
assert_eq!(c.read("prefs.json"), None);
|
||||
c.write("prefs.json", "{\"a\":1}").unwrap();
|
||||
assert_eq!(c.read("prefs.json").as_deref(), Some("{\"a\":1}"));
|
||||
c.remove("prefs.json").unwrap();
|
||||
assert_eq!(c.read("prefs.json"), None);
|
||||
// Config entries are not files.
|
||||
c.write("session.lock", "{}").unwrap();
|
||||
assert!(s.files().is_empty());
|
||||
let fs = c.files();
|
||||
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 1.ecproj"), b"{}").unwrap();
|
||||
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 2.ecproj"), b"{}").unwrap();
|
||||
fs.write(Path::new("/other.ecproj"), b"{}").unwrap();
|
||||
let mut names: Vec<String> = fs.list(Path::new("/EffectCraft Auto-Save")).into_iter().map(|e| e.0).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["P auto-save 1.ecproj", "P auto-save 2.ecproj"]);
|
||||
assert!(fs.is_file(Path::new("/other.ecproj")));
|
||||
fs.remove(Path::new("/other.ecproj")).unwrap();
|
||||
assert!(!fs.is_file(Path::new("/other.ecproj")));
|
||||
assert!(fs.remove(Path::new("/other.ecproj")).is_err());
|
||||
let pending = s.lock().take_pending();
|
||||
let keys: Vec<&str> = pending.iter().map(|p| p.key.as_str()).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"config/prefs.json",
|
||||
"config/session.lock",
|
||||
"files/EffectCraft Auto-Save/P auto-save 1.ecproj",
|
||||
"files/EffectCraft Auto-Save/P auto-save 2.ecproj",
|
||||
"files/other.ecproj"
|
||||
]
|
||||
);
|
||||
assert_eq!(pending[0].data, None, "removed");
|
||||
assert_eq!(pending[4].data, None, "removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_and_recovery_run_in_the_browser_store() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let s = store();
|
||||
let mut a = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
|
||||
a.load_settings();
|
||||
assert!(a.begin_recovery().is_none());
|
||||
a.execute("file.newProject", json!({})).unwrap();
|
||||
a.execute("comp.new", json!({"name": "C"})).unwrap();
|
||||
// Untitled: the auto-save lands in the store's file table.
|
||||
let p = a.autosave_now().unwrap();
|
||||
assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
|
||||
assert!(s.file(&p).is_some());
|
||||
// The tab closes without a clean exit: the next visit offers the auto-save.
|
||||
let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
|
||||
b.load_settings();
|
||||
let r = b.begin_recovery().expect("recovery");
|
||||
assert!(r.autosave.is_some());
|
||||
// Increment and Save sees the store's files.
|
||||
let fs = WebConfig::new(s.clone());
|
||||
fs.files().write(Path::new("/A.ecproj"), b"{}").unwrap();
|
||||
let next = effectcraft_engine::autosave::increment_path("/A.ecproj", |p| fs.files().is_file(Path::new(p)));
|
||||
assert_eq!(next, "/A 2.ecproj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_meta_round_trips() {
|
||||
let m = SnapshotMeta { path: Some("/p.ecproj".into()), dirty: true, state: Some(serde_json::json!({"activeComp": 3})), saved: 12.5 };
|
||||
let t = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::from_str::<SnapshotMeta>(&t).unwrap(), m);
|
||||
assert_eq!(serde_json::from_str::<SnapshotMeta>("{}").unwrap(), SnapshotMeta::default());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
//! Renders and analyses in Web Workers ([`effectcraft_engine::offload`]).
|
||||
//!
|
||||
//! **Page side** — [`WorkerOffload`], the session's [`Offload`]: serializes the job, ships the
|
||||
//! footage files it reads and the request to a worker from the host's pool (`js/host.js`
|
||||
//! `workerRun`), and turns the worker's messages back into [`WorkerReply`]s in the job's
|
||||
//! [`Inbox`]. Rendered files come back as their bytes and download like any render.
|
||||
//!
|
||||
//! **Worker side** — `web/worker.js` instantiates the same wasm module (the page's compiled
|
||||
//! `WebAssembly.Module`, so nothing is compiled twice) and calls [`worker_init`], then
|
||||
//! [`worker_file`] per footage file and [`worker_job`] per job: a plain engine session (footage
|
||||
//! from memory, expressions, the exporter) runs it blocking and posts replies as it goes. Each
|
||||
//! worker has its own memory: no shared-memory threads, so this works on stable Rust.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::offload::{Inbox, Offload, WorkerReply, WorkerRequest};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = workerRun)]
|
||||
fn worker_run(id: f64, json: &str, files: js_sys::Array, base: &str, on_message: &js_sys::Function);
|
||||
#[wasm_bindgen(js_name = workerCancel)]
|
||||
fn worker_cancel(id: f64);
|
||||
#[wasm_bindgen(js_name = workerStats)]
|
||||
pub fn stats() -> JsValue;
|
||||
}
|
||||
|
||||
type OnMessage = Closure<dyn FnMut(String, JsValue, JsValue, JsValue)>;
|
||||
|
||||
thread_local! {
|
||||
/// Message handlers of running jobs (dropped when the job ends).
|
||||
static HANDLERS: RefCell<HashMap<u64, OnMessage>> = RefCell::new(HashMap::new());
|
||||
/// The worker's session and its media pool.
|
||||
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
|
||||
static POOL: RefCell<Option<Arc<effectcraft_media::MediaPool>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The page's offload: one worker per running job (idle workers are reused).
|
||||
pub struct WorkerOffload;
|
||||
|
||||
impl Offload for WorkerOffload {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let id = req.id;
|
||||
let files = js_sys::Array::new();
|
||||
for p in &req.files {
|
||||
if let Some(d) = crate::files::get(p) {
|
||||
files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..])));
|
||||
}
|
||||
}
|
||||
let json = serde_json::to_string(&req).map_err(|e| e.to_string())?;
|
||||
let on: OnMessage = Closure::new(move |kind: String, json: JsValue, path: JsValue, bytes: JsValue| {
|
||||
match kind.as_str() {
|
||||
"reply" => {
|
||||
let Some(t) = json.as_string() else { return };
|
||||
match serde_json::from_str::<WorkerReply>(&t) {
|
||||
Ok(r) => {
|
||||
let done = matches!(r, WorkerReply::Done);
|
||||
inbox.push(r);
|
||||
if done {
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("worker reply: {e}"),
|
||||
}
|
||||
}
|
||||
"file" => {
|
||||
if let (Some(p), Ok(b)) = (path.as_string(), bytes.dyn_into::<js_sys::Uint8Array>()) {
|
||||
crate::files::add_output(&p, b.to_vec().into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
inbox.push(WorkerReply::Failed { error: json.as_string().unwrap_or_else(|| "worker failed".into()) });
|
||||
inbox.push(WorkerReply::Done);
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
crate::repaint();
|
||||
});
|
||||
worker_run(id as f64, &json, files, &crate::page_url(""), on.as_ref().unchecked_ref());
|
||||
HANDLERS.with(|h| h.borrow_mut().insert(id, on));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel(&self, id: u64) {
|
||||
worker_cancel(id as f64);
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a job's handler (after the current call returns).
|
||||
fn end(id: u64) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
HANDLERS.with(|h| h.borrow_mut().remove(&id));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
fn post(msg: &JsValue, transfer: Option<&js_sys::Array>) {
|
||||
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
|
||||
let r = match transfer {
|
||||
Some(t) => scope.post_message_with_transfer(msg, t),
|
||||
None => scope.post_message(msg),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("worker: postMessage: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
|
||||
let o = js_sys::Object::new();
|
||||
for (k, v) in pairs {
|
||||
let _ = js_sys::Reflect::set(&o, &JsValue::from_str(k), v);
|
||||
}
|
||||
o.into()
|
||||
}
|
||||
|
||||
/// Set up the worker's engine session (called once by `web/worker.js`).
|
||||
#[wasm_bindgen(js_name = workerInit)]
|
||||
pub fn worker_init() {
|
||||
crate::set_worker();
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
POOL.with(|p| *p.borrow_mut() = Some(pool.clone()));
|
||||
// Rendered files go back to the page (transferred, not copied).
|
||||
let sink: Arc<effectcraft_export::Sink> = Arc::new(|path: &str, data: Vec<u8>| {
|
||||
let bytes = js_sys::Uint8Array::from(&data[..]);
|
||||
let buf = bytes.buffer();
|
||||
post(&obj(&[("type", "file".into()), ("path", path.into()), ("bytes", buf.clone().into())]), Some(&js_sys::Array::of1(&buf)));
|
||||
});
|
||||
let s = Session {
|
||||
services: Arc::new(crate::files::WebServices),
|
||||
footage: pool.clone(),
|
||||
importer: Some(Arc::new(crate::files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(sink) })),
|
||||
..Default::default()
|
||||
};
|
||||
SESSION.with(|c| *c.borrow_mut() = Some(s));
|
||||
}
|
||||
|
||||
/// A footage file for the jobs that follow.
|
||||
#[wasm_bindgen(js_name = workerFile)]
|
||||
pub fn worker_file(path: String, bytes: js_sys::Uint8Array) {
|
||||
let data: Arc<[u8]> = bytes.to_vec().into();
|
||||
crate::files::STORE.put_file(&path, data.clone(), false);
|
||||
POOL.with(|p| {
|
||||
if let Some(p) = p.borrow().as_ref() {
|
||||
p.add_bytes(&path, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run one job (a JSON [`WorkerRequest`]) and post its replies.
|
||||
#[wasm_bindgen(js_name = workerJob)]
|
||||
pub fn worker_job(json: String) {
|
||||
let send: effectcraft_engine::offload::Post = Rc::new(|r: WorkerReply| {
|
||||
let done = matches!(r, WorkerReply::Done);
|
||||
let t = serde_json::to_string(&r).unwrap_or_default();
|
||||
post(&obj(&[("type", "reply".into()), ("json", t.into()), ("done", done.into())]), None);
|
||||
});
|
||||
let req: WorkerRequest = match serde_json::from_str(&json) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
send(WorkerReply::Failed { error: format!("bad request: {e}") });
|
||||
send(WorkerReply::Done);
|
||||
return;
|
||||
}
|
||||
};
|
||||
SESSION.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Some(s) = c.as_mut() else {
|
||||
send(WorkerReply::Failed { error: "worker not initialised".into() });
|
||||
send(WorkerReply::Done);
|
||||
return;
|
||||
};
|
||||
effectcraft_engine::offload::run_request(s, req, &send);
|
||||
});
|
||||
}
|
||||
|
|
@ -4,10 +4,15 @@
|
|||
// cargo xtask web --serve 8765 & # build + serve <target>/web/dist
|
||||
// node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out /tmp/ec-web
|
||||
//
|
||||
// Steps: load (timing), demo comp in the viewer (screenshot, viewer pixels), `render.frame`
|
||||
// through `window.effectcraft`, Render Queue GIF and PNG-sequence (.zip) exports (downloads), File ▸ Save As (download),
|
||||
// re-opening the saved project through `effectcraft.addFile`. Prints a JSON report; exits non-zero
|
||||
// on failure. `--headed` shows the browser.
|
||||
// Steps: load (timing), the GPU path (WebGPU → GPU compositor, viewer frames on the GPU), demo
|
||||
// comp in the viewer (screenshot, viewer pixels), `render.frame` through `window.effectcraft`,
|
||||
// Render Queue GIF and PNG-sequence (.zip) exports (downloads), a background Render Queue job in
|
||||
// a Web Worker while the page stays responsive (event-loop gaps measured), audio (the
|
||||
// AudioContext starts on a user gesture; preview playback feeds it), File ▸ Save As (download),
|
||||
// re-opening the saved project through `effectcraft.addFile`, persistence across a reload (the
|
||||
// session, imported media, settings, a project saved to browser storage, Open Recent) and an
|
||||
// offline reload through the service worker. Prints a JSON report; exits non-zero on failure.
|
||||
// `--headed` shows the browser.
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -85,6 +90,22 @@ async function js(expr, timeout = 300000) {
|
|||
return r.result.value;
|
||||
}
|
||||
|
||||
const check = (cond, msg) => {
|
||||
if (!cond) throw new Error(`check failed: ${msg}`);
|
||||
};
|
||||
|
||||
// Wait until the app runs (after a navigation or reload).
|
||||
async function ready(ms = 180000) {
|
||||
await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", ms);
|
||||
const load = await js("window.effectcraftLoad");
|
||||
if (load.error) throw new Error(load.error);
|
||||
return load;
|
||||
}
|
||||
|
||||
async function idle() {
|
||||
await until(`effectcraft.execute("renderQueue.list", {}).then(r => !r.rendering)`, 300000, 200);
|
||||
}
|
||||
|
||||
async function shot(name) {
|
||||
const r = await send("Page.captureScreenshot", { format: "png" });
|
||||
const p = join(out, `${name}.png`);
|
||||
|
|
@ -121,11 +142,11 @@ try {
|
|||
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const t0 = Date.now();
|
||||
await send("Page.navigate", { url });
|
||||
await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", 180000);
|
||||
report.load = await js("window.effectcraftLoad");
|
||||
report.load = await ready();
|
||||
report.load.wallMs = Date.now() - t0;
|
||||
if (report.load.error) throw new Error(report.load.error);
|
||||
report.info = await js("effectcraft.info()");
|
||||
// Storage: OPFS where the browser writes it (Chrome does), else IndexedDB.
|
||||
check(["opfs", "indexeddb"].includes(report.info.storage?.backend), `storage backend ${JSON.stringify(report.info.storage)}`);
|
||||
|
||||
// The demo comp in the viewer: wait for the first viewer frame (renderMs > 0).
|
||||
const ms = await until("effectcraft.inspect().then(i => i.renderMs > 0)", 120000, 500);
|
||||
|
|
@ -133,6 +154,15 @@ try {
|
|||
const insp = await js("effectcraft.inspect()");
|
||||
report.steps.viewer = { waitMs: ms, renderMs: insp.renderMs, activeComp: insp.activeComp, window: insp.window, screenshot: await shot("01-demo") };
|
||||
|
||||
// GPU: with WebGPU, eframe runs on it and the compositor renders viewer frames on the GPU
|
||||
// (no readback); without it, WebGL2 / CPU.
|
||||
const info = await js("effectcraft.info()");
|
||||
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
|
||||
if (info.webgpu && info.backend === "BrowserWebGpu") {
|
||||
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
|
||||
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
|
||||
}
|
||||
|
||||
// Comp pixels through the API.
|
||||
const f = await js("effectcraft.renderFrame({max_side: 480}).then(r => ({w: r.width, h: r.height, pngBytes: atob(r.png).length}))");
|
||||
report.steps.renderFrame = f;
|
||||
|
|
@ -145,12 +175,74 @@ try {
|
|||
await js(`effectcraft.execute("renderQueue.render", {})`);
|
||||
const gif = await waitDownload((n) => n.endsWith(".gif"));
|
||||
report.steps.gifExport = { ...gif, ms: Date.now() - rqT };
|
||||
await idle();
|
||||
|
||||
// An image sequence arrives as one .zip.
|
||||
await js(`effectcraft.execute("renderQueue.setRender", {index: 1, render: false})`);
|
||||
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "png", output: "seq_[####].png", resolution: 0.125, timeSpan: "custom", start: 0, end: 0.2}})`);
|
||||
await js(`effectcraft.execute("renderQueue.render", {})`);
|
||||
report.steps.pngSequence = await waitDownload((n) => n.endsWith(".zip"));
|
||||
await idle();
|
||||
|
||||
// A background render (`wait: false`, what the Render button does) runs in a Web Worker: the
|
||||
// page keeps its event loop. Measure the longest gap between 10 ms timers while it renders.
|
||||
await js(`effectcraft.execute("renderQueue.setRender", {index: 2, render: false})`);
|
||||
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "bg_[compName].gif", resolution: 0.5, timeSpan: "custom", start: 0, end: 4}})`);
|
||||
report.steps.backgroundRender = await js(`(async () => {
|
||||
const t0 = performance.now();
|
||||
const r = await effectcraft.execute("renderQueue.render", {wait: false});
|
||||
const callMs = performance.now() - t0;
|
||||
let maxGapMs = 0, last = performance.now(), ticks = 0, progress = 0;
|
||||
for (;;) {
|
||||
await new Promise((res) => setTimeout(res, 10));
|
||||
const now = performance.now();
|
||||
maxGapMs = Math.max(maxGapMs, now - last);
|
||||
last = now;
|
||||
if (++ticks % 5) continue;
|
||||
const l = await effectcraft.execute("renderQueue.list", {});
|
||||
if (l.progress) progress = Math.max(progress, l.progress.done);
|
||||
if (!l.rendering) break;
|
||||
}
|
||||
return {startedRendering: r.rendering, callMs, renderMs: performance.now() - t0, maxGapMs, ticks, progressFrames: progress, workers: effectcraft.info().workers};
|
||||
})()`);
|
||||
const bg = report.steps.backgroundRender;
|
||||
bg.download = await waitDownload((n) => n.startsWith("bg_") && n.endsWith(".gif"));
|
||||
check(bg.startedRendering, "render started in the background");
|
||||
check(bg.maxGapMs < 400, `event loop blocked ${bg.maxGapMs} ms during the render`);
|
||||
check(bg.progressFrames > 0, "progress reported while rendering");
|
||||
|
||||
// Audio: the AudioContext starts on a user gesture.
|
||||
report.steps.audio = { before: (await js("effectcraft.info()")).audio };
|
||||
check(report.steps.audio.before.state !== "running", "audio context not running before a gesture");
|
||||
// (a click in the window's bottom-right corner, the status bar: modifier keys alone don't
|
||||
// count as user activation)
|
||||
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1590, y: 995, button: "left", clickCount: 1 });
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: 1590, y: 995, button: "left", clickCount: 1 });
|
||||
await until("effectcraft.info().audio.state === 'running'", 10000);
|
||||
report.steps.audio.afterGesture = (await js("effectcraft.info()")).audio;
|
||||
// A 2 s tone in the comp; preview playback feeds the output and the meters.
|
||||
await js(`(async () => {
|
||||
const rate = 48000, n = 2 * rate, b = new ArrayBuffer(44 + n * 4), v = new DataView(b);
|
||||
const w = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
|
||||
w(0, "RIFF"); v.setUint32(4, 36 + n * 4, true); w(8, "WAVE"); w(12, "fmt "); v.setUint32(16, 16, true);
|
||||
v.setUint16(20, 1, true); v.setUint16(22, 2, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 4, true);
|
||||
v.setUint16(32, 4, true); v.setUint16(34, 16, true); w(36, "data"); v.setUint32(40, n * 4, true);
|
||||
for (let i = 0; i < n; i++) { const x = Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 0.5 * 32767); v.setInt16(44 + 4 * i, x, true); v.setInt16(46 + 4 * i, x, true); }
|
||||
return effectcraft.addFile(new File([b], "tone.wav"));
|
||||
})()`);
|
||||
await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300);
|
||||
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
||||
await js(`effectcraft.execute("time.set", {time: 0})`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "play"})`);
|
||||
await sleep(1500);
|
||||
const pb = await js(`effectcraft.request("ui.playback", {action: "status"})`);
|
||||
const au = (await js("effectcraft.info()")).audio;
|
||||
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
||||
report.steps.audio.playing = { playback: pb, output: au };
|
||||
check(pb.audio, "audio preview running");
|
||||
check(au.posted > 0.5 * au.sampleRate, `audio fed (${au.posted} frames)`);
|
||||
check(au.played > 0, "audio clock advancing");
|
||||
check(Math.max(...(pb.levelsDb || [-99])) > -20, `meters (${JSON.stringify(pb.levelsDb)})`);
|
||||
|
||||
// Save As → .ecproj download; reopen it.
|
||||
await js(`effectcraft.execute("file.saveAs", {path: "/smoke.ecproj"})`);
|
||||
|
|
@ -162,6 +254,60 @@ try {
|
|||
if (!comps) throw new Error("reopened project has no active comp");
|
||||
report.steps.reopen = { ...opened, activeComp: comps };
|
||||
report.steps.final = { screenshot: await shot("02-after") };
|
||||
|
||||
// Persistence: the session, imported media, settings and a project saved to browser storage
|
||||
// survive a reload; File ▸ Open Recent reopens the project.
|
||||
await js(`effectcraft.execute("layer.newSolid", {name: "Persisted Solid", color: "#22aa66"})`);
|
||||
await js(`effectcraft.execute("prefs.set", {key: "general.recentItems", value: 7})`);
|
||||
const saved = await js(`effectcraft.saveToBrowser("/persist-test.ecproj")`);
|
||||
await js(`effectcraft.execute("layer.newSolid", {name: "Unsaved Solid"})`);
|
||||
await sleep(1500); // the session snapshot is taken at most every second
|
||||
await js("effectcraft.flush()");
|
||||
const before = await js("effectcraft.listStored()");
|
||||
const tr = Date.now();
|
||||
await send("Page.reload", {});
|
||||
await sleep(500);
|
||||
const load2 = await ready();
|
||||
const after = await js("effectcraft.listStored()");
|
||||
const info2 = await js("effectcraft.info()");
|
||||
const layers = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
|
||||
const recentItems = await js(`effectcraft.execute("prefs.get", {key: "general.recentItems"})`);
|
||||
const rec = await js(`effectcraft.execute("file.recoveryInfo", {})`);
|
||||
const paths = after.files.map((f) => f.path);
|
||||
report.steps.persistence = { saved, reloadMs: Date.now() - tr, load: load2, restored: info2.restored, storage: info2.storage, layers, recentItems, recent: rec.recentProjects, stored: paths, usage: after.usage, filesBefore: before.files.length };
|
||||
check(info2.restored, "session restored after reload");
|
||||
check(layers.includes("Persisted Solid") && layers.includes("Unsaved Solid"), `layers after reload: ${layers}`);
|
||||
check(paths.includes("/tone.wav") && paths.includes("/persist-test.ecproj"), `stored files: ${paths}`);
|
||||
check(recentItems === 7, `setting after reload: ${recentItems}`);
|
||||
check(rec.recentProjects[0] === "/persist-test.ecproj", `recent projects: ${rec.recentProjects}`);
|
||||
await js(`effectcraft.execute("file.openRecent", {index: 0})`);
|
||||
const reopened = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
|
||||
check(reopened.includes("Persisted Solid") && !reopened.includes("Unsaved Solid"), `Open Recent: ${reopened}`);
|
||||
// The imported tone is still in the project and decodes from storage after the reload.
|
||||
const tone = await js(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => "ok", (e) => String(e))`);
|
||||
check(tone === "ok", `tone.wav after reload: ${tone}`);
|
||||
report.steps.persistence.openRecent = reopened;
|
||||
|
||||
// PWA: manifest, service worker in control, and an offline reload.
|
||||
const pwa = await js(`(async () => {
|
||||
const m = await (await fetch("manifest.webmanifest")).json();
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const keys = await caches.keys();
|
||||
const cached = keys.length ? (await (await caches.open(keys[0])).keys()).length : 0;
|
||||
return {manifest: m.name, icons: m.icons.length, display: m.display, sw: !!(reg && reg.active), controlled: !!navigator.serviceWorker.controller, caches: keys, cached};
|
||||
})()`);
|
||||
check(pwa.sw && pwa.cached > 4, `service worker: ${JSON.stringify(pwa)}`);
|
||||
await send("Network.enable", {});
|
||||
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||||
await send("Page.reload", {});
|
||||
await sleep(500);
|
||||
const offline = await ready();
|
||||
const info3 = await js("effectcraft.info()");
|
||||
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||||
report.steps.pwa = { ...pwa, offlineLoad: offline, offlineControlled: info3.serviceWorker, crossOriginIsolated: info3.crossOriginIsolated };
|
||||
check(info3.serviceWorker, "offline reload served by the service worker");
|
||||
check(info3.crossOriginIsolated, "cross-origin isolated under the service worker");
|
||||
report.steps.final2 = { screenshot: await shot("03-offline") };
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
report.error = String(e && e.stack || e);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
// EffectCraft audio output (AudioWorklet): plays interleaved stereo blocks posted by the page
|
||||
// (src/audio.rs feeds them from the preview mixdown). Running dry plays silence, reported back
|
||||
// as underrun frames.
|
||||
class EffectcraftOutput extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.queue = [];
|
||||
this.offset = 0;
|
||||
this.stopped = false;
|
||||
this.port.onmessage = (e) => {
|
||||
if (e.data === "stop") this.stopped = true;
|
||||
else this.queue.push(e.data);
|
||||
};
|
||||
}
|
||||
|
||||
process(_inputs, outputs) {
|
||||
const out = outputs[0];
|
||||
const l = out[0];
|
||||
const r = out[1] ?? out[0];
|
||||
let i = 0;
|
||||
while (i < l.length && this.queue.length) {
|
||||
const b = this.queue[0];
|
||||
const n = Math.min(l.length - i, (b.length - this.offset) >> 1);
|
||||
for (let k = 0; k < n; k++) {
|
||||
l[i + k] = b[this.offset + 2 * k];
|
||||
r[i + k] = b[this.offset + 2 * k + 1];
|
||||
}
|
||||
i += n;
|
||||
this.offset += 2 * n;
|
||||
if (this.offset >= b.length) {
|
||||
this.queue.shift();
|
||||
this.offset = 0;
|
||||
}
|
||||
}
|
||||
if (i < l.length) {
|
||||
l.fill(0, i);
|
||||
r.fill(0, i);
|
||||
if (!this.stopped) this.port.postMessage({ underrun: l.length - i });
|
||||
}
|
||||
return !this.stopped;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("effectcraft-output", EffectcraftOutput);
|
||||
|
|
@ -4,8 +4,12 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#1b1c1f">
|
||||
<meta name="description" content="EffectCraft: motion graphics and visual effects in the browser">
|
||||
<title>EffectCraft</title>
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="icon-256.png">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #1b1c1f; }
|
||||
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; outline: none; }
|
||||
|
|
@ -46,10 +50,34 @@
|
|||
}
|
||||
return ec.addFile(name, bytes);
|
||||
},
|
||||
info: () => ({ ...ec.info(), crossOriginIsolated: self.crossOriginIsolated, load: window.effectcraftLoad }),
|
||||
info: () => ({
|
||||
...ec.info(),
|
||||
crossOriginIsolated: self.crossOriginIsolated,
|
||||
webgpu: !!navigator.gpu,
|
||||
serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller),
|
||||
load: window.effectcraftLoad,
|
||||
}),
|
||||
// Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes.
|
||||
saveToBrowser: (path) => ec.saveToBrowser(path ?? undefined),
|
||||
listStored: () => ec.listStored(),
|
||||
removeStored: (path) => ec.removeStored(path),
|
||||
flush: () => ec.flush(),
|
||||
};
|
||||
// Offline start and install (PWA): the service worker caches the app's files.
|
||||
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
}
|
||||
try {
|
||||
await init();
|
||||
// Compile once: the page and its render workers instantiate the same module.
|
||||
const wasmUrl = new URL("effectcraft_web_bg.wasm", location.href);
|
||||
let module;
|
||||
try {
|
||||
module = await WebAssembly.compileStreaming(fetch(wasmUrl));
|
||||
} catch {
|
||||
module = await WebAssembly.compile(await (await fetch(wasmUrl)).arrayBuffer());
|
||||
}
|
||||
window.effectcraftModule = module;
|
||||
await init({ module_or_path: module });
|
||||
window.effectcraftLoad.wasmMs = performance.now() - t0;
|
||||
await ec.start("effectcraft_canvas");
|
||||
window.effectcraftLoad.readyMs = performance.now() - t0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "EffectCraft",
|
||||
"short_name": "EffectCraft",
|
||||
"description": "Motion graphics and visual effects compositor, in the browser",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1b1c1f",
|
||||
"theme_color": "#1b1c1f",
|
||||
"categories": ["graphics", "photo", "productivity"],
|
||||
"icons": [
|
||||
{ "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||
{ "src": "icon-256.png", "sizes": "256x256", "type": "image/png" },
|
||||
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// EffectCraft service worker: the app works offline once loaded, and installs as an app.
|
||||
//
|
||||
// `cargo xtask web` fills in the build's file list and version. Every file is precached into a
|
||||
// cache named after the version, served cache-first (responses keep the server's headers, so
|
||||
// the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time.
|
||||
const VERSION = "__EC_VERSION__";
|
||||
const FILES = __EC_FILES__;
|
||||
const CACHE = `effectcraft-${VERSION}`;
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE)
|
||||
.then((c) => c.addAll(FILES.map((f) => new Request(f, { cache: "reload" }))))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k.startsWith("effectcraft-") && k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// The page itself regardless of its query (?empty, ?demo…).
|
||||
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
|
||||
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
if (res.ok && res.type === "basic") cache.put(req, res.clone());
|
||||
return res;
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// An EffectCraft engine instance in a Web Worker: renders the Render Queue and runs analyses
|
||||
// off the page's thread (src/worker.rs, crates/engine/src/offload.rs). The page sends the
|
||||
// compiled module, then footage files and jobs; the worker replies with JSON messages and
|
||||
// rendered files.
|
||||
import init, * as ec from "./effectcraft_web.js";
|
||||
|
||||
let ready = null;
|
||||
self.onmessage = async (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "init") {
|
||||
ready = init({ module_or_path: m.module ?? new URL("effectcraft_web_bg.wasm", m.base) }).then(() => ec.workerInit());
|
||||
await ready;
|
||||
self.postMessage({ type: "ready" });
|
||||
return;
|
||||
}
|
||||
await ready;
|
||||
if (m.type === "file") ec.workerFile(m.path, m.bytes);
|
||||
else if (m.type === "job") ec.workerJob(m.json);
|
||||
};
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
//! 3D Camera Tracker (Effect ▸ Perspective ▸ 3D Camera Tracker; Animation ▸ Track Camera).
|
||||
//!
|
||||
//! The analysis runs as a background job in the engine (`camera.analyze`): step 1 tracks
|
||||
//! features through the clip ([`effectcraft_track::camtrack::TrackAnalyzer`]), step 2 solves
|
||||
//! the camera ([`effectcraft_track::camtrack::solve`]). Both results are stored as JSON in the
|
||||
//! instance's hidden parameters — **Tracks** (with **Tracks Key**: what the frames were made
|
||||
//! from, so source / In-Out / time edits invalidate them) and **Solve** (with **Solve Key**: the
|
||||
//! Shot Type, Angle of View, Solve Method and deleted points it was solved with, so changing
|
||||
//! those re-solves without re-tracking). **Method Used** and **Average Error** report the solve.
|
||||
//!
|
||||
//! The effect passes its input through; with **Render Track Points** on it draws the solved
|
||||
//! points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
|
||||
//! commands live in the engine and the UI.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use effectcraft_track::camtrack::{CameraSolve, CameraTracks, ShotType, SolveMethod, SolveSettings, linalg, point_color};
|
||||
|
||||
use crate::warp_stab::param;
|
||||
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
|
||||
|
||||
/// The effect's id.
|
||||
pub const ID: &str = "ec.perspective.cameratracker";
|
||||
|
||||
/// Hidden parameters (see the module docs).
|
||||
pub const TRACKS: &str = "tracks";
|
||||
pub const TRACKS_KEY: &str = "tracksKey";
|
||||
pub const SOLVE: &str = "solve";
|
||||
pub const SOLVE_KEY: &str = "solveKey";
|
||||
/// Deleted track point ids (JSON array).
|
||||
pub const DELETED: &str = "deleted";
|
||||
/// The "3D Tracker Camera" layer the instance created (layer id, 0 = none).
|
||||
pub const CAMERA: &str = "camera";
|
||||
pub const METHOD_USED: &str = "advanced/methodUsed";
|
||||
pub const AVERAGE_ERROR: &str = "advanced/averageError";
|
||||
|
||||
pub const SHOT_TYPES: [&str; 3] = ["Fixed Angle of View", "Variable Zoom", "Specify Angle of View"];
|
||||
pub const SHOW_POINTS: [&str; 2] = ["2D Source", "3D Solved"];
|
||||
pub const SOLVE_METHODS: [&str; 4] = ["Auto Detect", "Typical", "Mostly Flat Scene", "Tripod Pan"];
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![EffectSpec {
|
||||
id: ID,
|
||||
name: "3D Camera Tracker",
|
||||
category: "Perspective",
|
||||
params: vec![
|
||||
p("shotType", "Shot Type", Value::Enum(0), popup(&SHOT_TYPES)),
|
||||
p("horizontalAngleOfView", "Horizontal Angle of View", num(40.0), slider(1.0, 170.0, 10.0, 120.0, 1)),
|
||||
p("showTrackPoints", "Show Track Points", Value::Enum(1), popup(&SHOW_POINTS)),
|
||||
p("renderTrackPoints", "Render Track Points", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("trackPointSize", "Track Point Size", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 0)),
|
||||
p("targetSize", "Target Size", num(100.0), slider(1.0, 1000.0, 10.0, 300.0, 0)),
|
||||
p("advanced/solveMethod", "Solve Method", Value::Enum(0), popup(&SOLVE_METHODS)),
|
||||
p(METHOD_USED, "Method Used", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p(AVERAGE_ERROR, "Average Error", num(0.0), ParamUi::Hidden),
|
||||
p("advanced/detailedAnalysis", "Detailed Analysis", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("advanced/autoDeletePoints", "Auto-delete Points Across Time", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("advanced/hideWarningBanner", "Hide Warning Banner", Value::Bool(false), ParamUi::Checkbox),
|
||||
p(TRACKS, "Tracks", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p(TRACKS_KEY, "Tracks Key", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p(SOLVE, "Solve", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p(SOLVE_KEY, "Solve Key", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p(DELETED, "Deleted Points", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p(CAMERA, "Camera Layer", num(0.0), ParamUi::Hidden),
|
||||
],
|
||||
render,
|
||||
gpu: false,
|
||||
float: true,
|
||||
}]
|
||||
}
|
||||
|
||||
fn f(params: &Params, id: &str) -> f64 {
|
||||
param(params, id).map(Value::as_f64).unwrap_or(0.0)
|
||||
}
|
||||
fn e(params: &Params, id: &str) -> u32 {
|
||||
param(params, id).map(Value::as_enum).unwrap_or(0)
|
||||
}
|
||||
fn b(params: &Params, id: &str) -> bool {
|
||||
param(params, id).map(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
fn s<'a>(params: &'a Params, id: &str) -> &'a str {
|
||||
match param(params, id) {
|
||||
Some(Value::Str(s)) => s,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Deleted point ids of an instance (stored as a JSON array of numbers).
|
||||
pub fn deleted(params: &Params) -> Vec<u32> {
|
||||
s(params, DELETED).trim().trim_start_matches('[').trim_end_matches(']').split(',').filter_map(|v| v.trim().parse().ok()).collect()
|
||||
}
|
||||
|
||||
/// The solve settings of an instance.
|
||||
pub fn settings(params: &Params) -> SolveSettings {
|
||||
let mut del = deleted(params);
|
||||
del.sort_unstable();
|
||||
del.dedup();
|
||||
SolveSettings {
|
||||
shot: ShotType::ALL.get(e(params, "shotType") as usize).copied().unwrap_or_default(),
|
||||
hfov: f(params, "horizontalAngleOfView"),
|
||||
method: SolveMethod::ALL.get(e(params, "advanced/solveMethod") as usize).copied().unwrap_or_default(),
|
||||
deleted: del,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a solve depends on besides the tracks.
|
||||
pub fn solve_key(st: &SolveSettings) -> String {
|
||||
let hfov = if st.shot == ShotType::SpecifyAngle { format!("{:.4}", st.hfov) } else { String::new() };
|
||||
format!("{:?}|{hfov}|{:?}|{:?}", st.shot, st.method, st.deleted)
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
type Cache<T> = Mutex<HashMap<u64, Arc<T>>>;
|
||||
|
||||
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
|
||||
let key = fnv(json.as_bytes());
|
||||
let c = c.get_or_init(Default::default);
|
||||
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return Some(a.clone());
|
||||
}
|
||||
let a = Arc::new(parse(json)?);
|
||||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, a.clone());
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// The stored feature tracks of an instance (`None` when not analysed).
|
||||
pub fn tracks(params: &Params) -> Option<Arc<CameraTracks>> {
|
||||
static C: OnceLock<Cache<CameraTracks>> = OnceLock::new();
|
||||
let j = s(params, TRACKS);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// The stored camera solve of an instance (`None` when not solved).
|
||||
pub fn solve(params: &Params) -> Option<Arc<CameraSolve>> {
|
||||
static C: OnceLock<Cache<CameraSolve>> = OnceLock::new();
|
||||
let j = s(params, SOLVE);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the
|
||||
/// median depth of the frame's points.
|
||||
pub fn target_radius(size_pct: f64, z: f64, median_z: f64) -> f64 {
|
||||
(6.0 * size_pct / 100.0 * (median_z / z.max(1e-9))).clamp(1.5, 40.0)
|
||||
}
|
||||
|
||||
/// The solved points visible on frame `k`: (id, layer pixel position, camera depth).
|
||||
pub fn projected(solve: &CameraSolve, k: usize) -> Vec<(u32, [f64; 2], f64)> {
|
||||
let Some(cam) = solve.frames.get(k) else { return vec![] };
|
||||
solve
|
||||
.visible(k)
|
||||
.filter_map(|p| {
|
||||
let c = cam.to_cam(p.pos);
|
||||
cam.project(solve.size, p.pos).map(|q| (p.id, q, c[2]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Median of the depths.
|
||||
pub fn median_depth(v: &[(u32, [f64; 2], f64)]) -> f64 {
|
||||
let mut z: Vec<f64> = v.iter().map(|x| x.2).collect();
|
||||
if z.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
z.sort_by(f64::total_cmp);
|
||||
z[z.len() / 2]
|
||||
}
|
||||
|
||||
fn render(ctx: &EffectCtx, mut buf: Buf) -> Buf {
|
||||
if !b(ctx.params, "renderTrackPoints") {
|
||||
return buf;
|
||||
}
|
||||
let Some(sol) = solve(ctx.params) else { return buf };
|
||||
if (sol.size[0] - ctx.layer_size[0]).abs() > 0.5 || (sol.size[1] - ctx.layer_size[1]).abs() > 0.5 {
|
||||
return buf;
|
||||
}
|
||||
let Some(k) = sol.frame_at(ctx.time) else { return buf };
|
||||
let pts = projected(&sol, k);
|
||||
let med = median_depth(&pts);
|
||||
let size = f(ctx.params, "trackPointSize");
|
||||
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
|
||||
for (id, q, z) in pts {
|
||||
let c = point_color(id);
|
||||
let r = target_radius(size, z, med) * sc;
|
||||
let at = [q[0] * sc + buf.offset[0], q[1] * sc + buf.offset[1]];
|
||||
ring(&mut buf.img, at, r, (r * 0.25).max(1.0), [c[0], c[1], c[2], 1.0]);
|
||||
disc(&mut buf.img, at, (r * 0.2).max(0.8), [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn lerp_px(a: Px, b: Px, t: f32) -> Px {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]
|
||||
}
|
||||
|
||||
fn cover(img: &mut Image, c: [f64; 2], r_out: f64, cov: impl Fn(f64) -> f64, col: Px) {
|
||||
let (x0, x1) = ((c[0] - r_out - 1.0).floor().max(0.0) as i64, (c[0] + r_out + 1.0).ceil() as i64);
|
||||
let (y0, y1) = ((c[1] - r_out - 1.0).floor().max(0.0) as i64, (c[1] + r_out + 1.0).ceil() as i64);
|
||||
for y in y0..y1.min(img.height as i64) {
|
||||
for x in x0..x1.min(img.width as i64) {
|
||||
let d = (x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1]);
|
||||
let a = cov(d).clamp(0.0, 1.0) as f32;
|
||||
if a > 0.0 {
|
||||
let i = img.idx(x as u32, y as u32);
|
||||
img.data[i] = lerp_px(img.data[i], col, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disc(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
|
||||
cover(img, c, r, |d| r + 0.5 - d, col);
|
||||
}
|
||||
|
||||
fn ring(img: &mut Image, c: [f64; 2], r: f64, w: f64, col: Px) {
|
||||
cover(img, c, r + w, |d| (w * 0.5 + 0.5 - (d - r).abs()).min(1.0), col);
|
||||
}
|
||||
|
||||
/// Points of a target disc (canonical coordinates) for drawing: `n` points on the circle of
|
||||
/// radius `size / 2` in the plane through `center` with normal `normal`.
|
||||
pub fn target_circle(center: linalg::V3, normal: linalg::V3, radius: f64, n: usize) -> Vec<linalg::V3> {
|
||||
let a = if normal[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
|
||||
let u = linalg::normalize(linalg::cross(normal, a));
|
||||
let v = linalg::cross(normal, u);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let t = i as f64 / n as f64 * std::f64::consts::TAU;
|
||||
linalg::add(center, linalg::add(linalg::scale(u, radius * t.cos()), linalg::scale(v, radius * t.sin())))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EffectEnv;
|
||||
use effectcraft_track::camtrack::{SolvedFrame, SolvedPoint};
|
||||
|
||||
fn params_with(solve: &CameraSolve, render: bool) -> Params {
|
||||
let mut p = Params::default();
|
||||
p.values.insert(SOLVE.into(), Value::Str(solve.to_json()));
|
||||
p.values.insert("renderTrackPoints".into(), Value::Bool(render));
|
||||
p.values.insert("trackPointSize".into(), Value::Scalar(100.0));
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_track_points_draws_targets_and_passes_through_otherwise() {
|
||||
let sol = CameraSolve {
|
||||
version: 1,
|
||||
size: [64.0, 48.0],
|
||||
start: 0.0,
|
||||
frame_duration: 1.0 / 30.0,
|
||||
frames: vec![SolvedFrame { rot: [0.0; 3], center: [0.0; 3], focal: 50.0, solved: true }],
|
||||
points: vec![SolvedPoint { id: 3, pos: [0.0, 0.0, 1.0], error: 0.1, first: 0, last: 0 }],
|
||||
..Default::default()
|
||||
};
|
||||
let img = Image::new(64, 48);
|
||||
for on in [false, true] {
|
||||
let params = params_with(&sol, on);
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 48.0], seed: 0, adjustment: false, env: EffectEnv::default() };
|
||||
let out = render(&ctx, Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 });
|
||||
let px = out.img.data[out.img.idx(32, 24)];
|
||||
assert_eq!(px[3] > 0.5, on);
|
||||
}
|
||||
let st = settings(&Params::default());
|
||||
assert_eq!(st.shot, ShotType::FixedAngle);
|
||||
assert!(solve_key(&st).contains("FixedAngle"));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
pub mod audio_fx;
|
||||
mod blur2;
|
||||
mod blur3;
|
||||
pub mod camera_tracker;
|
||||
mod channel;
|
||||
mod channel2;
|
||||
mod channel3d;
|
||||
|
|
@ -382,6 +383,7 @@ pub fn registry() -> &'static [EffectSpec] {
|
|||
v.extend(paint::specs());
|
||||
v.extend(puppet::specs());
|
||||
v.extend(warp_stab::specs());
|
||||
v.extend(camera_tracker::specs());
|
||||
v.extend(roto::specs());
|
||||
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
|
||||
for s in v.iter_mut() {
|
||||
|
|
@ -530,6 +532,8 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
|||
"ec.obsolete.mochashape",
|
||||
// Each frame gets its own stabilizing warp.
|
||||
warp_stab::ID,
|
||||
// Render Track Points draws each frame's solved points.
|
||||
camera_tracker::ID,
|
||||
// Each frame has its own segmentation.
|
||||
roto::ID,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ConfigStore;
|
||||
use crate::config::{ConfigStore, FileOps, StdFiles};
|
||||
use crate::prefs::Prefs;
|
||||
|
||||
/// Folder created next to a project for its auto-saves.
|
||||
|
|
@ -68,16 +68,12 @@ pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) ->
|
|||
|
||||
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
|
||||
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let n = slot_of(stem, &name)?;
|
||||
let m = e.metadata().ok()?.modified().ok()?;
|
||||
Some((n, e.path(), m))
|
||||
})
|
||||
.collect();
|
||||
existing_in(&StdFiles, dir, stem)
|
||||
}
|
||||
|
||||
/// [`existing`] through `fs`.
|
||||
pub fn existing_in(fs: &dyn FileOps, dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
|
||||
let mut v: Vec<_> = fs.list(dir).into_iter().filter_map(|(name, m)| Some((slot_of(stem, &name)?, dir.join(&name), m))).collect();
|
||||
v.sort_by_key(|x| x.0);
|
||||
v
|
||||
}
|
||||
|
|
@ -95,18 +91,29 @@ pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Optio
|
|||
|
||||
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
|
||||
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
|
||||
write_in(&StdFiles, prefs, project, fallback, last_slot, json)
|
||||
}
|
||||
|
||||
/// [`write`] through `fs`.
|
||||
pub fn write_in(
|
||||
fs: &dyn FileOps,
|
||||
prefs: &Prefs,
|
||||
project: Option<&str>,
|
||||
fallback: Option<&Path>,
|
||||
last_slot: Option<u32>,
|
||||
json: &str,
|
||||
) -> std::io::Result<(PathBuf, u32)> {
|
||||
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let stem = project_stem(project);
|
||||
let max = prefs.auto_save.max_versions.max(1);
|
||||
let have = existing(&dir, &stem);
|
||||
let have = existing_in(fs, &dir, &stem);
|
||||
let slot = next_slot(&have, last_slot, max);
|
||||
let path = dir.join(file_name(&stem, slot));
|
||||
crate::config::atomic_write(&path, json.as_bytes())?;
|
||||
fs.write(&path, json.as_bytes())?;
|
||||
// Max versions lowered: drop slots above it.
|
||||
for (n, p, _) in have {
|
||||
if n > max {
|
||||
let _ = std::fs::remove_file(p);
|
||||
let _ = fs.remove(&p);
|
||||
}
|
||||
}
|
||||
Ok((path, slot))
|
||||
|
|
@ -114,8 +121,13 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last
|
|||
|
||||
/// The newest auto-save of a project, if any.
|
||||
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
latest_in(&StdFiles, prefs, project, fallback)
|
||||
}
|
||||
|
||||
/// [`latest`] through `fs`.
|
||||
pub fn latest_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
let dir = folder(prefs, project, fallback)?;
|
||||
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
|
||||
existing_in(fs, &dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
|
||||
}
|
||||
|
||||
/// What the crash-recovery sentinel records.
|
||||
|
|
@ -151,8 +163,8 @@ pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
|
|||
let autosave = s
|
||||
.autosave
|
||||
.clone()
|
||||
.filter(|p| Path::new(p).is_file())
|
||||
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
|
||||
.filter(|p| store.files().is_file(Path::new(p)))
|
||||
.or_else(|| latest_in(store.files(), prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
|
||||
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,655 @@
|
|||
//! 3D Camera Tracker runtime: the analysis job (`camera.analyze`, `track.camera`), keeping
|
||||
//! analyses valid, and turning a solve into composition layers (`camera.createFromSolve`).
|
||||
//!
|
||||
//! The job renders the layer's input to the effect (`Renderer::layer_input`) for every frame
|
||||
//! between the layer's In and Out points on a background thread and tracks features through
|
||||
//! them (step 1 of 2, "Analyzing in background"), then solves the camera (step 2, "Solving
|
||||
//! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method,
|
||||
//! deleted points) the stored tracks are re-solved without re-tracking. Results are written into
|
||||
//! the effect's hidden parameters as one undo step; edits that change the layer's frames clear
|
||||
//! them and queue the effect again ([`invalidate`], run after every edit), like After Effects.
|
||||
//!
|
||||
//! Composition space: the solve's canonical frame maps to comp world by
|
||||
//! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's
|
||||
//! scale in the comp), so the "3D Tracker Camera" reproduces the footage's view and created
|
||||
//! layers sit on the selected points.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_effects::camera_tracker::{self as ct, AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY, TRACKS, TRACKS_KEY};
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::{AutoOrient, GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
|
||||
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::camtrack::linalg::{self, Similarity, V3};
|
||||
use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::offload::JobKind;
|
||||
use crate::tracking::lock;
|
||||
use crate::warp::{analysis_times, signature};
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// Live analysis progress (serde for agents / the control channel).
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CameraProgress {
|
||||
/// 1 = tracking features, 2 = solving the camera.
|
||||
pub step: u8,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub elapsed: f64,
|
||||
pub fps: f64,
|
||||
pub finished: bool,
|
||||
pub cancelled: bool,
|
||||
pub error: Option<String>,
|
||||
/// Re-solving stored tracks (no tracking step).
|
||||
pub solve_only: bool,
|
||||
}
|
||||
|
||||
impl CameraProgress {
|
||||
/// The viewer banner text, as After Effects words it.
|
||||
pub fn banner(&self) -> String {
|
||||
match self.step {
|
||||
2 => "Solving camera".into(),
|
||||
_ => {
|
||||
let pc = (100 * self.done).checked_div(self.total).unwrap_or(0);
|
||||
format!("Analyzing in background (step 1 of 2): {pc}%")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CamResult {
|
||||
/// New tracks (None for a re-solve).
|
||||
tracks: Option<CameraTracks>,
|
||||
solve: Result<CameraSolve, String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CameraShared {
|
||||
pub cancel: AtomicBool,
|
||||
pub state: Mutex<CameraProgress>,
|
||||
result: Mutex<Option<CamResult>>,
|
||||
}
|
||||
|
||||
/// A running (or finished, not yet polled) camera analysis.
|
||||
pub struct CameraJob {
|
||||
pub shared: Arc<CameraShared>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
pub effect: Uid,
|
||||
/// The layer signature the tracks are made from, and the solve settings key.
|
||||
key: String,
|
||||
solve_key: String,
|
||||
}
|
||||
|
||||
impl CameraJob {
|
||||
pub fn is_finished(&self) -> bool {
|
||||
lock(&self.shared.state).finished
|
||||
}
|
||||
pub fn progress(&self) -> CameraProgress {
|
||||
lock(&self.shared.state).clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct CamWork {
|
||||
project: Arc<Project>,
|
||||
footage: Arc<dyn FootageSource>,
|
||||
expr: Option<Arc<dyn ExprHost>>,
|
||||
cache: Arc<LayerCache>,
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect_index: usize,
|
||||
size: [f64; 2],
|
||||
detailed: bool,
|
||||
times: Vec<Tick>,
|
||||
/// Stored tracks to re-solve (skips step 1).
|
||||
tracks: Option<Arc<CameraTracks>>,
|
||||
settings: SolveSettings,
|
||||
}
|
||||
|
||||
pub(crate) fn is_tracker(g: &PropGroup) -> bool {
|
||||
matches!(&g.kind, GroupKind::Effect { effect } if effect == ct::ID)
|
||||
}
|
||||
|
||||
fn str_of(g: &PropGroup, m: &str) -> String {
|
||||
match g.prop(m).map(|p| &p.value) {
|
||||
Some(Value::Str(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a tracker's tracks are made from: the layer's frames and Detailed Analysis.
|
||||
fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String {
|
||||
let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool);
|
||||
format!("{}{}", signature(project, cid, l), if detailed { "+detailed" } else { "" })
|
||||
}
|
||||
|
||||
/// Parameters of an instance from static values (the tracker's settings are not animated).
|
||||
pub fn static_params(g: &PropGroup) -> effectcraft_effects::Params {
|
||||
effectcraft_effects::flatten_params(g, &mut |p| p.value.clone())
|
||||
}
|
||||
|
||||
fn clear(g: &mut PropGroup, all: bool) {
|
||||
let mut ids = vec![SOLVE, SOLVE_KEY, METHOD_USED];
|
||||
if all {
|
||||
ids.extend([TRACKS, TRACKS_KEY]);
|
||||
}
|
||||
for m in ids {
|
||||
if let Some(p) = g.prop_mut(m) {
|
||||
p.value = Value::Str(String::new());
|
||||
p.keys.clear();
|
||||
}
|
||||
}
|
||||
if let Some(p) = g.prop_mut(AVERAGE_ERROR) {
|
||||
p.value = Value::Scalar(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear analyses that no longer match, after an edit: tracks made from other frames (and their
|
||||
/// solve), and solves made with other settings. Returns the trackers that need (re-)analysis:
|
||||
/// cleared ones and newly applied, unanalysed ones.
|
||||
pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> {
|
||||
let mut todo = vec![];
|
||||
let mut clears = vec![];
|
||||
for (cid, it) in &after.items {
|
||||
let ItemKind::Comp(c) = &it.kind else { continue };
|
||||
for l in &c.layers {
|
||||
let Some(fx) = l.effects() else { continue };
|
||||
if !fx.groups().any(is_tracker) {
|
||||
continue;
|
||||
}
|
||||
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
|
||||
for g in fx.groups().filter(|g| is_tracker(g)) {
|
||||
let tracked = !str_of(g, TRACKS).is_empty();
|
||||
if tracked && str_of(g, TRACKS_KEY) != tracks_key(after, *cid, l, g) {
|
||||
clears.push((*cid, l.id, g.uid, true));
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
} else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) {
|
||||
clears.push((*cid, l.id, g.uid, false));
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
} else if !tracked && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cid, lid, uid, all) in clears {
|
||||
if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) {
|
||||
clear(g, all);
|
||||
}
|
||||
}
|
||||
todo
|
||||
}
|
||||
|
||||
fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc<effectcraft_raster::Image>, [f64; 2])> {
|
||||
let buf = r.layer_input(ctx, layer, effect_index)?;
|
||||
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
|
||||
let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32;
|
||||
let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32;
|
||||
let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale];
|
||||
return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off));
|
||||
}
|
||||
Some((Arc::new(buf.img.clone()), buf.offset))
|
||||
}
|
||||
|
||||
fn run_work(w: CamWork, shared: &CameraShared) {
|
||||
let t0 = web_time::Instant::now();
|
||||
let fail = |msg: &str| {
|
||||
let mut s = lock(&shared.state);
|
||||
s.error = Some(msg.to_string());
|
||||
s.finished = true;
|
||||
};
|
||||
let cancelled = || {
|
||||
let mut s = lock(&shared.state);
|
||||
s.cancelled = true;
|
||||
s.finished = true;
|
||||
};
|
||||
let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") };
|
||||
let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") };
|
||||
let lt0 = layer.layer_time(w.times[0]).seconds();
|
||||
let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() };
|
||||
let (tracks, fresh) = match w.tracks.clone() {
|
||||
Some(t) => (t, false),
|
||||
None => {
|
||||
let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() });
|
||||
r.expr = w.expr.as_deref();
|
||||
r.cache = Some(&w.cache);
|
||||
let r = &r;
|
||||
let ctx0 = EvalCtx::new(&w.project, w.comp, comp, Tick::ZERO);
|
||||
let ctx0 = EvalCtx { expr: w.expr.as_deref(), ..ctx0 };
|
||||
let frame_at = |t: Tick| input_frame(r, &ctx0.at(t), layer, w.effect_index);
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
s.step = 1;
|
||||
s.total = w.times.len() as u64;
|
||||
}
|
||||
let mut an = TrackAnalyzer::new(w.size, AnalyzeOpts { detailed: w.detailed });
|
||||
let mut next = frame_at(w.times[0]);
|
||||
for k in 0..w.times.len() {
|
||||
if shared.cancel.load(Ordering::Relaxed) {
|
||||
return cancelled();
|
||||
}
|
||||
let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) };
|
||||
let ((), nf) = rayon::join(
|
||||
|| an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }),
|
||||
|| if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None },
|
||||
);
|
||||
next = nf;
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
(Arc::new(an.finish(lt0, fd)), true)
|
||||
}
|
||||
};
|
||||
lock(&shared.state).step = 2;
|
||||
let solve = effectcraft_track::camtrack::solve(&tracks, &w.settings, Some(&shared.cancel)).map_err(|e| e.0);
|
||||
if shared.cancel.load(Ordering::Relaxed) {
|
||||
return cancelled();
|
||||
}
|
||||
*lock(&shared.result) = Some(CamResult { tracks: fresh.then(|| (*tracks).clone()), solve });
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.finished = true;
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn is_camera_analyzing(&self) -> bool {
|
||||
self.camera_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Camera).is_some()
|
||||
}
|
||||
|
||||
/// Progress of the running (or just finished) analysis.
|
||||
pub fn camera_progress(&self) -> Option<CameraProgress> {
|
||||
self.camera_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::Camera).map(|j| {
|
||||
let p = &j.progress;
|
||||
CameraProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: crate::offload::rate(p.done, p.elapsed), ..Default::default() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The tracker being analysed: (comp, layer, effect uid).
|
||||
pub fn camera_target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
self.camera_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Camera).map(|j| (j.comp, j.layer, j.uid)))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (nothing is written).
|
||||
pub fn stop_camera(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::Camera) {
|
||||
return true;
|
||||
}
|
||||
match &self.camera_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start analysing 3D Camera Tracker `effect` on `layer` (re-solving the stored tracks when
|
||||
/// they are still valid). `wait`: block until done (wasm32 always does). Returns the number
|
||||
/// of frames.
|
||||
pub fn start_camera(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result<usize, String> {
|
||||
if self.is_camera_analyzing() {
|
||||
return Err("a 3D Camera Tracker analysis is already running".into());
|
||||
}
|
||||
self.poll_camera_job();
|
||||
self.camera_pending.retain(|x| *x != (comp, layer, effect));
|
||||
let c = self.project.comp(comp).ok_or("no composition")?;
|
||||
let l = c.layer(layer).ok_or("no layer")?;
|
||||
let fx = l.effects().ok_or("the layer has no effects")?;
|
||||
let index = fx.groups().position(|g| g.uid == effect).ok_or("no such effect")?;
|
||||
let g = fx.groups().nth(index).filter(|g| is_tracker(g)).ok_or("not a 3D Camera Tracker")?;
|
||||
if l.switches.adjustment {
|
||||
return Err("3D Camera Tracker needs footage, not an adjustment layer".into());
|
||||
}
|
||||
let times = analysis_times(c, l);
|
||||
if times.len() < 2 {
|
||||
return Err("the layer must be at least two frames long".into());
|
||||
}
|
||||
let (w, h) = effectcraft_render::source_size(&self.project, l);
|
||||
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
|
||||
let params = static_params(g);
|
||||
let key = tracks_key(&self.project, comp, l, g);
|
||||
let settings = ct::settings(¶ms);
|
||||
let solve_key = ct::solve_key(&settings);
|
||||
let tracks = ct::tracks(¶ms).filter(|_| str_of(g, TRACKS_KEY) == key);
|
||||
let n = times.len();
|
||||
let work = CamWork {
|
||||
project: self.project.clone(),
|
||||
footage: self.footage.clone(),
|
||||
expr: self.expr.clone(),
|
||||
cache: self.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
effect_index: index,
|
||||
size,
|
||||
detailed: params.values.iter().any(|(k, v)| k.ends_with("detailedAnalysis") && v.as_bool()),
|
||||
times,
|
||||
tracks: tracks.clone(),
|
||||
settings,
|
||||
};
|
||||
if !wait && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::Camera { comp, layer, effect })?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(CameraShared::default());
|
||||
{
|
||||
let mut st = lock(&shared.state);
|
||||
st.solve_only = tracks.is_some();
|
||||
st.step = if tracks.is_some() { 2 } else { 1 };
|
||||
st.total = n as u64;
|
||||
}
|
||||
let thread = if wait {
|
||||
run_work(work, &shared);
|
||||
None
|
||||
} else {
|
||||
let sh = shared.clone();
|
||||
Some(std::thread::Builder::new().name("camera-track".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?)
|
||||
};
|
||||
self.camera_job = Some(CameraJob { shared, thread, comp, layer, effect, key, solve_key });
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
self.poll_camera_job();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write a finished analysis into its effect (one undo step) and drop the job.
|
||||
fn poll_camera_job(&mut self) -> bool {
|
||||
let Some(job) = &self.camera_job else { return false };
|
||||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let mut job = self.camera_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
let st = job.progress();
|
||||
let result = lock(&job.shared.result).take();
|
||||
match (result, &st.error) {
|
||||
(Some(res), None) if !st.cancelled => {
|
||||
let (cid, lid, uid) = (job.comp, job.layer, job.effect);
|
||||
let (key, skey) = (job.key.clone(), job.solve_key.clone());
|
||||
let tracks_json = res.tracks.as_ref().map(CameraTracks::to_json);
|
||||
let (solve_json, method, avg, msg, failed) = match &res.solve {
|
||||
Ok(s) => (
|
||||
s.to_json(),
|
||||
s.method_used.label().to_string(),
|
||||
s.average_error,
|
||||
format!(
|
||||
"3D Camera Tracker: solved {} frame(s), {} points, average error {:.2} px ({:.1} s)",
|
||||
s.frames.len(),
|
||||
s.points.len(),
|
||||
s.average_error,
|
||||
st.elapsed
|
||||
),
|
||||
false,
|
||||
),
|
||||
Err(e) => (String::new(), String::new(), 0.0, format!("3D Camera Tracker: {e}"), true),
|
||||
};
|
||||
let r = self.edit(if st.solve_only { "3D Camera Tracker Solve" } else { "3D Camera Tracker Analysis" }, None, |p, _| {
|
||||
let g = p
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| crate::EngineError::Other("the 3D Camera Tracker was removed during analysis".into()))?;
|
||||
let mut set = |m: &str, v: Value| {
|
||||
if let Some(pr) = g.prop_mut(m) {
|
||||
pr.value = v;
|
||||
}
|
||||
};
|
||||
if let Some(t) = &tracks_json {
|
||||
set(TRACKS, Value::Str(t.clone()));
|
||||
set(TRACKS_KEY, Value::Str(key.clone()));
|
||||
}
|
||||
set(SOLVE, Value::Str(solve_json.clone()));
|
||||
set(SOLVE_KEY, Value::Str(skey.clone()));
|
||||
set(METHOD_USED, Value::Str(method.clone()));
|
||||
set(AVERAGE_ERROR, Value::Scalar(avg));
|
||||
Ok(())
|
||||
});
|
||||
let (msg, failed) = match r {
|
||||
Ok(()) => (msg, failed),
|
||||
Err(e) => (format!("3D Camera Tracker: {e}"), true),
|
||||
};
|
||||
self.events.push(Event::Toast { message: msg, error: failed });
|
||||
}
|
||||
_ => {
|
||||
let msg = match &st.error {
|
||||
Some(e) => format!("3D Camera Tracker analysis failed: {e}"),
|
||||
None => "3D Camera Tracker analysis cancelled".into(),
|
||||
};
|
||||
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
|
||||
self.bump();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Frontends call this every frame: finishes analyses and (with `auto`) starts queued
|
||||
/// (re-)analyses in the background, like After Effects.
|
||||
pub fn poll_camera(&mut self, auto: bool) -> bool {
|
||||
let changed = self.poll_camera_job();
|
||||
if auto && self.camera_job.is_none() && self.offloaded(JobKind::Camera).is_none() {
|
||||
while let Some((c, l, e)) = self.camera_pending.first().copied() {
|
||||
self.camera_pending.remove(0);
|
||||
if self.start_camera(c, l, e, false).is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- solve → composition
|
||||
|
||||
/// A tracker's solve placed in its composition.
|
||||
pub struct Placed {
|
||||
pub solve: Arc<CameraSolve>,
|
||||
/// Canonical → comp world.
|
||||
pub world: Similarity,
|
||||
/// Comp pixels per layer pixel.
|
||||
pub scale: f64,
|
||||
/// Comp times of the analysed frames.
|
||||
pub times: Vec<Tick>,
|
||||
pub comp_size: [f64; 2],
|
||||
}
|
||||
|
||||
impl Placed {
|
||||
/// Comp-world position of a canonical point.
|
||||
pub fn to_world(&self, x: V3) -> V3 {
|
||||
self.world.apply(x)
|
||||
}
|
||||
/// Canonical position of a comp-world point.
|
||||
pub fn from_world(&self, x: V3) -> V3 {
|
||||
let r = &self.world;
|
||||
linalg::scale(linalg::mtv(&r.r, linalg::sub(x, r.t)), 1.0 / r.s)
|
||||
}
|
||||
/// The 3D Tracker Camera on frame `k`: (position, orientation degrees, zoom).
|
||||
pub fn camera(&self, k: usize) -> (V3, V3, f64) {
|
||||
let c = &self.solve.frames[k];
|
||||
let m = linalg::mm(&self.world.r, &linalg::mt(&c.r()));
|
||||
(self.to_world(c.center), linalg::euler_xyz(&m), c.focal * self.scale)
|
||||
}
|
||||
/// Analysed frame at comp time `t`.
|
||||
pub fn frame_at(&self, t: Tick) -> usize {
|
||||
self.times.iter().enumerate().min_by_key(|(_, x)| (x.0 - t.0).abs()).map(|(i, _)| i).unwrap_or(0).min(self.solve.frames.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Comp pixels per layer pixel of `layer` (its 2D transform at its In point).
|
||||
fn layer_scale(project: &Project, cid: ItemId, layer: &Layer) -> f64 {
|
||||
let Some(comp) = project.comp(cid) else { return 1.0 };
|
||||
let ctx = EvalCtx::new(project, cid, comp, layer.in_point.max(Tick::ZERO));
|
||||
let m = ctx.world_matrix(layer).0;
|
||||
let s = m[0][0].hypot(m[1][0]);
|
||||
if s.is_finite() && s > 1e-9 { s } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The solve of tracker `uid` on `lid`, placed in the comp.
|
||||
pub fn placed(project: &Project, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Placed> {
|
||||
let comp = project.comp(cid)?;
|
||||
let layer = comp.layer(lid)?;
|
||||
let g = layer.props.find_group(uid).filter(|g| is_tracker(g))?;
|
||||
let solve = ct::solve(&static_params(g))?;
|
||||
let scale = layer_scale(project, cid, layer);
|
||||
let k0 = solve.first_solved()?;
|
||||
let comp_size = [comp.width as f64, comp.height as f64];
|
||||
let world = solve.world(comp_size, solve.frames[k0].focal * scale);
|
||||
let mut times = analysis_times(comp, layer);
|
||||
times.truncate(solve.frames.len());
|
||||
Some(Placed { solve, world, scale, times, comp_size })
|
||||
}
|
||||
|
||||
/// What to create from a target (`camera.createFromSolve`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CreateKind {
|
||||
Text,
|
||||
Solid,
|
||||
Null,
|
||||
ShadowCatcher,
|
||||
/// Only the camera.
|
||||
Camera,
|
||||
}
|
||||
|
||||
impl CreateKind {
|
||||
pub fn from_name(s: &str) -> Option<CreateKind> {
|
||||
match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
|
||||
"text" => Some(CreateKind::Text),
|
||||
"solid" => Some(CreateKind::Solid),
|
||||
"null" => Some(CreateKind::Null),
|
||||
"shadowcatcher" | "shadow" => Some(CreateKind::ShadowCatcher),
|
||||
"camera" => Some(CreateKind::Camera),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation (layer → world) of a layer lying on a plane with unit `normal` (towards the camera),
|
||||
/// its x axis along `right` projected on the plane: the layer's front (−z) faces the camera.
|
||||
pub fn plane_orientation(normal: V3, right: V3) -> V3 {
|
||||
let z = linalg::scale(normal, -1.0);
|
||||
let mut x = linalg::sub(right, linalg::scale(z, linalg::dot(right, z)));
|
||||
if linalg::norm(x) < 1e-9 {
|
||||
x = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
|
||||
x = linalg::sub(x, linalg::scale(z, linalg::dot(x, z)));
|
||||
}
|
||||
let x = linalg::normalize(x);
|
||||
let y = linalg::cross(z, x);
|
||||
let m = linalg::from_cols(x, y, z);
|
||||
norm_angles(linalg::euler_xyz(&m))
|
||||
}
|
||||
|
||||
fn norm_angles(a: V3) -> V3 {
|
||||
a.map(|v| {
|
||||
let r = v.rem_euclid(360.0);
|
||||
if (r - 360.0).abs() < 1e-9 { 0.0 } else { r }
|
||||
})
|
||||
}
|
||||
|
||||
/// Key `path` on `l` at every `(time, value)`.
|
||||
pub(crate) fn key_all(l: &mut Layer, path: &str, keys: &[(Tick, Value)]) {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
pr.keys = keys.iter().map(|(t, v)| Keyframe::new(*t, v.clone())).collect();
|
||||
if let Some((_, v)) = keys.first() {
|
||||
pr.value = v.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the solved camera path into camera layer `l` (one-node, keyed on every analysed frame;
|
||||
/// Zoom keyed for Variable Zoom).
|
||||
pub(crate) fn write_camera(l: &mut Layer, pl: &Placed) {
|
||||
l.auto_orient = AutoOrient::Off;
|
||||
let n = pl.times.len().min(pl.solve.frames.len());
|
||||
let mut pos = Vec::with_capacity(n);
|
||||
let mut ori = Vec::with_capacity(n);
|
||||
let mut zoom = Vec::with_capacity(n);
|
||||
let mut prev: Option<V3> = None;
|
||||
for k in 0..n {
|
||||
let (p, o, z) = pl.camera(k);
|
||||
// Unwrap the angles so per-frame keys interpolate the short way.
|
||||
let o = match prev {
|
||||
None => norm_angles(o),
|
||||
Some(q) => [0, 1, 2].map(|i| q[i] + (o[i] - q[i] + 180.0).rem_euclid(360.0) - 180.0),
|
||||
};
|
||||
prev = Some(o);
|
||||
let t = l.layer_time(pl.times[k]);
|
||||
pos.push((t, Value::Vec3(p)));
|
||||
ori.push((t, Value::Vec3(o)));
|
||||
zoom.push((t, Value::Scalar(z)));
|
||||
}
|
||||
key_all(l, "transform/position", &pos);
|
||||
key_all(l, "transform/orientation", &ori);
|
||||
let variable = pl.solve.frames.iter().any(|f| (f.focal - pl.solve.frames[0].focal).abs() > 1e-6 * f.focal);
|
||||
if variable {
|
||||
key_all(l, "cameraOptions/zoom", &zoom);
|
||||
} else if let Some((_, z)) = zoom.first()
|
||||
&& let Some(pr) = l.props.prop_mut("cameraOptions/zoom")
|
||||
{
|
||||
pr.keys.clear();
|
||||
pr.value = z.clone();
|
||||
}
|
||||
if let Some((_, z)) = zoom.first()
|
||||
&& let Some(pr) = l.props.prop_mut("cameraOptions/focusDistance")
|
||||
{
|
||||
pr.value = z.clone();
|
||||
}
|
||||
// A one-node camera has no Point of Interest.
|
||||
if let Some(tr) = l.props.sub_mut("transform") {
|
||||
tr.children.retain(|c| !matches!(c, effectcraft_project::Node::Prop(p) if p.match_id == "poi"));
|
||||
}
|
||||
for rot in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
|
||||
if let Some(pr) = l.props.prop_mut(rot) {
|
||||
pr.keys.clear();
|
||||
pr.value = Value::Scalar(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The target for `ids` on frame `k`: when fewer than three points are given, the nearest
|
||||
/// solved neighbours (in 3D) complete the plane.
|
||||
pub fn target_for(solve: &CameraSolve, ids: &[u32], k: usize) -> Option<Target> {
|
||||
let mut use_ids: Vec<u32> = ids.iter().copied().filter(|i| solve.point(*i).is_some()).collect();
|
||||
if use_ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if use_ids.len() < 3 {
|
||||
let c = solve.point(use_ids[0])?.pos;
|
||||
let mut others: Vec<(f64, u32)> = solve.visible(k).filter(|p| !use_ids.contains(&p.id)).map(|p| (linalg::norm(linalg::sub(p.pos, c)), p.id)).collect();
|
||||
others.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (_, id) in others.into_iter().take(6usize.saturating_sub(use_ids.len())) {
|
||||
use_ids.push(id);
|
||||
}
|
||||
let mut t = solve.target(&use_ids, k)?;
|
||||
// The target sits on the chosen point(s).
|
||||
let pts: Vec<V3> = ids.iter().filter_map(|i| solve.point(*i)).map(|p| p.pos).collect();
|
||||
t.center = linalg::scale(pts.iter().fold([0.0; 3], |a, b| linalg::add(a, *b)), 1.0 / pts.len() as f64);
|
||||
return Some(t);
|
||||
}
|
||||
solve.target(&use_ids, k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plane_orientation_faces_the_normal() {
|
||||
let n = linalg::normalize([0.2, -0.9, -0.3]);
|
||||
let o = plane_orientation(n, [1.0, 0.0, 0.0]);
|
||||
let m = linalg::from_euler_xyz(o);
|
||||
// Layer −z (its front) is the normal.
|
||||
let front = linalg::mv(&m, [0.0, 0.0, -1.0]);
|
||||
assert!(linalg::norm(linalg::sub(front, n)) < 1e-9);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,574 @@
|
|||
//! 3D Camera Tracker commands: Animation ▸ Track Camera (`track.camera`), the effect's Analyze /
|
||||
//! Cancel / Create Camera buttons, the viewer's track point selection and right-click menu
|
||||
//! (Set Ground Plane and Origin, Create Text / Solid / Null / Shadow Catcher … and Camera,
|
||||
//! Create Multiple …, Delete Selected Points) and the status / points queries.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_effects::camera_tracker::{self as ct, CAMERA, DELETED, SOLVE};
|
||||
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project, Solid, Uid};
|
||||
use effectcraft_track::camtrack::linalg::{self, V3};
|
||||
use effectcraft_track::camtrack::{ShotType, SolveMethod, Target, point_color};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, layer_p, str_p};
|
||||
use crate::camera_track::{CreateKind, Placed, is_tracker, placed, plane_orientation, static_params, target_for, write_camera};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"track.camera",
|
||||
"Track Camera",
|
||||
["Animation"],
|
||||
None,
|
||||
"{layer?, shotType?: fixed|variable|specify, aov?: degrees, solveMethod?: auto|typical|flat|tripod, detailed?, wait?}",
|
||||
can_track,
|
||||
track_camera
|
||||
),
|
||||
cmd!("camera.analyze", "Analyze", [], None, "{layer?, effect?: uid|name|index, wait?: block until done}", has_comp, analyze),
|
||||
cmd!("camera.cancel", "Cancel", [], None, "{}", is_analyzing, |s, _| Ok(json!({"stopped": s.stop_camera()}))),
|
||||
query!("camera.solveStatus", "3D Camera Tracker Status", "{layer?, effect?}", status),
|
||||
query!("camera.points", "3D Camera Tracker Points", "{layer?, effect?, time?: comp seconds} → visible solved points", points),
|
||||
cmd!("camera.selectPoints", "Select Track Points", [], None, "{points: [id], add?, toggle?}", has_comp, select_points),
|
||||
cmd!(
|
||||
"camera.createFromSolve",
|
||||
"Create from Camera Solve",
|
||||
[],
|
||||
None,
|
||||
"{kind: text|solid|null|shadowCatcher|camera, points?: [id] (default: selected), target?: {center, normal, size?} (comp world), multiple?, layer?, effect?}",
|
||||
has_comp,
|
||||
create_from_solve
|
||||
),
|
||||
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
|
||||
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
|
||||
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
|
||||
]
|
||||
}
|
||||
|
||||
fn with_kind(p: &Value, kind: &str) -> Value {
|
||||
let mut q = p.clone();
|
||||
if !q.is_object() {
|
||||
q = json!({});
|
||||
}
|
||||
q["kind"] = json!(kind);
|
||||
q
|
||||
}
|
||||
|
||||
fn can_track(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let c = s.active_comp().ok_or("no composition is open")?;
|
||||
match s.state.selected_layers.first().and_then(|l| c.layer(*l)) {
|
||||
Some(l) if l.has_video() && !l.switches.adjustment && !l.is_camera() && !l.is_light() => Ok(()),
|
||||
Some(_) => Err("Track Camera needs a footage or composition layer".into()),
|
||||
None => Err("select a layer to track".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_analyzing(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_camera_analyzing() { Ok(()) } else { Err("no 3D Camera Tracker analysis is running".into()) }
|
||||
}
|
||||
|
||||
/// (comp, layer, effect uid) of a 3D Camera Tracker: `layer` / `effect` (uid, name or 1-based
|
||||
/// index), else a selected one, else the first in the comp.
|
||||
fn ct_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let candidates: Vec<LayerId> = match p.get("layer") {
|
||||
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
|
||||
None => {
|
||||
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
|
||||
v.extend(s.state.selected_layers.iter().copied());
|
||||
v.extend(comp.layers.iter().map(|l| l.id));
|
||||
v
|
||||
}
|
||||
};
|
||||
for lid in candidates {
|
||||
let Some(fx) = comp.layer(lid).and_then(Layer::effects) else { continue };
|
||||
let g = match p.get("effect") {
|
||||
Some(Value::Number(n)) => {
|
||||
let n = n.as_u64().unwrap_or(0);
|
||||
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth((n as usize).saturating_sub(1)))
|
||||
}
|
||||
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name),
|
||||
_ => s
|
||||
.state
|
||||
.selected_props
|
||||
.iter()
|
||||
.find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u && is_tracker(g))).flatten())
|
||||
.or_else(|| fx.groups().find(|g| is_tracker(g))),
|
||||
};
|
||||
if let Some(g) = g.filter(|g| is_tracker(g)) {
|
||||
return Ok((cid, lid, g.uid));
|
||||
}
|
||||
if p.get("layer").is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(bad(cmd, "no 3D Camera Tracker found (apply Animation ▸ Track Camera first)"))
|
||||
}
|
||||
|
||||
fn shot_p(p: &Value) -> Result<Option<ShotType>> {
|
||||
match p.get("shotType") {
|
||||
None => Ok(None),
|
||||
Some(Value::Number(n)) => ShotType::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "shotType: 0..2")),
|
||||
Some(Value::String(v)) => ShotType::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "shotType: fixed|variable|specify")),
|
||||
Some(_) => Err(bad("track.camera", "shotType: fixed|variable|specify")),
|
||||
}
|
||||
}
|
||||
|
||||
fn method_p(p: &Value) -> Result<Option<SolveMethod>> {
|
||||
match p.get("solveMethod") {
|
||||
None => Ok(None),
|
||||
Some(Value::Number(n)) => {
|
||||
SolveMethod::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "solveMethod: 0..3"))
|
||||
}
|
||||
Some(Value::String(v)) => SolveMethod::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
|
||||
Some(_) => Err(bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Animation ▸ Track Camera / the Tracker panel button: apply the 3D Camera Tracker to the layer
|
||||
/// (reusing one it already has), set the given options and start the analysis.
|
||||
fn track_camera(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "track.camera")?;
|
||||
let shot = shot_p(p)?;
|
||||
let method = method_p(p)?;
|
||||
let aov = f_p(p, "aov").or_else(|| f_p(p, "angleOfView"));
|
||||
if aov.is_some_and(|a| !(1.0..=170.0).contains(&a)) {
|
||||
return Err(bad("track.camera", "aov: 1..170 degrees"));
|
||||
}
|
||||
let detailed = b_p(p, "detailed");
|
||||
let existing = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(Layer::effects).and_then(|fx| fx.groups().find(|g| is_tracker(g)).map(|g| g.uid));
|
||||
let uid = match existing {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0], "comp": cid.0}))?;
|
||||
r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?
|
||||
}
|
||||
};
|
||||
if shot.is_some() || method.is_some() || aov.is_some() || detailed.is_some() {
|
||||
s.edit("3D Camera Tracker Settings", None, |proj, _| {
|
||||
let g = proj
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
|
||||
let mut set = |m: &str, v: KV| {
|
||||
if let Some(pr) = g.prop_mut(m) {
|
||||
pr.keys.clear();
|
||||
pr.value = v;
|
||||
}
|
||||
};
|
||||
if let Some(sh) = shot {
|
||||
set("shotType", KV::Enum(ShotType::ALL.iter().position(|x| *x == sh).unwrap_or(0) as u32));
|
||||
}
|
||||
if let Some(a) = aov {
|
||||
set("horizontalAngleOfView", KV::Scalar(a));
|
||||
if shot.is_none() {
|
||||
set("shotType", KV::Enum(2));
|
||||
}
|
||||
}
|
||||
if let Some(m) = method {
|
||||
set("advanced/solveMethod", KV::Enum(SolveMethod::ALL.iter().position(|x| *x == m).unwrap_or(0) as u32));
|
||||
}
|
||||
if let Some(d) = detailed {
|
||||
set("advanced/detailedAnalysis", KV::Bool(d));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
// Select the effect so the viewer shows its track points.
|
||||
s.state.selected_props = vec![(lid, uid)];
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
|
||||
if wait {
|
||||
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = ct_p(s, p, "camera.analyze")?;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
|
||||
if wait {
|
||||
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.poll_camera(false);
|
||||
let mut out = json!({"running": s.is_camera_analyzing(), "progress": s.camera_progress(), "banner": s.camera_progress().map(|p| p.banner())});
|
||||
let Ok((cid, lid, uid)) = ct_p(s, p, "camera.solveStatus") else { return Ok(out) };
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
|
||||
let params = static_params(g);
|
||||
out["layer"] = json!(lid.0);
|
||||
out["effect"] = json!(uid);
|
||||
out["pending"] = json!(s.camera_pending.contains(&(cid, lid, uid)));
|
||||
let tracks = ct::tracks(¶ms);
|
||||
out["analyzed"] = json!(tracks.is_some());
|
||||
if let Some(t) = &tracks {
|
||||
out["tracks"] = json!(t.tracks.len());
|
||||
out["frames"] = json!(t.frames);
|
||||
}
|
||||
let settings = ct::settings(¶ms);
|
||||
out["shotType"] = json!(settings.shot.label());
|
||||
out["solveMethod"] = json!(settings.method.label());
|
||||
out["deleted"] = json!(settings.deleted.len());
|
||||
let solve = ct::solve(¶ms);
|
||||
out["solved"] = json!(solve.is_some());
|
||||
if let Some(sv) = &solve {
|
||||
let f = sv.focal();
|
||||
out["methodUsed"] = json!(sv.method_used.label());
|
||||
out["averageError"] = json!(sv.average_error);
|
||||
out["points"] = json!(sv.points.len());
|
||||
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
|
||||
out["focalLength"] = json!(f);
|
||||
out["horizontalAngleOfView"] = json!(sv.hfov(f));
|
||||
out["groundPlane"] = json!(sv.ground.is_some());
|
||||
if let Some(pl) = placed(&s.project, cid, lid, uid) {
|
||||
let k = pl.frame_at(s.time_of(cid));
|
||||
let (pos, ori, zoom) = pl.camera(k);
|
||||
out["frame"] = json!(k);
|
||||
out["camera"] = json!({"position": pos, "orientation": ori, "zoom": zoom});
|
||||
out["visible"] = json!(sv.visible(k).count());
|
||||
}
|
||||
}
|
||||
let cam = params.get(CAMERA).map(|v| v.as_f64() as u64).unwrap_or(0);
|
||||
out["cameraLayer"] = if cam > 0 && comp.layer(LayerId(cam)).is_some_and(Layer::is_camera) { json!(cam) } else { Value::Null };
|
||||
out["selected"] = json!(s.state.camera_points);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The visible solved points at the current (or given) time, with their comp positions.
|
||||
fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = ct_p(s, p, "camera.points")?;
|
||||
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad("camera.points", "the camera is not solved yet"))?;
|
||||
let t = f_p(p, "time").map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let k = pl.frame_at(t);
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
let m = ctx.world_matrix(layer);
|
||||
let list: Vec<Value> = ct::projected(&pl.solve, k)
|
||||
.into_iter()
|
||||
.map(|(id, q, z)| {
|
||||
let c = m.apply(effectcraft_geom::vec3(q[0], q[1], 0.0));
|
||||
let sp = pl.solve.point(id).copied().unwrap_or_default();
|
||||
let col = point_color(id);
|
||||
json!({"id": id, "comp": [c.x, c.y], "layer": q, "depth": z, "world": pl.to_world(sp.pos), "error": sp.error, "color": col, "selected": s.state.camera_points.contains(&id)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"frame": k, "points": list}))
|
||||
}
|
||||
|
||||
fn ids_p(s: &Session, p: &Value) -> Vec<u32> {
|
||||
match p.get("points") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
|
||||
Some(Value::Number(n)) => n.as_u64().map(|v| vec![v as u32]).unwrap_or_default(),
|
||||
_ => s.state.camera_points.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<u32> = match p.get("points") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
for id in ids {
|
||||
match s.state.camera_points.iter().position(|x| *x == id) {
|
||||
Some(i) => {
|
||||
s.state.camera_points.remove(i);
|
||||
}
|
||||
None => s.state.camera_points.push(id),
|
||||
}
|
||||
}
|
||||
} else if b_p(p, "add").unwrap_or(false) {
|
||||
for id in ids {
|
||||
if !s.state.camera_points.contains(&id) {
|
||||
s.state.camera_points.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.camera_points = ids;
|
||||
}
|
||||
s.bump();
|
||||
Ok(json!({"selected": s.state.camera_points}))
|
||||
}
|
||||
|
||||
/// The target of a create / ground command in canonical solve coordinates.
|
||||
fn target_p(s: &Session, p: &Value, pl: &Placed, k: usize, cmd: &str) -> Result<Target> {
|
||||
if let Some(t) = p.get("target") {
|
||||
let v3 = |k: &str| -> Option<V3> {
|
||||
let a = t.get(k)?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
|
||||
};
|
||||
let (c, n) = (v3("center").ok_or_else(|| bad(cmd, "target.center: [x, y, z]"))?, v3("normal").unwrap_or([0.0, -1.0, 0.0]));
|
||||
let size = t.get("size").and_then(Value::as_f64).unwrap_or(100.0);
|
||||
let nc = linalg::normalize(linalg::mtv(&pl.world.r, n));
|
||||
return Ok(Target { center: pl.from_world(c), normal: nc, size: size / pl.world.s });
|
||||
}
|
||||
let ids = ids_p(s, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select track points (or give `points` / `target`)"));
|
||||
}
|
||||
target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))
|
||||
}
|
||||
|
||||
fn next_name(c: &effectcraft_project::Comp, base: &str) -> String {
|
||||
let n = (1..).find(|i| !c.layers.iter().any(|l| l.name == format!("{base} {i}"))).unwrap_or(1);
|
||||
format!("{base} {n}")
|
||||
}
|
||||
|
||||
fn place(l: &mut Layer, pos: V3, ori: V3, src: &Layer) {
|
||||
l.switches.three_d = true;
|
||||
l.in_point = src.in_point;
|
||||
l.out_point = src.out_point;
|
||||
if let Some(pr) = l.props.prop_mut("transform/position") {
|
||||
pr.value = KV::Vec3(pos);
|
||||
}
|
||||
if let Some(pr) = l.props.prop_mut("transform/orientation") {
|
||||
pr.value = KV::Vec3(ori);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `l` above the tracked layer.
|
||||
fn insert_above(proj: &mut Project, cid: ItemId, above: LayerId, l: Layer) -> Result<LayerId> {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = comp.layers.iter().position(|x| x.id == above).unwrap_or(0);
|
||||
let id = l.id;
|
||||
comp.layers.insert(at, l);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn add_solid(proj: &mut Project, comp: &effectcraft_project::Comp, name: &str, size: u32, color: [f32; 3]) -> 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, width: size, height: size, pixel_aspect: 1.0 }));
|
||||
build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (size, size), None)
|
||||
}
|
||||
|
||||
/// Create Text / Solid / Null / Shadow Catcher (and Light) on the target, plus the 3D Tracker
|
||||
/// Camera when the tracker has none yet — one undo step.
|
||||
fn create_from_solve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.createFromSolve";
|
||||
let (cid, lid, uid) = ct_p(s, p, cmd)?;
|
||||
let kind = CreateKind::from_name(str_p(p, "kind").unwrap_or("null")).ok_or_else(|| bad(cmd, "kind: text|solid|null|shadowCatcher|camera"))?;
|
||||
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet (analysis still running or failed)"))?;
|
||||
let k = pl.frame_at(s.time_of(cid));
|
||||
let multiple = b_p(p, "multiple").unwrap_or(false);
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let src = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
|
||||
let g = src.props.find_group(uid).ok_or(EngineError::NoComp)?;
|
||||
let params = static_params(g);
|
||||
let size_pct = effectcraft_effects::warp_stab::param(¶ms, "targetSize").map(|v| v.as_f64()).unwrap_or(100.0) / 100.0;
|
||||
let existing_cam = params.get(CAMERA).map(|v| v.as_f64() as u64).filter(|id| *id > 0 && comp.layer(LayerId(*id)).is_some_and(Layer::is_camera));
|
||||
// Targets: one, or one per selected point (Create Multiple …) sharing the selection's plane.
|
||||
let targets: Vec<Target> = if kind == CreateKind::Camera {
|
||||
vec![]
|
||||
} else {
|
||||
let t = target_p(s, p, &pl, k, cmd)?;
|
||||
if multiple {
|
||||
let ids = ids_p(s, p);
|
||||
ids.iter().filter_map(|i| pl.solve.point(*i)).map(|sp| Target { center: sp.pos, ..t }).collect()
|
||||
} else {
|
||||
vec![t]
|
||||
}
|
||||
};
|
||||
// Camera right vector on this frame (world), to align created layers with the view.
|
||||
let cam = &pl.solve.frames[k];
|
||||
let right = linalg::mv(&pl.world.r, linalg::mtv(&cam.r(), [1.0, 0.0, 0.0]));
|
||||
let label = match (kind, multiple) {
|
||||
(CreateKind::Text, false) => "Create Text and Camera",
|
||||
(CreateKind::Solid, false) => "Create Solid and Camera",
|
||||
(CreateKind::Null, false) => "Create Null and Camera",
|
||||
(CreateKind::ShadowCatcher, _) => "Create Shadow Catcher, Camera and Light",
|
||||
(CreateKind::Text, true) => "Create Multiple Text Layers and Camera",
|
||||
(CreateKind::Solid, true) => "Create Multiple Solids and Camera",
|
||||
(CreateKind::Null, true) => "Create Multiple Nulls and Camera",
|
||||
(CreateKind::Camera, _) => "Create Camera",
|
||||
};
|
||||
let (camera, created) = s.edit(label, None, |proj, st| {
|
||||
let mut created = vec![];
|
||||
// The camera: reuse the tracker's, else create "3D Tracker Camera" at the top.
|
||||
let camera = match existing_cam {
|
||||
Some(id) if kind != CreateKind::Camera => LayerId(id),
|
||||
Some(id) => {
|
||||
let l = proj.comp_mut(cid).and_then(|c| c.layer_mut(LayerId(id))).ok_or(EngineError::NoComp)?;
|
||||
write_camera(l, &pl);
|
||||
LayerId(id)
|
||||
}
|
||||
None => {
|
||||
let mut l = build::layer(proj, &comp, "3D Tracker Camera", LayerSource::Camera, (comp.width, comp.height), None);
|
||||
l.in_point = src.in_point;
|
||||
l.out_point = src.out_point;
|
||||
write_camera(&mut l, &pl);
|
||||
let id = l.id;
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(0, l);
|
||||
if let Some(pr) = proj.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)).and_then(|g| g.prop_mut(CAMERA)) {
|
||||
pr.value = KV::Scalar(id.0 as f64);
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
for t in &targets {
|
||||
let center = pl.to_world(t.center);
|
||||
let normal = linalg::normalize(linalg::mv(&pl.world.r, t.normal));
|
||||
let ori = plane_orientation(normal, right);
|
||||
let size = (t.size * pl.world.s * size_pct).max(1.0);
|
||||
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
match kind {
|
||||
CreateKind::Text => {
|
||||
let mut l = build::layer(proj, &cur, "Text", LayerSource::Text, (comp.width, comp.height), None);
|
||||
let doc = TextDoc { text: "Text".into(), justify: Justify::Center, size: (size * 0.35).clamp(4.0, 2000.0), ..Default::default() };
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc));
|
||||
}
|
||||
place(&mut l, center, ori, &src);
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
}
|
||||
CreateKind::Solid => {
|
||||
let name = next_name(&cur, "Track Solid");
|
||||
let c = point_color((created.len() as u32).wrapping_add(7));
|
||||
let mut l = add_solid(proj, &cur, &name, size.round().max(1.0) as u32, c);
|
||||
place(&mut l, center, ori, &src);
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
}
|
||||
CreateKind::Null => {
|
||||
let name = next_name(&cur, "Track Null");
|
||||
let mut l = build::layer(proj, &cur, &name, LayerSource::Null, (comp.width, comp.height), None);
|
||||
place(&mut l, center, ori, &src);
|
||||
if let Some(pr) = l.props.prop_mut("transform/scale") {
|
||||
pr.value = KV::Vec3([size, size, size]);
|
||||
}
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
}
|
||||
CreateKind::ShadowCatcher => {
|
||||
let name = next_name(&cur, "Shadow Catcher");
|
||||
let mut l = add_solid(proj, &cur, &name, (size * 4.0).round().max(1.0) as u32, [1.0, 1.0, 1.0]);
|
||||
place(&mut l, center, ori, &src);
|
||||
if let Some(pr) = l.props.prop_mut("materialOptions/acceptsShadows") {
|
||||
pr.value = KV::Enum(2);
|
||||
}
|
||||
if let Some(pr) = l.props.prop_mut("materialOptions/castsShadows") {
|
||||
pr.value = KV::Enum(0);
|
||||
}
|
||||
let sc = created.len();
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let lname = next_name(&cur, "Light");
|
||||
let mut light = build::layer(proj, &cur, &lname, LayerSource::Light { kind: LightKind::Point }, (comp.width, comp.height), None);
|
||||
light.in_point = src.in_point;
|
||||
light.out_point = src.out_point;
|
||||
let lp = linalg::add(center, linalg::add(linalg::scale(normal, size * 2.0), linalg::scale(right, size)));
|
||||
if let Some(pr) = light.props.prop_mut("transform/position") {
|
||||
pr.value = KV::Vec3(lp);
|
||||
}
|
||||
if let Some(pr) = light.props.prop_mut("lightOptions/castsShadows") {
|
||||
pr.value = KV::Bool(true);
|
||||
}
|
||||
let _ = sc;
|
||||
created.push(insert_above(proj, cid, created[created.len() - 1], light)?);
|
||||
}
|
||||
CreateKind::Camera => {}
|
||||
}
|
||||
}
|
||||
st.selected_layers = if created.is_empty() { vec![camera] } else { created.clone() };
|
||||
st.selected_props.clear();
|
||||
Ok((camera, created))
|
||||
})?;
|
||||
let t0 = targets.first().map(
|
||||
|t| json!({"center": pl.to_world(t.center), "normal": linalg::normalize(linalg::mv(&pl.world.r, t.normal)), "size": t.size * pl.world.s * size_pct}),
|
||||
);
|
||||
Ok(json!({"camera": camera.0, "layers": created.iter().map(|l| l.0).collect::<Vec<_>>(), "target": t0}))
|
||||
}
|
||||
|
||||
fn set_ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.setGroundPlane";
|
||||
let (cid, lid, uid) = ct_p(s, p, cmd)?;
|
||||
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet"))?;
|
||||
let k = pl.frame_at(s.time_of(cid));
|
||||
let ids = ids_p(s, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select track points (or give `points`)"));
|
||||
}
|
||||
let t = target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
|
||||
let mut sv = (*pl.solve).clone();
|
||||
let mut g = sv.ground_from(&ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
|
||||
g.origin = t.center;
|
||||
sv.ground = Some(g);
|
||||
let json = sv.to_json();
|
||||
s.edit("Set Ground Plane and Origin", None, |proj, _| {
|
||||
let pr = proj
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.and_then(|g| g.prop_mut(SOLVE))
|
||||
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
|
||||
pr.value = KV::Str(json);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"origin": g.origin, "normal": g.normal}))
|
||||
}
|
||||
|
||||
/// Delete points (and, with Auto-delete Points Across Time, the points of other tracks on the
|
||||
/// same 3D feature), then re-solve.
|
||||
fn delete_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.deletePoints";
|
||||
let (cid, lid, uid) = ct_p(s, p, cmd)?;
|
||||
let ids = ids_p(s, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select track points (or give `points`)"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let g = comp.layer(lid).and_then(|l| l.props.find_group(uid)).ok_or(EngineError::NoComp)?;
|
||||
let params = static_params(g);
|
||||
let mut del = ct::deleted(¶ms);
|
||||
let auto = effectcraft_effects::warp_stab::param(¶ms, "advanced/autoDeletePoints").is_some_and(KV::as_bool);
|
||||
let mut add = ids.clone();
|
||||
if auto && let Some(sv) = ct::solve(¶ms) {
|
||||
// Same feature tracked again at other times: solved within 1 % of its depth.
|
||||
let cam0 = sv.first_solved().map(|k| sv.frames[k].center).unwrap_or([0.0; 3]);
|
||||
for id in &ids {
|
||||
let Some(pt) = sv.point(*id) else { continue };
|
||||
let r = linalg::norm(linalg::sub(pt.pos, cam0)) * 0.01;
|
||||
for q in &sv.points {
|
||||
let disjoint = q.last < pt.first || q.first > pt.last;
|
||||
if q.id != *id && disjoint && linalg::norm(linalg::sub(q.pos, pt.pos)) < r {
|
||||
add.push(q.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in add {
|
||||
if !del.contains(&id) {
|
||||
del.push(id);
|
||||
}
|
||||
}
|
||||
del.sort_unstable();
|
||||
let json = serde_json::to_string(&del).unwrap_or_default();
|
||||
s.edit("Delete Track Points", None, |proj, _| {
|
||||
let pr = proj
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.and_then(|g| g.prop_mut(DELETED))
|
||||
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
|
||||
pr.value = KV::Str(json);
|
||||
Ok(())
|
||||
})?;
|
||||
s.state.camera_points.retain(|x| !del.contains(x));
|
||||
// Re-solve now (After Effects re-solves after deleting points).
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
if !s.is_camera_analyzing() {
|
||||
s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(json!({"deleted": del, "running": s.is_camera_analyzing()}))
|
||||
}
|
||||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,780 @@
|
|||
//! Essential Graphics (Window ▸ Essential Graphics) and master properties.
|
||||
//!
|
||||
//! - **Authoring**: a comp's controls ([`EssentialGraphics`]): Add Property to Essential
|
||||
//! Graphics, Media Replacement for footage layers, groups, comments, renaming, reordering, the
|
||||
//! template name and the panel's Primary composition.
|
||||
//! - **Instances**: every precomp layer of such a comp shows the controls under **Essential
|
||||
//! Properties**; changing one overrides it for that instance only (`essential.set`, or any
|
||||
//! property edit of the master property); Push to Comp writes the override into the source
|
||||
//! comp, Revert drops it.
|
||||
//! - **Templates**: `essential.exportTemplate` writes an open `.ectemplate` (a ZIP holding
|
||||
//! `manifest.json` with the exposed controls, `project.ecproj` with the comp and everything it
|
||||
//! uses, the media files and a poster frame); `essential.importTemplate` merges one into the
|
||||
//! project and adds an instance to the active comp.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::essential::{self, ControlType, EgControl, EgKind, EssentialGraphics};
|
||||
use effectcraft_project::{GroupKind, ItemId, ItemKind, LayerId, LayerSource, ParamUi, Project, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, has_comp, layer_p, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// Format id written into template manifests.
|
||||
pub const TEMPLATE_FORMAT: &str = "effectcraft-template";
|
||||
pub const TEMPLATE_VERSION: u32 = 1;
|
||||
|
||||
fn control_p(p: &Value, cmd: &str) -> Result<u64> {
|
||||
p.get("control").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `control` (control id)"))
|
||||
}
|
||||
|
||||
/// Comp whose Essential Graphics a command edits: `comp`, else the panel's Primary comp, else
|
||||
/// the active comp.
|
||||
fn eg_comp(s: &Session, p: &Value) -> Result<ItemId> {
|
||||
if p.get("comp").is_some() {
|
||||
return comp_id(s, p);
|
||||
}
|
||||
if let Some(c) = s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()) {
|
||||
return Ok(c);
|
||||
}
|
||||
comp_id(s, p)
|
||||
}
|
||||
|
||||
fn eg_of(proj: &mut Project, cid: ItemId) -> Result<&mut EssentialGraphics> {
|
||||
let name = proj.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
Ok(c.essential.get_or_insert_with(|| EssentialGraphics { name, controls: vec![] }))
|
||||
}
|
||||
|
||||
/// Insert `ctl` into `group` (or the top level) at `index` (or the end).
|
||||
fn insert(eg: &mut EssentialGraphics, ctl: EgControl, group: Option<u64>, index: Option<usize>, cmd: &str) -> Result<()> {
|
||||
let list = eg.list_mut(group).ok_or_else(|| bad(cmd, "`group` is not a group control"))?;
|
||||
let i = index.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(i, ctl);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_primary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.state.essential_primary = Some(cid);
|
||||
s.bump();
|
||||
Ok(json!({"comp": cid.0}))
|
||||
}
|
||||
|
||||
fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad("essential.setName", "missing `name`"))?.to_string();
|
||||
s.edit("Essential Graphics Name", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
eg_of(proj, cid)?.name = name.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"comp": cid.0, "name": name}))
|
||||
}
|
||||
|
||||
/// Add Property to Essential Graphics: `{layer, path|prop}` or the selected properties.
|
||||
fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.addProperty";
|
||||
let targets: Vec<(ItemId, LayerId, Uid)> = if p.get("path").is_some() || p.get("prop").is_some() {
|
||||
vec![super::prop::prop_ref(s, p, C)?]
|
||||
} else {
|
||||
let cid = comp_id(s, p)?;
|
||||
selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect()
|
||||
};
|
||||
if targets.is_empty() {
|
||||
return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)"));
|
||||
}
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let rename = str_p(p, "name").map(str::to_string);
|
||||
let mut added = vec![];
|
||||
for (cid, lid, uid) in &targets {
|
||||
let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?;
|
||||
let pr = l.props.find(*uid).ok_or_else(|| bad(C, "no such property"))?;
|
||||
if control_type(pr).is_none() {
|
||||
return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)));
|
||||
}
|
||||
if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) {
|
||||
continue;
|
||||
}
|
||||
added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone())));
|
||||
}
|
||||
let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| {
|
||||
if let Some((cid, ..)) = added.first() {
|
||||
st.essential_primary.get_or_insert(*cid);
|
||||
}
|
||||
let mut ids = vec![];
|
||||
for (k, (cid, lid, uid, name)) in added.iter().enumerate() {
|
||||
let id = proj.alloc();
|
||||
let eg = eg_of(proj, *cid)?;
|
||||
insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?;
|
||||
ids.push(id);
|
||||
}
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"controls": ids}))
|
||||
}
|
||||
|
||||
fn control_type(pr: &effectcraft_project::Property) -> Option<ControlType> {
|
||||
essential::control_type(pr)
|
||||
}
|
||||
|
||||
/// Media Replacement for a footage layer.
|
||||
fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.addMedia";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
if !matches!(l.source, LayerSource::Footage { .. }) {
|
||||
return Err(bad(C, "Media Replacement needs a footage layer"));
|
||||
}
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Media)", l.name));
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let id = s.edit("Add Media Replacement", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
let eg = eg_of(proj, cid)?;
|
||||
if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) {
|
||||
return Err(bad(C, "this layer already has a Media Replacement control"));
|
||||
}
|
||||
insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid } }, group, None, C)?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn add_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let name = str_p(p, "name").unwrap_or("Group").to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let id = s.edit("Add Essential Graphics Group", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn add_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let text = str_p(p, "text").unwrap_or("Comment").to_string();
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let id = s.edit("Add Essential Graphics Comment", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.rename";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let id = control_p(p, C)?;
|
||||
let name = str_p(p, "name").or(str_p(p, "text")).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
s.edit("Rename Essential Graphics Control", None, |proj, _| {
|
||||
let c = eg_of(proj, cid)?.find_mut(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
||||
match &mut c.kind {
|
||||
// A comment's name is its text.
|
||||
EgKind::Comment { text } => *text = name.clone(),
|
||||
_ => c.name = name.clone(),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"control": id, "name": name}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.remove";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let id = control_p(p, C)?;
|
||||
s.edit("Remove Essential Graphics Control", None, |proj, _| {
|
||||
eg_of(proj, cid)?.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn move_control(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.move";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let id = control_p(p, C)?;
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
if group == Some(id) {
|
||||
return Err(bad(C, "a group can't contain itself"));
|
||||
}
|
||||
s.edit("Move Essential Graphics Control", None, |proj, _| {
|
||||
let eg = eg_of(proj, cid)?;
|
||||
let ctl = eg.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
||||
if matches!(ctl.kind, EgKind::Group { .. }) && group.is_some() {
|
||||
return Err(bad(C, "groups can't be nested"));
|
||||
}
|
||||
insert(eg, ctl, group, index, C)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
/// Dropdown Menu Control ▸ Edit: the menu items of a dropdown (popup) property.
|
||||
fn edit_dropdown(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.editDropdown";
|
||||
let items: Vec<String> = match p.get("items") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).filter(|x| !x.is_empty()).collect(),
|
||||
_ => return Err(bad(C, "missing `items` (array of strings)")),
|
||||
};
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "a dropdown needs at least one item"));
|
||||
}
|
||||
let q = if p.get("path").is_none() && p.get("prop").is_none() {
|
||||
// The first Dropdown Menu Control of the layer.
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let fx = l.effects().and_then(|fx| fx.groups().find(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.control.dropdown")));
|
||||
let uid = fx.and_then(|g| g.get("menu")).map(|pr| pr.uid).ok_or_else(|| bad(C, "the layer has no Dropdown Menu Control"))?;
|
||||
let mut q = p.clone();
|
||||
q["prop"] = json!(uid);
|
||||
q["layer"] = json!(lid.0);
|
||||
q
|
||||
} else {
|
||||
p.clone()
|
||||
};
|
||||
let (cid, lid, uid) = super::prop::prop_ref(s, &q, C)?;
|
||||
let n = items.len() as u32;
|
||||
s.edit("Edit Dropdown", None, |proj, _| {
|
||||
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
|
||||
if !matches!(pr.ui, ParamUi::Popup { .. }) {
|
||||
return Err(bad(C, "not a dropdown property"));
|
||||
}
|
||||
pr.ui = ParamUi::Popup { options: items.clone() };
|
||||
let clamp = |v: &mut KV| {
|
||||
if let KV::Enum(i) = v {
|
||||
*i = (*i).min(n - 1);
|
||||
}
|
||||
};
|
||||
clamp(&mut pr.value);
|
||||
for k in &mut pr.keys {
|
||||
clamp(&mut k.value);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"prop": uid, "items": items}))
|
||||
}
|
||||
|
||||
fn solo_supported(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = b_p(p, "on").unwrap_or(!s.state.essential_solo);
|
||||
s.state.essential_solo = on;
|
||||
s.bump();
|
||||
Ok(json!({"on": on}))
|
||||
}
|
||||
|
||||
/// Properties of a comp's layers that Essential Graphics can expose (Solo Supported
|
||||
/// Properties): `[{layer, uid, path, name, type}]`.
|
||||
pub fn supported_properties(project: &Project, cid: ItemId) -> Vec<Value> {
|
||||
let Some(c) = project.comp(cid) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
for l in &c.layers {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if pr.three_d_only && !l.is_3d() {
|
||||
return;
|
||||
}
|
||||
if let Some(t) = essential::control_type(pr) {
|
||||
let path = super::match_path_of(&l.props, pr.uid).unwrap_or_default();
|
||||
if path.starts_with(essential::GROUP) {
|
||||
return;
|
||||
}
|
||||
out.push(json!({"layer": l.id.0, "layerName": l.name, "uid": pr.uid, "path": path, "name": l.props.name_path_of(pr.uid), "type": t}));
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value {
|
||||
match &c.kind {
|
||||
EgKind::Property { layer, prop } => {
|
||||
let src = essential::source_prop(project, cid, c);
|
||||
json!({
|
||||
"id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop,
|
||||
"layerName": src.map(|(l, _)| l.name.clone()),
|
||||
"path": src.and_then(|(l, _)| super::match_path_of(&l.props, *prop)),
|
||||
"type": src.and_then(|(_, pr)| essential::control_type(pr)),
|
||||
"value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()),
|
||||
"missing": src.is_none(),
|
||||
})
|
||||
}
|
||||
EgKind::Media { layer } => json!({
|
||||
"id": c.id, "name": c.name, "kind": "media", "type": "media", "layer": layer.0,
|
||||
"item": essential::source_media(project, cid, c).map(|i| i.0),
|
||||
}),
|
||||
EgKind::Comment { text } => json!({"id": c.id, "name": c.name, "kind": "comment", "text": text}),
|
||||
EgKind::Group { children } => json!({
|
||||
"id": c.id, "name": c.name, "kind": "group",
|
||||
"children": children.iter().map(|x| control_json(project, cid, x)).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let name = if eg.name.is_empty() { s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default() } else { eg.name.clone() };
|
||||
Ok(json!({
|
||||
"comp": cid.0,
|
||||
"primary": s.state.essential_primary.map(|c| c.0),
|
||||
"name": name,
|
||||
"solo": s.state.essential_solo,
|
||||
"controls": eg.controls.iter().map(|c| control_json(&s.project, cid, c)).collect::<Vec<_>>(),
|
||||
"supported": if b_p(p, "supported").unwrap_or(false) { json!(supported_properties(&s.project, cid)) } else { Value::Null },
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ instances
|
||||
|
||||
/// The precomp layer (an instance) and its source comp.
|
||||
fn instance(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, ItemId)> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
match l.source {
|
||||
LayerSource::Comp { item } if essential::group(l).is_some() => Ok((cid, lid, item)),
|
||||
_ => Err(bad(cmd, "the layer is not a precomp with Essential Properties")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The master property (instance child) of a control.
|
||||
fn master_uid(s: &Session, cid: ItemId, lid: LayerId, control: u64) -> Option<Uid> {
|
||||
let l = s.project.comp(cid)?.layer(lid)?;
|
||||
let g = essential::group(l)?;
|
||||
let m = essential::match_id(control);
|
||||
let mut found = None;
|
||||
g.walk("", &mut |_, pr| {
|
||||
if pr.match_id == m {
|
||||
found = Some(pr.uid);
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
fn instance_json(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, src) = instance(s, p, "essential.instance")?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let over = essential::overridden(l);
|
||||
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let t = s.time();
|
||||
let g = essential::group(l).cloned();
|
||||
let mut out = vec![];
|
||||
for c in eg.flat() {
|
||||
let m = essential::match_id(c.id);
|
||||
let mut pr = None;
|
||||
if let Some(g) = &g {
|
||||
g.walk("", &mut |_, x| {
|
||||
if x.match_id == m {
|
||||
pr = Some(x.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
let kind = match c.kind {
|
||||
EgKind::Property { .. } => "property",
|
||||
EgKind::Media { .. } => "media",
|
||||
EgKind::Comment { .. } => "comment",
|
||||
EgKind::Group { .. } => "group",
|
||||
};
|
||||
out.push(json!({
|
||||
"control": c.id, "name": c.name, "kind": kind,
|
||||
"uid": pr.as_ref().map(|x| x.uid),
|
||||
"path": pr.as_ref().and_then(|x| super::match_path_of(&l.props, x.uid)),
|
||||
"value": pr.as_ref().map(|x| x.value_at(l.layer_time(t)).to_json()),
|
||||
"type": pr.as_ref().and_then(essential::control_type).or((kind == "media").then_some(ControlType::Media)),
|
||||
"overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)),
|
||||
}));
|
||||
}
|
||||
Ok(json!({"layer": lid.0, "comp": src.0, "name": eg.name, "controls": out}))
|
||||
}
|
||||
|
||||
/// Set an instance's value for a control (`value`, or `item` for Media Replacement).
|
||||
fn set_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.set";
|
||||
let (cid, lid, _) = instance(s, p, C)?;
|
||||
let control = control_p(p, C)?;
|
||||
let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?;
|
||||
if let Some(item) = p.get("item") {
|
||||
let item = match item {
|
||||
Value::Number(n) => ItemId(n.as_u64().unwrap_or(0)),
|
||||
Value::String(name) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(C, format!("no item `{name}`")))?,
|
||||
_ => return Err(bad(C, "`item` is an item id or name")),
|
||||
};
|
||||
if !matches!(s.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_video) {
|
||||
return Err(bad(C, "replacement media must be footage with video"));
|
||||
}
|
||||
s.edit("Replace Media", None, |proj, _| {
|
||||
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
|
||||
pr.value = KV::Scalar(item.0 as f64);
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"control": control, "item": item.0}));
|
||||
}
|
||||
let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?});
|
||||
if let Some(m) = p.get("merge") {
|
||||
q["merge"] = m.clone();
|
||||
}
|
||||
s.execute("prop.set", q)
|
||||
}
|
||||
|
||||
/// Controls of an instance to act on: `control` or every overridden one.
|
||||
fn targets(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Vec<(u64, Uid)> {
|
||||
let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return vec![] };
|
||||
let over = essential::overridden(l);
|
||||
let Some(g) = essential::group(l) else { return vec![] };
|
||||
let want = p.get("control").and_then(Value::as_u64);
|
||||
let mut out = vec![];
|
||||
g.walk("", &mut |_, pr| {
|
||||
if let Some(c) = essential::control_of(&pr.match_id)
|
||||
&& want.is_none_or(|w| w == c)
|
||||
&& (want.is_some() || over.contains(&pr.uid))
|
||||
{
|
||||
out.push((c, pr.uid));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.revert";
|
||||
let (cid, lid, _) = instance(s, p, C)?;
|
||||
let ts = targets(s, cid, lid, p);
|
||||
if ts.is_empty() {
|
||||
return Ok(json!({"reverted": []}));
|
||||
}
|
||||
s.edit("Revert Essential Property", None, |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let g = l.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
|
||||
if let GroupKind::Essential { overridden } = &mut g.kind {
|
||||
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
|
||||
}
|
||||
for (_, uid) in &ts {
|
||||
if let Some(pr) = g.find_mut(*uid) {
|
||||
// The sync below brings back the source value.
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// Edits that only clear an override must not be taken for a new override: values are
|
||||
// refreshed by the sync inside `edit`, which ran after the override list was trimmed.
|
||||
Ok(json!({"reverted": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Push to Comp: write the instance's override values into the source comp's properties (and
|
||||
/// replaced media into the source layer), then drop the overrides.
|
||||
fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.pushToComp";
|
||||
let (cid, lid, src) = instance(s, p, C)?;
|
||||
let ts = targets(s, cid, lid, p);
|
||||
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
|
||||
let mut writes = vec![];
|
||||
for (control, uid) in &ts {
|
||||
let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue };
|
||||
writes.push((ctl.kind.clone(), pr.clone()));
|
||||
}
|
||||
s.edit("Push Override Values to Source", None, |proj, _| {
|
||||
let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?;
|
||||
for (kind, pr) in &writes {
|
||||
match kind {
|
||||
EgKind::Property { layer, prop } => {
|
||||
let Some(inner) = sc.layer_mut(*layer) else { continue };
|
||||
// Instance keys are in the precomp layer's time (= the source comp's time);
|
||||
// the source property's keys are in the inner layer's time.
|
||||
let keys: Vec<_> = pr
|
||||
.keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let mut k = k.clone();
|
||||
k.time = inner.layer_time(k.time);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
if let Some(target) = inner.props.find_mut(*prop) {
|
||||
target.value = pr.value.clone();
|
||||
target.keys = keys;
|
||||
}
|
||||
}
|
||||
EgKind::Media { layer } => {
|
||||
if let Some(inner) = sc.layer_mut(*layer) {
|
||||
inner.source = LayerSource::Footage { item: ItemId(pr.value.as_f64().max(0.0) as u64) };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let g = super::layer_mut(proj, cid, lid)?.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
|
||||
if let GroupKind::Essential { overridden } = &mut g.kind {
|
||||
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
|
||||
}
|
||||
for (_, uid) in &ts {
|
||||
if let Some(pr) = g.find_mut(*uid) {
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"pushed": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ templates
|
||||
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// The manifest's control list (flattened, with the group each belongs to).
|
||||
fn manifest_controls(project: &Project, cid: ItemId, eg: &EssentialGraphics) -> Vec<Value> {
|
||||
fn go(project: &Project, cid: ItemId, v: &[EgControl], group: Option<u64>, out: &mut Vec<Value>) {
|
||||
for c in v {
|
||||
let mut j = control_json(project, cid, c);
|
||||
if let Some(o) = j.as_object_mut() {
|
||||
o.remove("children");
|
||||
o.insert("group".into(), json!(group));
|
||||
}
|
||||
out.push(j);
|
||||
if let EgKind::Group { children } = &c.kind {
|
||||
go(project, cid, children, Some(c.id), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
go(project, cid, &eg.controls, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn export_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.exportTemplate";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let eg = comp.essential.clone().filter(|e| !e.controls.is_empty()).ok_or_else(|| bad(C, "the composition has no Essential Graphics controls"))?;
|
||||
let comp_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() });
|
||||
// The project subset: the comp and everything it uses, at the root.
|
||||
let deps = essential::dependencies(&s.project, cid);
|
||||
let mut sub = Project { settings: s.project.settings.clone(), next_id: s.project.next_id, ..Project::default() };
|
||||
let mut entries: Vec<(String, Vec<u8>)> = vec![];
|
||||
let mut media = vec![];
|
||||
for id in &deps {
|
||||
let Some(mut it) = s.project.item(*id).cloned() else { continue };
|
||||
it.parent = None;
|
||||
it.proxy = None;
|
||||
if let ItemKind::Footage(f) = &mut it.kind
|
||||
&& f.data.is_none()
|
||||
{
|
||||
let files: Vec<String> = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() };
|
||||
let mut stored = vec![];
|
||||
for (k, file) in files.iter().enumerate() {
|
||||
if let Ok(bytes) = s.services.read_file(file) {
|
||||
let entry = format!("media/{}/{}", id.0, file_name(file));
|
||||
entries.push((entry.clone(), bytes));
|
||||
if k == 0 {
|
||||
f.path = entry.clone();
|
||||
}
|
||||
stored.push(entry);
|
||||
}
|
||||
}
|
||||
if !stored.is_empty() {
|
||||
if !f.sequence.is_empty() {
|
||||
f.sequence = stored.clone();
|
||||
}
|
||||
media.push(json!({"item": id.0, "files": stored}));
|
||||
}
|
||||
}
|
||||
sub.items.insert(*id, it);
|
||||
}
|
||||
let manifest = json!({
|
||||
"format": TEMPLATE_FORMAT,
|
||||
"version": TEMPLATE_VERSION,
|
||||
"generator": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
|
||||
"name": name,
|
||||
"comp": cid.0,
|
||||
"compName": comp_name,
|
||||
"width": comp.width,
|
||||
"height": comp.height,
|
||||
"frameRate": comp.frame_rate.as_f64(),
|
||||
"duration": comp.duration.seconds(),
|
||||
"controls": manifest_controls(&s.project, cid, &eg),
|
||||
"media": media,
|
||||
"poster": "poster.png",
|
||||
});
|
||||
// A poster frame (longest side ≤ 320 px).
|
||||
if let Ok((w, h, rgba)) = s.render_rgba8(cid, comp.poster_time, 320)
|
||||
&& let Ok(png) = super::comp_more::encode_png(&rgba, w, h)
|
||||
{
|
||||
entries.push(("poster.png".into(), png));
|
||||
}
|
||||
entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes()));
|
||||
entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()));
|
||||
let bytes = effectcraft_lottie::zip::store(&entries);
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Exported template “{name}” to {path}"));
|
||||
Ok(json!({"path": path, "name": name, "bytes": bytes.len(), "controls": eg.flat().len(), "items": deps.len()}))
|
||||
}
|
||||
|
||||
/// Read a template's manifest and project (with media paths still archive-relative).
|
||||
pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(String, Vec<u8>)>), String> {
|
||||
let entries = effectcraft_lottie::zip::read_stored(bytes);
|
||||
let get = |n: &str| entries.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
|
||||
let manifest: Value =
|
||||
serde_json::from_slice(&get("manifest.json").ok_or("not an EffectCraft template (no manifest.json)")?).map_err(|e| format!("manifest.json: {e}"))?;
|
||||
if manifest.get("format").and_then(Value::as_str) != Some(TEMPLATE_FORMAT) {
|
||||
return Err("not an EffectCraft template".into());
|
||||
}
|
||||
if manifest.get("version").and_then(Value::as_u64).unwrap_or(0) > TEMPLATE_VERSION as u64 {
|
||||
return Err("the template was made by a newer EffectCraft".into());
|
||||
}
|
||||
let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?;
|
||||
let project = Project::from_json(&text).map_err(|e| e.to_string())?;
|
||||
Ok((manifest, project, entries))
|
||||
}
|
||||
|
||||
fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.importTemplate";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
||||
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let (manifest, mut sub, entries) = read_template(&bytes).map_err(|e| bad(C, e))?;
|
||||
let name = manifest.get("name").and_then(Value::as_str).unwrap_or("Template").to_string();
|
||||
let tcomp = manifest.get("comp").and_then(Value::as_u64).map(ItemId).ok_or_else(|| bad(C, "manifest has no `comp`"))?;
|
||||
// Media next to the template: `<dir>/<stem> Media/<item>/<file>`.
|
||||
let pp = std::path::Path::new(&path);
|
||||
let dir = pp.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let stem = pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Template".into());
|
||||
let media_dir = dir.join(format!("{stem} Media"));
|
||||
let mut written = std::collections::HashMap::new();
|
||||
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
|
||||
let rel = k.trim_start_matches("media/");
|
||||
let out = media_dir.join(rel);
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let out_s = out.to_string_lossy().to_string();
|
||||
if s.services.write_file(&out_s, v).is_ok() {
|
||||
written.insert(k.clone(), out_s);
|
||||
}
|
||||
}
|
||||
for it in sub.items.values_mut() {
|
||||
if let ItemKind::Footage(f) = &mut it.kind {
|
||||
if let Some(w) = written.get(&f.path) {
|
||||
f.path = w.clone();
|
||||
}
|
||||
for x in &mut f.sequence {
|
||||
if let Some(w) = written.get(x) {
|
||||
*x = w.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = if b_p(p, "addToComp") == Some(false) { None } else { comp_id(s, p).ok() };
|
||||
let base = s.project.next_id;
|
||||
essential::offset_ids(&mut sub, base);
|
||||
let new_comp = ItemId(tcomp.0 + base);
|
||||
let ids = s.edit("Import Template", None, |proj, st| {
|
||||
let folder = proj.add_item(&name, effectcraft_color::Label::Yellow, None, ItemKind::Folder);
|
||||
let mut ids = vec![];
|
||||
for (id, mut it) in std::mem::take(&mut sub.items) {
|
||||
if it.parent.is_none() {
|
||||
it.parent = Some(folder);
|
||||
}
|
||||
ids.push(id.0);
|
||||
proj.items.insert(id, it);
|
||||
}
|
||||
proj.next_id = proj.next_id.max(sub.next_id);
|
||||
proj.fix_next_id();
|
||||
st.project_selection = vec![new_comp];
|
||||
Ok(ids)
|
||||
})?;
|
||||
let mut layer = Value::Null;
|
||||
if let Some(cid) = target
|
||||
&& s.project.comp(cid).is_some()
|
||||
{
|
||||
let r = s.execute("layer.addItem", json!({"comp": cid.0, "item": new_comp.0, "time": 0.0}))?;
|
||||
layer = r["layer"].clone();
|
||||
}
|
||||
s.toast(format!("Imported template “{name}”"));
|
||||
Ok(json!({"comp": new_comp.0, "items": ids, "layer": layer, "name": name}))
|
||||
}
|
||||
|
||||
/// Read a template's manifest without importing it (Essential Graphics / agents).
|
||||
fn template_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("essential.templateInfo", "missing `path`"))?;
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let (manifest, _, _) = read_template(&bytes).map_err(|e| bad("essential.templateInfo", e))?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn has_instance(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let ok = s.active_comp().is_some_and(|c| s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| essential::group(l).is_some())));
|
||||
if ok { Ok(()) } else { Err("select a precomp layer with Essential Properties".into()) }
|
||||
}
|
||||
|
||||
fn has_eg(s: &Session) -> std::result::Result<(), String> {
|
||||
let cid = s.state.essential_primary.or(s.state.active_comp).ok_or("no composition is open")?;
|
||||
if s.project.comp(cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| !e.controls.is_empty()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("add properties to Essential Graphics first".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.selected_props.is_empty() { Err("select a property in the timeline".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("essential.setPrimary", "Primary Composition", [], None, "{comp}", has_comp, set_primary),
|
||||
cmd!("essential.setName", "Essential Graphics Name", [], None, "{comp?, name}", has_comp, set_name),
|
||||
cmd!(
|
||||
"essential.addProperty",
|
||||
"Add Property to Essential Graphics",
|
||||
["Animation"],
|
||||
None,
|
||||
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}",
|
||||
has_selected_props,
|
||||
add_property
|
||||
),
|
||||
cmd!("essential.addMedia", "Add Media Replacement", [], None, "{layer, name?, group?}", has_comp, add_media),
|
||||
cmd!("essential.addGroup", "Add Group", [], None, "{comp?, name?, index?}", has_comp, add_group),
|
||||
cmd!("essential.addComment", "Add Comment", [], None, "{comp?, text?, group?}", has_comp, add_comment),
|
||||
cmd!("essential.rename", "Rename Control", [], None, "{comp?, control, name}", has_comp, rename),
|
||||
cmd!("essential.remove", "Remove Control", [], None, "{comp?, control}", has_comp, remove),
|
||||
cmd!("essential.move", "Move Control", [], None, "{comp?, control, group?: id (null = top level), index?}", has_comp, move_control),
|
||||
cmd!("essential.soloSupported", "Solo Supported Properties", [], None, "{on?}", has_comp, solo_supported),
|
||||
cmd!(
|
||||
"essential.exportTemplate",
|
||||
"Essential Graphics Template...",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{comp?, path (.ectemplate), name?}",
|
||||
has_eg,
|
||||
export_template
|
||||
),
|
||||
cmd!(
|
||||
"essential.importTemplate",
|
||||
"Essential Graphics Template...",
|
||||
["File", "Import"],
|
||||
None,
|
||||
"{path (.ectemplate), comp?, addToComp?: bool}",
|
||||
super::always,
|
||||
import_template
|
||||
),
|
||||
cmd!("essential.set", "Set Essential Property", [], None, "{layer, control, value | item (media)}", has_instance, set_override),
|
||||
cmd!("essential.pushToComp", "Push Override Values to Source", [], None, "{layer, control? (default: all overridden)}", has_instance, push_to_comp),
|
||||
cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert),
|
||||
cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown),
|
||||
query!("essential.list", "Essential Graphics controls", "{comp?, supported?: bool}", list),
|
||||
query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json),
|
||||
query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
//! Expression tools: the error list behind the expression error bar, and the Expression
|
||||
//! Language menu (the ⓕ button next to an expression), written from the public Expression
|
||||
//! Language Reference.
|
||||
|
||||
use effectcraft_project::{ItemId, Layer, Property};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, comp_id, f_p};
|
||||
use crate::{EngineError, Result, Session, query};
|
||||
|
||||
/// One failing expression.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ExprError {
|
||||
pub comp: u64,
|
||||
pub layer: u64,
|
||||
#[serde(rename = "layerIndex")]
|
||||
pub layer_index: usize,
|
||||
#[serde(rename = "layerName")]
|
||||
pub layer_name: String,
|
||||
pub prop: u64,
|
||||
/// Match path (`transform/opacity`).
|
||||
pub path: String,
|
||||
/// Property name path as shown in the timeline (`Transform/Opacity`).
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
/// The expression is disabled (it failed to compile).
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
impl ExprError {
|
||||
/// The error bar's text: `Error at line 1 in property 'Opacity' of layer 1 ('Solid') in comp 'Main'. …`
|
||||
pub fn describe(&self, comp_name: &str) -> String {
|
||||
let prop = self.name.rsplit('/').next().unwrap_or(&self.name);
|
||||
format!("{} — property '{}' of layer {} ('{}') in comp '{}'", self.message, prop, self.layer_index, self.layer_name, comp_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every failing expression of comp `cid` at comp time `t`: evaluation errors of enabled
|
||||
/// expressions and the syntax errors that disabled others.
|
||||
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
let mut out = vec![];
|
||||
for (i, l) in comp.layers.iter().enumerate() {
|
||||
let mut props: Vec<&Property> = vec![];
|
||||
l.props.walk("", &mut |_, p| {
|
||||
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
|
||||
props.push(p);
|
||||
}
|
||||
});
|
||||
for p in props {
|
||||
let e = p.expr.as_ref().expect("filtered");
|
||||
let msg = if e.enabled {
|
||||
match &ctx.expr {
|
||||
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
s.expr_check.and_then(|check| check(&e.text).err())
|
||||
};
|
||||
if let Some(message) = msg {
|
||||
out.push(error_of(cid, i, l, p, message, !e.enabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
|
||||
ExprError {
|
||||
comp: cid.0,
|
||||
layer: l.id.0,
|
||||
layer_index: i + 1,
|
||||
layer_name: l.name.clone(),
|
||||
prop: p.uid,
|
||||
path: super::match_path_of(&l.props, p.uid).unwrap_or_default(),
|
||||
name: l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone()),
|
||||
message,
|
||||
disabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn errors_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let name = s.project.item(cid).map(|i| i.name.clone()).ok_or(EngineError::NoComp)?;
|
||||
let list = errors(s, cid, t);
|
||||
let text: Vec<String> = list.iter().map(|e| e.describe(&name)).collect();
|
||||
Ok(json!({"comp": cid.0, "time": t.seconds(), "count": list.len(), "errors": list, "text": text}))
|
||||
}
|
||||
|
||||
/// The Expression Language menu: (category, [(label, inserted text)]). Submenus are nested
|
||||
/// with `▸` in the category name.
|
||||
pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
vec![
|
||||
(
|
||||
"Global",
|
||||
vec![
|
||||
("comp(name)", "comp(\"name\")"),
|
||||
("footage(name)", "footage(\"name\")"),
|
||||
("thisComp", "thisComp"),
|
||||
("thisLayer", "thisLayer"),
|
||||
("thisProperty", "thisProperty"),
|
||||
("thisProject", "thisProject"),
|
||||
("time", "time"),
|
||||
("colorDepth", "colorDepth"),
|
||||
("posterizeTime(framesPerSecond)", "posterizeTime(framesPerSecond)"),
|
||||
("value", "value"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Vector Math",
|
||||
vec![
|
||||
("add(vec1, vec2)", "add(vec1, vec2)"),
|
||||
("sub(vec1, vec2)", "sub(vec1, vec2)"),
|
||||
("mul(vec, amount)", "mul(vec, amount)"),
|
||||
("div(vec, amount)", "div(vec, amount)"),
|
||||
("clamp(value, limit1, limit2)", "clamp(value, limit1, limit2)"),
|
||||
("dot(vec1, vec2)", "dot(vec1, vec2)"),
|
||||
("cross(vec1, vec2)", "cross(vec1, vec2)"),
|
||||
("normalize(vec)", "normalize(vec)"),
|
||||
("length(vec)", "length(vec)"),
|
||||
("length(point1, point2)", "length(point1, point2)"),
|
||||
("lookAt(fromPoint, atPoint)", "lookAt(fromPoint, atPoint)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Random Numbers",
|
||||
vec![
|
||||
("seedRandom(offset, timeless=false)", "seedRandom(offset, timeless=false)"),
|
||||
("random()", "random()"),
|
||||
("random(maxValOrArray)", "random(maxValOrArray)"),
|
||||
("random(minValOrArray, maxValOrArray)", "random(minValOrArray, maxValOrArray)"),
|
||||
("gaussRandom()", "gaussRandom()"),
|
||||
("gaussRandom(maxValOrArray)", "gaussRandom(maxValOrArray)"),
|
||||
("gaussRandom(minValOrArray, maxValOrArray)", "gaussRandom(minValOrArray, maxValOrArray)"),
|
||||
("noise(valOrArray)", "noise(valOrArray)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Interpolation",
|
||||
vec![
|
||||
("linear(t, value1, value2)", "linear(t, value1, value2)"),
|
||||
("linear(t, tMin, tMax, value1, value2)", "linear(t, tMin, tMax, value1, value2)"),
|
||||
("ease(t, value1, value2)", "ease(t, value1, value2)"),
|
||||
("ease(t, tMin, tMax, value1, value2)", "ease(t, tMin, tMax, value1, value2)"),
|
||||
("easeIn(t, value1, value2)", "easeIn(t, value1, value2)"),
|
||||
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
|
||||
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
|
||||
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Color Conversion",
|
||||
vec![
|
||||
("rgbToHsl(rgbaArray)", "rgbToHsl(rgbaArray)"),
|
||||
("hslToRgb(hslaArray)", "hslToRgb(hslaArray)"),
|
||||
("hexToRgb(hexString)", "hexToRgb(hexString)"),
|
||||
],
|
||||
),
|
||||
("Other Math", vec![("degreesToRadians(degrees)", "degreesToRadians(degrees)"), ("radiansToDegrees(radians)", "radiansToDegrees(radians)")]),
|
||||
(
|
||||
"JavaScript Math",
|
||||
vec![
|
||||
("Math.cos(value)", "Math.cos(value)"),
|
||||
("Math.sin(value)", "Math.sin(value)"),
|
||||
("Math.atan2(y, x)", "Math.atan2(y, x)"),
|
||||
("Math.sqrt(value)", "Math.sqrt(value)"),
|
||||
("Math.abs(value)", "Math.abs(value)"),
|
||||
("Math.round(value)", "Math.round(value)"),
|
||||
("Math.floor(value)", "Math.floor(value)"),
|
||||
("Math.min(value1, value2)", "Math.min(value1, value2)"),
|
||||
("Math.max(value1, value2)", "Math.max(value1, value2)"),
|
||||
("Math.PI", "Math.PI"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Comp",
|
||||
vec![
|
||||
("layer(index)", "layer(index)"),
|
||||
("layer(name)", "layer(\"name\")"),
|
||||
("layer(otherLayer, relIndex)", "layer(otherLayer, relIndex)"),
|
||||
("marker", "marker"),
|
||||
("numLayers", "numLayers"),
|
||||
("activeCamera", "activeCamera"),
|
||||
("width", "width"),
|
||||
("height", "height"),
|
||||
("duration", "duration"),
|
||||
("frameDuration", "frameDuration"),
|
||||
("pixelAspect", "pixelAspect"),
|
||||
("name", "name"),
|
||||
("bgColor", "bgColor"),
|
||||
("displayStartTime", "displayStartTime"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Footage",
|
||||
vec![
|
||||
("width", "width"),
|
||||
("height", "height"),
|
||||
("duration", "duration"),
|
||||
("frameDuration", "frameDuration"),
|
||||
("pixelAspect", "pixelAspect"),
|
||||
("name", "name"),
|
||||
("sourceText", "sourceText"),
|
||||
("sourceData", "sourceData"),
|
||||
("dataValue(dataPath)", "dataValue(dataPath)"),
|
||||
("dataKeyCount", "dataKeyCount"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Sub-objects",
|
||||
vec![
|
||||
("source", "source"),
|
||||
("effect(name)", "effect(\"name\")"),
|
||||
("effect(index)", "effect(index)"),
|
||||
("mask(name)", "mask(\"name\")"),
|
||||
("mask(index)", "mask(index)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > General",
|
||||
vec![
|
||||
("width", "width"),
|
||||
("height", "height"),
|
||||
("index", "index"),
|
||||
("parent", "parent"),
|
||||
("hasParent", "hasParent"),
|
||||
("inPoint", "inPoint"),
|
||||
("outPoint", "outPoint"),
|
||||
("startTime", "startTime"),
|
||||
("hasVideo", "hasVideo"),
|
||||
("enabled", "enabled"),
|
||||
("active", "active"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Properties",
|
||||
vec![
|
||||
("anchorPoint", "anchorPoint"),
|
||||
("position", "position"),
|
||||
("scale", "scale"),
|
||||
("rotation", "rotation"),
|
||||
("opacity", "opacity"),
|
||||
("marker", "marker"),
|
||||
("name", "name"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Space Transforms",
|
||||
vec![
|
||||
("toComp(point, t = time)", "toComp(point, t = time)"),
|
||||
("fromComp(point, t = time)", "fromComp(point, t = time)"),
|
||||
("toWorld(point, t = time)", "toWorld(point, t = time)"),
|
||||
("fromWorld(point, t = time)", "fromWorld(point, t = time)"),
|
||||
("toCompVec(vec, t = time)", "toCompVec(vec, t = time)"),
|
||||
("fromCompVec(vec, t = time)", "fromCompVec(vec, t = time)"),
|
||||
("toWorldVec(vec, t = time)", "toWorldVec(vec, t = time)"),
|
||||
("fromWorldVec(vec, t = time)", "fromWorldVec(vec, t = time)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Other",
|
||||
vec![
|
||||
("sourceRectAtTime(t = time, includeExtents = false)", "sourceRectAtTime(t = time, includeExtents = false)"),
|
||||
("sampleImage(point, radius = [.5, .5], postEffect = true, t = time)", "sampleImage(point, radius = [.5, .5], postEffect = true, t = time)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Property",
|
||||
vec![
|
||||
("value", "value"),
|
||||
("valueAtTime(t)", "valueAtTime(t)"),
|
||||
("velocity", "velocity"),
|
||||
("velocityAtTime(t)", "velocityAtTime(t)"),
|
||||
("speed", "speed"),
|
||||
("speedAtTime(t)", "speedAtTime(t)"),
|
||||
("wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
|
||||
("temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
|
||||
("smooth(width = .2, samples = 5, t = time)", "smooth(width = .2, samples = 5, t = time)"),
|
||||
("loopIn(type = \"cycle\", numKeyframes = 0)", "loopIn(type = \"cycle\", numKeyframes = 0)"),
|
||||
("loopOut(type = \"cycle\", numKeyframes = 0)", "loopOut(type = \"cycle\", numKeyframes = 0)"),
|
||||
("loopInDuration(type = \"cycle\", duration = 0)", "loopInDuration(type = \"cycle\", duration = 0)"),
|
||||
("loopOutDuration(type = \"cycle\", duration = 0)", "loopOutDuration(type = \"cycle\", duration = 0)"),
|
||||
("key(index)", "key(index)"),
|
||||
("key(markerName)", "key(markerName)"),
|
||||
("nearestKey(t)", "nearestKey(t)"),
|
||||
("numKeys", "numKeys"),
|
||||
("name", "name"),
|
||||
("propertyIndex", "propertyIndex"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Path Property",
|
||||
vec![
|
||||
("points(t = time)", "points(t = time)"),
|
||||
("inTangents(t = time)", "inTangents(t = time)"),
|
||||
("outTangents(t = time)", "outTangents(t = time)"),
|
||||
("isClosed()", "isClosed()"),
|
||||
(
|
||||
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
|
||||
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Text > Style",
|
||||
vec![
|
||||
("text.sourceText.style", "text.sourceText.style"),
|
||||
("getStyleAt(charIndex, t = time)", "getStyleAt(charIndex, t = time)"),
|
||||
("createStyle()", "createStyle()"),
|
||||
("setFontSize(value)", "setFontSize(value)"),
|
||||
("setFillColor(value)", "setFillColor(value)"),
|
||||
("setText(value)", "setText(value)"),
|
||||
],
|
||||
),
|
||||
("Text > Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]),
|
||||
("Key", vec![("value", "value"), ("time", "time"), ("index", "index")]),
|
||||
(
|
||||
"Marker Key",
|
||||
vec![
|
||||
("duration", "duration"),
|
||||
("comment", "comment"),
|
||||
("chapter", "chapter"),
|
||||
("url", "url"),
|
||||
("frameTarget", "frameTarget"),
|
||||
("eventCuePoint", "eventCuePoint"),
|
||||
("cuePointName", "cuePointName"),
|
||||
("parameters", "parameters"),
|
||||
("protectedRegion", "protectedRegion"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Time Conversion",
|
||||
vec![
|
||||
(
|
||||
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
),
|
||||
("framesToTime(frames, fps = 1.0 / thisComp.frameDuration)", "framesToTime(frames, fps = 1.0 / thisComp.frameDuration)"),
|
||||
(
|
||||
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
|
||||
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
|
||||
),
|
||||
(
|
||||
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
|
||||
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
|
||||
),
|
||||
(
|
||||
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn menu_cmd(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!(
|
||||
language_menu()
|
||||
.into_iter()
|
||||
.map(|(c, items)| json!({"category": c, "items": items.into_iter().map(|(l, t)| json!({"label": l, "text": t})).collect::<Vec<_>>()}))
|
||||
.collect::<Vec<_>>()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("expr.errors", "Expression errors (the error bar)", "{comp?, time? (s)}", errors_cmd),
|
||||
query!("expr.languageMenu", "Expression Language menu", "{}", menu_cmd),
|
||||
]
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
|
||||
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
|
||||
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
|
||||
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
|
||||
save_to(s, &next)
|
||||
}
|
||||
|
||||
|
|
@ -84,12 +84,61 @@ 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 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 {
|
||||
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
|
||||
if let Some(f) = data_footage(s, path) {
|
||||
match f {
|
||||
Ok(f) => probed.push((path.clone(), f)),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(importer) = s.importer.clone() else {
|
||||
errors.push(format!("{path}: media import is not available in this build"));
|
||||
continue;
|
||||
};
|
||||
match importer.probe(path) {
|
||||
Ok(mut f) => {
|
||||
// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
|
|
@ -99,6 +148,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,10 +168,15 @@ 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,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
FootageKind::Data => Label::Sandstone,
|
||||
};
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
|
||||
ids.push(id.0);
|
||||
|
|
@ -118,7 +184,56 @@ 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))
|
||||
}
|
||||
|
||||
/// Extensions imported as data footage.
|
||||
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
|
||||
|
||||
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
|
||||
pub(crate) fn data_footage(s: &Session, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some((|| {
|
||||
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
|
||||
if ext == "json" {
|
||||
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
}
|
||||
Ok(effectcraft_project::Footage { path: path.to_string(), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text), ..Default::default() })
|
||||
})())
|
||||
}
|
||||
|
||||
fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -224,7 +339,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,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -442,56 +443,179 @@ fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
/// Interpret Footage settings parsed from command parameters (every field optional).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Interpretation {
|
||||
rate: Option<FrameRate>,
|
||||
/// Back to the file's own rate (Use frame rate from file).
|
||||
native: bool,
|
||||
alpha: Option<AlphaMode>,
|
||||
/// Alpha ▸ Guess: decide Straight / Premultiplied (and the matte colour) from a frame.
|
||||
guess: bool,
|
||||
matte: Option<[f32; 3]>,
|
||||
invert_alpha: Option<bool>,
|
||||
loops: Option<u32>,
|
||||
par: Option<f64>,
|
||||
fields: Option<effectcraft_project::FieldOrder>,
|
||||
profile: Option<Option<effectcraft_project::ColorSpace>>,
|
||||
linear: Option<bool>,
|
||||
}
|
||||
|
||||
impl Interpretation {
|
||||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Interpretation> {
|
||||
let mut it = Interpretation { rate: f_p(p, "frameRate").map(FrameRate::from_f64), ..Default::default() };
|
||||
if str_p(p, "frameRate") == Some("file") {
|
||||
it.native = true;
|
||||
}
|
||||
match str_p(p, "alpha") {
|
||||
Some("straight") => it.alpha = Some(AlphaMode::Straight),
|
||||
Some("premultiplied") => it.alpha = Some(AlphaMode::Premultiplied),
|
||||
Some("ignore") => it.alpha = Some(AlphaMode::Ignore),
|
||||
Some("guess") => it.guess = true,
|
||||
Some(a) => return Err(bad(cmd, format!("alpha: straight|premultiplied|ignore|guess, not `{a}`"))),
|
||||
None => {}
|
||||
}
|
||||
it.guess |= super::b_p(p, "guessAlpha").unwrap_or(false);
|
||||
it.matte = match p.get("matteColor").or(p.get("premulColor")) {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
effectcraft_keyframe::Value::Color([0.0, 0.0, 0.0, 1.0])
|
||||
.coerce_json(v)
|
||||
.map(|c| {
|
||||
let c = c.as_color();
|
||||
[c[0], c[1], c[2]]
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, "matteColor: #hex or [r, g, b] (0..1)"))?,
|
||||
),
|
||||
};
|
||||
it.invert_alpha = super::b_p(p, "invertAlpha");
|
||||
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
|
||||
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
|
||||
it.fields = match str_p(p, "fields") {
|
||||
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
|
||||
None => None,
|
||||
};
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
it.profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad(cmd, format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
it.linear = super::b_p(p, "linearLight");
|
||||
Ok(it)
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
if self.native {
|
||||
if let Some(n) = f.native_rate.take() {
|
||||
f.frame_rate = n;
|
||||
}
|
||||
} else if let Some(r) = self.rate {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = r;
|
||||
}
|
||||
if let Some(a) = self.alpha {
|
||||
f.alpha = a;
|
||||
}
|
||||
if let Some((a, m)) = guessed {
|
||||
f.alpha = a;
|
||||
f.premul_color = m;
|
||||
}
|
||||
if let Some(m) = self.matte {
|
||||
f.premul_color = m;
|
||||
}
|
||||
if let Some(v) = self.invert_alpha {
|
||||
f.invert_alpha = v;
|
||||
}
|
||||
if let Some(l) = self.loops {
|
||||
f.loop_count = l;
|
||||
}
|
||||
if let Some(x) = self.par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(x) = self.fields {
|
||||
f.fields = x;
|
||||
}
|
||||
if let Some(c) = self.profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
if let Some(v) = self.linear {
|
||||
f.linear_light = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Alpha ▸ Guess: read the first frame as straight colour and decide
|
||||
/// whether the file is premultiplied (every partly transparent pixel's colour within its alpha
|
||||
/// of a black or white matte) or straight. `None` when the frame has no partial alpha.
|
||||
pub(crate) fn guess_alpha(s: &Session, item: ItemId, f: &Footage) -> Option<(AlphaMode, [f32; 3])> {
|
||||
let probe = Footage { alpha: AlphaMode::Straight, invert_alpha: false, ..f.clone() };
|
||||
let img = s.footage.frame(item, &probe, Tick::ZERO)?;
|
||||
let (mut partial, mut black, mut white) = (0usize, true, true);
|
||||
let eps = 1.5 / 255.0;
|
||||
for px in img.data.iter() {
|
||||
let a = px[3];
|
||||
if a <= eps || a >= 1.0 - eps {
|
||||
continue;
|
||||
}
|
||||
partial += 1;
|
||||
for c in &px[..3] {
|
||||
// The file's colour (the decoder premultiplied it as straight).
|
||||
let file = c / a;
|
||||
black &= file <= a + eps;
|
||||
white &= file >= 1.0 - a - eps;
|
||||
}
|
||||
}
|
||||
if partial == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(if black {
|
||||
(AlphaMode::Premultiplied, [0.0; 3])
|
||||
} else if white {
|
||||
(AlphaMode::Premultiplied, [1.0; 3])
|
||||
} else {
|
||||
(AlphaMode::Straight, f.premul_color)
|
||||
})
|
||||
}
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad("file.interpretFootage", "select footage"));
|
||||
}
|
||||
let rate = f_p(p, "frameRate").map(FrameRate::from_f64);
|
||||
let alpha = match str_p(p, "alpha") {
|
||||
Some("straight") => Some(AlphaMode::Straight),
|
||||
Some("premultiplied") => Some(AlphaMode::Premultiplied),
|
||||
Some("ignore") => Some(AlphaMode::Ignore),
|
||||
Some(a) => return Err(bad("file.interpretFootage", format!("alpha: straight|premultiplied|ignore, not `{a}`"))),
|
||||
None => None,
|
||||
};
|
||||
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let par = f_p(p, "pixelAspect");
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
let profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let it = Interpretation::parse(p, "file.interpretFootage")?;
|
||||
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
|
||||
.iter()
|
||||
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
|
||||
(true, Some(ItemKind::Footage(f))) => guess_alpha(s, *i, f),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for i in &items {
|
||||
for (i, g) in items.iter().zip(&guesses) {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
if let Some(r) = rate {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = r;
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
f.alpha = a;
|
||||
}
|
||||
if let Some(l) = loops {
|
||||
f.loop_count = l;
|
||||
}
|
||||
if let Some(x) = par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(c) = profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
it.apply(f, *g);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
let out: Vec<Value> = items
|
||||
.iter()
|
||||
.filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
|
||||
Some(ItemKind::Footage(f)) => Some(json!({
|
||||
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
|
||||
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
|
||||
"linearLight": f.linear_light,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"items": out}))
|
||||
}
|
||||
|
||||
fn remember_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -512,6 +636,10 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
f.premul_color = src.premul_color;
|
||||
f.pixel_aspect = src.pixel_aspect;
|
||||
f.loop_count = src.loop_count;
|
||||
f.fields = src.fields;
|
||||
f.invert_alpha = src.invert_alpha;
|
||||
f.linear_light = src.linear_light;
|
||||
f.color_profile = src.color_profile;
|
||||
if src.native_rate.is_some() {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
|
|
@ -699,7 +827,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
|
||||
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
|
||||
let folder = solids_folder(proj);
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect }));
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
|
||||
if adjustment {
|
||||
l.switches.adjustment = true;
|
||||
|
|
@ -239,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
|
||||
(LayerSource::Footage { item }, (f.width, f.height), d)
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => {
|
||||
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
|
||||
}
|
||||
|
|
@ -519,7 +522,8 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
|
||||
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
|
||||
let compensate = b_p(p, "compensate").unwrap_or(true);
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() };
|
||||
let ectx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
|
||||
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
|||
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
|
||||
}
|
||||
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() };
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
|
||||
.layers
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@
|
|||
mod anim;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
mod camera_cmds;
|
||||
mod comp;
|
||||
mod comp_more;
|
||||
mod create;
|
||||
mod edit;
|
||||
mod effect;
|
||||
pub mod essential;
|
||||
pub mod expr_tools;
|
||||
mod file;
|
||||
mod file_more;
|
||||
pub(crate) mod file_more;
|
||||
mod focus;
|
||||
mod frontend;
|
||||
mod help;
|
||||
|
|
@ -30,6 +34,7 @@ mod paths;
|
|||
mod project_items;
|
||||
mod prop;
|
||||
mod prop_groups;
|
||||
mod proxy;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
|
|
@ -127,6 +132,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());
|
||||
|
|
@ -141,16 +147,25 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(track::specs());
|
||||
v.extend(mask_interp::specs());
|
||||
v.extend(warp_cmds::specs());
|
||||
v.extend(camera_cmds::specs());
|
||||
v.extend(roto_cmds::specs());
|
||||
v.extend(paint::specs());
|
||||
v.extend(puppet::specs());
|
||||
v.extend(liquify::specs());
|
||||
v.extend(settings::specs());
|
||||
v.extend(essential::specs());
|
||||
v.extend(expr_tools::specs());
|
||||
v.extend(proxy::specs());
|
||||
v.extend(stubs::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
/// Set `footage` as the proxy of `item` (File ▸ Create Proxy's post-render action).
|
||||
pub(crate) fn proxy_set(s: &mut Session, item: ItemId, footage: effectcraft_project::Footage) -> Result<()> {
|
||||
proxy::set_proxy_footage(s, &[item], footage)
|
||||
}
|
||||
|
||||
/// (id, name) of the text animation presets.
|
||||
pub fn text_preset_list() -> Vec<(String, String)> {
|
||||
text_anim::preset_names()
|
||||
|
|
|
|||
|
|
@ -557,7 +557,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let fd = comp.frame_duration();
|
||||
let mut t = layer.in_point;
|
||||
while t < layer.out_point {
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
|
||||
t += fd;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
//! Proxies (File ▸ Create Proxy / Set Proxy / Interpret Footage ▸ Proxy): a stand-in file for
|
||||
//! a footage item or a composition. The Project panel's proxy switch (`file.useProxy`) turns
|
||||
//! it on or off per item; renders follow Render Settings ▸ Proxy Use (the viewer uses each
|
||||
//! item's switch).
|
||||
|
||||
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_project_selection, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Footage items and comps a proxy command acts on: `items`/`item`, else the Project panel
|
||||
/// selection.
|
||||
fn targets(s: &Session, p: &Value) -> Vec<ItemId> {
|
||||
let ids: Vec<ItemId> = match p.get("items").or(p.get("item")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(),
|
||||
Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(),
|
||||
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).into_iter().collect(),
|
||||
_ => s.state.project_selection.clone(),
|
||||
};
|
||||
ids.into_iter().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Comp(_)))).collect()
|
||||
}
|
||||
|
||||
fn has_targets(s: &Session) -> std::result::Result<(), String> {
|
||||
has_project_selection(s)?;
|
||||
if targets(s, &Value::Null).is_empty() { Err("select footage or a composition in the Project panel".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_proxy(s: &Session) -> std::result::Result<(), String> {
|
||||
has_targets(s)?;
|
||||
if targets(s, &Value::Null).iter().any(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the selected items have no proxy".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe a proxy file through the importer.
|
||||
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
if !f.has_video {
|
||||
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Attach `footage` as the proxy of `items` (Use Proxy on). One undo step.
|
||||
pub(crate) fn set_proxy_footage(s: &mut Session, items: &[ItemId], footage: Footage) -> Result<()> {
|
||||
s.edit("Set Proxy", None, |proj, _| {
|
||||
for i in items {
|
||||
if let Some(it) = proj.item_mut(*i) {
|
||||
it.proxy = Some(Box::new(Proxy { footage: footage.clone(), enabled: true }));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.setProxy";
|
||||
let items = targets(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "select footage or a composition"));
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path`"))?.to_string();
|
||||
let f = probe(s, &path, C)?;
|
||||
let (w, h) = (f.width, f.height);
|
||||
set_proxy_footage(s, &items, f)?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "path": path, "width": w, "height": h}))
|
||||
}
|
||||
|
||||
fn set_proxy_none(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = targets(s, p);
|
||||
s.edit("Set Proxy None", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(it) = proj.item_mut(*i) {
|
||||
it.proxy = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// The Project panel's proxy switch: use the proxy (filled box) or the full-resolution source.
|
||||
fn use_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.useProxy";
|
||||
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let first = s.project.item(items[0]).and_then(|x| x.proxy.as_ref()).is_some_and(|px| px.enabled);
|
||||
let on = b_p(p, "on").unwrap_or(!first);
|
||||
s.edit(if on { "Use Proxy" } else { "Use Full Resolution" }, None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
|
||||
px.enabled = on;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "on": on}))
|
||||
}
|
||||
|
||||
/// File ▸ Interpret Footage ▸ Proxy: Interpret Footage settings for the proxy file.
|
||||
fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.interpretProxy";
|
||||
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let it = super::file_more::Interpretation::parse(p, C)?;
|
||||
let guesses: Vec<_> = items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let px = s.project.item(*i).and_then(|x| x.proxy.as_ref());
|
||||
match (p.get("alpha").and_then(Value::as_str) == Some("guess") || b_p(p, "guessAlpha") == Some(true), px) {
|
||||
(true, Some(px)) => super::file_more::guess_alpha(s, *i, &px.footage),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.edit("Interpret Proxy", None, |proj, _| {
|
||||
for (i, g) in items.iter().zip(&guesses) {
|
||||
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
|
||||
it.apply(&mut px.footage, *g);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// File ▸ Create Proxy ▸ Still / Movie: queue a half-resolution render of the composition whose
|
||||
/// post-render action sets the result as its proxy.
|
||||
fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.createProxy";
|
||||
let cid = match p.get("comp").or(p.get("item")) {
|
||||
Some(_) => {
|
||||
let q = json!({"comp": p.get("comp").or(p.get("item")).cloned()});
|
||||
super::comp_id(s, &q)?
|
||||
}
|
||||
None => s
|
||||
.state
|
||||
.project_selection
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|i| s.project.comp(*i).is_some())
|
||||
.or(s.active_comp_id())
|
||||
.ok_or_else(|| bad(C, "select a composition"))?,
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let still = match str_p(p, "kind").unwrap_or("movie") {
|
||||
"still" => true,
|
||||
"movie" => false,
|
||||
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
|
||||
};
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
it.settings.quality = RenderQuality::Draft;
|
||||
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
|
||||
if still {
|
||||
// One frame at the current time (the comp's poster frame when not the active comp).
|
||||
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
|
||||
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
|
||||
it.output = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
it.output.output = "[compName]_proxy_[#####].png".into();
|
||||
} else {
|
||||
it.settings.time_span = TimeSpan::LengthOfComp;
|
||||
it.output = OutputModule::for_format(OutputFormat::ProRes);
|
||||
it.output.prores_profile = ProResProfile::P4444;
|
||||
it.output.output = "[compName]_proxy.[fileExtension]".into();
|
||||
}
|
||||
it.output.channels = Channels::Rgba;
|
||||
if let Some(o) = str_p(p, "path").or(str_p(p, "output")) {
|
||||
it.output.output = o.to_string();
|
||||
}
|
||||
it.post_render = PostRenderAction::SetProxy;
|
||||
let id = s.edit("Create Proxy", None, |proj, _| {
|
||||
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
|
||||
let id = it.id;
|
||||
proj.render_queue.push(it);
|
||||
Ok(id)
|
||||
})?;
|
||||
let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
|
||||
Ok(json!({"item": id, "comp": cid.0, "kind": if still { "still" } else { "movie" }, "output": path, "time": Tick::ZERO.seconds()}))
|
||||
}
|
||||
|
||||
fn has_comp_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) || s.active_comp_id().is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a composition".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"file.createProxy",
|
||||
"Create Proxy",
|
||||
[],
|
||||
None,
|
||||
"{kind: still|movie, comp?|item?, path?|output? (template), resolution? (default 0.5)}",
|
||||
has_comp_selection,
|
||||
create_proxy
|
||||
),
|
||||
cmd!("file.setProxy", "File...", ["File", "Set Proxy"], None, "{path, items?|item?}", has_targets, set_proxy),
|
||||
cmd!("file.setProxyNone", "None", ["File", "Set Proxy"], None, "{items?|item?}", has_proxy, set_proxy_none),
|
||||
cmd!("file.useProxy", "Use Proxy", [], None, "{items?|item?, on?}", has_proxy, use_proxy),
|
||||
cmd!(
|
||||
"file.interpretProxy",
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
|
||||
has_proxy,
|
||||
interpret_proxy
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
|||
let fx = layer.effects()?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid)?;
|
||||
let g = fx.groups().nth(index)?;
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
|
|||
{
|
||||
return Err(bad(cmd, "end must be after start"));
|
||||
}
|
||||
if let Some(u) = str_p(p, "proxyUse") {
|
||||
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
|
||||
any = true;
|
||||
}
|
||||
match p.get("frameRate") {
|
||||
Some(Value::Null) => {
|
||||
rs.frame_rate = None;
|
||||
|
|
@ -131,7 +135,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 +260,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 +438,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 +513,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, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
|
|
@ -520,7 +524,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Render Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}",
|
||||
has_items,
|
||||
set_render_settings
|
||||
),
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let root = s.default_autosave_root();
|
||||
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
Ok(json!({
|
||||
"lastAutoSave": s.autosave.last_path,
|
||||
"latestOnDisk": latest,
|
||||
|
|
|
|||
|
|
@ -17,18 +17,11 @@ macro_rules! stub {
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// Render queue / export (export milestone).
|
||||
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
|
||||
// Proxies (no proxy model yet).
|
||||
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
|
||||
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
|
||||
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
// 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).
|
||||
|
|
@ -46,7 +39,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// Keyframes / text / tracking.
|
||||
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
|
||||
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
|
||||
let f = cam.forward();
|
||||
let lights: Vec<Value> = comp
|
||||
|
|
@ -535,7 +535,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// through each layer's world matrix; cameras and lights contribute their position).
|
||||
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let mut out = vec![];
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
let m = ctx.world_matrix(l);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
|
||||
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
|
||||
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
|
||||
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
|
||||
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app backs it
|
||||
//! with the browser's Origin Private File System (IndexedDB fallback), tests and headless runs use
|
||||
//! [`MemoryConfig`] (or none at all).
|
||||
//!
|
||||
//! Auto-saves are project files, not settings, but they live wherever the store says: the store's
|
||||
//! [`FileOps`] (the file system by default; the browser's storage on the web).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -12,11 +16,52 @@ pub trait ConfigStore: Send + Sync {
|
|||
fn read(&self, name: &str) -> Option<String>;
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
|
||||
fn remove(&self, name: &str) -> std::io::Result<()>;
|
||||
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
|
||||
/// projects); `None` for stores that aren't on the file system.
|
||||
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
|
||||
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
/// Where auto-saves are listed, written and found (default: the file system).
|
||||
fn files(&self) -> &dyn FileOps {
|
||||
&StdFiles
|
||||
}
|
||||
}
|
||||
|
||||
/// The file operations auto-save and crash recovery need.
|
||||
pub trait FileOps: Send + Sync {
|
||||
/// Files directly in `dir`: (file name, modified time).
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)>;
|
||||
/// Write `data` to `path` atomically, creating its folder.
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()>;
|
||||
fn is_file(&self, path: &Path) -> bool;
|
||||
}
|
||||
|
||||
/// [`FileOps`] on the file system.
|
||||
pub struct StdFiles;
|
||||
|
||||
impl FileOps for StdFiles {
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
rd.flatten()
|
||||
.filter_map(|e| {
|
||||
let m = e.metadata().ok().filter(|m| m.is_file())?.modified().ok()?;
|
||||
Some((e.file_name().to_string_lossy().to_string(), m))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
atomic_write(path, data)
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store (tests, headless sessions).
|
||||
|
|
|
|||
|
|
@ -9,18 +9,22 @@
|
|||
//! (compositions are `Arc`s, so untouched comps are shared).
|
||||
|
||||
pub mod autosave;
|
||||
pub mod camera_track;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod demo;
|
||||
pub mod links;
|
||||
pub mod mask_track;
|
||||
pub mod menus;
|
||||
pub mod offload;
|
||||
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;
|
||||
|
||||
|
|
@ -172,6 +176,9 @@ pub struct EditorState {
|
|||
/// Tracker panel ▸ Method in mask mode (Track Mask).
|
||||
#[serde(default)]
|
||||
pub mask_track_method: mask_track::MaskMethod,
|
||||
/// 3D Camera Tracker: the selected track points (ids) in the viewer.
|
||||
#[serde(default)]
|
||||
pub camera_points: Vec<u32>,
|
||||
/// Mask Interpolation panel options.
|
||||
#[serde(default)]
|
||||
pub mask_interp: commands::mask_interp::MaskInterpOptions,
|
||||
|
|
@ -202,6 +209,13 @@ pub struct EditorState {
|
|||
/// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews).
|
||||
#[serde(default)]
|
||||
pub viewer: commands::viewer_cmds::ViewOptions,
|
||||
/// Essential Graphics panel ▸ Primary composition (`None` = the active comp).
|
||||
#[serde(default)]
|
||||
pub essential_primary: Option<ItemId>,
|
||||
/// Essential Graphics panel ▸ Solo Supported Properties (the timeline shows only properties
|
||||
/// Essential Graphics can expose).
|
||||
#[serde(default)]
|
||||
pub essential_solo: bool,
|
||||
}
|
||||
|
||||
fn one_view() -> u8 {
|
||||
|
|
@ -268,6 +282,10 @@ pub struct Session {
|
|||
pub warp_job: Option<warp::WarpJob>,
|
||||
/// Warp Stabilizers waiting for (re-)analysis: (comp, layer, effect uid).
|
||||
pub warp_pending: Vec<(ItemId, LayerId, Uid)>,
|
||||
/// The running (or finished, not yet polled) 3D Camera Tracker analysis.
|
||||
pub camera_job: Option<camera_track::CameraJob>,
|
||||
/// 3D Camera Trackers waiting for (re-)analysis: (comp, layer, effect uid).
|
||||
pub camera_pending: Vec<(ItemId, LayerId, Uid)>,
|
||||
/// The running (or finished, not yet polled) Roto Brush propagation / Freeze.
|
||||
pub roto_job: Option<roto::RotoJob>,
|
||||
/// Roto Brush instances edited since their last propagation: (comp, layer, effect uid).
|
||||
|
|
@ -277,6 +295,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…).
|
||||
|
|
@ -297,6 +317,11 @@ pub struct Session {
|
|||
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
|
||||
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
|
||||
pub script: Option<ScriptRunner>,
|
||||
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
|
||||
/// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32).
|
||||
pub offload: Option<Arc<dyn offload::Offload>>,
|
||||
/// Analyses running in the [`Session::offload`] worker.
|
||||
pub offloaded: Vec<offload::OffloadedJob>,
|
||||
}
|
||||
|
||||
/// A script to run (see [`Session::script`]).
|
||||
|
|
@ -334,11 +359,14 @@ impl Default for Session {
|
|||
mask_job: None,
|
||||
warp_job: None,
|
||||
warp_pending: vec![],
|
||||
camera_job: None,
|
||||
camera_pending: vec![],
|
||||
roto_job: None,
|
||||
roto_pending: vec![],
|
||||
events: vec![],
|
||||
journal: vec![],
|
||||
layer_cache: Arc::new(LayerCache::default()),
|
||||
disk_cache: None,
|
||||
prefs: prefs::Prefs::default(),
|
||||
prefs_revision: 0,
|
||||
config: None,
|
||||
|
|
@ -348,6 +376,8 @@ impl Default for Session {
|
|||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
script: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -362,7 +392,8 @@ impl Session {
|
|||
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
// Explicit targets (agents, scripts) don't need a UI selection.
|
||||
let explicit = ["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok();
|
||||
let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok())
|
||||
|| ["item", "items"].iter().any(|k| params.get(k).is_some());
|
||||
if !explicit {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
|
|
@ -397,12 +428,20 @@ impl Session {
|
|||
let r = f(&mut p, &mut st)?;
|
||||
// Layer styles: one Global Light per comp, whichever layer edited it.
|
||||
effectcraft_project::styles::sync_global_light(&before, &mut p);
|
||||
// Essential Properties of precomp layers follow their comps' Essential Graphics.
|
||||
effectcraft_project::essential::sync_project(&before, &mut p);
|
||||
// Warp Stabilizer analyses made from other frames are cleared (and queued again).
|
||||
for w in warp::invalidate(&before, &mut p) {
|
||||
if !self.warp_pending.contains(&w) {
|
||||
self.warp_pending.push(w);
|
||||
}
|
||||
}
|
||||
// So are 3D Camera Tracker tracks (and solves made with other settings).
|
||||
for w in camera_track::invalidate(&before, &mut p) {
|
||||
if !self.camera_pending.contains(&w) {
|
||||
self.camera_pending.push(w);
|
||||
}
|
||||
}
|
||||
// Roto Brush Input Keys follow the frames; edited instances propagate again.
|
||||
for r in roto::sync(&before, &mut p) {
|
||||
if !self.roto_pending.contains(&r) {
|
||||
|
|
@ -428,6 +467,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 });
|
||||
}
|
||||
|
||||
|
|
@ -586,6 +628,9 @@ impl Session {
|
|||
self.stop_warp();
|
||||
self.warp_job = None;
|
||||
self.warp_pending.clear();
|
||||
self.stop_camera();
|
||||
self.camera_job = None;
|
||||
self.camera_pending.clear();
|
||||
self.stop_roto();
|
||||
self.roto_job = None;
|
||||
self.roto_pending.clear();
|
||||
|
|
@ -612,8 +657,14 @@ mod tests_3d;
|
|||
#[cfg(test)]
|
||||
mod tests_anim_tools;
|
||||
#[cfg(test)]
|
||||
mod tests_camera_track;
|
||||
#[cfg(test)]
|
||||
mod tests_disk_cache;
|
||||
#[cfg(test)]
|
||||
mod tests_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_essential;
|
||||
#[cfg(test)]
|
||||
mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
|
|
@ -628,6 +679,8 @@ mod tests_model3d;
|
|||
#[cfg(test)]
|
||||
mod tests_project_items;
|
||||
#[cfg(test)]
|
||||
mod tests_proxy;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
#[cfg(test)]
|
||||
mod tests_stubs;
|
||||
|
|
@ -644,6 +697,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).
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
|
|||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
|
||||
s.time = w.times[k].seconds();
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
|
|
@ -214,16 +215,19 @@ fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
|
|||
impl Session {
|
||||
/// Whether a mask track is running.
|
||||
pub fn is_mask_tracking(&self) -> bool {
|
||||
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.mask_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::MaskTrack).is_some()
|
||||
}
|
||||
|
||||
/// Live progress of the running (or just finished) mask track.
|
||||
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
|
||||
self.mask_job.as_ref().map(|j| j.progress())
|
||||
self.mask_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::MaskTrack).map(|j| j.progress.track()))
|
||||
}
|
||||
|
||||
/// Cancel the running mask track (frames tracked so far are kept).
|
||||
pub fn stop_mask_track(&mut self) -> bool {
|
||||
if self.cancel_offloaded(crate::offload::JobKind::MaskTrack) {
|
||||
return true;
|
||||
}
|
||||
match &self.mask_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
|
|
@ -238,6 +242,10 @@ impl Session {
|
|||
return Err("a track analysis is already running".into());
|
||||
}
|
||||
self.poll_mask_track();
|
||||
if !wait && self.offloads() {
|
||||
let MaskWork { comp, layer, path, method, times, .. } = work;
|
||||
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
self.history.undo.push(("Track Mask".into(), self.project.clone()));
|
||||
self.history.redo.clear();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
//! An entry's shortcut is the command's default shortcut unless the line gives one (needed for
|
||||
//! entries with bound parameters). Adobe-service entries (Team Projects, Libraries, Bridge, Media
|
||||
//! Encoder, Behance, Creative Cloud…) are intentionally absent; the Essential Graphics workspace
|
||||
//! uses the Properties panel.
|
||||
//! uses the Properties panel and the Essential Graphics panel, whose templates use EffectCraft's
|
||||
//! own open `.ectemplate` format.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
|
|
@ -338,9 +339,11 @@ File
|
|||
Placeholder... | file.importPlaceholder
|
||||
Solid... | file.importSolid
|
||||
Lottie... | file.importLottie
|
||||
Essential Graphics Template... | essential.importTemplate
|
||||
Export
|
||||
Add to Render Queue | renderQueue.add
|
||||
Lottie JSON... | file.exportLottie
|
||||
Essential Graphics Template... | essential.exportTemplate
|
||||
---
|
||||
Find | app.find
|
||||
---
|
||||
|
|
@ -819,6 +822,7 @@ Animation
|
|||
Remove All Text Animators | text.removeAllAnimators
|
||||
---
|
||||
Add Expression | prop.setExpression
|
||||
Add Property to Essential Graphics | essential.addProperty
|
||||
Separate Dimensions | prop.separateDimensions
|
||||
Track Camera | track.camera
|
||||
Warp Stabilizer VFX | track.warpStabilizer
|
||||
|
|
@ -940,6 +944,7 @@ Window
|
|||
Character | window.panel {"panel":"character"} | Cmd+6
|
||||
Content-Aware Fill | window.unavailablePanel {"panel":"contentAwareFill"}
|
||||
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
|
||||
Essential Graphics | window.panel {"panel":"essentialGraphics"}
|
||||
Info | window.panel {"panel":"info"} | Cmd+2
|
||||
Lumetri Scopes | window.unavailablePanel {"panel":"lumetriScopes"}
|
||||
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,754 @@
|
|||
//! Background jobs where the platform has no threads (the browser).
|
||||
//!
|
||||
//! On the desktop, renders and analyses run on threads that share the session's project and
|
||||
//! footage. A wasm build without atomics has one thread per instance, so the web app runs a
|
||||
//! second engine instance in a Web Worker instead and talks to it with messages: an
|
||||
//! [`Offload`] (set as [`Session::offload`]) ships a [`WorkerRequest`] — the serialized project,
|
||||
//! the footage files it reads and the job — and feeds the worker's [`WorkerReply`]s back into an
|
||||
//! [`Inbox`] that the session drains every frame ([`Session::poll_render`],
|
||||
//! [`Session::poll_offload`]). The worker side is [`run_request`]: a plain session fed the
|
||||
//! project runs the job blocking and reports through a callback.
|
||||
//!
|
||||
//! | Job | Worker runs | Reply applied on the UI thread |
|
||||
//! |---|---|---|
|
||||
//! | Render Queue | the export of every queued item | item status, progress; files arrive through the host |
|
||||
//! | Warp Stabilizer analysis | [`Session::start_warp`] | the effect's property group (one undo step) |
|
||||
//! | Track Motion / Stabilize Motion | the tracker analysis | the tracker group |
|
||||
//! | Mask tracking | the mask track | the mask group |
|
||||
//! | Roto Brush Freeze | the freeze | the effect group |
|
||||
//! | 3D Camera Tracker analysis | [`Session::start_camera`] | the effect's property group |
|
||||
//!
|
||||
//! Roto Brush propagation fills the session's segmentation cache rather than the project, so it
|
||||
//! keeps running on the UI thread.
|
||||
//!
|
||||
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
|
||||
//! Queue item being rendered becomes "User Stopped".
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
use effectcraft_project::render_queue::{RenderQueueItem, RenderStatus};
|
||||
use effectcraft_project::tracking::TrackerSettings;
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerId, Project, PropGroup, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::mask_track::MaskMethod;
|
||||
use crate::render_queue::{ItemUpdate, JobShared, JobState};
|
||||
use crate::tracking::PointValues;
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// One job for a worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkerRequest {
|
||||
pub id: u64,
|
||||
/// The project ([`Project::to_json`]).
|
||||
pub project: String,
|
||||
/// Footage files the job reads; the host ships their bytes before the request.
|
||||
pub files: Vec<String>,
|
||||
pub job: WorkerJob,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum WorkerJob {
|
||||
/// Render Queue: per item, one (item with that output module, resolved output path) per
|
||||
/// output module.
|
||||
Render {
|
||||
items: Vec<Vec<(RenderQueueItem, String)>>,
|
||||
},
|
||||
Warp {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
Camera {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
RotoFreeze {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
Track {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
tracker: Uid,
|
||||
direction: crate::tracking::Direction,
|
||||
settings: TrackerSettings,
|
||||
points: Vec<PointValues>,
|
||||
times: Vec<Tick>,
|
||||
},
|
||||
MaskTrack {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
mask: Uid,
|
||||
direction: crate::tracking::Direction,
|
||||
path: ShapePath,
|
||||
method: MaskMethod,
|
||||
times: Vec<Tick>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkerJob {
|
||||
pub fn kind(&self) -> JobKind {
|
||||
match self {
|
||||
WorkerJob::Render { .. } => JobKind::Render,
|
||||
WorkerJob::Warp { .. } => JobKind::Warp,
|
||||
WorkerJob::Camera { .. } => JobKind::Camera,
|
||||
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
|
||||
WorkerJob::Track { .. } => JobKind::Track,
|
||||
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
|
||||
}
|
||||
}
|
||||
|
||||
/// (comp, layer, property group uid) an analysis writes to.
|
||||
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
match *self {
|
||||
WorkerJob::Render { .. } => None,
|
||||
WorkerJob::Warp { comp, layer, effect } | WorkerJob::Camera { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => {
|
||||
Some((comp, layer, effect))
|
||||
}
|
||||
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
|
||||
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JobKind {
|
||||
Render,
|
||||
Warp,
|
||||
Camera,
|
||||
RotoFreeze,
|
||||
Track,
|
||||
MaskTrack,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
/// The undo step an analysis result becomes.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
JobKind::Render => "Render",
|
||||
JobKind::Warp => "Warp Stabilizer Analysis",
|
||||
JobKind::Camera => "3D Camera Tracker Analysis",
|
||||
JobKind::RotoFreeze => "Freeze",
|
||||
JobKind::Track => "Analyze Track",
|
||||
JobKind::MaskTrack => "Track Mask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a worker reports.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum WorkerReply {
|
||||
/// Render Queue progress.
|
||||
Render {
|
||||
state: JobState,
|
||||
},
|
||||
/// A Render Queue item started or finished.
|
||||
Item {
|
||||
update: ItemUpdate,
|
||||
},
|
||||
/// Analysis progress (frames).
|
||||
Progress {
|
||||
done: u64,
|
||||
total: u64,
|
||||
},
|
||||
/// An analysis finished: its property group after the job, and the job's message.
|
||||
Group {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
group: Box<PropGroup>,
|
||||
message: String,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
/// The job ended (always the last reply).
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Replies waiting for the UI thread.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox(Mutex<Vec<WorkerReply>>);
|
||||
|
||||
impl Inbox {
|
||||
pub fn push(&self, r: WorkerReply) {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
|
||||
}
|
||||
pub fn take(&self) -> Vec<WorkerReply> {
|
||||
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs jobs off the UI thread (the web app: a pool of Web Workers).
|
||||
pub trait Offload: Send + Sync {
|
||||
/// Start `req`; replies go to `inbox` (ending with [`WorkerReply::Done`]).
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String>;
|
||||
/// Stop job `id` now (the worker is terminated; no more replies arrive).
|
||||
fn cancel(&self, id: u64);
|
||||
}
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub fn next_id() -> u64 {
|
||||
NEXT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Footage files a project reads (paths, sequence frames).
|
||||
pub fn footage_files(p: &Project) -> Vec<String> {
|
||||
let mut v: Vec<String> = p
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- progress hook (worker side)
|
||||
|
||||
thread_local! {
|
||||
static HOOK: RefCell<Option<Box<dyn FnMut(u64, u64)>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Analyses call this as frames complete; a worker forwards it (no-op elsewhere).
|
||||
pub fn report(done: u64, total: u64) {
|
||||
HOOK.with(|h| {
|
||||
if let Ok(mut h) = h.try_borrow_mut()
|
||||
&& let Some(f) = h.as_mut()
|
||||
{
|
||||
f(done, total);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn with_hook<R>(hook: Box<dyn FnMut(u64, u64)>, f: impl FnOnce() -> R) -> R {
|
||||
HOOK.with(|h| *h.borrow_mut() = Some(hook));
|
||||
let r = f();
|
||||
HOOK.with(|h| *h.borrow_mut() = None);
|
||||
r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
/// Where a worker sends its replies.
|
||||
pub type Post = Rc<dyn Fn(WorkerReply)>;
|
||||
|
||||
/// Run `req` on `s` (a session with footage, expressions and an exporter, but no project yet),
|
||||
/// blocking, reporting through `post`. Always ends with [`WorkerReply::Done`].
|
||||
pub fn run_request(s: &mut Session, req: WorkerRequest, post: &Post) {
|
||||
match Project::from_json(&req.project) {
|
||||
Ok(p) => {
|
||||
s.replace_project(p, None);
|
||||
if let Err(e) = run_job(s, req.job, post.clone()) {
|
||||
post(WorkerReply::Failed { error: e });
|
||||
}
|
||||
}
|
||||
Err(e) => post(WorkerReply::Failed { error: format!("project: {e}") }),
|
||||
}
|
||||
post(WorkerReply::Done);
|
||||
}
|
||||
|
||||
fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
|
||||
let kind = job.kind();
|
||||
let target = job.target();
|
||||
match job {
|
||||
WorkerJob::Render { items } => {
|
||||
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
|
||||
let shared = JobShared::default();
|
||||
// Item updates and progress go out as they happen (progress at most every 100 ms).
|
||||
let last = Cell::new(None::<web_time::Instant>);
|
||||
let notify = |sh: &JobShared, force: bool| {
|
||||
let ups = std::mem::take(&mut *crate::render_queue::lock(&sh.updates));
|
||||
for u in ups {
|
||||
post(WorkerReply::Item { update: u });
|
||||
}
|
||||
if force || last.get().is_none_or(|t| t.elapsed().as_millis() >= 100) {
|
||||
last.set(Some(web_time::Instant::now()));
|
||||
post(WorkerReply::Render { state: sh.snapshot() });
|
||||
}
|
||||
};
|
||||
crate::render_queue::run_items(s, exporter, items, &shared, &mut |sh| notify(sh, false));
|
||||
notify(&shared, true);
|
||||
Ok(())
|
||||
}
|
||||
WorkerJob::Warp { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_warp(comp, layer, effect, true).map(|_| ())),
|
||||
WorkerJob::Camera { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_camera(comp, layer, effect, true).map(|_| ())),
|
||||
WorkerJob::RotoFreeze { comp, layer, effect } => analysis(s, kind, target, post, |s| {
|
||||
s.start_roto(comp, layer, effect, crate::roto::RotoTask::Freeze, crate::roto::Direction::Both, true).map(|_| ())
|
||||
}),
|
||||
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
|
||||
let work = crate::tracking::Work {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
cache: s.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
settings,
|
||||
points,
|
||||
times,
|
||||
};
|
||||
s.start_track(work, tracker, direction, true)
|
||||
}),
|
||||
WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times } => analysis(s, kind, target, post, |s| {
|
||||
let work = crate::mask_track::MaskWork {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
cache: s.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
path,
|
||||
method,
|
||||
times,
|
||||
};
|
||||
s.start_mask_track(work, mask, direction, true)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn analysis(
|
||||
s: &mut Session,
|
||||
kind: JobKind,
|
||||
target: Option<(ItemId, LayerId, Uid)>,
|
||||
post: Post,
|
||||
run: impl FnOnce(&mut Session) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let (comp, layer, uid) = target.ok_or("no target")?;
|
||||
let rev = s.revision;
|
||||
// Progress at most every 100 ms.
|
||||
let p = post.clone();
|
||||
let mut last = None::<web_time::Instant>;
|
||||
let hook = Box::new(move |done: u64, total: u64| {
|
||||
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 100) {
|
||||
last = Some(web_time::Instant::now());
|
||||
p(WorkerReply::Progress { done, total });
|
||||
}
|
||||
});
|
||||
with_hook(hook, || run(s))?;
|
||||
let message = s
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
Event::Toast { message, .. } => Some(message.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let failed = s.events.iter().any(|e| matches!(e, Event::Toast { error: true, .. }));
|
||||
if failed {
|
||||
return Err(message);
|
||||
}
|
||||
if s.revision == rev && kind != JobKind::Track && kind != JobKind::MaskTrack {
|
||||
// Nothing written (e.g. the analysis was empty).
|
||||
return Err(if message.is_empty() { "nothing was analysed".into() } else { message });
|
||||
}
|
||||
let group = s.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().ok_or("the analysed property is gone")?;
|
||||
post(WorkerReply::Group { comp, layer, group: Box::new(group), message });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- UI side
|
||||
|
||||
/// Analysis progress of an offloaded job.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OffloadProgress {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub elapsed: f64,
|
||||
pub finished: bool,
|
||||
pub cancelled: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Frames per second of `done` frames in `elapsed` seconds.
|
||||
pub(crate) fn rate(done: u64, elapsed: f64) -> f64 {
|
||||
if elapsed > 0.0 { done as f64 / elapsed } else { 0.0 }
|
||||
}
|
||||
|
||||
impl OffloadProgress {
|
||||
pub(crate) fn track(&self) -> crate::tracking::TrackProgress {
|
||||
crate::tracking::TrackProgress { done: self.done, total: self.total, elapsed: self.elapsed, fps: rate(self.done, self.elapsed), ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An analysis running in a worker.
|
||||
pub struct OffloadedJob {
|
||||
pub id: u64,
|
||||
pub kind: JobKind,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
pub uid: Uid,
|
||||
pub inbox: Arc<Inbox>,
|
||||
pub progress: OffloadProgress,
|
||||
started: web_time::Instant,
|
||||
}
|
||||
|
||||
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
|
||||
pub struct RemoteRender {
|
||||
pub id: u64,
|
||||
pub inbox: Arc<Inbox>,
|
||||
/// The queue items in the job.
|
||||
pub ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Whether jobs started without `wait` go to the [`Offload`].
|
||||
pub fn offloads(&self) -> bool {
|
||||
self.offload.is_some()
|
||||
}
|
||||
|
||||
/// The offloaded analysis of `kind`, if one runs.
|
||||
pub fn offloaded(&self, kind: JobKind) -> Option<&OffloadedJob> {
|
||||
self.offloaded.iter().find(|j| j.kind == kind && !j.progress.finished)
|
||||
}
|
||||
|
||||
/// Send an analysis to the worker.
|
||||
pub(crate) fn offload_analysis(&mut self, job: WorkerJob) -> Result<(), String> {
|
||||
let off = self.offload.clone().ok_or("no background worker")?;
|
||||
let kind = job.kind();
|
||||
let (comp, layer, uid) = job.target().ok_or("not an analysis")?;
|
||||
if self.offloaded(kind).is_some() {
|
||||
return Err(format!("a {} job is already running", kind.label()));
|
||||
}
|
||||
let id = next_id();
|
||||
let inbox = Arc::new(Inbox::default());
|
||||
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
|
||||
off.start(req, inbox.clone())?;
|
||||
self.offloaded.push(OffloadedJob { id, kind, comp, layer, uid, inbox, progress: OffloadProgress::default(), started: web_time::Instant::now() });
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the offloaded analysis of `kind` (nothing is written).
|
||||
pub fn cancel_offloaded(&mut self, kind: JobKind) -> bool {
|
||||
let off = self.offload.clone();
|
||||
let Some(j) = self.offloaded.iter_mut().find(|j| j.kind == kind && !j.progress.finished) else { return false };
|
||||
if let Some(off) = off {
|
||||
off.cancel(j.id);
|
||||
}
|
||||
j.progress.cancelled = true;
|
||||
j.progress.finished = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply worker replies to offloaded analyses (results become one undo step each) and drop
|
||||
/// finished jobs. Frontends call this every frame while [`Session::offloaded`] is not empty.
|
||||
pub fn poll_offload(&mut self) -> bool {
|
||||
if self.offloaded.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
let mut results = vec![];
|
||||
for j in &mut self.offloaded {
|
||||
if j.progress.cancelled {
|
||||
continue;
|
||||
}
|
||||
for r in j.inbox.take() {
|
||||
changed = true;
|
||||
match r {
|
||||
WorkerReply::Progress { done, total } => {
|
||||
j.progress.done = done;
|
||||
j.progress.total = total;
|
||||
}
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
|
||||
WorkerReply::Failed { error } => j.progress.error = Some(error),
|
||||
WorkerReply::Done => j.progress.finished = true,
|
||||
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
|
||||
}
|
||||
}
|
||||
j.progress.elapsed = j.started.elapsed().as_secs_f64();
|
||||
}
|
||||
for (kind, comp, layer, group, message) in results {
|
||||
let uid = group.uid;
|
||||
let r = self.edit(kind.label(), None, |p, _| {
|
||||
let g = p
|
||||
.comp_mut(comp)
|
||||
.and_then(|c| c.layer_mut(layer))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
|
||||
*g = *group;
|
||||
Ok(())
|
||||
});
|
||||
match r {
|
||||
Ok(()) => self.events.push(Event::Toast { message, error: false }),
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
|
||||
}
|
||||
}
|
||||
let mut done = vec![];
|
||||
self.offloaded.retain(|j| {
|
||||
if j.progress.finished {
|
||||
done.push((j.kind, j.progress.clone()));
|
||||
}
|
||||
!j.progress.finished
|
||||
});
|
||||
for (kind, p) in done {
|
||||
changed = true;
|
||||
if let Some(e) = p.error {
|
||||
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
|
||||
} else if p.cancelled {
|
||||
self.events.push(Event::Toast { message: format!("{} stopped", kind.label()), error: false });
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Feed a remote render's replies into its [`JobShared`]; a cancelled one is wrapped up here
|
||||
/// (the item being rendered and the ones after it become "User Stopped").
|
||||
pub(crate) fn drain_remote_render(&mut self) {
|
||||
let Some(job) = &self.render_job else { return };
|
||||
let Some(remote) = &job.remote else { return };
|
||||
let shared = job.shared.clone();
|
||||
for r in remote.inbox.take() {
|
||||
match r {
|
||||
WorkerReply::Render { state } => {
|
||||
let fin = crate::render_queue::lock(&shared.state).finished;
|
||||
*crate::render_queue::lock(&shared.state) = JobState { finished: fin, ..state };
|
||||
}
|
||||
WorkerReply::Item { update } => crate::render_queue::lock(&shared.updates).push(update),
|
||||
WorkerReply::Failed { error } => {
|
||||
log::warn!("render worker: {error}");
|
||||
let cur = crate::render_queue::lock(&shared.state).current;
|
||||
if let Some(id) = cur {
|
||||
crate::render_queue::lock(&shared.updates).push(ItemUpdate::Finished {
|
||||
id,
|
||||
status: RenderStatus::Failed(error),
|
||||
seconds: 0.0,
|
||||
output: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
WorkerReply::Done => crate::render_queue::lock(&shared.state).finished = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> String {
|
||||
let a = serde_json::to_string(v).unwrap();
|
||||
let back: T = serde_json::from_str(&a).unwrap();
|
||||
let b = serde_json::to_string(&back).unwrap();
|
||||
assert_eq!(a, b);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_round_trips() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
|
||||
let item = s.project.render_queue[0].clone();
|
||||
let reqs = [
|
||||
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
|
||||
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::Track {
|
||||
comp,
|
||||
layer: LayerId(1),
|
||||
tracker: 4,
|
||||
direction: crate::tracking::Direction::Backward,
|
||||
settings: TrackerSettings::default(),
|
||||
points: vec![PointValues {
|
||||
uid: 5,
|
||||
spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] },
|
||||
attach_offset: [0.25, -1.0],
|
||||
}],
|
||||
times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)],
|
||||
},
|
||||
WorkerJob::MaskTrack {
|
||||
comp,
|
||||
layer: LayerId(2),
|
||||
mask: 7,
|
||||
direction: crate::tracking::Direction::Forward,
|
||||
path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0),
|
||||
method: MaskMethod::Perspective,
|
||||
times: vec![Tick::from_seconds_f64(1.0)],
|
||||
},
|
||||
];
|
||||
for job in reqs {
|
||||
let kind = job.kind();
|
||||
let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job };
|
||||
let t = round(&req);
|
||||
assert!(t.contains("\"kind\":"), "{kind:?}");
|
||||
let back: WorkerRequest = serde_json::from_str(&t).unwrap();
|
||||
assert_eq!(back.job.kind(), kind);
|
||||
assert_eq!(Project::from_json(&back.project).unwrap(), *s.project);
|
||||
}
|
||||
let g = s.active_comp().unwrap().layers[0].props.clone();
|
||||
for r in [
|
||||
WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } },
|
||||
WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } },
|
||||
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
|
||||
WorkerReply::Progress { done: 1, total: 2 },
|
||||
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
|
||||
WorkerReply::Failed { error: "boom".into() },
|
||||
WorkerReply::Done,
|
||||
] {
|
||||
let t = round(&r);
|
||||
assert!(t.starts_with("{\"type\":"), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
|
||||
/// Web Worker does, minus the threads).
|
||||
struct Inline;
|
||||
impl Offload for Inline {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() };
|
||||
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
|
||||
run_request(&mut w, req, &post);
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_runs_through_the_offload() {
|
||||
let mut s = Session {
|
||||
exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })),
|
||||
offload: Some(Arc::new(Inline)),
|
||||
..Default::default()
|
||||
};
|
||||
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap();
|
||||
let ids = s.start_render(false).unwrap();
|
||||
// (the inline offload has replied already; replies are applied when polled)
|
||||
s.poll_render();
|
||||
assert!(s.render_job.is_none());
|
||||
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
|
||||
assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status);
|
||||
assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif")));
|
||||
// `wait` renders inline.
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap();
|
||||
s.start_render(true).unwrap();
|
||||
assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none());
|
||||
}
|
||||
|
||||
/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests).
|
||||
struct InlineWith(Arc<dyn effectcraft_render::FootageSource>);
|
||||
impl Offload for InlineWith {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
let mut w = Session { footage: self.0.clone(), ..Default::default() };
|
||||
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
|
||||
run_request(&mut w, req, &post);
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
/// Never replies (a job still running); records cancellations.
|
||||
#[derive(Default)]
|
||||
struct Hanging(Mutex<Vec<u64>>);
|
||||
impl Offload for Hanging {
|
||||
fn start(&self, _: WorkerRequest, _: Arc<Inbox>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, id: u64) {
|
||||
self.0.lock().unwrap().push(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn pose(f: u32) -> ([f64; 2], f64, f64) {
|
||||
([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_analysis_runs_through_the_offload_as_one_undo_step() {
|
||||
let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
|
||||
s.offload = Some(Arc::new(InlineWith(s.footage.clone())));
|
||||
s.execute("track.motion", serde_json::json!({})).unwrap();
|
||||
s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap();
|
||||
assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread");
|
||||
assert!(s.track_progress().is_some());
|
||||
assert!(s.poll_offload());
|
||||
assert!(!s.is_tracking() && s.offloaded.is_empty());
|
||||
let l = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone();
|
||||
let fc = tp.get("featureCenter").unwrap();
|
||||
assert_eq!(fc.keys.len(), 25);
|
||||
let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2();
|
||||
assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}");
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track");
|
||||
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked"))));
|
||||
assert!(s.undo());
|
||||
let l = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
|
||||
// `wait` runs inline.
|
||||
s.redo();
|
||||
s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok();
|
||||
assert!(s.offloaded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_offloaded_jobs() {
|
||||
let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
|
||||
let off = Arc::new(Hanging::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() }));
|
||||
// Analysis: stopping drops the job, nothing is written.
|
||||
s.execute("track.motion", serde_json::json!({})).unwrap();
|
||||
let rev = s.revision;
|
||||
s.execute("track.analyze", serde_json::json!({})).unwrap();
|
||||
assert!(s.is_tracking());
|
||||
assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time");
|
||||
assert!(s.stop_track());
|
||||
s.poll_offload();
|
||||
assert!(!s.is_tracking());
|
||||
assert_eq!(off.0.lock().unwrap().len(), 1);
|
||||
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped"))));
|
||||
assert!(s.revision > rev);
|
||||
// Render: the queued items become User Stopped.
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap();
|
||||
let ids = s.start_render(false).unwrap();
|
||||
assert!(s.is_rendering());
|
||||
assert!(s.stop_render());
|
||||
s.poll_render();
|
||||
assert!(!s.is_rendering());
|
||||
assert_eq!(off.0.lock().unwrap().len(), 2);
|
||||
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
|
||||
assert_eq!(it.status, RenderStatus::UserStopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_hook_is_scoped() {
|
||||
report(1, 2); // no hook: nothing happens
|
||||
let seen = Rc::new(RefCell::new(vec![]));
|
||||
let s2 = seen.clone();
|
||||
with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), || {
|
||||
report(1, 3);
|
||||
report(2, 3);
|
||||
});
|
||||
report(3, 3);
|
||||
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,635 @@
|
|||
//! 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() }),
|
||||
..Default::default()
|
||||
};
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
|
|||
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ pub trait Exporter: Send + Sync {
|
|||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// Live progress of a render job (serde for agents / the control channel).
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct JobState {
|
||||
/// Item being rendered.
|
||||
pub current: Option<u64>,
|
||||
|
|
@ -73,7 +73,8 @@ pub struct JobState {
|
|||
}
|
||||
|
||||
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "camelCase")]
|
||||
pub enum ItemUpdate {
|
||||
Started { id: u64, unix: u64 },
|
||||
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
|
||||
|
|
@ -86,7 +87,7 @@ pub struct JobShared {
|
|||
pub updates: Mutex<Vec<ItemUpdate>>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +101,8 @@ impl JobShared {
|
|||
pub struct RenderJob {
|
||||
pub shared: Arc<JobShared>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
/// Rendering in a worker ([`crate::offload`]).
|
||||
pub remote: Option<crate::offload::RemoteRender>,
|
||||
}
|
||||
|
||||
impl RenderJob {
|
||||
|
|
@ -132,7 +135,19 @@ fn unix_now() -> u64 {
|
|||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn run_work(w: Work, shared: &JobShared) {
|
||||
/// Render `items` with the session's footage, expressions and GPU (a worker's render).
|
||||
pub(crate) fn run_items(
|
||||
s: &Session,
|
||||
exporter: Arc<dyn Exporter>,
|
||||
items: Vec<Vec<(RenderQueueItem, String)>>,
|
||||
shared: &JobShared,
|
||||
notify: &mut dyn FnMut(&JobShared),
|
||||
) {
|
||||
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
|
||||
run_work(work, shared, notify);
|
||||
}
|
||||
|
||||
fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
|
||||
let t0 = web_time::Instant::now();
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
|
|
@ -154,18 +169,22 @@ fn run_work(w: Work, shared: &JobShared) {
|
|||
s.items_done = k;
|
||||
s.remaining = None;
|
||||
}
|
||||
notify(shared);
|
||||
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
|
||||
// first module's file is the item's output.
|
||||
let mut r: Result<ExportResult, String> = Err("no output module".into());
|
||||
for (mi, (mitem, path)) in modules.iter().enumerate() {
|
||||
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
|
||||
let rr = w.exporter.export(&job, &mut |done, total| {
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = done;
|
||||
s.total = total;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.item_elapsed = t1.elapsed().as_secs_f64();
|
||||
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = done;
|
||||
s.total = total;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.item_elapsed = t1.elapsed().as_secs_f64();
|
||||
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
|
||||
}
|
||||
notify(shared);
|
||||
!shared.cancel.load(Ordering::Relaxed)
|
||||
});
|
||||
match rr {
|
||||
|
|
@ -187,6 +206,7 @@ fn run_work(w: Work, shared: &JobShared) {
|
|||
}
|
||||
};
|
||||
lock(&shared.updates).push(up);
|
||||
notify(shared);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.items_done = w.items.len();
|
||||
|
|
@ -245,10 +265,12 @@ impl Session {
|
|||
}
|
||||
|
||||
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
|
||||
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
|
||||
/// wasm32 has no threads: the render always runs to completion before this returns.
|
||||
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
|
||||
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
|
||||
/// runs to completion before this returns.
|
||||
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let offload = self.offload.clone().filter(|_| !wait);
|
||||
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
|
||||
if self.is_rendering() {
|
||||
return Err("a render is already in progress".into());
|
||||
}
|
||||
|
|
@ -291,15 +313,31 @@ impl Session {
|
|||
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
|
||||
}
|
||||
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
|
||||
if let Some(off) = offload {
|
||||
let id = crate::offload::next_id();
|
||||
let inbox = Arc::new(crate::offload::Inbox::default());
|
||||
let req = crate::offload::WorkerRequest {
|
||||
id,
|
||||
project: self.project.to_json(),
|
||||
files: crate::offload::footage_files(&self.project),
|
||||
job: crate::offload::WorkerJob::Render { items },
|
||||
};
|
||||
off.start(req, inbox.clone())?;
|
||||
let shared = Arc::new(JobShared::default());
|
||||
lock(&shared.state).items_total = ids.len();
|
||||
self.render_job = Some(RenderJob { shared, thread: None, remote: Some(crate::offload::RemoteRender { id, inbox, ids: ids.clone() }) });
|
||||
self.poll_render();
|
||||
return Ok(ids);
|
||||
}
|
||||
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
|
||||
let shared = Arc::new(JobShared::default());
|
||||
if wait {
|
||||
run_work(work, &shared);
|
||||
self.render_job = Some(RenderJob { shared, thread: None });
|
||||
run_work(work, &shared, &mut |_| {});
|
||||
self.render_job = Some(RenderJob { shared, thread: None, remote: None });
|
||||
} else {
|
||||
let sh = shared.clone();
|
||||
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
|
||||
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
|
||||
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh, &mut |_| {})).map_err(|e| e.to_string())?;
|
||||
self.render_job = Some(RenderJob { shared, thread: Some(thread), remote: None });
|
||||
}
|
||||
self.poll_render();
|
||||
Ok(ids)
|
||||
|
|
@ -310,6 +348,25 @@ impl Session {
|
|||
match &self.render_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.cancel();
|
||||
if let Some(r) = &j.remote {
|
||||
// A worker can't be interrupted mid-frame: terminate it and stop what's left.
|
||||
if let Some(off) = &self.offload {
|
||||
off.cancel(r.id);
|
||||
}
|
||||
self.drain_remote_render();
|
||||
let j = self.render_job.as_ref().expect("job");
|
||||
let r = j.remote.as_ref().expect("remote");
|
||||
let finished: Vec<u64> =
|
||||
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
|
||||
for id in &r.ids {
|
||||
let done = finished.contains(id)
|
||||
|| self.project.render_queue.iter().any(|i| i.id == *id && matches!(i.status, RenderStatus::Done | RenderStatus::Failed(_)));
|
||||
if !done {
|
||||
lock(&j.shared.updates).push(ItemUpdate::Finished { id: *id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
|
||||
}
|
||||
}
|
||||
lock(&j.shared.state).finished = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
|
|
@ -319,6 +376,7 @@ impl Session {
|
|||
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
|
||||
/// anything changed. Frontends call this every frame while rendering.
|
||||
pub fn poll_render(&mut self) -> bool {
|
||||
self.drain_remote_render();
|
||||
let Some(job) = &self.render_job else { return false };
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
let finished = job.is_finished();
|
||||
|
|
@ -388,6 +446,11 @@ impl Session {
|
|||
}
|
||||
let importer = self.importer.clone().ok_or("media import is not available in this build")?;
|
||||
let footage = importer.probe(path)?;
|
||||
if item.post_render == PostRenderAction::SetProxy {
|
||||
crate::commands::proxy_set(self, item.comp, footage).map_err(|e| e.to_string())?;
|
||||
self.toast(format!("Proxy set from {path}"));
|
||||
return Ok(None);
|
||||
}
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let replace = item.post_render == PostRenderAction::ImportAndReplace;
|
||||
let comp = item.comp;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
|
||||
//! mattes made from other frames.
|
||||
|
||||
use crate::offload::JobKind;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -62,7 +63,7 @@ impl Default for RotoOptions {
|
|||
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
|
||||
|
||||
/// What a job does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RotoTask {
|
||||
Propagate,
|
||||
|
|
@ -378,6 +379,7 @@ fn run_work(w: Work, shared: &RotoShared) {
|
|||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
if w.task == RotoTask::Freeze {
|
||||
*lock(&shared.frozen) = Some(frozen);
|
||||
|
|
@ -402,19 +404,37 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
|
|||
|
||||
impl Session {
|
||||
pub fn is_roto_running(&self) -> bool {
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
|
||||
}
|
||||
|
||||
pub fn roto_progress(&self) -> Option<RotoProgress> {
|
||||
self.roto_job.as_ref().map(|j| j.progress())
|
||||
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::RotoFreeze).map(|j| {
|
||||
let p = &j.progress;
|
||||
RotoProgress {
|
||||
task: Some(RotoTask::Freeze),
|
||||
done: p.done,
|
||||
total: p.total,
|
||||
elapsed: p.elapsed,
|
||||
fps: crate::offload::rate(p.done, p.elapsed),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// (comp, layer, effect uid, task) of the running job.
|
||||
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
|
||||
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task))
|
||||
self.roto_job
|
||||
.as_ref()
|
||||
.map(|j| (j.comp, j.layer, j.effect, j.task))
|
||||
.or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze)))
|
||||
}
|
||||
|
||||
pub fn stop_roto(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::RotoFreeze) {
|
||||
return true;
|
||||
}
|
||||
match &self.roto_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
|
|
@ -471,6 +491,11 @@ impl Session {
|
|||
task,
|
||||
};
|
||||
let strokes = str_of(g, fx::STROKES);
|
||||
if !wait && task == RotoTask::Freeze && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(RotoShared::default());
|
||||
let thread = if wait {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ fn demo() -> Session {
|
|||
}
|
||||
|
||||
/// Records exports; fails items whose path contains "fail"; honours cancellation.
|
||||
struct MockExporter {
|
||||
log: Mutex<Vec<String>>,
|
||||
pub(crate) struct MockExporter {
|
||||
pub(crate) log: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Exporter for MockExporter {
|
||||
|
|
@ -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,43 @@ 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() && !cfg!(target_arch = "wasm32")).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);
|
||||
// (no file system in the browser: the disk cache stays off there)
|
||||
let Some(folder) = folder.filter(|_| d.disk_cache_enabled && !cfg!(target_arch = "wasm32")) 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
|
||||
|
|
@ -89,11 +127,19 @@ impl Session {
|
|||
self.config.as_ref().and_then(|c| c.dir())
|
||||
}
|
||||
|
||||
/// Where auto-saves live: the config store's [`crate::config::FileOps`], else the file system.
|
||||
pub fn file_ops(&self) -> &dyn crate::config::FileOps {
|
||||
match &self.config {
|
||||
Some(c) => c.files(),
|
||||
None => &crate::config::StdFiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
|
||||
pub fn autosave_now(&mut self) -> Result<String> {
|
||||
let json = self.project.to_json();
|
||||
let root = self.default_autosave_root();
|
||||
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
|
||||
let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
|
||||
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
|
||||
let p = path.to_string_lossy().to_string();
|
||||
self.autosave.last_slot = Some(slot);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
//! The 3D Camera Tracker end to end through the commands, on synthetic footage rendered in-test:
|
||||
//! textured planes (a floor, a back wall and a box face) seen through a known moving camera.
|
||||
|
||||
use effectcraft_effects::camera_tracker as ct;
|
||||
use effectcraft_geom::vec3;
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_render::three_d::camera::active_camera;
|
||||
use effectcraft_track::camtrack::linalg::{self, M3, V3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::tests_track::{H, W, frame_time, setup, texture};
|
||||
|
||||
const F: f64 = 260.0;
|
||||
|
||||
/// The true camera on frame `f`: (world → camera rotation, centre).
|
||||
fn true_camera(f: u32) -> (M3, V3) {
|
||||
let t = f as f64 / 24.0;
|
||||
let c = [-1.0 + 2.0 * t, -0.3 + 0.2 * (t * 3.0).sin(), 0.5 * t];
|
||||
let fwd = linalg::normalize(linalg::sub([0.2 * t, 0.4, 8.0], c));
|
||||
let right = linalg::normalize(linalg::cross([0.0, 1.0, 0.0], fwd));
|
||||
([right, linalg::cross(fwd, right), fwd], c)
|
||||
}
|
||||
|
||||
/// Planes: (point, normal, u axis, v axis, half extent or 0 = infinite).
|
||||
const PLANES: [(V3, V3, V3, V3, f64); 3] = [
|
||||
([0.0, 1.5, 0.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0], 0.0),
|
||||
([0.0, 0.0, 10.0], [0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.0),
|
||||
([-1.2, 0.2, 6.0], [0.3, 0.0, -1.0], [0.957_826, 0.0, 0.287_348], [0.0, 1.0, 0.0], 1.1),
|
||||
];
|
||||
|
||||
fn scene_frame(f: u32) -> Image {
|
||||
let (r, c) = true_camera(f);
|
||||
let mut img = Image::new(W, H);
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let d = linalg::mtv(&r, [(x as f64 + 0.5 - W as f64 / 2.0) / F, (y as f64 + 0.5 - H as f64 / 2.0) / F, 1.0]);
|
||||
let mut best = (f64::INFINITY, 0.2f32);
|
||||
for (i, (p0, n, ua, va, ext)) in PLANES.iter().enumerate() {
|
||||
let den = linalg::dot(*n, d);
|
||||
if den.abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let tt = linalg::dot(*n, linalg::sub(*p0, c)) / den;
|
||||
if tt <= 0.1 || tt >= best.0 {
|
||||
continue;
|
||||
}
|
||||
let hit = linalg::add(c, linalg::scale(d, tt));
|
||||
let (u, v) = (linalg::dot(linalg::sub(hit, *p0), *ua), linalg::dot(linalg::sub(hit, *p0), *va));
|
||||
if *ext > 0.0 && (u.abs() > *ext || v.abs() > *ext) {
|
||||
continue;
|
||||
}
|
||||
best = (tt, texture(u * 40.0, v * 40.0, 11 + i as u32));
|
||||
}
|
||||
img.set(x, y, [best.1, best.1, best.1, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// The comp with the clip tracked (blocking) with `params`.
|
||||
fn tracked(params: Value) -> (Session, LayerId) {
|
||||
let (mut s, clip, _) = setup(scene_frame);
|
||||
s.execute("layer.select", json!({"layers": [clip.0]})).unwrap();
|
||||
assert!(s.is_enabled("track.camera"));
|
||||
let mut p = params;
|
||||
p["wait"] = json!(true);
|
||||
let r = s.execute_checked("track.camera", p).unwrap();
|
||||
assert_eq!(r["frames"], 25, "{r}");
|
||||
assert!(s.camera_job.is_none());
|
||||
(s, clip)
|
||||
}
|
||||
|
||||
fn status(s: &mut Session) -> Value {
|
||||
s.execute("camera.solveStatus", json!({})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
let (mut s, clip) = tracked(json!({}));
|
||||
let st = status(&mut s);
|
||||
assert_eq!(st["solved"], true, "{st}");
|
||||
let err = st["averageError"].as_f64().unwrap();
|
||||
assert!(err < 0.5, "average error {err}");
|
||||
let f = st["focalLength"].as_f64().unwrap();
|
||||
assert!((f - F).abs() / F < 0.05, "focal {f}");
|
||||
assert_ne!(st["methodUsed"], "Tripod Pan");
|
||||
// Method Used / Average Error are reported in the effect.
|
||||
let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty()));
|
||||
|
||||
// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
|
||||
// ones seen below the horizon at the bottom of the frame).
|
||||
s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap();
|
||||
let pts = s.execute("camera.points", json!({})).unwrap();
|
||||
let list = pts["points"].as_array().unwrap();
|
||||
assert!(list.len() > 30, "{}", list.len());
|
||||
let floor: Vec<u64> = list.iter().filter(|p| p["comp"][1].as_f64().unwrap() > H as f64 * 0.8).map(|p| p["id"].as_u64().unwrap()).take(8).collect();
|
||||
assert!(floor.len() >= 3, "{floor:?}");
|
||||
s.execute("camera.selectPoints", json!({"points": floor})).unwrap();
|
||||
assert_eq!(s.state.camera_points.len(), floor.len());
|
||||
|
||||
// Create Solid and Camera: one undo step, camera + solid.
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
|
||||
let cam = LayerId(r["camera"].as_u64().unwrap());
|
||||
let solid = LayerId(r["layers"][0].as_u64().unwrap());
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
assert!(comp.layer(cam).unwrap().is_camera());
|
||||
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
|
||||
assert_eq!(comp.layers[0].id, cam);
|
||||
let sl = comp.layer(solid).unwrap();
|
||||
assert!(sl.switches.three_d);
|
||||
assert!(sl.name.starts_with("Track Solid"));
|
||||
// The camera is keyed on every frame.
|
||||
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
|
||||
|
||||
// Every solved point projects through the created camera exactly where the solve puts it,
|
||||
// and onto its tracked position (the footage fills the comp, so layer pixels are comp
|
||||
// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
|
||||
// features on occlusion edges), 0.5 px on average.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let uid = g.uid;
|
||||
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
|
||||
let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap();
|
||||
let mut errs = vec![];
|
||||
for k in [0u32, 6, 12, 18, 24] {
|
||||
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k));
|
||||
let camst = active_camera(&ctx);
|
||||
for sp in pl.solve.visible(k as usize) {
|
||||
let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue };
|
||||
let w = pl.to_world(sp.pos);
|
||||
let q = camst.project(W as f64, H as f64, vec3(w[0], w[1], w[2])).unwrap();
|
||||
let own = pl.solve.frames[k as usize].project(pl.solve.size, sp.pos).unwrap();
|
||||
assert!((q.x - own[0]).hypot(q.y - own[1]) < 0.01, "camera layer disagrees with the solve");
|
||||
errs.push((q.x - obs[0]).hypot(q.y - obs[1]));
|
||||
}
|
||||
}
|
||||
assert!(errs.len() > 100, "{}", errs.len());
|
||||
errs.sort_by(f64::total_cmp);
|
||||
let p95 = errs[errs.len() * 95 / 100];
|
||||
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
|
||||
assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px");
|
||||
|
||||
// The solid lies on the selected points' plane.
|
||||
let tr = sl.props.sub("transform").unwrap();
|
||||
let pos = tr.get("position").unwrap().value.as_vec3();
|
||||
let ori = tr.get("orientation").unwrap().value.as_vec3();
|
||||
let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]);
|
||||
let size = r["target"]["size"].as_f64().unwrap();
|
||||
for id in &s.state.camera_points {
|
||||
let w = pl.to_world(pl.solve.point(*id).unwrap().pos);
|
||||
let d = linalg::dot(linalg::sub(w, pos), normal).abs();
|
||||
assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane");
|
||||
}
|
||||
// The solid faces the camera.
|
||||
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12));
|
||||
let eye = active_camera(&ctx).eye;
|
||||
assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0);
|
||||
|
||||
// Undo removes both layers; redo brings them back.
|
||||
let before = comp.layers.len();
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before);
|
||||
|
||||
// A second create reuses the camera: Create Multiple Nulls and Camera.
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap();
|
||||
assert_eq!(r["camera"].as_u64().unwrap(), cam.0);
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), floor.len());
|
||||
// Text and Shadow Catcher (+ light).
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap();
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), 1);
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "shadowCatcher"})).unwrap();
|
||||
let ids: Vec<LayerId> = r["layers"].as_array().unwrap().iter().map(|v| LayerId(v.as_u64().unwrap())).collect();
|
||||
let comp = s.active_comp().unwrap();
|
||||
assert!(comp.layer(ids[0]).unwrap().name.starts_with("Shadow Catcher"));
|
||||
assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2);
|
||||
assert!(comp.layer(ids[1]).unwrap().is_light());
|
||||
assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err());
|
||||
|
||||
// Set Ground Plane and Origin: the target becomes the origin; a null created there sits at
|
||||
// (0, 0, 0) lying on the X-Z plane (its front facing up, −y).
|
||||
s.execute_checked("camera.setGroundPlane", json!({})).unwrap();
|
||||
assert_eq!(status(&mut s)["groundPlane"], true);
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap();
|
||||
let null = LayerId(r["layers"][0].as_u64().unwrap());
|
||||
let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone();
|
||||
let pos = tr.get("position").unwrap().value.as_vec3();
|
||||
assert!(linalg::norm(pos) < 1e-6, "{pos:?}");
|
||||
let up = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
|
||||
assert!(linalg::norm(linalg::sub(up, [0.0, -1.0, 0.0])) < 1e-6, "{up:?}");
|
||||
|
||||
// An explicit target (comp world) places the layer there, its front facing the normal.
|
||||
let r = s
|
||||
.execute_checked("camera.createFromSolve", json!({"kind": "solid", "target": {"center": [10.0, 0.0, 20.0], "normal": [0.0, 0.0, -1.0], "size": 80.0}}))
|
||||
.unwrap();
|
||||
let l = s.active_comp().unwrap().layer(LayerId(r["layers"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let tr = l.props.sub("transform").unwrap();
|
||||
let pos = tr.get("position").unwrap().value.as_vec3();
|
||||
assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}");
|
||||
let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
|
||||
assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}");
|
||||
|
||||
// Serde keeps the analysis.
|
||||
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
|
||||
let g2 = back.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap();
|
||||
let sv = ct::solve(&crate::camera_track::static_params(g2)).unwrap();
|
||||
assert!(sv.ground.is_some());
|
||||
assert_eq!(sv.points.len(), pl.solve.points.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidation_resolve_cancel_and_undo() {
|
||||
let (mut s, clip) = tracked(json!({"shotType": "specify", "aov": 2.0 * (W as f64 / 2.0 / F).atan().to_degrees()}));
|
||||
let st = status(&mut s);
|
||||
assert_eq!(st["solved"], true, "{st}");
|
||||
assert_eq!(st["shotType"], "Specify Angle of View");
|
||||
assert!((st["focalLength"].as_f64().unwrap() - F).abs() < 0.01 * F);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let uid = st["effect"].as_u64().unwrap();
|
||||
// Undo removes the analysis (one step); redo restores it.
|
||||
assert!(s.undo());
|
||||
assert_eq!(status(&mut s)["analyzed"], false);
|
||||
assert!(s.redo());
|
||||
assert_eq!(status(&mut s)["solved"], true);
|
||||
|
||||
// Changing the Solve Method keeps the tracks and only re-solves.
|
||||
s.execute_checked("prop.set", json!({"layer": clip.0, "path": "effects/#1/advanced/solveMethod", "value": 1})).unwrap();
|
||||
let st = status(&mut s);
|
||||
assert_eq!(st["analyzed"], true);
|
||||
assert_eq!(st["solved"], false);
|
||||
assert_eq!(st["pending"], true);
|
||||
s.execute_checked("camera.analyze", json!({"wait": true})).unwrap();
|
||||
let st = status(&mut s);
|
||||
assert_eq!(st["solved"], true, "{st}");
|
||||
assert_eq!(st["methodUsed"], "Typical");
|
||||
|
||||
// Deleting points re-solves without them.
|
||||
let pts = s.execute("camera.points", json!({})).unwrap();
|
||||
let del: Vec<u64> = pts["points"].as_array().unwrap().iter().take(3).map(|p| p["id"].as_u64().unwrap()).collect();
|
||||
s.execute_checked("camera.deletePoints", json!({"points": del, "wait": true})).unwrap();
|
||||
s.poll_camera(false);
|
||||
let st = status(&mut s);
|
||||
assert_eq!(st["solved"], true, "{st}");
|
||||
assert!(st["deleted"].as_u64().unwrap() >= 3);
|
||||
let g = s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap().clone();
|
||||
let sv = ct::solve(&crate::camera_track::static_params(&g)).unwrap();
|
||||
assert!(sv.points.iter().all(|p| !del.contains(&(p.id as u64))));
|
||||
|
||||
// Trimming the layer changes its frames: tracks and solve are cleared and queued.
|
||||
s.edit("trim", None, |p, _| {
|
||||
p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().in_point = frame_time(2);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let st = status(&mut s);
|
||||
assert_eq!(st["analyzed"], false);
|
||||
assert_eq!(st["pending"], true);
|
||||
|
||||
// A background analysis can be cancelled: nothing is written.
|
||||
s.execute_checked("camera.analyze", json!({})).unwrap();
|
||||
assert!(s.is_camera_analyzing() || s.camera_job.is_some());
|
||||
let banner = status(&mut s)["banner"].as_str().map(str::to_string);
|
||||
if s.is_camera_analyzing() {
|
||||
assert!(banner.unwrap().starts_with("Analyzing in background (step 1 of 2)"));
|
||||
}
|
||||
s.execute("camera.cancel", json!({})).ok();
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.camera_job.is_some() {
|
||||
assert!(t0.elapsed().as_secs() < 120);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
s.poll_camera(false);
|
||||
}
|
||||
// (When the job finished before the cancel landed it wrote a valid analysis.)
|
||||
let st = status(&mut s);
|
||||
assert_eq!(st["running"], false);
|
||||
assert!(s.execute_checked("track.camera", json!({"layer": clip.0, "shotType": "bogus"})).is_err());
|
||||
assert!(s.execute_checked("track.camera", json!({"layer": clip.0, "aov": 500})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_parameters_match_after_effects() {
|
||||
let spec = effectcraft_effects::registry().iter().find(|e| e.id == ct::ID).unwrap();
|
||||
assert_eq!(spec.name, "3D Camera Tracker");
|
||||
assert_eq!(spec.category, "Perspective");
|
||||
let names: Vec<&str> = spec.params.iter().filter(|p| !matches!(p.ui, effectcraft_project::ParamUi::Hidden)).map(|p| p.name).collect();
|
||||
for n in [
|
||||
"Shot Type",
|
||||
"Horizontal Angle of View",
|
||||
"Show Track Points",
|
||||
"Render Track Points",
|
||||
"Track Point Size",
|
||||
"Target Size",
|
||||
"Solve Method",
|
||||
"Detailed Analysis",
|
||||
"Auto-delete Points Across Time",
|
||||
"Hide Warning Banner",
|
||||
] {
|
||||
assert!(names.contains(&n), "{n}");
|
||||
}
|
||||
// The Animation menu entry is a real command now.
|
||||
let c = crate::find_command("track.camera").unwrap();
|
||||
assert_eq!(c.label, "Track Camera");
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
//! 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,
|
||||
..Default::default()
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
//! Essential Graphics: authoring controls, master properties (per-instance overrides, Push to
|
||||
//! Comp, Revert), `.ectemplate` export/import, and Responsive Design — Time.
|
||||
|
||||
use effectcraft_project::essential;
|
||||
use effectcraft_project::{ItemId, LayerId};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::RenderOpts;
|
||||
|
||||
fn out_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A "Card" comp (64×64): a white solid with a Fill effect (red) and a text layer; its Fill
|
||||
/// colour, opacity and text are exposed. Returns (session, card comp, solid layer).
|
||||
fn card() -> (Session, ItemId, u64, Value) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let card = s.active_comp_id().unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color", "name": "Card Color"})).unwrap();
|
||||
let opacity = s.execute("essential.addProperty", json!({"layer": solid, "path": "transform/opacity"})).unwrap();
|
||||
let g = s.execute("essential.addGroup", json!({"name": "Words"})).unwrap()["control"].as_u64().unwrap();
|
||||
let title = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "group": g})).unwrap();
|
||||
s.execute("essential.addComment", json!({"text": "Pick a colour"})).unwrap();
|
||||
s.execute("essential.setName", json!({"name": "Lower Card"})).unwrap();
|
||||
let ids = json!({
|
||||
"color": color["controls"][0], "opacity": opacity["controls"][0], "group": g, "title": title["controls"][0],
|
||||
});
|
||||
(s, card, solid, ids)
|
||||
}
|
||||
|
||||
fn center(s: &Session, comp: ItemId) -> [f32; 4] {
|
||||
let img = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
img.get(img.width as i64 / 2, img.height as i64 / 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_are_listed_typed_and_editable() {
|
||||
let (mut s, card, solid, ids) = card();
|
||||
let l = s.execute("essential.list", json!({"supported": true})).unwrap();
|
||||
assert_eq!(l["name"], "Lower Card");
|
||||
let top: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap()).collect();
|
||||
assert_eq!(top, ["property", "property", "group", "comment"]);
|
||||
assert_eq!(l["controls"][0]["name"], "Card Color");
|
||||
assert_eq!(l["controls"][0]["type"], "color");
|
||||
assert_eq!(l["controls"][1]["type"], "slider");
|
||||
assert_eq!(l["controls"][2]["children"][0]["type"], "text");
|
||||
assert!(l["supported"].as_array().unwrap().iter().any(|p| p["path"] == "transform/position" && p["type"] == "point"));
|
||||
assert!(l["supported"].as_array().unwrap().iter().any(|p| p["path"] == "transform/rotation" && p["type"] == "angle"));
|
||||
// Rename, move into the group, remove; all undoable.
|
||||
let opacity = ids["opacity"].as_u64().unwrap();
|
||||
s.execute("essential.rename", json!({"control": opacity, "name": "Fade"})).unwrap();
|
||||
s.execute("essential.move", json!({"control": opacity, "group": ids["group"], "index": 0})).unwrap();
|
||||
let l = s.execute("essential.list", json!({})).unwrap();
|
||||
assert_eq!(l["controls"][1]["children"][0]["name"], "Fade");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let l = s.execute("essential.list", json!({})).unwrap();
|
||||
assert_eq!(l["controls"][1]["name"], "Opacity");
|
||||
s.execute("essential.remove", json!({"control": opacity})).unwrap();
|
||||
assert_eq!(s.execute("essential.list", json!({})).unwrap()["controls"].as_array().unwrap().len(), 3);
|
||||
// Unsupported properties are refused; adding the same property twice adds nothing.
|
||||
s.execute("layer.addMask", json!({"layer": solid})).unwrap();
|
||||
assert!(s.execute("essential.addProperty", json!({"layer": solid, "path": "masks/#1/path"})).is_err());
|
||||
let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap();
|
||||
assert_eq!(again["controls"], json!([]));
|
||||
// Dropdown Menu Control: its items are editable and it becomes a dropdown control.
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "Dropdown Menu Control"})).unwrap();
|
||||
s.execute("effect.editDropdown", json!({"layer": solid, "items": ["Small", "Medium", "Large", "Huge"]})).unwrap();
|
||||
let d = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#2/menu"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let l = s.execute("essential.list", json!({})).unwrap();
|
||||
let c = l["controls"].as_array().unwrap().iter().find(|c| c["id"] == d).unwrap().clone();
|
||||
assert_eq!(c["type"], "dropdown");
|
||||
let comp = s.project.comp(card).unwrap();
|
||||
let menu = comp.layer(LayerId(solid)).unwrap().props.prop("effects/#2/menu").unwrap();
|
||||
assert!(matches!(&menu.ui, effectcraft_project::ParamUi::Popup { options } if options.len() == 4 && options[3] == "Huge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_properties_override_per_instance_push_and_revert() {
|
||||
let (mut s, card, solid, ids) = card();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
// Both instances show the controls under Essential Properties.
|
||||
let inst = s.execute("essential.instance", json!({"layer": a})).unwrap();
|
||||
let names: Vec<&str> = inst["controls"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Card Color", "Opacity", "Words", "Source Text", "Comment"]);
|
||||
assert_eq!(inst["controls"][0]["value"], json!([1.0, 0.0, 0.0, 1.0]));
|
||||
// Override the colour of the top instance (a = layer #2, b on top is #1).
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": b, "control": ids["color"], "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
let px = center(&s, main);
|
||||
assert!(px[2] > 0.99 && px[0] < 0.01, "instance b is blue: {px:?}");
|
||||
let inst_b = s.execute("essential.instance", json!({"layer": b})).unwrap();
|
||||
assert_eq!(inst_b["controls"][0]["overridden"], true);
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][0]["overridden"], false);
|
||||
// The source comp is untouched; hiding b shows a still red.
|
||||
assert!(center(&s, card)[0] > 0.99);
|
||||
s.execute("layer.setSwitch", json!({"layer": b, "switch": "video", "value": false})).unwrap();
|
||||
let px = center(&s, main);
|
||||
assert!(px[0] > 0.99 && px[2] < 0.01, "instance a stays red: {px:?}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Editing the master property in the timeline (prop.set) also overrides.
|
||||
let path = inst_b["controls"][1]["path"].as_str().unwrap().to_string();
|
||||
assert!(path.starts_with("essential/"), "{path}");
|
||||
s.execute("prop.set", json!({"layer": a, "path": path, "value": 50})).unwrap();
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][1]["overridden"], true);
|
||||
// A change to the source shows in instances that don't override it.
|
||||
s.execute("prop.set", json!({"comp": card.0, "layer": solid, "path": "transform/opacity", "value": 80})).unwrap();
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][1]["value"], json!(80.0));
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][1]["value"], json!(50.0));
|
||||
// Revert a's opacity.
|
||||
s.execute("essential.revert", json!({"layer": a, "control": ids["opacity"]})).unwrap();
|
||||
let inst = s.execute("essential.instance", json!({"layer": a})).unwrap();
|
||||
assert_eq!(inst["controls"][1]["overridden"], false);
|
||||
assert_eq!(inst["controls"][1]["value"], json!(80.0));
|
||||
// Push b's colour to the source comp: the override goes away and every instance is blue.
|
||||
s.execute("essential.pushToComp", json!({"layer": b})).unwrap();
|
||||
let src = s.project.comp(card).unwrap().layer(LayerId(solid)).unwrap().props.prop("effects/#1/color").unwrap().value.clone();
|
||||
assert_eq!(src.to_json(), json!([0.0, 0.0, 1.0, 1.0]));
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][0]["overridden"], false);
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": a})).unwrap()["controls"][0]["value"], json!([0.0, 0.0, 1.0, 1.0]));
|
||||
// Undo restores the override and the red source.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": b})).unwrap()["controls"][0]["overridden"], true);
|
||||
let src = s.project.comp(card).unwrap().layer(LayerId(solid)).unwrap().props.prop("effects/#1/color").unwrap().value.clone();
|
||||
assert_eq!(src.to_json(), json!([1.0, 0.0, 0.0, 1.0]));
|
||||
// Removing a control removes it from instances.
|
||||
s.execute("essential.remove", json!({"comp": card.0, "control": ids["color"]})).unwrap();
|
||||
let l = s.project.comp(main).unwrap().layer(LayerId(b)).unwrap().clone();
|
||||
assert!(essential::group(&l).unwrap().children.iter().all(|n| n.name() != "Card Color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_export_import_round_trip_with_overrides() {
|
||||
let dir = out_dir("ectemplate");
|
||||
let (mut s, card, _solid, ids) = card();
|
||||
let path = dir.join("Lower Card.ectemplate").to_string_lossy().to_string();
|
||||
let r = s.execute("essential.exportTemplate", json!({"comp": card.0, "path": path})).unwrap();
|
||||
assert_eq!(r["name"], "Lower Card");
|
||||
let info = s.execute("essential.templateInfo", json!({"path": path})).unwrap();
|
||||
assert_eq!(info["format"], "effectcraft-template");
|
||||
assert_eq!(info["width"], 64);
|
||||
let kinds: Vec<&str> = info["controls"].as_array().unwrap().iter().map(|c| c["kind"].as_str().unwrap()).collect();
|
||||
assert_eq!(kinds, ["property", "property", "group", "property", "comment"]);
|
||||
assert_eq!(info["controls"][3]["group"], ids["group"]);
|
||||
let entries = effectcraft_lottie::zip::read_stored(&std::fs::read(&path).unwrap());
|
||||
let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(names.contains(&"manifest.json") && names.contains(&"project.ecproj") && names.contains(&"poster.png"), "{names:?}");
|
||||
|
||||
// A fresh project imports it and gets an instance in its active comp.
|
||||
let mut t = Session::default();
|
||||
t.execute("comp.new", json!({"name": "Show", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let show = t.active_comp_id().unwrap();
|
||||
t.execute("layer.newSolid", json!({"name": "Back", "color": "#00ff00"})).unwrap();
|
||||
let r = t.execute("essential.importTemplate", json!({"path": path})).unwrap();
|
||||
let tcomp = ItemId(r["comp"].as_u64().unwrap());
|
||||
let inst = r["layer"].as_u64().unwrap();
|
||||
assert_eq!(t.project.item(tcomp).unwrap().name, "Card");
|
||||
assert!(t.project.items.values().any(|i| i.is_folder() && i.name == "Lower Card"));
|
||||
// Ids were remapped consistently: the template comp's controls point at its own layers.
|
||||
let eg = t.project.comp(tcomp).unwrap().essential.clone().unwrap();
|
||||
for c in eg.flat() {
|
||||
if let essential::EgKind::Property { .. } = c.kind {
|
||||
assert!(essential::source_prop(&t.project, tcomp, c).is_some(), "{c:?}");
|
||||
}
|
||||
}
|
||||
let px = center(&t, show);
|
||||
assert!(px[0] > 0.99 && px[1] < 0.01, "the card renders red over green: {px:?}");
|
||||
// Override on the imported instance, then save/load keeps it.
|
||||
let color_ctl = eg.flat().iter().find(|c| c.name == "Card Color").unwrap().id;
|
||||
t.execute("essential.set", json!({"layer": inst, "control": color_ctl, "value": "#ffff00"})).unwrap();
|
||||
let px = center(&t, show);
|
||||
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "yellow: {px:?}");
|
||||
let saved = effectcraft_project::Project::from_json(&t.project.to_json()).unwrap();
|
||||
let mut u = Session::default();
|
||||
u.replace_project(saved, None);
|
||||
u.open_comp(show);
|
||||
let px = center(&u, show);
|
||||
assert!(px[0] > 0.99 && px[1] > 0.99 && px[2] < 0.01, "override survives save/load: {px:?}");
|
||||
// Importing again keeps both copies apart (fresh ids).
|
||||
let r2 = t.execute("essential.importTemplate", json!({"path": path, "addToComp": false})).unwrap();
|
||||
assert_ne!(r2["comp"], r["comp"]);
|
||||
assert!(r2["layer"].is_null());
|
||||
// Not a template.
|
||||
std::fs::write(dir.join("bad.ectemplate"), b"nope").unwrap();
|
||||
assert!(t.execute("essential.importTemplate", json!({"path": dir.join("bad.ectemplate").to_string_lossy()})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_replacement_swaps_footage_per_instance() {
|
||||
use std::sync::Arc;
|
||||
// Two "footage" items rendered by a fake source: item → solid colour.
|
||||
struct Colors;
|
||||
impl crate::render::FootageSource for Colors {
|
||||
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
|
||||
let c = if f.path.contains("red") { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] };
|
||||
Some(Arc::new(effectcraft_raster::Image::filled(16, 16, c)))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(Colors), ..Default::default() };
|
||||
let foot = |p: &str| effectcraft_project::Footage {
|
||||
path: p.into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 16,
|
||||
height: 16,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (red, blue) = s
|
||||
.edit("x", None, |proj, _| {
|
||||
let r = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(foot("red.png")));
|
||||
let b = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(foot("blue.png")));
|
||||
Ok((r, b))
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("comp.new", json!({"name": "Slot", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let slot = s.active_comp_id().unwrap();
|
||||
let fl = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let m = s.execute("essential.addMedia", json!({"layer": fl})).unwrap()["control"].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let inst = s.execute("layer.addItem", json!({"item": slot.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(center(&s, main)[0] > 0.99);
|
||||
s.execute("essential.set", json!({"layer": inst, "control": m, "item": blue.0})).unwrap();
|
||||
let px = center(&s, main);
|
||||
assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}");
|
||||
assert!(center(&s, slot)[0] > 0.99, "the source keeps its media");
|
||||
assert_eq!(s.execute("essential.instance", json!({"layer": inst})).unwrap()["controls"][0]["type"], "media");
|
||||
s.execute("essential.revert", json!({"layer": inst})).unwrap();
|
||||
assert!(center(&s, main)[0] > 0.99);
|
||||
}
|
||||
|
||||
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
|
||||
/// original speed; the unprotected parts absorb the stretch.
|
||||
#[test]
|
||||
fn protected_regions_keep_speed_under_time_stretch() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
// Intro 0–1 s and outro 3–4 s.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("comp.responsiveTime", json!({"op": "intro"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3.0})).unwrap();
|
||||
s.execute("comp.responsiveTime", json!({"op": "outro"})).unwrap();
|
||||
let comp = s.project.comp(inner).unwrap().clone();
|
||||
assert_eq!(crate::render::eval::protected_regions(&comp), vec![(0.0, 1.0), (3.0, 4.0)]);
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 32, "height": 32, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
|
||||
let st = |s: &Session, t: f64| {
|
||||
let c = s.project.comp(outer).unwrap();
|
||||
let layer = c.layer(LayerId(l)).unwrap();
|
||||
crate::render::EvalCtx::new(&s.project, outer, c, Tick::from_seconds_f64(t)).source_time(layer).seconds()
|
||||
};
|
||||
// 8 s total: intro 1 s at speed 1, middle 2 s stretched to 6 s, outro 1 s at speed 1.
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
assert!(near(st(&s, 0.5), 0.5));
|
||||
assert!(near(st(&s, 1.0), 1.0));
|
||||
assert!(near(st(&s, 4.0), 2.0));
|
||||
assert!(near(st(&s, 7.0), 3.0));
|
||||
assert!(near(st(&s, 7.5), 3.5), "outro plays at full speed: {}", st(&s, 7.5));
|
||||
// Protected regions play at the original speed: one comp second = one source second.
|
||||
assert!(near(st(&s, 0.8) - st(&s, 0.3), 0.5));
|
||||
assert!(near(st(&s, 7.9) - st(&s, 7.4), 0.5));
|
||||
assert!(near(st(&s, 5.0) - st(&s, 4.0), 1.0 / 3.0), "the middle is slowed down 3×");
|
||||
// Compressing to 75 %: the middle plays faster.
|
||||
s.execute("layer.timeStretch", json!({"percent": 75})).unwrap();
|
||||
assert!(near(st(&s, 0.5), 0.5));
|
||||
assert!(near(st(&s, 1.0 + 0.5), 1.0 + 0.5 / 0.5), "{}", st(&s, 1.5));
|
||||
// Without protected regions the plain stretch applies.
|
||||
s.execute("comp.open", json!({"comp": inner.0})).ok();
|
||||
let mut p = (*s.project).clone();
|
||||
p.comp_mut(inner).unwrap().markers.clear();
|
||||
s.project = std::sync::Arc::new(p);
|
||||
assert!(near(st(&s, 1.5), 2.0));
|
||||
}
|
||||
|
|
@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
|
|||
let ev = frontend_events(&mut s);
|
||||
assert_eq!(ev[0].0, "view.zoomIn");
|
||||
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
|
||||
for id in ["file.createProxy", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
|
||||
for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ impl Importer for ModelImporter {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
|
||||
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
|
||||
//! a synthetic footage source.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::render_queue::ProxyUse;
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId, ItemKind};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
|
||||
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
|
||||
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
|
||||
/// (white at 25 % alpha), `wide.png` (10×10 white).
|
||||
struct Synthetic;
|
||||
|
||||
fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
match name {
|
||||
"red16.png" => [1.0, 0.0, 0.0, 1.0],
|
||||
"blue8.png" => [0.0, 0.0, 1.0, 1.0],
|
||||
"lines.mov" => {
|
||||
if y.is_multiple_of(2) {
|
||||
[1.0, 0.0, 0.0, 1.0]
|
||||
} else {
|
||||
[0.0, 0.0, 1.0, 1.0]
|
||||
}
|
||||
}
|
||||
// Straight interpretation of a file whose colour is green·a + matte·(1 − a), a = 0.5.
|
||||
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
|
||||
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
|
||||
"half.png" => [1.0, 1.0, 1.0, 0.25],
|
||||
_ => {
|
||||
let _ = x;
|
||||
[1.0, 1.0, 1.0, 1.0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size_of(path: &str) -> (u32, u32) {
|
||||
match std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("") {
|
||||
"red16.png" => (16, 16),
|
||||
"blue8.png" => (8, 8),
|
||||
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
|
||||
_ => (10, 10),
|
||||
}
|
||||
}
|
||||
|
||||
impl FootageSource for Synthetic {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
|
||||
let (w, h) = size_of(&f.path);
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Decoders hand out premultiplied pixels (straight file colour × alpha).
|
||||
let p = file_px(&f.path, x, y);
|
||||
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
|
||||
}
|
||||
}
|
||||
Some(Arc::new(img))
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let (w, h) = size_of(path);
|
||||
let video = path.ends_with(".mov");
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: if video { FootageKind::Video } else { FootageKind::Still },
|
||||
width: w,
|
||||
height: h,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(10.0),
|
||||
duration: Tick::from_seconds_f64(if video { 2.0 } else { 0.0 }),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
Session { footage: Arc::new(Synthetic), importer: Some(Arc::new(Probe)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, path: &str) -> ItemId {
|
||||
ItemId(s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn px(s: &Session, comp: ItemId, t: f64, opts: RenderOpts, x: i64, y: i64) -> [f32; 4] {
|
||||
s.render(comp, Tick::from_seconds_f64(t), opts).get(x, y)
|
||||
}
|
||||
|
||||
fn red(p: [f32; 4]) -> bool {
|
||||
p[0] > 0.99 && p[2] < 0.01 && p[3] > 0.99
|
||||
}
|
||||
fn blue(p: [f32; 4]) -> bool {
|
||||
p[2] > 0.99 && p[0] < 0.01 && p[3] > 0.99
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_proxy_follows_use_proxy_and_proxy_use() {
|
||||
let mut s = session();
|
||||
let full = import(&mut s, "/m/red16.png");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": full.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
assert!(red(px(&s, c, 0.0, o, 8, 8)));
|
||||
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/blue8.png"})).unwrap();
|
||||
// The 8×8 proxy fills the 16×16 footage frame.
|
||||
for (x, y) in [(1, 1), (8, 8), (14, 14)] {
|
||||
assert!(blue(px(&s, c, 0.0, o, x, y)), "proxy at {x},{y}");
|
||||
}
|
||||
assert!(s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
|
||||
// Render Settings ▸ Proxy Use.
|
||||
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
|
||||
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
|
||||
// Use Proxy off: the viewer shows the source, Use All Proxies still uses it.
|
||||
s.execute("file.useProxy", json!({"item": full.0})).unwrap();
|
||||
assert!(!s.project.item(full).unwrap().proxy.as_ref().unwrap().enabled);
|
||||
assert!(red(px(&s, c, 0.0, o, 8, 8)));
|
||||
assert!(blue(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
|
||||
// Undo / Set Proxy ▸ None.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(blue(px(&s, c, 0.0, o, 8, 8)));
|
||||
s.execute("file.setProxyNone", json!({"item": full.0})).unwrap();
|
||||
assert!(s.project.item(full).unwrap().proxy.is_none());
|
||||
assert!(red(px(&s, c, 0.0, RenderOpts { proxy: ProxyUse::UseAll, ..o }, 8, 8)));
|
||||
// Render queue items carry Proxy Use (Best Settings: none).
|
||||
let r = s.execute("renderQueue.add", json!({"proxyUse": "all"})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].settings.proxy_use, ProxyUse::UseAll, "{r}");
|
||||
assert_eq!(effectcraft_project::render_queue::RenderSettings::default().proxy_use, ProxyUse::UseNone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_proxy_and_create_proxy() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
assert!(red(px(&s, outer, 0.0, o, 8, 8)));
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/blue8.png"})).unwrap();
|
||||
assert!(blue(px(&s, outer, 0.0, o, 14, 1)));
|
||||
assert!(blue(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseCompOnly, ..o }, 8, 8)));
|
||||
assert!(red(px(&s, outer, 0.0, RenderOpts { proxy: ProxyUse::UseNone, ..o }, 8, 8)));
|
||||
s.execute("file.setProxyNone", json!({"item": inner.0})).unwrap();
|
||||
// Create Proxy queues a half-resolution render whose post-render action sets the proxy.
|
||||
s.state.project_selection = vec![inner];
|
||||
let r = s.execute("file.createProxy", json!({"kind": "still"})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap().clone();
|
||||
assert_eq!(it.comp, inner);
|
||||
assert_eq!(it.post_render, effectcraft_project::render_queue::PostRenderAction::SetProxy);
|
||||
assert_eq!(it.settings.resolution, 0.5);
|
||||
assert!(r["output"].as_str().unwrap().contains("Inner_proxy_"), "{r}");
|
||||
let m = s.execute("file.createProxy", json!({"kind": "movie", "comp": inner.0})).unwrap();
|
||||
assert!(m["output"].as_str().unwrap().ends_with("Inner_proxy.mov"), "{m}");
|
||||
// The post-render action (what a finished render runs) attaches the file.
|
||||
let id = it.id;
|
||||
s.post_render_action(id, "/m/blue8.png").unwrap();
|
||||
assert_eq!(s.project.item(inner).unwrap().proxy.as_ref().unwrap().footage.path, "/m/blue8.png");
|
||||
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
let mut s = session();
|
||||
let full = import(&mut s, "/m/red16.png");
|
||||
s.execute("file.setProxy", json!({"item": full.0, "path": "/m/half.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": full.0, "alpha": "ignore", "invertAlpha": true})).unwrap();
|
||||
let px = s.project.item(full).unwrap().proxy.clone().unwrap();
|
||||
assert_eq!(px.footage.alpha, AlphaMode::Ignore);
|
||||
assert!(px.footage.invert_alpha);
|
||||
let f = match &s.project.item(full).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
|
||||
}
|
||||
|
||||
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
|
||||
/// interpolated; the dominant field comes first.
|
||||
#[test]
|
||||
fn separate_fields_make_frames_from_fields() {
|
||||
let mut s = session();
|
||||
let mov = import(&mut s, "/m/lines.mov");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 20, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": mov.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
// Progressive: alternating rows.
|
||||
assert!(red(px(&s, c, 0.0, o, 1, 0)) && blue(px(&s, c, 0.0, o, 1, 1)));
|
||||
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "upper"})).unwrap();
|
||||
// Field 1 (upper = even rows, red) then field 2 (odd rows, blue), each 1/20 s.
|
||||
for y in 0..3 {
|
||||
assert!(red(px(&s, c, 0.0, o, 1, y)), "upper field at row {y}");
|
||||
assert!(blue(px(&s, c, 0.05, o, 1, y + 1)), "lower field at row {}", y + 1);
|
||||
}
|
||||
s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "lower"})).unwrap();
|
||||
assert!(blue(px(&s, c, 0.0, o, 1, 2)));
|
||||
assert!(red(px(&s, c, 0.05, o, 1, 1)));
|
||||
let r = s.execute("file.interpretFootage", json!({"item": mov.0, "fields": "off"})).unwrap();
|
||||
assert_eq!(r["items"][0]["fields"], "Off");
|
||||
}
|
||||
|
||||
/// Non-square pixels: a 10×10 footage with pixel aspect 2 covers 20×10 comp pixels.
|
||||
#[test]
|
||||
fn pixel_aspect_stretches_footage_in_the_comp() {
|
||||
let mut s = session();
|
||||
let w = import(&mut s, "/m/wide.png");
|
||||
s.execute("comp.new", json!({"name": "C", "width": 40, "height": 20, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": w.0})).unwrap();
|
||||
let o = RenderOpts::default();
|
||||
let covered = |s: &Session| s.render(c, Tick::ZERO, o).data.iter().filter(|p| p[3] > 0.5).count();
|
||||
assert_eq!(covered(&s), 100);
|
||||
s.execute("file.interpretFootage", json!({"item": w.0, "pixelAspect": 2.0})).unwrap();
|
||||
assert_eq!(covered(&s), 200);
|
||||
// Centered on the comp: x 10..30 (edge pixels are filtered).
|
||||
assert!(px(&s, c, 0.0, o, 11, 10)[3] > 0.99 && px(&s, c, 0.0, o, 28, 10)[3] > 0.99);
|
||||
assert!(px(&s, c, 0.0, o, 8, 10)[3] < 0.01 && px(&s, c, 0.0, o, 31, 10)[3] < 0.01);
|
||||
// A comp with the same pixel aspect shows the footage 1:1.
|
||||
s.execute("comp.settings", json!({"pixelAspect": 2.0})).unwrap();
|
||||
assert_eq!(covered(&s), 100);
|
||||
}
|
||||
|
||||
/// Alpha ▸ Guess: premultiplied files are recognised with their matte colour; Invert Alpha
|
||||
/// and Interpret As Linear Light change the decoded pixels.
|
||||
#[test]
|
||||
fn guess_alpha_invert_and_linear_light() {
|
||||
let mut s = session();
|
||||
let b = import(&mut s, "/m/matte_black.png");
|
||||
let w = import(&mut s, "/m/matte_white.png");
|
||||
let h = import(&mut s, "/m/half.png");
|
||||
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
|
||||
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);
|
||||
assert_eq!(foot(&s, b).premul_color, [0.0; 3]);
|
||||
assert_eq!(foot(&s, w).alpha, AlphaMode::Premultiplied);
|
||||
assert_eq!(foot(&s, w).premul_color, [1.0; 3]);
|
||||
// White at 25 %: not explained by a black matte (1 > 0.25) but it is by white… either way
|
||||
// the guess is consistent with the pixels.
|
||||
assert!(matches!(foot(&s, h).alpha, AlphaMode::Straight | AlphaMode::Premultiplied));
|
||||
// An explicit matte colour.
|
||||
s.execute("file.interpretFootage", json!({"item": b.0, "alpha": "premultiplied", "matteColor": "#808080"})).unwrap();
|
||||
assert!((foot(&s, b).premul_color[0] - 128.0 / 255.0).abs() < 1e-3);
|
||||
// Invert Alpha: 25 % → 75 %.
|
||||
s.execute("file.interpretFootage", json!({"item": h.0, "alpha": "straight", "invertAlpha": true})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": h.0})).unwrap();
|
||||
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
|
||||
assert!((p[3] - 0.75).abs() < 0.01 && p[0] > 0.7, "{p:?}");
|
||||
// Interpret As Linear Light keeps alpha and re-encodes the colour.
|
||||
s.execute("file.interpretFootage", json!({"item": h.0, "invertAlpha": false, "linearLight": true})).unwrap();
|
||||
let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1);
|
||||
assert!((p[3] - 0.25).abs() < 0.01, "{p:?}");
|
||||
// Remember / Apply Interpretation carries the new settings.
|
||||
s.state.project_selection = vec![h];
|
||||
s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap();
|
||||
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
|
||||
assert!(foot(&s, w).linear_light);
|
||||
}
|
||||
|
|
@ -257,6 +257,7 @@ impl Importer for MockImporter {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -331,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
|
|||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
|
||||
}
|
||||
// What remains a stub is still disabled.
|
||||
for id in ["view.displayColorManagement", "file.createProxy", "track.camera"] {
|
||||
for id in ["view.displayColorManagement", "keys.audioToKeyframes"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ pub(crate) fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Ses
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
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,
|
||||
..Default::default()
|
||||
};
|
||||
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());
|
||||
}
|
||||
|
|
@ -70,7 +70,8 @@ pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
|
|||
}
|
||||
|
||||
/// A track point's regions and attach offset read from its properties at a layer time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PointValues {
|
||||
pub uid: Uid,
|
||||
pub spec: trk::PointSpec,
|
||||
|
|
@ -206,7 +207,7 @@ pub(crate) fn source_frame(
|
|||
t: Tick,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
) -> Option<(Arc<Image>, [f64; 2])> {
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr };
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
|
||||
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
|
||||
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
|
||||
&& f.has_video
|
||||
|
|
@ -292,6 +293,7 @@ pub(crate) fn run_work(w: Work, shared: &TrackShared) {
|
|||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
|
||||
s.time = w.times[k].seconds();
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
|
|
@ -322,16 +324,19 @@ fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
|
|||
impl Session {
|
||||
/// Whether a track analysis is running.
|
||||
pub fn is_tracking(&self) -> bool {
|
||||
self.track_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
|
||||
}
|
||||
|
||||
/// Live progress of the running (or just finished) analysis.
|
||||
pub fn track_progress(&self) -> Option<TrackProgress> {
|
||||
self.track_job.as_ref().map(|j| j.progress())
|
||||
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (results so far are kept).
|
||||
pub fn stop_track(&mut self) -> bool {
|
||||
if self.cancel_offloaded(crate::offload::JobKind::Track) {
|
||||
return true;
|
||||
}
|
||||
match &self.track_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.cancel();
|
||||
|
|
@ -348,6 +353,10 @@ impl Session {
|
|||
return Err("a track analysis is already running".into());
|
||||
}
|
||||
self.poll_track();
|
||||
if !wait && self.offloads() {
|
||||
let Work { comp, layer, settings, points, times, .. } = work;
|
||||
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
self.history.undo.push(("Analyze Track".into(), self.project.clone()));
|
||||
self.history.redo.clear();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
|
||||
//! automatically too).
|
||||
|
||||
use crate::offload::{JobKind, rate};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
|
@ -232,6 +233,7 @@ fn run_work(w: WarpWork, shared: &WarpShared) {
|
|||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
lock(&shared.state).step = 2;
|
||||
let lt0 = layer.layer_time(w.times[0]).seconds();
|
||||
|
|
@ -264,21 +266,29 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) ->
|
|||
|
||||
impl Session {
|
||||
pub fn is_warp_analyzing(&self) -> bool {
|
||||
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.warp_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Warp).is_some()
|
||||
}
|
||||
|
||||
/// Progress of the running (or just finished) analysis.
|
||||
pub fn warp_progress(&self) -> Option<WarpProgress> {
|
||||
self.warp_job.as_ref().map(|j| j.progress())
|
||||
self.warp_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::Warp).map(|j| {
|
||||
let p = &j.progress;
|
||||
WarpProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: rate(p.done, p.elapsed), ..Default::default() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The effect being analysed: (comp, layer, effect uid).
|
||||
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
|
||||
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Warp).map(|j| (j.comp, j.layer, j.uid)))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (nothing is written).
|
||||
pub fn stop_warp(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::Warp) {
|
||||
return true;
|
||||
}
|
||||
match &self.warp_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
|
|
@ -325,6 +335,11 @@ impl Session {
|
|||
times,
|
||||
};
|
||||
let key = signature(&self.project, comp, l);
|
||||
if !wait && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(WarpShared::default());
|
||||
let thread = if wait {
|
||||
|
|
@ -391,7 +406,7 @@ impl Session {
|
|||
/// re-analyses in the background, like After Effects.
|
||||
pub fn poll_warp(&mut self, auto: bool) -> bool {
|
||||
let changed = self.poll_warp_job();
|
||||
if auto && self.warp_job.is_none() {
|
||||
if auto && self.warp_job.is_none() && self.offloaded(JobKind::Warp).is_none() {
|
||||
while let Some((c, l, e)) = self.warp_pending.first().copied() {
|
||||
self.warp_pending.remove(0);
|
||||
if self.start_warp(c, l, e, false).is_ok() {
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
};
|
||||
|
|
@ -176,6 +182,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
|
|||
view: None,
|
||||
backend: effectcraft_render::Backend::Auto,
|
||||
roi: None,
|
||||
proxy: job.settings.proxy_use,
|
||||
};
|
||||
let mut r = Renderer::new(job.project, job.footage, opts);
|
||||
r.expr = job.expr;
|
||||
|
|
|
|||
|
|
@ -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[..]);
|
||||
}
|
||||
|
|
@ -52,6 +52,93 @@ pub enum Req {
|
|||
/// A Source Text value as JSON `{doc, text, runs: [{len, style}], paras}` (styles as
|
||||
/// `layer.setText` keys), or null for other properties.
|
||||
Doc { comp: u64, layer: u64, path: String, t: Secs, pre: bool },
|
||||
/// `footage(name)`: `[name, width, height, duration, frameDuration, pixelAspect, kind,
|
||||
/// sourceText|null, dataFormat|null]` or null.
|
||||
Footage { name: String },
|
||||
/// A data footage item's parsed data as JSON `{data, rows, header}` (`rows`/`header` for
|
||||
/// CSV/TSV), or null.
|
||||
FootageData { name: String },
|
||||
/// `sampleImage`: the average straight `[r, g, b, a]` of the layer's pixels in the box
|
||||
/// `point ± radius` (layer space) at comp time `t`, after masks and effects when `post`.
|
||||
Sample { comp: u64, layer: u64, x: Secs, y: Secs, rx: Secs, ry: Secs, post: bool, t: Secs },
|
||||
/// `thisProject`: `[bitsPerChannel, linearBlending, workingSpace]`.
|
||||
Project,
|
||||
}
|
||||
|
||||
/// Parse CSV/TSV text (RFC 4180 quoting) into rows of cells.
|
||||
pub fn parse_delimited(text: &str, sep: char) -> Vec<Vec<String>> {
|
||||
let mut rows = vec![];
|
||||
let mut row = vec![];
|
||||
let mut cell = String::new();
|
||||
let mut quoted = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if quoted {
|
||||
match c {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
cell.push('"');
|
||||
}
|
||||
'"' => quoted = false,
|
||||
_ => cell.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if cell.is_empty() => quoted = true,
|
||||
c if c == sep => row.push(std::mem::take(&mut cell)),
|
||||
'\r' => {}
|
||||
'\n' => {
|
||||
row.push(std::mem::take(&mut cell));
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
_ => cell.push(c),
|
||||
}
|
||||
}
|
||||
if !cell.is_empty() || !row.is_empty() {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
}
|
||||
rows.retain(|r| !(r.len() == 1 && r[0].trim().is_empty()));
|
||||
rows
|
||||
}
|
||||
|
||||
/// A CSV/TSV cell as JSON: numbers become numbers.
|
||||
fn cell_json(c: &str) -> serde_json::Value {
|
||||
let t = c.trim();
|
||||
match t.parse::<f64>() {
|
||||
Ok(x) if x.is_finite() && !t.is_empty() => serde_json::json!(x),
|
||||
_ => serde_json::json!(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// The data of a data footage item as `{data, rows, header}` JSON (see [`Req::FootageData`]).
|
||||
pub fn footage_data_json(f: &effectcraft_project::Footage) -> Option<String> {
|
||||
let text = f.data.as_deref()?;
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
Some(match f.data_format()? {
|
||||
"json" => {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
serde_json::json!({"data": v, "rows": null, "header": null}).to_string()
|
||||
}
|
||||
fmt => {
|
||||
let rows = parse_delimited(text, if fmt == "tsv" { '\t' } else { ',' });
|
||||
let (header, body) = match rows.split_first() {
|
||||
Some((h, b)) => (h.clone(), b.to_vec()),
|
||||
None => (vec![], vec![]),
|
||||
};
|
||||
let objects: Vec<serde_json::Value> = body
|
||||
.iter()
|
||||
.map(|r| {
|
||||
serde_json::Value::Object(
|
||||
header.iter().enumerate().map(|(i, k)| (k.clone(), r.get(i).map(|c| cell_json(c)).unwrap_or(serde_json::Value::Null))).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cells: Vec<Vec<serde_json::Value>> = body.iter().map(|r| r.iter().map(|c| cell_json(c)).collect()).collect();
|
||||
serde_json::json!({"data": objects, "rows": cells, "header": header}).to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A text document as the style API's JSON.
|
||||
|
|
@ -192,7 +279,7 @@ impl<'a> Resolver<'a> {
|
|||
}
|
||||
fn ctx_at(&self, c: u64, t: f64) -> Option<EvalCtx<'a>> {
|
||||
let comp = self.comp(c)?;
|
||||
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr })
|
||||
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage })
|
||||
}
|
||||
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
|
||||
let layer = self.layer(c, l)?;
|
||||
|
|
@ -345,6 +432,36 @@ impl<'a> Resolver<'a> {
|
|||
};
|
||||
Resp::nums(r)
|
||||
}
|
||||
Req::Footage { name } => {
|
||||
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
Resp::List(vec![
|
||||
Resp::Str(it.name.clone()),
|
||||
Resp::Num(f.width as f64),
|
||||
Resp::Num(f.height as f64),
|
||||
Resp::Num(f.duration.seconds()),
|
||||
Resp::Num(f.frame_rate.frame_duration().seconds()),
|
||||
Resp::Num(f.pixel_aspect),
|
||||
Resp::Str(it.type_name().into()),
|
||||
f.data.clone().map_or(Resp::Null, Resp::Str),
|
||||
f.data_format().map_or(Resp::Null, |x| Resp::Str(x.into())),
|
||||
])
|
||||
}
|
||||
Req::FootageData { name } => {
|
||||
let it = project.items.values().find(|i| &i.name == name && matches!(i.kind, ItemKind::Footage(_)))?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
Resp::Str(footage_data_json(f)?)
|
||||
}
|
||||
Req::Project => Resp::List(vec![
|
||||
Resp::Num(project.settings.bit_depth.bits() as f64),
|
||||
Resp::Bool(project.settings.blend_linear || project.settings.linearize),
|
||||
Resp::Str(project.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default()),
|
||||
]),
|
||||
Req::Sample { comp, layer, x, y, rx, ry, post, t } => {
|
||||
let l = self.layer(*comp, *layer)?;
|
||||
let f = |b: &Secs| f64::from_bits(*b);
|
||||
Resp::nums(crate::sample::sample_image(self.ctx, ItemId(*comp), l, [f(x), f(y)], [f(rx), f(ry)], *post, f(t))?.map(|v| v as f64))
|
||||
}
|
||||
Req::Markers { comp, layer } => {
|
||||
let marks: Vec<(f64, &effectcraft_project::Marker)> = match layer {
|
||||
Some(l) => {
|
||||
|
|
@ -363,6 +480,17 @@ impl<'a> Resolver<'a> {
|
|||
Resp::Str(m.comment.clone()),
|
||||
Resp::Str(m.chapter.clone()),
|
||||
Resp::Str(m.url.clone()),
|
||||
Resp::Str(m.frame_target.clone()),
|
||||
Resp::Bool(m.cue_point.as_ref().is_some_and(|c| !c.navigation)),
|
||||
Resp::Str(m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default()),
|
||||
Resp::List(
|
||||
m.cue_point
|
||||
.iter()
|
||||
.flat_map(|c| c.params.iter())
|
||||
.map(|(k, v)| Resp::List(vec![Resp::Str(k.clone()), Resp::Str(v.clone())]))
|
||||
.collect(),
|
||||
),
|
||||
Resp::Bool(m.protected),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod host;
|
|||
mod noise;
|
||||
pub mod rewrite;
|
||||
mod runtime;
|
||||
mod sample;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{ItemId, Layer, LayerId, Project, Property};
|
||||
|
|
@ -112,7 +113,7 @@ fn evaluate_out(
|
|||
if depth >= MAX_DEPTH {
|
||||
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
|
||||
}
|
||||
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host) };
|
||||
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
|
||||
let comp = ctx.comp_id.0;
|
||||
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
|
||||
let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } };
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ var selectorValue = [100, 100, 100];
|
|||
|
||||
// Request ops (must match runtime.rs).
|
||||
var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10;
|
||||
var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14;
|
||||
|
||||
function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
||||
time = t;
|
||||
|
|
@ -29,6 +30,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
|||
__rc = 0;
|
||||
__seed = 0;
|
||||
__timeless = false;
|
||||
__projectInfo = null;
|
||||
}
|
||||
|
||||
function __finish(r) {
|
||||
|
|
@ -269,6 +271,18 @@ function timeToTimecode(t, base, isDuration) {
|
|||
return (neg ? '-' : '') + h + ':' + __pad(mm) + ':' + __pad(ss) + ':' + __pad(ff);
|
||||
}
|
||||
|
||||
function timeToFeetAndFrames(t, fps, framesPerFoot, isDuration) {
|
||||
if (t === undefined) t = time + thisComp.displayStartTime;
|
||||
if (fps === undefined) fps = 1 / __S.fd;
|
||||
if (framesPerFoot === undefined) framesPerFoot = 16;
|
||||
var neg = t < 0;
|
||||
var f = Math.floor(Math.abs(t) * fps + 1e-6);
|
||||
var feet = Math.floor(f / framesPerFoot), fr = f % framesPerFoot;
|
||||
var s = String(fr);
|
||||
while (s.length < 2) s = '0' + s;
|
||||
return (neg ? '-' : '') + feet + '+' + s;
|
||||
}
|
||||
|
||||
function timeToCurrentFormat(t, fps) {
|
||||
if (fps === undefined) fps = Math.round(1 / __S.fd);
|
||||
return timeToTimecode(t, fps);
|
||||
|
|
@ -371,6 +385,14 @@ Comp.prototype = {
|
|||
get displayStartTime() { return this.__info[7]; },
|
||||
get bgColor() { return this.__info[8]; },
|
||||
get marker() { return new Markers(this.__c, -1); },
|
||||
// The topmost enabled camera active at the current time.
|
||||
get activeCamera() {
|
||||
for (var i = 1; i <= this.numLayers; i++) {
|
||||
var l = this.layer(i);
|
||||
if (l.__info[10] === 'Camera' && l.active) return l;
|
||||
}
|
||||
__err('Composition "' + this.name + '" has no active camera');
|
||||
},
|
||||
layer: function (a, rel) {
|
||||
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
|
||||
var id = __h(__LAYER, this.__c, a);
|
||||
|
|
@ -386,6 +408,68 @@ function comp(name) {
|
|||
return new Comp(id);
|
||||
}
|
||||
|
||||
// Footage (data-driven animation) and the project ------------------------------------------------
|
||||
|
||||
function Footage(info) { this.__i = info; this.__d = null; }
|
||||
Footage.prototype = {
|
||||
__isFootage: true,
|
||||
get name() { return this.__i[0]; },
|
||||
get width() { return this.__i[1]; },
|
||||
get height() { return this.__i[2]; },
|
||||
get duration() { return this.__i[3]; },
|
||||
get frameDuration() { return this.__i[4]; },
|
||||
get pixelAspect() { return this.__i[5]; },
|
||||
get ntscDropFrame() { return false; },
|
||||
// The data file's text (JSON / CSV / TSV), or "" for media.
|
||||
get sourceText() { return this.__i[7] === null ? '' : this.__i[7]; },
|
||||
get __data() {
|
||||
if (this.__d !== null) return this.__d;
|
||||
var j = __h(__FDATA, this.__i[0]);
|
||||
if (j === null) __err('Footage "' + this.name + '" has no data (or it could not be parsed)');
|
||||
return (this.__d = JSON.parse(j));
|
||||
},
|
||||
// JSON: the parsed document; CSV/TSV: one object per row keyed by the header row.
|
||||
get sourceData() { return this.__data.data; },
|
||||
// CSV/TSV cell by [column, row] (0-based, rows after the header).
|
||||
dataValue: function (idx) {
|
||||
idx = __v(idx);
|
||||
var rows = this.__data.rows;
|
||||
if (rows === null) {
|
||||
// JSON: a path of keys / indexes.
|
||||
var v = this.__data.data;
|
||||
for (var i = 0; i < idx.length; i++) {
|
||||
if (v === null || v === undefined) __err('dataValue: no value at ' + JSON.stringify(idx));
|
||||
v = v[idx[i]];
|
||||
}
|
||||
return v;
|
||||
}
|
||||
var r = rows[idx[1]];
|
||||
if (r === undefined || r[idx[0]] === undefined) __err('dataValue: no cell at column ' + idx[0] + ', row ' + idx[1]);
|
||||
return r[idx[0]];
|
||||
},
|
||||
get dataKeyCount() { var r = this.__data.rows; return r === null ? 0 : r.length; },
|
||||
// The column names (CSV/TSV header row).
|
||||
get dataKeyNames() { return this.__data.header || []; },
|
||||
toString: function () { return '[object Footage]'; },
|
||||
};
|
||||
|
||||
function footage(name) {
|
||||
var info = __h(__FOOT, String(name));
|
||||
if (info === null) __err('Footage named "' + name + '" does not exist');
|
||||
return new Footage(info);
|
||||
}
|
||||
|
||||
var __projectInfo = null;
|
||||
// The project's bits per channel (8, 16 or 32).
|
||||
__global('colorDepth', function () { return thisProject.bitsPerChannel; });
|
||||
var thisProject = {
|
||||
get __info() { return __projectInfo || (__projectInfo = __h(__PROJ)); },
|
||||
get fullPath() { return ''; },
|
||||
get bitsPerChannel() { return this.__info[0]; },
|
||||
get linearBlending() { return this.__info[1]; },
|
||||
toString: function () { return '[object Project]'; },
|
||||
};
|
||||
|
||||
// Layer -----------------------------------------------------------------------------------------
|
||||
|
||||
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
|
||||
|
|
@ -464,7 +548,14 @@ Layer.prototype = {
|
|||
var r = __h(__RECT, this.__c, this.__l, t === undefined ? time : t, !!includeExtents);
|
||||
return { top: r[0], left: r[1], width: r[2], height: r[3] };
|
||||
},
|
||||
sampleImage: function () { return [0, 0, 0, 0]; },
|
||||
// Average straight RGBA of the layer's pixels in `point ± radius` (layer space).
|
||||
sampleImage: function (point, radius, postEffect, t) {
|
||||
point = __v(point);
|
||||
radius = radius === undefined ? [0.5, 0.5] : __v(radius);
|
||||
if (!Array.isArray(radius)) radius = [radius, radius];
|
||||
return __h(__SAMPLE, this.__c, this.__l, point[0], point[1], radius[0], radius.length > 1 ? radius[1] : radius[0],
|
||||
postEffect === undefined ? true : !!__v(postEffect), t === undefined ? time : __v(t));
|
||||
},
|
||||
toString: function () { return '[object Layer]'; },
|
||||
};
|
||||
|
||||
|
|
@ -817,7 +908,12 @@ Markers.prototype = {
|
|||
}
|
||||
if (!(n >= 1 && n <= list.length)) __err('Marker index ' + n + ' out of range');
|
||||
var m = list[n - 1];
|
||||
return { time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n };
|
||||
var params = {};
|
||||
for (var k = 0; k < m[8].length; k++) params[m[8][k][0]] = m[8][k][1];
|
||||
return {
|
||||
time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n,
|
||||
frameTarget: m[5], eventCuePoint: m[6], cuePointName: m[7], parameters: params, protectedRegion: m[9],
|
||||
};
|
||||
},
|
||||
nearestKey: function (t) {
|
||||
var list = this.__list;
|
||||
|
|
|
|||
|
|
@ -145,6 +145,19 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
|||
t: secs(num(args, 4, ctx)?),
|
||||
pre: arg(args, 5).to_boolean(),
|
||||
},
|
||||
11 => Req::Footage { name: string(args, 1, ctx)? },
|
||||
12 => Req::FootageData { name: string(args, 1, ctx)? },
|
||||
13 => Req::Sample {
|
||||
comp: id(args, 1, ctx)?,
|
||||
layer: id(args, 2, ctx)?,
|
||||
x: secs(num(args, 3, ctx)?),
|
||||
y: secs(num(args, 4, ctx)?),
|
||||
rx: secs(num(args, 5, ctx)?),
|
||||
ry: secs(num(args, 6, ctx)?),
|
||||
post: arg(args, 7).to_boolean(),
|
||||
t: secs(num(args, 8, ctx)?),
|
||||
},
|
||||
14 => Req::Project,
|
||||
_ => return Err(JsNativeError::typ().with_message("unknown host request").into()),
|
||||
};
|
||||
let found = FRAMES.with(|f| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
//! `sampleImage`: read a layer's rendered pixels from an expression.
|
||||
//!
|
||||
//! The layer is rendered at full resolution through the renderer that is evaluating the
|
||||
//! expression (its footage source; [`EvalCtx::footage`]): source → masks → effects for
|
||||
//! `postEffect = true`, the bare source otherwise, in layer space (before the transform). Buffers
|
||||
//! are cached per thread for the frame, so many expressions sampling the same layer at the same
|
||||
//! time render it once.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{ItemId, Layer};
|
||||
use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
/// A rendered layer buffer: straight-from-the-renderer premultiplied pixels with the layer
|
||||
/// space → buffer pixel mapping.
|
||||
#[derive(Clone)]
|
||||
struct Sampled {
|
||||
img: Arc<effectcraft_render::Image>,
|
||||
offset: [f64; 2],
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
type Key = (usize, u64, u64, u64, bool, u64);
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<HashMap<Key, Option<Sampled>>> = RefCell::new(HashMap::new());
|
||||
/// Nested sampleImage renders (a sampled layer whose expressions sample again).
|
||||
static DEPTH: RefCell<usize> = const { RefCell::new(0) };
|
||||
}
|
||||
|
||||
const MAX_CACHE: usize = 32;
|
||||
const MAX_DEPTH: usize = 2;
|
||||
|
||||
/// A fingerprint of the layer's definition (so edits never reuse stale pixels).
|
||||
fn fingerprint(layer: &Layer) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
format!("{layer:?}").hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Option<Sampled> {
|
||||
let depth = DEPTH.with(|d| *d.borrow());
|
||||
if depth >= MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let key: Key = (ctx.project as *const _ as usize, comp.0, layer.id.0, t.to_bits(), post, fingerprint(layer));
|
||||
if let Some(hit) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
return hit;
|
||||
}
|
||||
DEPTH.with(|d| *d.borrow_mut() += 1);
|
||||
struct Pop;
|
||||
impl Drop for Pop {
|
||||
fn drop(&mut self) {
|
||||
DEPTH.with(|d| *d.borrow_mut() -= 1);
|
||||
}
|
||||
}
|
||||
let _pop = Pop;
|
||||
let footage: &dyn effectcraft_render::FootageSource = ctx.footage.unwrap_or(&NoFootage);
|
||||
let mut r = Renderer::new(ctx.project, footage, RenderOpts { motion_blur: false, ..Default::default() });
|
||||
r.expr = ctx.expr;
|
||||
let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?;
|
||||
let out = if post {
|
||||
r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
|
||||
} else {
|
||||
r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale })
|
||||
};
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() >= MAX_CACHE {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, out.clone());
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The average colour (straight RGBA, 0..1) of `layer`'s pixels in the box `point ± radius`
|
||||
/// (layer pixels) at comp time `t`. Transparent outside the layer.
|
||||
pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2], radius: [f64; 2], post: bool, t: f64) -> Option<[f32; 4]> {
|
||||
if !layer.source.is_av() {
|
||||
return Some([0.0; 4]);
|
||||
}
|
||||
let Some(b) = render(ctx, comp, layer, post, t) else { return Some([0.0; 4]) };
|
||||
let to_px = |x: f64, y: f64| (x * b.scale + b.offset[0], y * b.scale + b.offset[1]);
|
||||
let (rx, ry) = (radius[0].abs() * b.scale, radius[1].abs() * b.scale);
|
||||
let (cx, cy) = to_px(point[0], point[1]);
|
||||
// Pixels whose centres (at +0.5) fall in the box; at least the one under the point.
|
||||
let (x0, x1, y0, y1) = if rx < 0.5 && ry < 0.5 {
|
||||
let (px, py) = (cx.floor() as i64, cy.floor() as i64);
|
||||
(px, px, py, py)
|
||||
} else {
|
||||
let lo = |c: f64, r: f64| (c - r - 0.5).ceil() as i64;
|
||||
let hi = |c: f64, r: f64| (c + r - 0.5).floor() as i64;
|
||||
(lo(cx, rx), hi(cx, rx).max(lo(cx, rx)), lo(cy, ry), hi(cy, ry).max(lo(cy, ry)))
|
||||
};
|
||||
let mut acc = [0.0f64; 4];
|
||||
let mut n = 0.0;
|
||||
for y in y0..=y1.min(y0 + 4096) {
|
||||
for x in x0..=x1.min(x0 + 4096) {
|
||||
let p = b.img.get(x, y);
|
||||
for k in 0..4 {
|
||||
acc[k] += p[k] as f64;
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
if n == 0.0 {
|
||||
return Some([0.0; 4]);
|
||||
}
|
||||
let a = acc[3] / n;
|
||||
if a <= 1e-9 {
|
||||
return Some([0.0; 4]);
|
||||
}
|
||||
Some([(acc[0] / n / a) as f32, (acc[1] / n / a) as f32, (acc[2] / n / a) as f32, a as f32])
|
||||
}
|
||||
|
|
@ -1019,7 +1019,7 @@ fn text_expression_selector_values() {
|
|||
let sel_of = |p: &Project| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
let l = c.layer(lid).unwrap();
|
||||
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions) };
|
||||
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None };
|
||||
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
|
||||
let lay = effectcraft_text::layout_doc(&doc);
|
||||
let anim = l.props.group("text/animators/#1").unwrap();
|
||||
|
|
@ -1051,3 +1051,79 @@ fn text_expression_selector_values() {
|
|||
let v = eval_property(&p, cid, lid, "text/animators/#1/selectors/#2/amount", 0.0).unwrap();
|
||||
assert_eq!(v, Value::Vec3([100.0; 3]));
|
||||
}
|
||||
|
||||
/// `sampleImage` reads the layer's rendered pixels (layer space, straight colour) after masks
|
||||
/// and effects, or the bare source with `postEffect = false`; the radius averages a box.
|
||||
#[test]
|
||||
fn sample_image_reads_rendered_pixels() {
|
||||
let f = fx();
|
||||
let code = |c: &str| format!("var c = {c}; c[0] * 1000 + c[1] * 100 + c[2] * 10 + c[3]");
|
||||
// B: 40×40 blue solid.
|
||||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([20, 20])"), 0.0), 11.0);
|
||||
// Half the box is outside the layer: alpha 0.5, colour still pure blue (straight).
|
||||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([0, 20], [1, 1])"), 0.0), 10.5);
|
||||
// A: red with a mask covering 5..15: outside the mask it's transparent after masks, red before.
|
||||
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2])"), 0.0), 0.0);
|
||||
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([2, 2], [0.5, 0.5], false)"), 0.0), 1001.0);
|
||||
assert_close!(f.num(&code("thisComp.layer(\"A\").sampleImage([10, 10])"), 0.0), 1001.0);
|
||||
// The result has four values; outside the layer it's transparent.
|
||||
assert_close!(f.num("thisComp.layer(\"B\").sampleImage([20, 20]).length", 0.0), 4.0);
|
||||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
|
||||
}
|
||||
|
||||
fn data_item(p: &mut Project, name: &str, text: &str) {
|
||||
let f = effectcraft_project::Footage {
|
||||
path: format!("/data/{name}"),
|
||||
kind: effectcraft_project::FootageKind::Data,
|
||||
data: Some(text.into()),
|
||||
..Default::default()
|
||||
};
|
||||
p.add_item(name, Label::Sandstone, None, ItemKind::Footage(f));
|
||||
}
|
||||
|
||||
/// Data-driven animation: `footage(name)` with `sourceData` / `sourceText` / `dataValue` for
|
||||
/// JSON, CSV and TSV items.
|
||||
#[test]
|
||||
fn data_footage_json_csv_tsv() {
|
||||
let mut f = fx();
|
||||
data_item(&mut f.p, "stats.json", r#"{"title": "Q3", "items": [{"v": 3}, {"v": 7.5}]}"#);
|
||||
data_item(&mut f.p, "scores.csv", "name,score\n\"Smith, J\",12\nLee,30.5\n");
|
||||
data_item(&mut f.p, "table.tsv", "a\tb\n1\t2\n3\t4\n");
|
||||
assert_close!(f.num("footage(\"stats.json\").sourceData.items[1].v", 0.0), 7.5);
|
||||
assert_eq!(f.text("footage(\"stats.json\").sourceData.title", 0.0), "Q3");
|
||||
assert_close!(f.num("footage(\"stats.json\").dataValue([\"items\", 0, \"v\"])", 0.0), 3.0);
|
||||
assert_close!(f.num("footage(\"scores.csv\").sourceData[1].score", 0.0), 30.5);
|
||||
assert_eq!(f.text("footage(\"scores.csv\").sourceData[0].name", 0.0), "Smith, J");
|
||||
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, 0])", 0.0), 12.0);
|
||||
assert_close!(f.num("footage(\"scores.csv\").dataKeyCount", 0.0), 2.0);
|
||||
assert_eq!(f.text("footage(\"scores.csv\").dataKeyNames.join('|')", 0.0), "name|score");
|
||||
assert_close!(f.num("footage(\"table.tsv\").dataValue([1, 1])", 0.0), 4.0);
|
||||
assert_close!(f.num("footage(\"table.tsv\").sourceText.length", 0.0), 12.0);
|
||||
assert!(f.err("footage(\"missing.json\").sourceData").contains("does not exist"));
|
||||
// Animated from data: the value follows the CSV row picked by time.
|
||||
assert_close!(f.num("footage(\"scores.csv\").dataValue([1, Math.floor(time)])", 1.2), 30.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_time_globals() {
|
||||
let mut f = fx();
|
||||
assert_close!(f.num("thisProject.bitsPerChannel", 0.0), 8.0);
|
||||
assert_close!(f.num("colorDepth", 0.0), 8.0);
|
||||
assert_close!(f.num("thisProject.linearBlending ? 1 : 0", 0.0), 0.0);
|
||||
assert_eq!(f.text("timeToFeetAndFrames(2)", 0.0), "3+12");
|
||||
assert_close!(f.num("posterizeTime(4); time", 0.6), 0.5);
|
||||
// Path API.
|
||||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0);
|
||||
assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0);
|
||||
assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0);
|
||||
assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0);
|
||||
// Marker keys carry protected regions and cue points.
|
||||
{
|
||||
let a = f.a;
|
||||
let l = f.layer_mut(a);
|
||||
l.markers[0].protected = true;
|
||||
l.markers[0].cue_point = Some(effectcraft_project::CuePoint { name: "go".into(), navigation: false, params: vec![("k".into(), "v".into())] });
|
||||
}
|
||||
assert_close!(f.num("thisLayer.marker.key(1).protectedRegion ? 1 : 0", 0.0), 1.0);
|
||||
assert_eq!(f.text("thisComp.layer(\"A\").marker.key(1).cuePointName + thisComp.layer(\"A\").marker.key(1).parameters.k", 0.0), "gov");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -484,6 +484,41 @@ impl<'g> Enc<'g> {
|
|||
Some(out)
|
||||
}
|
||||
|
||||
/// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done`
|
||||
/// gets the bytes once the copy is mapped (from the browser's event loop on the web; from
|
||||
/// a later device poll natively).
|
||||
pub fn read_texture_async(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
|
||||
let row = w * bpp;
|
||||
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback-async"),
|
||||
size: padded as u64 * h as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.encoder().copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h) } },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
self.submit();
|
||||
let b = buf.clone();
|
||||
buf.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let out = r.ok().and_then(|_| {
|
||||
let view = b.get_mapped_range(..).ok()?;
|
||||
let mut out = Vec::with_capacity((row * h) as usize);
|
||||
for y in 0..h as usize {
|
||||
out.extend_from_slice(&view[y * padded as usize..y * padded as usize + row as usize]);
|
||||
}
|
||||
Some(out)
|
||||
});
|
||||
b.unmap();
|
||||
done(out);
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = self.g.device.poll(wgpu::PollType::Poll);
|
||||
}
|
||||
|
||||
/// A data buffer for kernels that read tables (curves).
|
||||
pub fn data(&self, v: &[f32]) -> wgpu::Buffer {
|
||||
let mut bytes = Vec::with_capacity(v.len().max(4) * 4);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ impl Gpu {
|
|||
Enc::new(&self.ctx).read_texture(&f.texture, f.width, f.height, 4)
|
||||
}
|
||||
|
||||
/// [`Gpu::read_display`] without blocking (the browser's main thread): `done` gets the bytes
|
||||
/// once the GPU has them.
|
||||
pub fn read_display_async(&self, f: &DisplayFrame, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
|
||||
Enc::new(&self.ctx).read_texture_async(&f.texture, f.width, f.height, 4, done);
|
||||
}
|
||||
|
||||
/// Render a frame on the GPU and read it back (`None` = not handled).
|
||||
pub fn render(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
if !self.ctx.can_readback() {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ impl Scene {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
};
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ fn scene_project() -> (Project, ItemId) {
|
|||
|
||||
fn scene(p: &Project, cid: ItemId) -> Scene {
|
||||
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
|
||||
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
|
||||
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
|
||||
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -230,7 +230,8 @@ mod tests {
|
|||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap();
|
||||
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref() };
|
||||
let ctx =
|
||||
effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
|
||||
ctx.value(layer, layer.props.prop(path).unwrap())
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +269,134 @@ mod tests {
|
|||
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0);
|
||||
}
|
||||
|
||||
/// The expression error bar's list: evaluation errors and syntax errors, with the layer and
|
||||
/// property they belong to.
|
||||
#[test]
|
||||
fn expression_errors_are_collected() {
|
||||
let mut s = super::session();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 0);
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "thisComp.layer(\"Nope\").rotation"})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap();
|
||||
let r = s.execute("expr.errors", json!({})).unwrap();
|
||||
assert_eq!(r["count"], 2, "{r}");
|
||||
let errs = r["errors"].as_array().unwrap();
|
||||
let rot = errs.iter().find(|e| e["path"] == "transform/rotation").unwrap();
|
||||
assert!(rot["message"].as_str().unwrap().contains("Nope"), "{rot}");
|
||||
assert_eq!(rot["layerIndex"], 1);
|
||||
assert_eq!(rot["layerName"], "Box");
|
||||
assert_eq!(rot["disabled"], false);
|
||||
let op = errs.iter().find(|e| e["path"] == "transform/opacity").unwrap();
|
||||
assert_eq!(op["disabled"], true);
|
||||
assert!(r["text"][0].as_str().unwrap().contains("of layer 1 ('Box') in comp 'C'"), "{r}");
|
||||
// A user-disabled (but valid) expression is not an error.
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "45"})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "enabled": false})).unwrap();
|
||||
assert_eq!(s.execute("expr.errors", json!({})).unwrap()["count"], 1);
|
||||
// The Expression Language menu is categorised.
|
||||
let m = s.execute("expr.languageMenu", json!({})).unwrap();
|
||||
assert!(m.as_array().unwrap().iter().any(|c| c["category"] == "Footage" && c["items"].as_array().unwrap().iter().any(|i| i["text"] == "sourceData")));
|
||||
}
|
||||
|
||||
/// File ▸ Import of JSON/CSV data files as data footage, read by expressions.
|
||||
#[test]
|
||||
fn imported_data_files_drive_expressions() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-data");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("levels.csv"), "label,value\nlow,10\nhigh,90\n").unwrap();
|
||||
std::fs::write(dir.join("cfg.json"), r#"{"angle": 33}"#).unwrap();
|
||||
std::fs::write(dir.join("broken.json"), "{nope").unwrap();
|
||||
let mut s = super::session();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let paths: Vec<String> = ["levels.csv", "cfg.json", "broken.json"].iter().map(|n| dir.join(n).to_string_lossy().to_string()).collect();
|
||||
let r = s.execute("file.import", json!({"paths": paths})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}");
|
||||
assert_eq!(r["errors"].as_array().unwrap().len(), 1, "{r}");
|
||||
let csv = r["items"][0].as_u64().unwrap();
|
||||
assert_eq!(s.project.item(effectcraft_engine::project::ItemId(csv)).unwrap().type_name(), "Data");
|
||||
assert!(s.execute("layer.addItem", json!({"item": csv})).is_err(), "data files are not layers");
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "footage(\"levels.csv\").sourceData[1].value"})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "footage(\"cfg.json\").sourceData.angle"})).unwrap();
|
||||
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 90.0);
|
||||
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 33.0);
|
||||
}
|
||||
|
||||
/// sampleImage through the renderer: a box's Fill colour follows the solid under it.
|
||||
#[test]
|
||||
fn sample_image_drives_a_colour() {
|
||||
let mut s = super::session();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bg", "color": "#00ff00"})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "Fg", "color": "#ffffff", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"layer": a, "effect": "Fill"})).unwrap();
|
||||
s.execute(
|
||||
"prop.setExpression",
|
||||
json!({"layer": a, "path": "effects/#1/color", "expression": "thisComp.layer(\"Bg\").sampleImage(thisComp.layer(\"Bg\").fromComp(toComp(anchorPoint)))"}),
|
||||
)
|
||||
.unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts::default());
|
||||
let px = img.get(50, 50);
|
||||
assert!(px[1] > 0.99 && px[0] < 0.01, "the box is filled with the sampled green: {px:?}");
|
||||
}
|
||||
|
||||
/// Proxies through the real media and export layers: Render Settings ▸ Proxy Use decides
|
||||
/// whether an export uses the proxy, and Create Proxy renders and attaches one.
|
||||
#[test]
|
||||
fn proxies_in_exports_and_create_proxy() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-proxy");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = |n: &str| dir.join(n).to_string_lossy().to_string();
|
||||
let mut s = super::session();
|
||||
// Source and proxy stills, written by the app itself.
|
||||
s.execute("comp.new", json!({"name": "Red", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let red = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.saveFrameAs", json!({"path": p("red.png")})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Blue", "width": 8, "height": 8, "frameRate": 10, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("comp.saveFrameAs", json!({"path": p("blue.png")})).unwrap();
|
||||
let foot = s.execute("file.import", json!({"paths": [p("red.png")]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("file.setProxy", json!({"item": foot, "path": p("blue.png")})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Out", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let out = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": foot})).unwrap();
|
||||
// Export one frame with each Proxy Use.
|
||||
for (name, using) in [("all", "use_all_[#].png"), ("none", "use_none_[#].png")] {
|
||||
s.execute(
|
||||
"renderQueue.add",
|
||||
json!({"comp": out.0, "format": "png", "output": p(using), "timeSpan": "custom", "start": 0.0, "end": 0.1, "proxyUse": name}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
||||
s.poll_render();
|
||||
let centre = |s: &mut effectcraft_engine::Session, path: &str| {
|
||||
let id = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Probe", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": id})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.render(c, Default::default(), Default::default()).get(8, 8)
|
||||
};
|
||||
let all = centre(&mut s, &p("use_all_0.png"));
|
||||
assert!(all[2] > 0.99 && all[0] < 0.01, "Use All Proxies renders the blue proxy: {all:?}");
|
||||
let none = centre(&mut s, &p("use_none_0.png"));
|
||||
assert!(none[0] > 0.99 && none[2] < 0.01, "Use No Proxies renders the red source: {none:?}");
|
||||
// Create Proxy ▸ Still: rendered at half size and attached to the comp.
|
||||
s.execute("file.createProxy", json!({"kind": "still", "comp": red.0, "path": p("red_proxy_[#####].png")})).unwrap();
|
||||
s.execute("renderQueue.render", json!({"wait": true})).unwrap();
|
||||
s.poll_render();
|
||||
let px = s.project.item(red).unwrap().proxy.clone().expect("Create Proxy attached the rendered still");
|
||||
assert!(px.enabled);
|
||||
assert_eq!((px.footage.width, px.footage.height), (8, 8));
|
||||
assert!(std::path::Path::new(&px.footage.path).exists(), "{}", px.footage.path);
|
||||
}
|
||||
|
||||
/// A script builds a comp, queues it and renders it through the Render Queue.
|
||||
#[test]
|
||||
fn script_renders_through_the_render_queue() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
..Default::default()
|
||||
};
|
||||
let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot));
|
||||
cx.items.push(item);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ mod export;
|
|||
mod import;
|
||||
mod shapes;
|
||||
mod text;
|
||||
mod zip;
|
||||
pub mod zip;
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
|
|
|||
|
|
@ -593,6 +593,7 @@ fn images_embed_as_base64_and_come_back() {
|
|||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
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,7 +386,12 @@ 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 }
|
||||
}
|
||||
FootageKind::Still | FootageKind::Sequence | FootageKind::Model => Loc { key: key(&footage.path, 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 | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
|
||||
FootageKind::Video | FootageKind::Audio => {
|
||||
let rate = footage.frame_rate;
|
||||
let n = Self::frame_count(rate, footage.duration);
|
||||
|
|
@ -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,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue