diff --git a/Cargo.lock b/Cargo.lock index c3dcb41c6..073487278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1223,6 +1223,7 @@ dependencies = [ "effectcraft-text", "effectcraft-time", "effectcraft-track", + "exr", "image", "kurbo", "log", @@ -1409,9 +1410,14 @@ dependencies = [ name = "effectcraft-pdf" version = "0.1.0" dependencies = [ + "effectcraft-raster", "effectcraft-svg", + "effectcraft-text", + "image", "kurbo", "miniz_oxide 0.8.9", + "skrifa", + "zune-jpeg", ] [[package]] diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 7b6200eb8..1fcdc40bf 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -333,3 +333,158 @@ export function workerCancel(id) { export function workerStats() { return { running: running.size, idle: idle.length }; } + +// ------------------------------------------------------------------ frame workers + +// Long-lived workers that render viewer frames from a project replica (src/frames.rs). Unlike job +// workers they are never terminated between requests; the page sends one frame at a time each. +const frameWorkers = []; + +/// Start a frame worker; returns its index. `onMessage(index, json, bytes, error)` receives each +/// `frameReply` (JSON, pixels as a Uint8Array) or, if the worker dies, the error. +export function frameWorkerStart(base, onMessage) { + const index = frameWorkers.length; + const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` }); + worker.onmessage = (e) => { + const m = e.data; + if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined); + }; + worker.onerror = (e) => { + onMessage(index, undefined, undefined, String(e.message || "frame worker failed")); + worker.terminate(); + }; + worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base }); + frameWorkers.push(worker); + return index; +} + +/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`. +export function frameWorkerPost(index, msg, transfer) { + const w = frameWorkers[index]; + if (w) w.postMessage(msg, transfer ?? []); +} + +// ------------------------------------------------------------------ media browser + +// Folders opened with the File System Access API (src/browse.rs): name → {handle, files: Map of +// "name/sub/file" → FileSystemFileHandle}. Their handles are remembered in IndexedDB so the +// folders come back on the next visit (once the browser grants access again). +const folders = new Map(); +const HANDLES_DB = "effectcraft-handles"; +const MAX_ENTRIES = 20000; + +async function handlesDb() { + const open = indexedDB.open(HANDLES_DB, 1); + open.onupgradeneeded = () => open.result.createObjectStore("folders", { keyPath: "name" }); + return req(open); +} + +async function enumerate(dir, prefix, out, files, depth) { + for await (const [name, h] of dir.entries()) { + if (name.startsWith(".") || out.length >= MAX_ENTRIES) continue; + const rel = `${prefix}/${name}`; + if (h.kind === "directory") { + out.push({ path: rel, dir: true, size: 0, modified: 0 }); + if (depth < 8) await enumerate(h, rel, out, files, depth + 1); + } else { + const f = await h.getFile(); + out.push({ path: rel, dir: false, size: f.size, modified: Math.floor(f.lastModified / 1000) }); + files.set(rel, h); + } + } +} + +async function openFolder(handle, remember) { + const name = handle.name; + const entries = []; + const files = new Map(); + await enumerate(handle, name, entries, files, 0); + folders.set(name, { handle, files }); + if (remember) { + try { + const db = await handlesDb(); + await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle })); + } catch (e) { console.info("media browser: cannot remember the folder", e); } + } + return { name, entries }; +} + +/// Whether the browser can open folders (File System Access API). +export function browseCanPickFolder() { + return typeof self.showDirectoryPicker === "function"; +} + +/// Ask for a folder; resolves with {name, entries: [{path, dir, size, modified}]}. +export async function browsePickFolder() { + const handle = await self.showDirectoryPicker({ id: "effectcraft-media", mode: "read" }); + return openFolder(handle, true); +} + +/// Folders opened on earlier visits: [{name, granted, entries?}] (entries when access is granted). +export async function browseRestore() { + if (!self.indexedDB || !browseCanPickFolder()) return []; + let all = []; + try { + const db = await handlesDb(); + all = await req(db.transaction("folders", "readonly").objectStore("folders").getAll()); + } catch { return []; } + const out = []; + for (const { name, handle } of all) { + let granted = false; + try { granted = (await handle.queryPermission({ mode: "read" })) === "granted"; } catch {} + if (granted) { + try { out.push({ granted, ...(await openFolder(handle, false)) }); continue; } catch {} + } + out.push({ name, granted: false }); + } + return out; +} + +/// Ask for access to a remembered folder again (needs a user gesture); resolves like +/// browsePickFolder. +export async function browseReconnect(name) { + const db = await handlesDb(); + const rec = await req(db.transaction("folders", "readonly").objectStore("folders").get(name)); + if (!rec) throw new Error(`${name} is not remembered`); + if ((await rec.handle.requestPermission({ mode: "read" })) !== "granted") throw new Error(`access to ${name} was not granted`); + return openFolder(rec.handle, false); +} + +/// Forget a remembered folder. +export async function browseForget(name) { + folders.delete(name); + try { + const db = await handlesDb(); + await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name)); + } catch {} +} + +/// A file of an opened folder ("/Folders/name/sub/file"): its bytes. +export async function browseRead(path) { + const rel = path.replace(/^\/Folders\//, ""); + const folder = folders.get(rel.split("/")[0]); + const h = folder && folder.files.get(rel); + if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`); + return new Uint8Array(await (await h.getFile()).arrayBuffer()); +} + +/// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a +/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). +export function browsePickFiles(directory) { + return new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + if (directory) input.webkitdirectory = true; + input.onchange = async () => { + try { + const out = []; + for (const f of input.files) { + out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) }); + } + resolve(out); + } catch (e) { reject(e); } + }; + input.click(); + }); +} diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 0076cd4f3..e6712e3d7 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; -use crate::{api, audio, files, persist, worker}; +use crate::{api, audio, browse, files, frames, persist, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; @@ -103,6 +103,7 @@ impl eframe::App for WebApp { 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()})); + api::set_info("frameWorkers", frames::stats()); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -137,6 +138,7 @@ pub fn session() -> Session { 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), + browser: Some(Arc::new(browse::WebBrowser)), ..Default::default() } } @@ -183,6 +185,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { }; api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0})); audio::install_unlock(); + browse::restore(); let runner = eframe::WebRunner::new(); runner .start( @@ -206,6 +209,12 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app.offer_recovery(r); } install_hooks(&mut app); + // Viewer frames render in frame workers (`?frameworkers=N`, 0 or `?noworkers`: + // on the page's thread). + let n = if query_flag("noworkers") { 0 } else { query_value("frameworkers").and_then(|v| v.parse().ok()).unwrap_or(2usize).min(8) }; + if n > 0 { + app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n)))); + } let (tx, rx) = std::sync::mpsc::channel(); api::set_sender(tx); app = app.with_control(rx); diff --git a/apps/effectcraft-web/src/browse.rs b/apps/effectcraft-web/src/browse.rs new file mode 100644 index 000000000..aba2eb22d --- /dev/null +++ b/apps/effectcraft-web/src/browse.rs @@ -0,0 +1,277 @@ +//! The Media Browser in the browser ([`effectcraft_engine::media_browser::Browser`]). +//! +//! It browses a virtual tree: +//! +//! - **Browser Storage** (`/Browser Storage/…`): every file in the persistent file table (OPFS / +//! IndexedDB): imported media, uploaded files and folders, saved projects. Files added with +//! **Add Files…** / **Upload Folder…** (plain ``, so every browser) are copied +//! there and stay across visits: the recent list. +//! - **Folders** (`/Folders//…`): folders opened with **Open Folder…** through the File +//! System Access API (`showDirectoryPicker`, Chromium browsers). Only their listing is read; +//! a file's bytes are read when it is imported ([`Browser::fetch`]) and then kept in the file +//! table. The folder handles are remembered (IndexedDB), so after a reload the folders come +//! back once the browser grants access again (**Reconnect **). + +use std::sync::{LazyLock, Mutex}; + +use effectcraft_engine::media_browser::{Browser, Entry, VirtualTree}; +use serde_json::{Value, json}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = browseCanPickFolder)] + fn can_pick_folder() -> bool; + #[wasm_bindgen(js_name = browsePickFolder)] + fn pick_folder() -> js_sys::Promise; + #[wasm_bindgen(js_name = browseRestore)] + fn restore_folders() -> js_sys::Promise; + #[wasm_bindgen(js_name = browseReconnect)] + fn reconnect(name: &str) -> js_sys::Promise; + #[wasm_bindgen(js_name = browseForget)] + fn forget(name: &str) -> js_sys::Promise; + #[wasm_bindgen(js_name = browseRead)] + fn read(path: &str) -> js_sys::Promise; + #[wasm_bindgen(js_name = browsePickFiles)] + fn pick_files(directory: bool) -> js_sys::Promise; +} + +const STORAGE: &str = "/Browser Storage"; +const FOLDERS: &str = "/Folders"; + +#[derive(Default)] +struct State { + /// Opened folders' files (`/Folders/…`). + folders: VirtualTree, + /// Opened folder names, and remembered ones waiting for access. + open: Vec, + disconnected: Vec, +} + +static STATE: LazyLock> = LazyLock::new(Mutex::default); + +fn state() -> std::sync::MutexGuard<'static, State> { + STATE.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The web app's Media Browser. +pub struct WebBrowser; + +/// The stored files as a tree under `/Browser Storage` (rebuilt per listing: storage changes). +fn storage_tree() -> VirtualTree { + let mut t = VirtualTree::default(); + t.add_dir(STORAGE); + for (path, size, modified, persisted) in crate::files::STORE.files() { + if !persisted || path.starts_with(FOLDERS) { + continue; + } + t.add_file(&format!("{STORAGE}{path}"), &path, size as u64, Some((modified / 1000.0) as u64)); + } + t +} + +/// Add an opened folder's listing (`[{path, dir, size, modified}]`, paths `name/…`). +fn add_folder(v: &Value) -> Option { + let name = v["name"].as_str()?.to_string(); + let mut st = state(); + st.folders.remove(&format!("{FOLDERS}/{name}")); + st.folders.add_dir(&format!("{FOLDERS}/{name}")); + for e in v["entries"].as_array().into_iter().flatten() { + let Some(rel) = e["path"].as_str() else { continue }; + let path = format!("{FOLDERS}/{rel}"); + if e["dir"].as_bool() == Some(true) { + st.folders.add_dir(&path); + } else { + st.folders.add_file(&path, &path, e["size"].as_u64().unwrap_or(0), e["modified"].as_u64()); + } + } + st.open.retain(|n| *n != name); + st.open.push(name.clone()); + st.disconnected.retain(|n| *n != name); + Some(name) +} + +fn to_value(v: &JsValue) -> Value { + js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null) +} + +/// Run a promise; on success, `then` gets its value (as JSON) on the UI thread. +fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcraft_ui_egui::EffectcraftApp, Value) + 'static) { + wasm_bindgen_futures::spawn_local(async move { + match wasm_bindgen_futures::JsFuture::from(p).await { + Ok(v) => { + let v = to_value(&v); + crate::post(move |app| then(app, v)); + } + Err(e) => { + let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default(); + // Cancelling a picker is not an error. + if !msg.contains("abort") && !msg.contains("AbortError") { + crate::post(move |app| app.ui.status = format!("{what}: {msg}")); + } + } + } + }); +} + +fn go(app: &mut effectcraft_ui_egui::EffectcraftApp, path: &str) { + if let Err(e) = app.session.execute("mediaBrowser.go", json!({"path": path})) { + app.ui.status = e.to_string(); + } +} + +/// Bring back the folders opened on earlier visits (those the browser still grants access to). +pub fn restore() { + spawn(restore_folders(), "Media Browser", |_, v| { + for f in v.as_array().into_iter().flatten() { + if f["granted"].as_bool() == Some(true) { + add_folder(f); + } else if let Some(n) = f["name"].as_str() { + let mut st = state(); + if !st.disconnected.iter().any(|d| d == n) { + st.disconnected.push(n.to_string()); + } + } + } + }); +} + +impl Browser for WebBrowser { + fn home(&self) -> String { + STORAGE.into() + } + + fn places(&self) -> Vec<(String, String)> { + let mut v = vec![("Browser Storage".to_string(), STORAGE.to_string())]; + v.extend(state().open.iter().map(|n| (n.clone(), format!("{FOLDERS}/{n}")))); + v + } + + fn list(&self, dir: &str, importable_only: bool) -> Result, String> { + let dir = if dir.len() > 1 { dir.trim_end_matches('/') } else { dir }; + if dir == STORAGE || dir.starts_with(&format!("{STORAGE}/")) { + return storage_tree().list(dir, importable_only); + } + if dir == "/" { + let mut t = VirtualTree::default(); + t.add_dir(STORAGE); + t.add_dir(FOLDERS); + return t.list("/", false); + } + let st = state(); + if dir == FOLDERS && st.open.is_empty() { + return Ok(vec![]); + } + st.folders.list(dir, importable_only).map_err(|e| { + let name = dir.trim_start_matches(FOLDERS).trim_start_matches('/').split('/').next().unwrap_or(""); + if st.disconnected.iter().any(|d| d == name) { format!("{name}: click Reconnect {name} to allow access again") } else { e } + }) + } + + fn parent(&self, dir: &str) -> Option { + let d = dir.trim_end_matches('/'); + match d.rfind('/') { + Some(0) if d.len() > 1 => Some("/".into()), + Some(i) if i > 0 => Some(d[..i].to_string()), + _ => None, + } + } + + fn fetch(&self, paths: &[String], params: &Value) -> bool { + let missing: Vec = paths.iter().filter(|p| p.starts_with(&format!("{FOLDERS}/")) && crate::files::get(p).is_none()).cloned().collect(); + if missing.is_empty() { + return true; + } + let params = params.clone(); + wasm_bindgen_futures::spawn_local(async move { + for p in &missing { + match wasm_bindgen_futures::JsFuture::from(read(p)).await { + Ok(b) => crate::files::put(p, js_sys::Uint8Array::new(&b).to_vec().into()), + Err(e) => { + let msg = format!("{p}: {}", e.as_string().unwrap_or_else(|| "cannot read the file (reconnect its folder)".into())); + crate::post(move |app| app.ui.status = msg); + return; + } + } + } + crate::post(move |app| match app.session.execute("mediaBrowser.import", params) { + 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::>().join("; "); + } + } + Err(e) => app.ui.status = e.to_string(), + }); + }); + false + } + + fn actions(&self) -> Vec<(String, String)> { + let mut v = vec![]; + if can_pick_folder() { + v.push(("openFolder".to_string(), "Open Folder…".to_string())); + } + v.push(("addFiles".into(), "Add Files…".into())); + v.push(("uploadFolder".into(), "Upload Folder…".into())); + for n in &state().disconnected { + v.push((format!("reconnect:{n}"), format!("Reconnect {n}"))); + } + v + } + + fn action(&self, id: &str) -> Result { + match id { + "openFolder" => { + if !can_pick_folder() { + return Err("this browser cannot open folders (use Add Files… or Upload Folder…)".into()); + } + spawn(pick_folder(), "Open Folder", |app, v| { + if let Some(n) = add_folder(&v) { + go(app, &format!("{FOLDERS}/{n}")); + } + }); + Ok(json!({"pending": true})) + } + "addFiles" | "uploadFolder" => { + let p = pick_files(id == "uploadFolder"); + wasm_bindgen_futures::spawn_local(async move { + let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return }; + let mut n = 0; + for f in js_sys::Array::from(&list).iter() { + let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string()); + let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::().ok()); + if let (Some(path), Some(bytes)) = (path, bytes) { + crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into()); + n += 1; + } + } + crate::post(move |app| { + app.ui.status = format!("{n} file(s) added to Browser Storage"); + go(app, STORAGE); + }); + }); + Ok(json!({"pending": true})) + } + _ => { + if let Some(name) = id.strip_prefix("reconnect:") { + spawn(reconnect(name), "Reconnect", |app, v| { + if let Some(n) = add_folder(&v) { + go(app, &format!("{FOLDERS}/{n}")); + } + }); + return Ok(json!({"pending": true})); + } + if let Some(name) = id.strip_prefix("forget:") { + let _ = forget(name); + let mut st = state(); + st.open.retain(|n| n != name); + st.disconnected.retain(|n| n != name); + st.folders.remove(&format!("{FOLDERS}/{name}")); + return Ok(json!({"forgotten": name})); + } + Err(format!("unknown Media Browser action `{id}`")) + } + } + } +} diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs new file mode 100644 index 000000000..abad352f9 --- /dev/null +++ b/apps/effectcraft-web/src/frames.rs @@ -0,0 +1,261 @@ +//! Viewer frames rendered in Web Workers ([`effectcraft_engine::remote`]). +//! +//! **Page side** — [`WebFrames`], the frame cache's [`RemoteFrames`]: a few long-lived frame +//! workers (`js/host.js` `frameWorkerStart`), each holding a replica of the project. Before a +//! render, the worker gets the footage files it lacks and a sync (the whole project the first +//! time, then diffs: [`Mirror`]); the frame comes back as premultiplied RGBA8 in a transferred +//! buffer. One frame per worker at a time, so the page always decides what renders next (the +//! viewer's frame before prefetch). A worker that fails is dropped; with none left, frames render +//! on the page's thread again (`Frames::pump`). +//! +//! **Worker side** — [`worker_frame`]: the worker's [`FrameServer`] (footage from the worker's +//! media pool, expressions) handles each message and posts its replies. + +use std::cell::RefCell; +use std::collections::HashSet; + +use effectcraft_engine::offload::footage_files; +use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror}; +use effectcraft_ui_egui::frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob}; +use serde_json::{Value, json}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = frameWorkerStart)] + fn frame_worker_start(base: &str, on_message: &js_sys::Function) -> u32; + #[wasm_bindgen(js_name = frameWorkerPost)] + fn frame_worker_post(index: u32, msg: &JsValue, transfer: &js_sys::Array); +} + +/// One frame worker as the page sees it. +struct Fw { + index: u32, + mirror: Mirror, + files: HashSet, + busy: Option<(u64, RemoteDone)>, + alive: bool, +} + +#[derive(Default)] +struct State { + workers: Vec, + next_id: u64, + rendered: u64, + failed: u64, + last_ms: f64, + /// Bytes of project sync messages sent (diffs keep this small). + sync_bytes: u64, + syncs: u64, +} + +type OnMessage = Closure; + +thread_local! { + static STATE: RefCell = RefCell::new(State::default()); + static HANDLER: RefCell> = const { RefCell::new(None) }; + /// The worker's frame server (worker side). + static SERVER: RefCell> = const { RefCell::new(None) }; +} + +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() +} + +/// The page's frame workers (see the module docs). +pub struct WebFrames; + +impl WebFrames { + /// Start `n` frame workers. + pub fn start(n: usize) -> WebFrames { + let on: OnMessage = Closure::new(|index: u32, json: JsValue, bytes: JsValue, error: JsValue| on_message(index, json, bytes, error)); + STATE.with(|s| { + let mut s = s.borrow_mut(); + for _ in 0..n { + let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref()); + s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true }); + } + }); + HANDLER.with(|h| *h.borrow_mut() = Some(on)); + WebFrames + } +} + +impl RemoteFrames for WebFrames { + fn slots(&self) -> usize { + STATE.with(|s| s.borrow().workers.iter().filter(|w| w.alive).count()) + } + + fn purge(&self) { + purge(); + } + + fn start(&self, job: RemoteJob, done: RemoteDone) { + let r = STATE.with(|s| { + let mut s = s.borrow_mut(); + s.next_id += 1; + let id = s.next_id; + let Some(k) = s.workers.iter().position(|w| w.alive && w.busy.is_none()) else { return Err((done, "no idle frame worker".to_string())) }; + // Footage the worker hasn't got yet. + let w = &mut s.workers[k]; + for p in footage_files(&job.project) { + if w.files.contains(&p) { + continue; + } + if let Some(d) = crate::files::get(&p) { + let bytes = js_sys::Uint8Array::from(&d[..]); + frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new()); + w.files.insert(p); + } + } + let sync = w.mirror.sync(job.revision, &job.project); + let index = w.index; + let mut sent = 0; + if let Some(m) = sync { + let t = serde_json::to_string(&m).unwrap_or_default(); + sent = t.len() as u64; + frame_worker_post(index, &obj(&[("type", "frame".into()), ("json", t.into())]), &js_sys::Array::new()); + } + let req = FrameMsg::Render { id, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts) }; + frame_worker_post( + index, + &obj(&[("type", "frame".into()), ("json", serde_json::to_string(&req).unwrap_or_default().into())]), + &js_sys::Array::new(), + ); + s.workers[k].busy = Some((id, done)); + if sent > 0 { + s.syncs += 1; + s.sync_bytes += sent; + } + Ok(()) + }); + if let Err((done, e)) = r { + done(Err(e)); + } + } +} + +/// A message from frame worker `index`: a JSON [`FrameReply`] (with the pixels in `bytes`), or +/// `error` when the worker died. +fn on_message(index: u32, json: JsValue, bytes: JsValue, error: JsValue) { + let done = STATE.with(|s| { + let mut s = s.borrow_mut(); + let st = &mut *s; + let w = st.workers.iter_mut().find(|w| w.index == index)?; + if let Some(e) = error.as_string() { + log::warn!("frame worker {index}: {e}; frames render on the page's thread if none is left"); + w.alive = false; + return w.busy.take().map(|(_, d)| (d, Err(e))); + } + let reply: FrameReply = serde_json::from_str(&json.as_string()?).ok()?; + match reply { + FrameReply::Frame { id, width, height, ms } => { + let (_, d) = w.busy.take_if(|b| b.0 == id)?; + let rgba = bytes.dyn_into::().map(|b| b.to_vec()).unwrap_or_default(); + st.rendered += 1; + st.last_ms = ms; + Some((d, Ok(RemoteFrame { width, height, rgba, ms }))) + } + FrameReply::Failed { id, error, .. } => { + st.failed += 1; + let (_, d) = w.busy.take_if(|b| b.0 == id)?; + Some((d, Err(error))) + } + FrameReply::Resync { error } => { + log::info!("frame worker {index}: {error}: sending the whole project again"); + w.mirror.reset(); + None + } + } + }); + if let Some((d, r)) = done { + d(r); + } + crate::repaint(); +} + +/// Relay Roto Brush segmentations (from a propagation worker) to the frame workers, so their +/// renders use them instead of computing them again. +pub fn relay_segs(segs: &[effectcraft_engine::offload::SegData]) { + if segs.is_empty() { + return; + } + let t = serde_json::to_string(&FrameMsg::Segs { segs: segs.to_vec() }).unwrap_or_default(); + STATE.with(|s| { + for w in s.borrow().workers.iter().filter(|w| w.alive) { + frame_worker_post(w.index, &obj(&[("type", "frame".into()), ("json", t.as_str().into())]), &js_sys::Array::new()); + } + }); +} + +/// Ask every frame worker to drop its cached layer buffers (Edit ▸ Purge). +pub fn purge() { + let t = serde_json::to_string(&FrameMsg::Purge).unwrap_or_default(); + STATE.with(|s| { + for w in s.borrow().workers.iter().filter(|w| w.alive) { + frame_worker_post(w.index, &obj(&[("type", "frame".into()), ("json", t.as_str().into())]), &js_sys::Array::new()); + } + }); +} + +/// `effectcraft.info().frameWorkers`. +pub fn stats() -> Value { + STATE.with(|s| { + let s = s.borrow(); + json!({ + "workers": s.workers.len(), + "alive": s.workers.iter().filter(|w| w.alive).count(), + "busy": s.workers.iter().filter(|w| w.busy.is_some()).count(), + "rendered": s.rendered, + "failed": s.failed, + "lastMs": s.last_ms, + "syncs": s.syncs, + "syncBytes": s.sync_bytes, + }) + }) +} + +// ---------------------------------------------------------------- worker side + +/// Handle one frame message (a JSON [`FrameMsg`]) in a frame worker; replies are posted with +/// their pixels transferred. +#[wasm_bindgen(js_name = workerFrame)] +pub fn worker_frame(json: String) { + let msg: FrameMsg = match serde_json::from_str(&json) { + Ok(m) => m, + Err(e) => { + log::warn!("frame worker: bad message: {e}"); + return; + } + }; + let replies = SERVER.with(|c| { + let mut c = c.borrow_mut(); + let server = c.get_or_insert_with(|| { + let pool = crate::worker::pool(); + FrameServer::new(pool, Some(std::sync::Arc::new(effectcraft_expr::Expressions))) + }); + server.handle(msg) + }); + let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into(); + for (r, px) in replies { + let t = serde_json::to_string(&r).unwrap_or_default(); + let res = match px { + Some(px) => { + let buf = js_sys::Uint8Array::from(&px[..]).buffer(); + scope.post_message_with_transfer( + &obj(&[("type", "frameReply".into()), ("json", t.into()), ("bytes", buf.clone().into())]), + &js_sys::Array::of1(&buf), + ) + } + None => scope.post_message(&obj(&[("type", "frameReply".into()), ("json", t.into())])), + }; + if let Err(e) = res { + log::warn!("frame worker: postMessage: {e:?}"); + } + } +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 59de4433f..96b17db24 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -9,8 +9,9 @@ //! | 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 | `` pickers; drop files on the page | +//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) | //! | 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 | +//! | frame render threads | frame workers, each with a project replica fed by diffs ([`frames`]); `Frames::pump` on the UI thread without workers | //! | 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`]) | @@ -26,8 +27,12 @@ mod app; #[cfg(target_arch = "wasm32")] pub mod audio; #[cfg(target_arch = "wasm32")] +pub mod browse; +#[cfg(target_arch = "wasm32")] pub mod files; #[cfg(target_arch = "wasm32")] +pub mod frames; +#[cfg(target_arch = "wasm32")] pub mod persist; #[cfg(target_arch = "wasm32")] pub mod worker; diff --git a/apps/effectcraft-web/src/worker.rs b/apps/effectcraft-web/src/worker.rs index 2d96f641c..07c9aadc7 100644 --- a/apps/effectcraft-web/src/worker.rs +++ b/apps/effectcraft-web/src/worker.rs @@ -5,6 +5,9 @@ //! `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. //! +//! Viewer frames use separate, long-lived workers ([`crate::frames`]); Roto Brush +//! segmentations a propagation job sends back are relayed to them. +//! //! **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 @@ -60,6 +63,9 @@ impl Offload for WorkerOffload { let Some(t) = json.as_string() else { return }; match serde_json::from_str::(&t) { Ok(r) => { + if let WorkerReply::Segs { segs } = &r { + crate::frames::relay_segs(segs); + } let done = matches!(r, WorkerReply::Done); inbox.push(r); if done { @@ -146,6 +152,11 @@ pub fn worker_init() { SESSION.with(|c| *c.borrow_mut() = Some(s)); } +/// The worker's media pool (footage files sent by the page). +pub fn pool() -> Arc { + POOL.with(|p| p.borrow().clone()).unwrap_or_else(|| Arc::new(effectcraft_media::MediaPool::new())) +} + /// A footage file for the jobs that follow. #[wasm_bindgen(js_name = workerFile)] pub fn worker_file(path: String, bytes: js_sys::Uint8Array) { diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs index 3ba193433..81bc81ccb 100644 --- a/apps/effectcraft-web/tests/smoke.mjs +++ b/apps/effectcraft-web/tests/smoke.mjs @@ -167,14 +167,48 @@ try { 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; - // Render Queue: a short, small GIF → download. + // Viewer frames render in frame workers (project replicas fed by diffs): with the CPU + // renderer, scrubbing (and an edit, which travels as a diff) keeps the page's event loop free. + await js(`effectcraft.execute("render.backend", {backend: "cpu"})`); + report.steps.frameWorkers = await js(`(async () => { + const before = effectcraft.info().frameWorkers; + let maxGapMs = 0, last = performance.now(); + const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10); + await effectcraft.execute("layer.newSolid", {name: "Frame Worker Solid", color: "#3366cc", width: 120, height: 80}); + for (let k = 0; k < 12; k++) { + await effectcraft.execute("time.set", {time: k / 4}); + await new Promise((r) => setTimeout(r, 150)); + } + const t0 = performance.now(); + while (effectcraft.info().frameWorkers.rendered < before.rendered + 3 && performance.now() - t0 < 120000) await new Promise((r) => setTimeout(r, 100)); + clearInterval(timer); + await effectcraft.execute("edit.undo", {}); + return {before, after: effectcraft.info().frameWorkers, maxGapMs}; + })()`); + const fwk = report.steps.frameWorkers; + await js(`effectcraft.execute("render.backend", {backend: "gpu"})`); + check(fwk.after.alive > 0, `frame workers alive: ${JSON.stringify(fwk.after)}`); + check(fwk.after.rendered >= fwk.before.rendered + 3, `frames rendered in workers: ${JSON.stringify(fwk)}`); + check(fwk.after.syncs >= 2, `project synced as diffs: ${JSON.stringify(fwk.after)}`); + check(fwk.maxGapMs < 400, `event loop blocked ${fwk.maxGapMs} ms while scrubbing`); + + // Render Queue: a short, small GIF → download. `renderQueue.render` waits by default; the + // render still runs in a worker and the reply comes when it ends (the page keeps running). const rqT = Date.now(); // (`ui.menu.invoke`: `engine.execute`'s strict parameter check does not know the spread // Render Settings / Output Module keys) await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "smoke_[compName].gif", resolution: 0.25, timeSpan: "custom", start: 0, end: 1}})`); - await js(`effectcraft.execute("renderQueue.render", {})`); + const waited = await js(`(async () => { + let maxGapMs = 0, last = performance.now(); + const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10); + const r = await effectcraft.execute("renderQueue.render", {}); + clearInterval(timer); + return {rendering: r.rendering, status: r.items.map((i) => i.status), maxGapMs}; + })()`); const gif = await waitDownload((n) => n.endsWith(".gif")); - report.steps.gifExport = { ...gif, ms: Date.now() - rqT }; + report.steps.gifExport = { ...gif, ms: Date.now() - rqT, waited }; + check(!waited.rendering && waited.status.every((s) => s === "Done"), `wait:true replies when done: ${JSON.stringify(waited)}`); + check(waited.maxGapMs < 400, `event loop blocked ${waited.maxGapMs} ms during a waiting render`); await idle(); // An image sequence arrives as one .zip. @@ -231,6 +265,13 @@ try { return effectcraft.addFile(new File([b], "tone.wav")); })()`); await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300); + // Media Browser: browser storage (where tone.wav now is) and its actions. + const mb = await js(`effectcraft.execute("mediaBrowser.go", {})`); + report.steps.mediaBrowser = { path: mb.path, entries: mb.entries.map((e) => e.name), places: mb.places, actions: mb.actions.map((a) => a.id) }; + check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`); + check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…"); + const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`); + check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`); await js(`effectcraft.request("ui.playback", {action: "stop"})`); await js(`effectcraft.execute("time.set", {time: 0})`); await js(`effectcraft.request("ui.playback", {action: "play"})`); diff --git a/apps/effectcraft-web/web/worker.js b/apps/effectcraft-web/web/worker.js index c29663d79..6c6853c5c 100644 --- a/apps/effectcraft-web/web/worker.js +++ b/apps/effectcraft-web/web/worker.js @@ -1,7 +1,9 @@ -// 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. +// An EffectCraft engine instance in a Web Worker (src/worker.rs, src/frames.rs, +// crates/engine/src/offload.rs and remote.rs). The page sends the compiled module, then: +// - job workers: footage files and jobs (Render Queue, analyses, Roto Brush); the worker replies +// with JSON messages and rendered files; +// - frame workers: footage files and frame messages (project syncs as diffs, render requests, +// Roto Brush segmentations); the worker replies with frames (pixels transferred). import init, * as ec from "./effectcraft_web.js"; let ready = null; @@ -16,4 +18,5 @@ self.onmessage = async (e) => { await ready; if (m.type === "file") ec.workerFile(m.path, m.bytes); else if (m.type === "job") ec.workerJob(m.json); + else if (m.type === "frame") ec.workerFrame(m.json); }; diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index f41b4b2e4..a711ce43c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -87,6 +87,10 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ), ("ec.noise.addgrain", &["viewingMode", "preset", "color/redBalance", "color/greenBalance", "color/blueBalance", "previewRegion/"]), ("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]), + ("ec.noise.removegrain", &["temporalFiltering/enabled"]), + ("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]), + ("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]), + ("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]), ]; /// Whether the GPU kernel of effect `id` covers the instance's current settings (see diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 67063d319..8f929874a 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -330,10 +330,21 @@ fn stretch(v: f32, lo: f32, hi: f32) -> f32 { /// 0 = Auto Levels (per channel), 1 = Auto Contrast (shared), 2 = Auto Color (per channel + neutral mids). fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { + let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; + let (ranges, gam) = auto_correct_settings(ctx, &b.img, kind); + map_ca(&mut b.img, |c, a| { + let o = [0, 1, 2].map(|k| stretch(c[k], ranges[k].0, ranges[k].1).powf(gam[k])); + (lerp3(o, c, blend), a) + }); + b +} + +/// What Auto Levels (`kind` 0), Auto Contrast (1) and Auto Color (2) measure on `img`: each +/// channel's input range (low, high) and gamma. (The GPU effects apply them.) +pub fn auto_correct_settings(ctx: &EffectCtx, img: &Image, kind: u32) -> ([(f32, f32); 3], [f32; 3]) { let black = ctx.params.f("blackClip").clamp(0.0, 49.0) / 100.0; let white = ctx.params.f("whiteClip").clamp(0.0, 49.0) / 100.0; - let blend = (ctx.params.f("blend") / 100.0).clamp(0.0, 1.0) as f32; - let h = smoothed_histograms(ctx, &b.img); + let h = smoothed_histograms(ctx, img); let ranges: [(f32, f32); 3] = if kind == 1 { let mut combined = vec![0.0; BINS]; for k in 0..3 { @@ -350,7 +361,7 @@ fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { let mut gam = [1.0f32; 3]; if kind == 2 && ctx.params.b("snapNeutralMidtones") { let mut sums = [0.0f64; 4]; - for px in &b.img.data { + for px in &img.data { let (c, a) = unpremul(*px); if a > 1e-4 { for k in 0..3 { @@ -369,11 +380,7 @@ fn auto_correct(ctx: &EffectCtx, mut b: Buf, kind: u32) -> Buf { } } } - map_ca(&mut b.img, |c, a| { - let o = [0, 1, 2].map(|k| stretch(c[k], ranges[k].0, ranges[k].1).powf(gam[k])); - (lerp3(o, c, blend), a) - }); - b + (ranges, gam) } fn auto_levels(ctx: &EffectCtx, b: Buf) -> Buf { @@ -397,24 +404,12 @@ fn cdf(h: &[f64]) -> Vec { .collect() } -fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { - let amt = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; - if amt <= 0.0 { - return b; - } - let style = ctx.params.e("style"); - let h = histograms(&b.img); - let look = |c: &Vec, v: f32| c[((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1)]; +/// Equalize's lookup tables (`BINS` entries each) for `img`: the R, G and B cumulative +/// histograms (Style RGB), the luminance one (Brightness) or the combined one (Photoshop Style). +pub fn equalize_tables(img: &Image, style: u32) -> Vec> { + let h = histograms(img); match style { - 1 => { - let c = cdf(&h[3]); - map_ca(&mut b.img, |col, a| { - let l = luminance(col[0], col[1], col[2]); - let nl = look(&c, l); - let o = if l > 1e-5 { col.map(|v| v * nl / l) } else { [nl; 3] }; - (lerp3(col, o, amt), a) - }); - } + 1 => vec![cdf(&h[3])], 2 => { let mut combined = vec![0.0; BINS]; for k in 0..3 { @@ -422,12 +417,34 @@ fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { combined[i] += h[k][i]; } } - let c = cdf(&combined); - map_ca(&mut b.img, |col, a| (lerp3(col, col.map(|v| look(&c, v)), amt), a)); + vec![cdf(&combined)] + } + _ => vec![cdf(&h[0]), cdf(&h[1]), cdf(&h[2])], + } +} + +fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amt = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if amt <= 0.0 { + return b; + } + let style = ctx.params.e("style"); + let t = equalize_tables(&b.img, style); + let look = |c: &Vec, v: f32| c[((v.clamp(0.0, 1.0) * (BINS - 1) as f32).round() as usize).min(BINS - 1)]; + match style { + 1 => { + map_ca(&mut b.img, |col, a| { + let l = luminance(col[0], col[1], col[2]); + let nl = look(&t[0], l); + let o = if l > 1e-5 { col.map(|v| v * nl / l) } else { [nl; 3] }; + (lerp3(col, o, amt), a) + }); + } + 2 => { + map_ca(&mut b.img, |col, a| (lerp3(col, col.map(|v| look(&t[0], v)), amt), a)); } _ => { - let cs = [cdf(&h[0]), cdf(&h[1]), cdf(&h[2])]; - map_ca(&mut b.img, |col, a| (lerp3(col, [0, 1, 2].map(|k| look(&cs[k], col[k])), amt), a)); + map_ca(&mut b.img, |col, a| (lerp3(col, [0, 1, 2].map(|k| look(&t[k], col[k])), amt), a)); } } b @@ -436,17 +453,29 @@ fn equalize(ctx: &EffectCtx, mut b: Buf) -> Buf { // --------------------------------------------------------------------------------------------- // Shadow/Highlight -fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Shadow/Highlight's shadow and highlight amounts for `img` (Auto Amounts measures them). +pub fn shadow_highlight_amounts(ctx: &EffectCtx, img: &Image) -> (f32, f32) { let (mut s_amt, mut h_amt) = (ctx.params.f("shadowAmount") as f32 / 100.0, ctx.params.f("highlightAmount") as f32 / 100.0); - let luma = Plane::luma(&b.img); - let src_hist = histograms(&b.img); if ctx.params.b("autoAmounts") { // Temporal Smoothing / Scene Detect average the analysis over neighbouring frames. - let mean = - if ctx.params.f("temporalSmoothing") > 0.0 { hist_mean(&smoothed_histograms(ctx, &b.img)[3]) as f32 } else { hist_mean(&src_hist[3]) as f32 }; + let mean = if ctx.params.f("temporalSmoothing") > 0.0 { hist_mean(&smoothed_histograms(ctx, img)[3]) } else { hist_mean(&histograms(img)[3]) } as f32; s_amt = ((0.6 - mean) * 1.5).clamp(0.0, 1.0) * 0.8 + 0.1; h_amt = ((mean - 0.6) * 1.5).clamp(0.0, 1.0) * 0.8; } + (s_amt, h_amt) +} + +/// The luminance values clipping `black` / `white` fractions of `img`'s alpha-weighted +/// luminance histogram (Shadow/Highlight's Black / White Clip). +pub fn luma_clip_points(img: &Image, black: f64, white: f64) -> (f32, f32) { + clip_points(&histograms(img)[3], black, white) +} + +fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (s_amt, h_amt) = shadow_highlight_amounts(ctx, &b.img); + let luma = Plane::luma(&b.img); + let (bc, wc) = (ctx.params.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, ctx.params.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); + let clip0 = (bc > 0.0 || wc > 0.0).then(|| luma_clip_points(&b.img, bc, wc)); let s_tw = (ctx.params.f("moreOptions/shadowTonalWidth") as f32 / 100.0).max(0.01); let h_tw = (ctx.params.f("moreOptions/highlightTonalWidth") as f32 / 100.0).max(0.01); let s_r = ctx.params.f("moreOptions/shadowRadius").max(0.0) * b.scale / 2.0; @@ -478,10 +507,8 @@ fn shadow_highlight(ctx: &EffectCtx, mut b: Buf) -> Buf { // Black / White Clip: the adjusted image's extremes (ignoring those fractions of pixels) // are mapped back onto the original's, so lifting shadows or recovering highlights doesn't // flatten the ends of the tonal range. - let (bc, wc) = (ctx.params.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, ctx.params.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); - if bc > 0.0 || wc > 0.0 { - let (lo0, hi0) = clip_points(&src_hist[3], bc, wc); - let (lo1, hi1) = clip_points(&histograms(&b.img)[3], bc, wc); + if let Some((lo0, hi0)) = clip0 { + let (lo1, hi1) = luma_clip_points(&b.img, bc, wc); if (lo0, hi0) != (lo1, hi1) && hi1 - lo1 > 1e-3 { let k = (hi0 - lo0) / (hi1 - lo1); map_ca(&mut b.img, |c, a| (c.map(|v| (lo0 + (v - lo1) * k).max(0.0)), a)); diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index 7d626d190..c85ab8d09 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -269,7 +269,7 @@ fn parse_points(s: &str) -> Vec<(f32, f32)> { /// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points. /// Tabulated as offsets `y − 0.5`. #[derive(Clone, Debug)] -pub(crate) struct OffsetCurve { +pub struct OffsetCurve { lut: Vec, } @@ -277,7 +277,7 @@ impl OffsetCurve { const N: usize = 512; /// `None` for an empty or flat-neutral curve. - pub(crate) fn parse(s: &str, periodic: bool) -> Option { + pub fn parse(s: &str, periodic: bool) -> Option { let pts = parse_points(s); if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) { return None; @@ -327,7 +327,7 @@ impl OffsetCurve { } /// Offset from neutral at `x` (clamped to 0..1). - pub(crate) fn at(&self, x: f32) -> f32 { + pub fn at(&self, x: f32) -> f32 { let f = x.clamp(0.0, 1.0) * Self::N as f32; let i = (f as usize).min(Self::N - 1); let t = f - i as f32; diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 43378528c..8f430a969 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -354,7 +354,7 @@ fn magnify(ctx: &EffectCtx, mut b: Buf) -> Buf { } /// Magnify's Blending Mode choices after "None". -const MAGNIFY_MODES: [BlendMode; 17] = [ +pub const MAGNIFY_MODES: [BlendMode; 17] = [ BlendMode::Normal, BlendMode::Add, BlendMode::Multiply, diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs index c576feadb..d1d1b0c2c 100644 --- a/crates/effects/src/distort4.rs +++ b/crates/effects/src/distort4.rs @@ -325,7 +325,9 @@ pub fn build_mesh(strokes: &[LiquifyStroke], w: f64, h: f64, frozen: &(dyn Fn(f6 type MeshCache = Mutex>>; -fn cached_mesh(ctx: &EffectCtx) -> Arc { +/// Liquify's displacement mesh for the instance (replayed strokes, cached; also used by the +/// GPU kernels). +pub fn liquify_mesh(ctx: &EffectCtx) -> Arc { use std::hash::{Hash, Hasher}; let text = ctx.params.s("distortionMesh"); let mask = ctx.params.f("freezeAreaMask").round() as usize; @@ -369,7 +371,7 @@ fn liquify(ctx: &EffectCtx, mut b: Buf) -> Buf { if ctx.params.s("distortionMesh").trim().is_empty() && !show_freeze && !show_mesh { return b; } - let mesh = cached_mesh(ctx); + let mesh = liquify_mesh(ctx); let off = ctx.params.v2("distortionMeshOffset"); let mesh_color = ctx.params.color("meshColor"); let src = b.img.clone(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 6f6716306..8432a88d4 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -66,25 +66,36 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; +pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color_fx::{ - HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip, + HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, + levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; -pub use color2::Curve; +pub use color2::{Curve, auto_correct_settings, equalize_tables, luma_clip_points, shadow_highlight_amounts}; +pub use color3::OffsetCurve; pub use distort::transform_shutter; -pub use distort2::parse_mesh; +pub use distort2::{MAGNIFY_MODES, parse_mesh}; +pub use distort4::liquify_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation}; pub use sim::particle_state; pub use sim3::playground_state; +pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). pub use blur2::{camera_lens_plain, camera_lens_spans}; pub use generate::gen_mode; pub use generate2::pattern_kind as cell_pattern_kind; pub use noise::GrainLook; pub use transition::place_layer; +// effectcraft-gpu fx_noise. +pub use noise::remove_grain_level; +pub use noise3::{FractalGpu, fractal_gpu}; +pub use time_fx::{posterized_time, time_frames}; +// effectcraft-gpu fx_tone. +pub use ocio::color_stabilizer_maps; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -806,6 +817,103 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.selectivecolor", "ec.key.linearcolor", "ec.keying.keylight", + // effectcraft-gpu fx_key (keying, matte, channel) + "ec.key.colorkey", + "ec.key.luma", + "ec.key.colorrange", + "ec.key.extract", + "ec.key.differencematte", + "ec.key.colordifference", + "ec.key.screen", + "ec.key.spill", + "ec.key.advancedspill", + "ec.key.keycleaner", + "ec.key.unmult", + "ec.matte.simplechoker", + "ec.matte.mattechoker", + "ec.matte.refinesoft", + "ec.matte.refinehard", + "ec.channel.setmatte", + "ec.channel.setchannels", + "ec.channel.shiftchannels", + "ec.channel.removecolormatting", + "ec.channel.arithmetic", + "ec.channel.solidcomposite", + "ec.channel.combiner", + "ec.channel.blend", + "ec.channel.calculations", + "ec.channel.compoundarithmetic", + // effectcraft-gpu fx_tone (colour correction) + "ec.color.levelsic", + "ec.color.gammapedestalgain", + "ec.color.photofilter", + "ec.color.changecolor", + "ec.color.changetocolor", + "ec.color.leavecolor", + "ec.color.broadcast", + "ec.color.colorbalancehls", + "ec.color.videolimiter", + "ec.color.psarbitrarymap", + "ec.color.cctoner", + "ec.color.cccoloroffset", + "ec.color.cckernel", + "ec.color.autolevels", + "ec.color.autocontrast", + "ec.color.autocolor", + "ec.color.equalize", + "ec.color.shadowhighlight", + "ec.color.cccolorneutralizer", + "ec.color.colorstabilizer", + // effectcraft-gpu fx_stylize (stylize, distort) + "ec.stylize.posterize", + "ec.stylize.threshold", + "ec.stylize.ccthreshold", + "ec.stylize.ccthresholdrgb", + "ec.stylize.strobe", + "ec.stylize.ccvignette", + "ec.stylize.scatter", + "ec.stylize.brushstrokes", + "ec.stylize.roughenedges", + "ec.stylize.texturize", + "ec.stylize.motiontile", + "ec.stylize.cckaleida", + "ec.stylize.ccrepetile", + "ec.distort.mirror", + "ec.distort.offset", + "ec.distort.polar", + "ec.distort.spherize", + "ec.distort.cornerpin", + "ec.distort.opticscompensation", + "ec.distort.magnify", + "ec.distort.ccslant", + "ec.distort.ccsmear", + "ec.distort.ccsplit", + "ec.distort.ccsplit2", + "ec.distort.cctiler", + "ec.distort.ccgriddler", + "ec.distort.liquify", + "ec.distort.twirllegacy", + "ec.distort.ccripplepulse", + "ec.distort.ccpowerpin", + "ec.distort.ccflomotion", + // effectcraft-gpu fx_noise (noise, blur, time) + "ec.noise.turbulent", + "ec.noise.median", + "ec.noise.medianlegacy", + "ec.noise.dustscratches", + "ec.noise.removegrain", + "ec.noise.noisealpha", + "ec.noise.noisehls", + "ec.noise.noisehlsauto", + "ec.blur.smart", + "ec.blur.bilateral", + "ec.blur.sharpen", + "ec.blur.unsharp", + "ec.blur.compound", + "ec.blur.channel", + "ec.channel.minimax", + "ec.time.echo", + "ec.time.posterizetime", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 89b496bb5..c119118db 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -103,7 +103,7 @@ fn unsharp(ctx: &EffectCtx, mut b: Buf) -> Buf { /// Position (0 = Color A, 1 = Color B) in Glow's A & B gradient for brightness `l`: `loops` /// cycles starting at `phase` (revolutions), shaped by Color Looping (0 Sawtooth A>B, /// 1 Sawtooth B>A, 2 Triangle A>B>A, 3 Triangle B>A>B) and biased so `mid` maps to the middle. -pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 { +pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 { let u = l.clamp(0.0, 1.0) * loops.max(0.0) + phase; let mut t = u - u.floor(); if u > 0.0 && t <= 0.0 { diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4681fd991..d39fd6528 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -477,6 +477,28 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u sample_boxes(img, n, s) } +/// The grain level (0.5..2) measured in the sample boxes of `img`. +fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 { + let mut m = vec![false; img.data.len()]; + let w = img.width as usize; + for &(x, y, s) in boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + let st = crate::noise2::grain_stats_masked(img, Some(&m)); + let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; + if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } +} + +/// Remove Grain's measured grain level for buffer `b` without Temporal Filtering (the GPU +/// kernels' spatial pass, effectcraft-gpu `fx_noise`). +pub fn remove_grain_level(ctx: &EffectCtx, b: &Buf) -> f32 { + grain_level(&b.img, &sampling_boxes(ctx, &b.img, b.scale)) +} + fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let amt = pr.f("noiseReductionSettings/noiseReduction") as f32; @@ -535,20 +557,7 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } // The grain level measured in the sample boxes scales the smoothing. - let level = { - let mut m = vec![false; b.img.data.len()]; - let w = b.img.width as usize; - for &(x, y, s) in &boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - let st = crate::noise2::grain_stats_masked(&b.img, Some(&m)); - let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; - if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } - }; + let level = grain_level(&b.img, &boxes); let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize; let multichannel = pr.e("noiseReductionSettings/mode") == 0; let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0); diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 6a952be1a..37bf853aa 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -326,6 +326,89 @@ impl Fractal { } } +/// Fractal / Turbulent Noise resolved for the GPU kernel (effectcraft-gpu `fx_noise`): the +/// per-pixel constants and, per octave, the terms [`Fractal::raw`] computes before touching the +/// pixel, in the same f32 order. +pub struct FractalGpu { + pub kind: u32, + pub noise_type: u32, + pub seed: u32, + pub invert: bool, + pub overflow: u32, + pub contrast: f32, + pub brightness: f32, + /// Rotation (sin, cos). + pub rot: (f32, f32), + pub size: [f32; 2], + /// Cycle Evolution: (t, cycle, t / cycle). + pub cycle: Option<[f32; 3]>, + pub sub_rotation: bool, + /// Σ amplitude × weight (the normaliser). + pub norm: f32, + /// Per octave: origin x, y, sub-rotation sin, cos, frequency, amplitude, weight, evolution + /// (turbulence applied), z offset, Dynamic Progressive warp factor. + pub octaves: Vec<[f32; 10]>, + /// Blending Mode (`None` = the noise alone), Opacity and Blend With Original. + pub mode: Option, + pub opacity: f32, + pub blend: f32, +} + +/// [`FractalGpu`] for a buffer with `offset` / `scale`. +pub fn fractal_gpu(ctx: &EffectCtx, offset: [f64; 2], scale: f64, turbulent: bool) -> FractalGpu { + let b = Buf { img: effectcraft_raster::Image::new(0, 0), offset, scale }; + let fr = Fractal::from_params(ctx, &b, turbulent); + let n_oct = fr.octaves.ceil() as usize; + let frac = fr.octaves - fr.octaves.floor(); + let (mut norm, mut amp, mut f, mut depth) = (0.0f32, 1.0f32, 1.0f32, 1.0f32); + let mut octaves = Vec::with_capacity(n_oct); + for o in 0..n_oct { + let w = if o + 1 == n_oct && frac > 0.0 { frac } else { 1.0 }; + let of = o as f32; + let k = if fr.perspective { depth } else { 1.0 } * (1.0 + fr.turbulence * of); + let mut org = fr.origin; + if k != 1.0 { + org = [fr.center[0] + (fr.origin[0] - fr.center[0]) * k, fr.center[1] + (fr.origin[1] - fr.center[1]) * k]; + } + if !fr.center_subscale { + org = [org[0] + fr.sub_offset[0] * of, org[1] + fr.sub_offset[1] * of]; + } + let (s, c) = (fr.sub_rotation * of).sin_cos(); + let evo = fr.evolution * (1.0 + fr.turbulence * of); + let dk = if fr.kind == 6 { 0.5 + of * 0.35 } else { 1.0 }; + octaves.push([org[0], org[1], s, c, f, amp, w, evo, of * 7.31, dk]); + norm += amp * w; + amp *= fr.influence; + f /= fr.sub_scaling; + depth *= fr.sub_scaling; + } + let cycle = fr.cycle.map(|c| { + let t = fr.evolution.rem_euclid(c); + [t, c, t / c] + }); + FractalGpu { + kind: fr.kind, + noise_type: fr.noise_type, + seed: fr.seed, + invert: fr.invert, + overflow: fr.overflow, + contrast: fr.contrast, + brightness: fr.brightness, + rot: fr.rot, + size: fr.size, + cycle, + sub_rotation: fr.sub_rotation != 0.0, + norm, + octaves, + mode: match ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE) { + BLEND_NONE => None, + m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)), + }, + opacity: (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0), + blend: ctx.params.f("blend") as f32 / 100.0, + } +} + /// Map a value by an [`OVERFLOWS`] option. pub fn overflow(v: f32, mode: u32) -> f32 { match mode { diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index 6dd17c4fe..1969f50ad 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -905,6 +905,46 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9) } +/// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points +/// with increasing x; the end segments extend), the same maps [`color_stabilizer`] applies. +/// `None` without a host or reference frame (the layer passes through). (The GPU effect.) +pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> { + let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?; + let r = ctx.params.f("sampleSize").max(0.5); + let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; + let cur = pts.map(|pt| sample_mean(b, pt, r)); + let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let mode = ctx.params.e("stabilize"); + Some([0, 1, 2].map(|k| { + let offset = |c0: f64, r0: f64| vec![(0.0, r0 - c0), (1.0, 1.0 + r0 - c0)]; + match mode { + 0 => offset(cur[0][k], refv[0][k]), + 1 => { + let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); + if (c1 - c0).abs() < 1e-6 { + offset(c0, r0) + } else if c0 < c1 { + vec![(c0, r0), (c1, r1)] + } else { + vec![(c1, r1), (c0, r0)] + } + } + _ => { + let mut v = vec![(0.0, 0.0)]; + let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect(); + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (x, y) in pairs { + if x > v.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 { + v.push((x, y)); + } + } + v.push((1.0, 1.0)); + v + } + } + })) +} + fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(host) = ctx.env.host else { return b }; let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b }; diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 06c09d2ef..b957b4e41 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -223,7 +223,8 @@ fn scatter(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Strobe Light ---- -pub(crate) fn strobe_on(time: f64, duration: f64, period: f64, prob: f64, seed: u32) -> bool { +/// Whether Strobe Light flashes at `time` (also used by the GPU kernels). +pub fn strobe_on(time: f64, duration: f64, period: f64, prob: f64, seed: u32) -> bool { if period <= 0.0 { return false; } diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index e0c1aad79..e01283b9b 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -219,6 +219,12 @@ fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option { Some(Frames { grid, imgs }) } +/// The layer's frames at layer times `times` placed on one grid (the GPU Echo / Posterize Time, +/// effectcraft-gpu `fx_noise`): (grid buffer, one image per time). `None` without a host. +pub fn time_frames(ctx: &EffectCtx, times: &[f64]) -> Option<(Buf, Vec)> { + fetch(ctx, times).map(|f| (f.grid, f.imgs)) +} + fn with_img(grid: Buf, img: Image) -> Buf { Buf { img, ..grid } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7a7adb30b..6381de0bb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,3 +34,4 @@ log = { workspace = true } rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } +exr = { workspace = true } diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index fb0473d4f..414fae223 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -395,7 +395,7 @@ fn report(s: &mut Session, p: &Value) -> Result { /// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the /// given world directions: searched over quarter turns, which is all the cube faces need. -fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] { +pub(crate) fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] { let mut best = ([0.0; 3], f64::MAX); for a in 0..4 { for b in 0..4 { @@ -416,7 +416,7 @@ fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] { /// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up /// in the VR convention (X right, Y up, Z front). -const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [ +pub(crate) const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [ ("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), ("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), ("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]), @@ -425,10 +425,10 @@ const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [ ("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), ]; /// (column, row) of each face in the 3:2 cube map. -const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]; +pub(crate) const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]; /// VR world direction → After Effects world (Y down). -fn ae(v: [f64; 3]) -> Vec3 { +pub(crate) fn ae(v: [f64; 3]) -> Vec3 { vec3(v[0], -v[1], v[2]) } diff --git a/crates/engine/src/commands/create.rs b/crates/engine/src/commands/create.rs index a1d22fb59..07e6af7d3 100644 --- a/crates/engine/src/commands/create.rs +++ b/crates/engine/src/commands/create.rs @@ -33,7 +33,7 @@ fn create(s: &mut Session, p: &Value) -> Result { continue; }; let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?; - let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref()) + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page) .unwrap_or_else(|| Err(format!("{}: not a vector file", f.path))) .map_err(EngineError::Other)?; let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| { diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index 305c3f7e4..cbffdc21f 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -88,7 +88,16 @@ fn add_property(s: &mut Session, p: &Value) -> Result { 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); - // A property already in the panel is added again as a mirror (`mirror: false`: skipped). + // `as`: show the property as another control (Source Text as `font`, Scale as `scale`). + let as_type = match str_p(p, "as") { + None => None, + Some(a) => { + let t = ControlType::parse(a).ok_or_else(|| bad(C, format!("as: font|scale|text|color|slider|checkbox|point|angle|dropdown, not `{a}`")))?; + Some(t) + } + }; + // A property already in the panel (as the same kind of control) is added again as a mirror + // (`mirror: false`: skipped). let mirror = b_p(p, "mirror").unwrap_or(true); let mut added = vec![]; for (cid, lid, uid) in &targets { @@ -97,24 +106,38 @@ fn add_property(s: &mut Session, p: &Value) -> Result { if control_type(pr).is_none() { return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name))); } - let existing = s.project.comp(*cid).and_then(|c| c.essential.as_ref()).and_then(|e| e.driver_of(*lid, *uid)).map(|c| (c.id, c.name.clone())); - let kind = match existing { + // The natural control needs no `as`. + let as_type = as_type.filter(|t| control_type(pr) != Some(*t)); + if let Some(t) = as_type + && !essential::can_show_as(pr, t) + { + return Err(bad(C, format!("`{}` can't be shown as a {} control", pr.name, t.label()))); + } + let eg = s.project.comp(*cid).and_then(|c| c.essential.as_ref()); + let existing = eg + .and_then(|e| e.control_for_as(*lid, *uid, as_type).or_else(|| as_type.is_none().then(|| e.driver_of(*lid, *uid)).flatten())) + .map(|c| (c.id, c.name.clone())); + let (kind, as_type) = match existing { Some(_) if !mirror => continue, - Some((of, _)) => EgKind::Mirror { of }, - None => EgKind::Property { layer: *lid, prop: *uid, links: vec![] }, + Some((of, _)) => (EgKind::Mirror { of }, None), + None => (EgKind::Property { layer: *lid, prop: *uid, links: vec![] }, as_type), }; - let name = rename.clone().or(existing.map(|e| e.1)).unwrap_or_else(|| pr.name.clone()); - added.push((*cid, kind, name)); + let name = rename.clone().or(existing.map(|e| e.1)).unwrap_or_else(|| match as_type { + Some(ControlType::Font) => format!("{} Font", l.name), + _ => pr.name.clone(), + }); + added.push((*cid, kind, name, as_type)); } 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, kind, name)) in added.iter().enumerate() { + for (k, (cid, kind, name, as_type)) in added.iter().enumerate() { let id = proj.alloc(); let eg = eg_of(proj, *cid)?; - insert(eg, EgControl { id, name: name.clone(), kind: kind.clone() }, group, index.map(|i| i + k), C)?; + let ctl = EgControl { id, name: name.clone(), kind: kind.clone(), as_type: *as_type }; + insert(eg, ctl, group, index.map(|i| i + k), C)?; ids.push(id); } Ok(ids) @@ -122,7 +145,6 @@ fn add_property(s: &mut Session, p: &Value) -> Result { let mirrors: Vec = ids.iter().zip(&added).filter(|(_, a)| matches!(a.1, EgKind::Mirror { .. })).map(|(i, _)| *i).collect(); Ok(json!({"controls": ids, "mirrors": mirrors})) } - /// Add a mirror of a property control (the same property shown again, e.g. in another group). fn add_mirror(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addMirror"; @@ -137,7 +159,7 @@ fn add_mirror(s: &mut Session, p: &Value) -> Result { let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let id = s.edit("Add Mirror to Essential Graphics", None, |proj, _| { let id = proj.alloc(); - insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Mirror { of } }, group, index, C)?; + insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Mirror { of }, as_type: None }, group, index, C)?; Ok(id) })?; Ok(json!({"control": id, "of": of})) @@ -167,6 +189,9 @@ fn link_property(s: &mut Session, p: &Value) -> Result { } let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); let ctl = eg.resolve(control).ok_or_else(|| bad(C, format!("no control {control}")))?.clone(); + if ctl.as_type == Some(ControlType::Font) { + return Err(bad(C, "a Font control can't drive other properties")); + } let (main, _) = essential::source_prop(&s.project, cid, &ctl).ok_or_else(|| bad(C, format!("control {control} is not a property control")))?; let main = main.props.find(match ctl.kind { EgKind::Property { prop, .. } => prop, @@ -210,6 +235,39 @@ fn control_type(pr: &effectcraft_project::Property) -> Option { essential::control_type(pr) } +/// A Font control's value for `prop.set`: `cur` (a Source Text value) with the font family, +/// style and size from `v` (`{font?, style?, size?}` or a family name). `None` when `cur` is +/// not text. +pub fn font_value(cur: &KV, v: &Value) -> Option { + let KV::Text(doc) = cur else { return None }; + let mut d = (**doc).clone(); + match v { + Value::String(f) => d.font = f.clone(), + Value::Object(o) => { + if let Some(f) = o.get("font").and_then(Value::as_str) { + d.font = f.to_string(); + } + if let Some(st) = o.get("style").and_then(Value::as_str) { + d.style = st.to_string(); + } + if let Some(z) = o.get("size").and_then(Value::as_f64) { + d.size = z.max(0.1); + } + } + _ => return None, + } + serde_json::to_value(&d).ok() +} + +/// A control's value for agents: Font controls show `{font, style, size}`, the rest the +/// property's value. +fn value_json(ty: Option, v: &KV) -> Value { + match (ty, v) { + (Some(ControlType::Font), KV::Text(d)) => json!({"font": d.font, "style": d.style, "size": d.size}), + _ => v.to_json(), + } +} + /// Media Replacement for a footage layer. fn add_media(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.addMedia"; @@ -227,7 +285,7 @@ fn add_media(s: &mut Session, p: &Value) -> Result { 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)?; + insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid }, as_type: None }, group, None, C)?; Ok(id) })?; Ok(json!({"control": id})) @@ -240,7 +298,7 @@ fn add_group(s: &mut Session, p: &Value) -> Result { 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")?; + insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] }, as_type: None }, None, index, "essential.addGroup")?; Ok(id) })?; Ok(json!({"control": id})) @@ -253,7 +311,13 @@ fn add_comment(s: &mut Session, p: &Value) -> Result { 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")?; + insert( + eg_of(proj, cid)?, + EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text }, as_type: None }, + group, + None, + "essential.addComment", + )?; Ok(id) })?; Ok(json!({"control": id})) @@ -358,6 +422,35 @@ fn edit_dropdown(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "items": items})) } +/// Composition ▸ Open in Essential Graphics. +fn open_in_eg(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + s.state.essential_primary = Some(cid); + s.bump(); + s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "essentialGraphics"}) }); + let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + Ok(json!({"comp": cid.0, "name": name})) +} + +/// Whether a property can be exposed (`property.canAddToMotionGraphicsTemplate`). +fn can_add(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.canAdd"; + let (cid, lid, uid) = super::prop::prop_ref(s, p, C)?; + 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"))?; + let as_type = str_p(p, "as").and_then(ControlType::parse); + let ty = match as_type { + Some(t) if essential::can_show_as(pr, t) => Some(t), + Some(t) => return Ok(json!({"ok": false, "reason": format!("`{}` can't be shown as a {} control", pr.name, t.label())})), + None => control_type(pr), + }; + let in_eg = super::match_path_of(&l.props, uid).is_some_and(|m| m.starts_with(essential::GROUP)); + Ok(match ty { + Some(t) if !in_eg => json!({"ok": true, "type": t}), + _ => json!({"ok": false, "reason": format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)}), + }) +} + fn solo_supported(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "on").unwrap_or(!s.state.essential_solo); s.state.essential_solo = on; @@ -399,23 +492,26 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { "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()), + "type": src.and_then(|(_, pr)| essential::effective_type(c, pr)), + "value": src.map(|(_, pr)| value_json(essential::effective_type(c, pr), &pr.value_at(Tick::ZERO))), "missing": src.is_none(), "links": links, "mirrors": project.comp(cid).and_then(|c| c.essential.as_ref()).map(|e| e.mirrors_of(c.id)).unwrap_or_default(), }) } EgKind::Mirror { of } => { + // Shown as its master (same kind of control). + let master = project.comp(cid).and_then(|x| x.essential.as_ref()).and_then(|e| e.resolve(c.id)).cloned(); let src = essential::source_prop(project, cid, c); + let ty = |pr| master.as_ref().and_then(|m| essential::effective_type(m, pr)); json!({ "id": c.id, "name": c.name, "kind": "mirror", "of": of, "layer": src.map(|(l, _)| l.id.0), "prop": src.map(|(_, pr)| pr.uid), "layerName": src.map(|(l, _)| l.name.clone()), "path": src.and_then(|(l, pr)| super::match_path_of(&l.props, pr.uid)), - "type": src.and_then(|(_, pr)| essential::control_type(pr)), - "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), + "type": src.and_then(|(_, pr)| ty(pr)), + "value": src.map(|(_, pr)| value_json(ty(pr), &pr.value_at(Tick::ZERO))), "missing": src.is_none(), }) } @@ -500,8 +596,8 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { "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)), + "value": pr.as_ref().map(|x| value_json(essential::effective_type(c, x), &x.value_at(l.layer_time(t)))), + "type": pr.as_ref().and_then(|x| essential::effective_type(c, x)).or((kind == "media").then_some(ControlType::Media)), "overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)), })); } @@ -511,7 +607,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { /// Set an instance's value for a control (`value`, or `item` for Media Replacement). fn set_override(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.set"; - let (cid, lid, _) = instance(s, p, C)?; + let (cid, lid, src) = 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") { @@ -530,7 +626,13 @@ fn set_override(s: &mut Session, p: &Value) -> Result { })?; 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`)"))?}); + let mut value = p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?; + let as_type = s.project.comp(src).and_then(|c| c.essential.as_ref()).and_then(|e| e.find(control)).and_then(|c| c.as_type); + if as_type == Some(ControlType::Font) { + let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).map(|pr| pr.value.clone()).unwrap_or(KV::Scalar(0.0)); + value = font_value(&cur, &value).ok_or_else(|| bad(C, "a Font control takes {font?, style?, size?} or a font family name"))?; + } + let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": value}); if let Some(m) = p.get("merge") { q["merge"] = m.clone(); } @@ -604,12 +706,21 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result { for (control, uid) in &ts { // A mirror pushes into its master's properties. let (Some(ctl), Some(pr)) = (eg.resolve(*control), l.props.find(*uid)) else { continue }; - writes.push((ctl.kind.clone(), pr.clone())); + writes.push((ctl.kind.clone(), pr.clone(), ctl.as_type)); } s.edit("Push Override Values to Source", None, |proj, _| { let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?; - for (kind, pr) in &writes { + for (kind, pr, as_type) in &writes { match kind { + // A Font control writes only the font (the source keeps its text and keys). + EgKind::Property { layer, prop, .. } if *as_type == Some(ControlType::Font) => { + if let Some(target) = sc.layer_mut(*layer).and_then(|l| l.props.find_mut(*prop)) { + essential::merge_font(&mut target.value, &pr.value); + for k in &mut target.keys { + essential::merge_font(&mut k.value, &pr.value); + } + } + } EgKind::Property { .. } => { for (layer, prop) in kind.targets() { let Some(inner) = sc.layer_mut(layer) else { continue }; @@ -630,6 +741,7 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result { } } } + 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) }; @@ -869,7 +981,7 @@ pub fn specs() -> Vec { "Add Property to Essential Graphics", ["Animation"], None, - "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, mirror?: bool (a property already present is added as a mirror; default true)}", + "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, as?: font (Source Text font family/style/size) | scale (uniform Scale), mirror?: bool (a property already present is added as a mirror; default true)}", has_selected_props, add_property ), @@ -922,6 +1034,16 @@ pub fn specs() -> Vec { 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.canAdd", "Can Add Property to Essential Graphics", "{layer, path|prop, as?} → {ok, type?, reason?}", can_add), + cmd!( + "comp.openInEssentialGraphics", + "Open in Essential Graphics", + ["Composition"], + None, + "{comp?} → makes it the Essential Graphics panel's Primary composition and shows the panel", + has_comp, + open_in_eg + ), query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json), query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info), ] diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index fe057683a..14cbb7587 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -102,6 +102,14 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { "compositionLayerSizes" | "compositionRetainLayerSizes" | "layerSizes" => Some(true), other => return Err(bad("file.import", format!("importAs: footage|composition|compositionLayerSizes, not `{other}`"))), }; + // PDF / Illustrator: which page (1-based, as in the Import dialog). + let page = match p.get("page") { + None | Some(Value::Null) => 0, + Some(v) => match v.as_u64().or_else(|| v.as_f64().filter(|x| x.fract() == 0.0 && *x >= 1.0).map(|x| x as u64)) { + Some(n) if n >= 1 => (n - 1) as u32, + _ => return Err(bad("file.import", "page: a page number from 1")), + }, + }; let mut paths = paths; let mut out_comps = vec![]; let mut ids = vec![]; @@ -113,7 +121,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { Ok(b) if effectcraft_psd::is_psd(&b) => b, Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { // PDF / Illustrator / EPS: one layer per file layer. - match import_vector_comp(s, &path, &b) { + match import_vector_comp(s, &path, &b, page) { Ok((comp, items)) => { out_comps.push(comp); ids.extend(items); @@ -185,13 +193,31 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { f.frame_rate = r; f.duration = r.tick_of(frames.max(1)); } + // Page: another page of a PDF / Illustrator file. + if page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") { + match s + .services + .read_file(path) + .map_err(|e| e.to_string()) + .and_then(|b| effectcraft_pdf::parse_page(&b, page as usize).map_err(|e| e.to_string())) + { + Ok(doc) => { + (f.width, f.height) = doc.pixel_size(); + f.page = page; + } + Err(e) => { + errors.push(format!("{path}: {e}")); + continue; + } + } + } // 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, embedded: None }) + f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false }) } None => { errors.push(format!("{path}: no layer {sel}")); @@ -210,6 +236,7 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result { 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 if f.page > 0 => format!("{name} (Page {})", f.page + 1), None => name, }; let label = match f.kind { @@ -298,14 +325,18 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retai Ok((r.comp.0, items, r.warnings)) } -/// Import a PDF / Illustrator / EPS file as a composition (one undo step). Returns (comp, items). -fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8]) -> Result<(u64, Vec)> { - let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); +/// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step). +/// Returns (comp, items). +fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec)> { + let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); + if page > 0 { + name = format!("{name} (Page {})", page + 1); + } 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 (comp, folder, items) = s.edit("Import", None, |proj, st| { - let r = crate::vector::import_vector_comp(proj, path, bytes, &name, rate, duration).map_err(EngineError::Other)?; + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?; st.project_selection = vec![r.0]; Ok(r) })?; @@ -478,7 +509,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", always, import ), diff --git a/crates/engine/src/commands/frame_export.rs b/crates/engine/src/commands/frame_export.rs new file mode 100644 index 000000000..77e0f5d6d --- /dev/null +++ b/crates/engine/src/commands/frame_export.rs @@ -0,0 +1,240 @@ +//! Composition ▸ Save Frame As ▸ Photoshop Layers… and ProEXR…: the current frame with every +//! visible layer kept apart. +//! +//! - **Photoshop Layers** writes a layered PSD (crates/psd's writer): one pixel layer per comp +//! layer, each rendered in isolation (solo, Normal mode, 100% opacity) and cropped to its +//! pixels, carrying the layer's blend mode and opacity, plus the flattened frame as the merged +//! image. Photoshop (and EffectCraft's PSD import) rebuild the frame from the layers. +//! - **ProEXR** writes one multi-layer OpenEXR (half-float-free, 32-bit float, linear light, +//! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `.R/G/B/A` channel +//! set per layer (the layer-prefixed channel convention multi-layer EXR readers use). + +use effectcraft_color::BlendMode; +use effectcraft_project::{ItemId, LayerId, Project}; +use effectcraft_raster::Image; +use effectcraft_render::{Backend, RenderOpts, Renderer}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, comp_id, f_p, has_comp, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// One isolated layer of a frame: name, blend mode, opacity (0..1), premultiplied pixels. +struct Isolated { + name: String, + blend: BlendMode, + opacity: f64, + image: Image, +} + +fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { + let opts = RenderOpts { + scale, + backend: Backend::Auto, + nested_switches: s.prefs.general.switches_affect_nested_comps, + draft_shadows: s.prefs.three_d.realtime_shadows, + ..Default::default() + }; + let mut r = Renderer::new(project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + r.accel = s.accel.as_deref(); + r.comp_frame(comp, t) +} + +/// The comp frame and each visible layer rendered alone (top layer first). +fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec)> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let full = render_with(s, &s.project, cid, t, scale); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video()); + let ids: Vec = comp + .layers + .iter() + .filter(|l| l.has_video() && l.is_active_at(t) && !l.switches.adjustment && !l.switches.guide && !l.environment && (!any_solo || l.switches.solo)) + .map(|l| l.id) + .collect(); + let mut out = vec![]; + for id in ids { + let mut p: Project = (*s.project).clone(); + let Some(c) = p.comp_mut(cid) else { continue }; + let mut info = None; + for l in c.layers.iter_mut() { + l.switches.solo = l.id == id; + if l.id == id { + let lt = l.layer_time(t); + let opacity = l.props.prop("transform/opacity").map(|o| o.value_at(lt).as_f64() / 100.0).unwrap_or(1.0); + info = Some((l.name.clone(), l.blend_mode, opacity.clamp(0.0, 1.0))); + l.blend_mode = BlendMode::Normal; + if let Some(o) = l.props.prop_mut("transform/opacity") { + o.keys.clear(); + o.expr = None; + o.value = effectcraft_keyframe::Value::Scalar(100.0); + } + } + } + let Some((name, blend, opacity)) = info else { continue }; + let image = render_with(s, &p, cid, t, scale); + out.push(Isolated { name, blend, opacity, image }); + } + Ok((full, out)) +} + +fn straight(p: [f32; 4]) -> [f32; 4] { + if p[3] <= 0.0 { [0.0; 4] } else { [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3].min(1.0)] } +} + +/// The Photoshop key of a blend mode (Normal for modes Photoshop lacks). +pub fn psd_blend_key(m: BlendMode) -> [u8; 4] { + const KEYS: [&[u8; 4]; 26] = [ + b"diss", b"dark", b"mul ", b"idiv", b"lbrn", b"dkCl", b"lite", b"scrn", b"div ", b"lddg", b"lgCl", b"over", b"sLit", b"hLit", b"vLit", b"lLit", + b"pLit", b"hMix", b"diff", b"smud", b"fsub", b"fdiv", b"hue ", b"sat ", b"colr", b"lum ", + ]; + KEYS.iter().find(|k| crate::psd_import::blend_mode(std::str::from_utf8(&k[..]).unwrap_or("")) == m).map(|k| **k).unwrap_or(*b"norm") +} + +/// The bounding box of non-transparent pixels (`None` when empty). +fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> { + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); + for y in 0..img.height { + for x in 0..img.width { + if img.data[(y * img.width + x) as usize][3] > 0.5 / 255.0 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x + 1); + y1 = y1.max(y + 1); + } + } + } + (x1 > x0).then_some((x0, y0, x1, y1)) +} + +/// The layered PSD bytes of a frame. +pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec, usize, u32, u32)> { + use effectcraft_psd::Rect; + use effectcraft_psd::write::{WDoc, WLayer, write}; + let (full, layers) = isolate(s, cid, t, scale)?; + let mut doc = WDoc::new(full.width, full.height); + let n = layers.len(); + // File order is bottom first. + for l in layers.into_iter().rev() { + let mut w = match alpha_bounds(&l.image) { + Some((x0, y0, x1, y1)) => { + let mut px = Vec::with_capacity(((x1 - x0) * (y1 - y0)) as usize); + for y in y0..y1 { + for x in x0..x1 { + px.push(straight(l.image.data[(y * l.image.width + x) as usize])); + } + } + WLayer::pixels(&l.name, Rect::new(x0 as i32, y0 as i32, (x1 - x0) as i32, (y1 - y0) as i32), px) + } + None => WLayer::pixels(&l.name, Rect::default(), vec![]), + }; + w.blend = psd_blend_key(l.blend); + w.opacity = (l.opacity * 255.0).round() as u8; + doc.layers.push(w); + } + doc.composite = Some(full.data.iter().map(|p| straight(*p)).collect()); + Ok((write(&doc), n, full.width, full.height)) +} + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// A unique EXR layer name (no dots: they separate the layer from the channel). +fn exr_name(name: &str, used: &mut Vec) -> String { + let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect(); + let base = if base.trim().is_empty() { "Layer".to_string() } else { base }; + let mut n = base.clone(); + let mut k = 2; + while used.contains(&n) { + n = format!("{base} {k}"); + k += 1; + } + used.push(n.clone()); + n +} + +/// The multi-layer EXR bytes of a frame and the channel names. +pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec, Vec, u32, u32)> { + use exr::prelude::*; + let (full, layers) = isolate(s, cid, t, scale)?; + let (w, h) = (full.width as usize, full.height as usize); + let planes = |img: &effectcraft_raster::Image| -> [Vec; 4] { + let mut out: [Vec; 4] = Default::default(); + for p in &img.data { + let sp = straight(*p); + let a = sp[3]; + for c in 0..3 { + out[c].push(srgb_to_linear(sp[c].clamp(0.0, 1.0)) * a); + } + out[3].push(a); + } + out + }; + let mut channels = vec![]; + let mut names = vec![]; + let mut push = |prefix: &str, img: &effectcraft_raster::Image| { + for (c, data) in ["R", "G", "B", "A"].iter().zip(planes(img)) { + let n = if prefix.is_empty() { c.to_string() } else { format!("{prefix}.{c}") }; + names.push(n.clone()); + channels.push(AnyChannel::new(n.as_str(), FlatSamples::F32(data))); + } + }; + push("", &full); + let mut used = vec![]; + for l in &layers { + let n = exr_name(&l.name, &mut used); + push(&n, &l.image); + } + let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into())); + let mut buf = std::io::Cursor::new(Vec::new()); + Image::from_layer(layer).write().to_buffered(&mut buf).map_err(|e| EngineError::Other(format!("EXR encoding failed: {e}")))?; + Ok((buf.into_inner(), names, full.width, full.height)) +} + +fn frame_args(s: &Session, p: &Value, cmd: &str, ext: &str) -> Result<(String, ItemId, Tick, f64)> { + let path = str_p(p, "path").ok_or_else(|| bad(cmd, format!("missing `path` (.{ext})")))?.to_string(); + let cid = comp_id(s, p)?; + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time()); + let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); + Ok((path, cid, t, scale)) +} + +fn save_psd(s: &mut Session, p: &Value) -> Result { + let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsPsd", "psd")?; + let (bytes, n, w, h) = layered_psd(s, cid, t, scale)?; + s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.toast(format!("Saved {n} layers to {path}")); + Ok(json!({"path": path, "layers": n, "width": w, "height": h, "time": t.seconds()})) +} + +fn save_exr(s: &mut Session, p: &Value) -> Result { + let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsExr", "exr")?; + let (bytes, channels, w, h) = layered_exr(s, cid, t, scale)?; + s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.toast(format!("Saved a multi-layer EXR to {path}")); + Ok(json!({"path": path, "channels": channels, "width": w, "height": h, "time": t.seconds()})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "comp.saveFrameAsPsd", + "Photoshop Layers...", + ["Composition", "Save Frame As"], + None, + "{path (.psd), comp?, time?, scale?} → a layered PSD: one layer per visible comp layer (blend mode, opacity) + the merged frame", + has_comp, + save_psd + ), + cmd!( + "comp.saveFrameAsExr", + "ProEXR...", + ["Composition", "Save Frame As"], + None, + "{path (.exr), comp?, time?, scale?} → a multi-layer OpenEXR: composite R,G,B,A + `.R/G/B/A` per layer (linear, premultiplied)", + has_comp, + save_exr + ), + ] +} diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 01dd27f15..62cfd351b 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Show Panel", [], None, - "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill}", + "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}", always ), fe!( diff --git a/crates/engine/src/commands/help.rs b/crates/engine/src/commands/help.rs index 2ed358b54..0e9dd04d2 100644 --- a/crates/engine/src/commands/help.rs +++ b/crates/engine/src/commands/help.rs @@ -35,6 +35,12 @@ pub fn specs() -> Vec { cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())), cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())), cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())), + cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())), + cmd!("help.inAppTutorials", "In-App Tutorials...", ["Help"], None, "{} → the Home screen's Learn tab", always, |s, p| super::frontend( + s, + "help.inAppTutorials", + p + )), cmd!("help.reportIssue", "Provide Feedback...", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())), cmd!( "help.sibling", diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 58135b357..f8edd1029 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1163,7 +1163,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index c2adf9683..151727ea6 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -6,7 +6,7 @@ mod align_data; mod anim; pub mod anim_tools; mod animation; -mod app_more; +pub(crate) mod app_more; mod autotrace; mod camera_cmds; mod comp; @@ -21,6 +21,7 @@ mod file; pub(crate) mod file_more; mod focus; pub mod footage_panel; +pub(crate) mod frame_export; mod frontend; mod help; mod key_labels; @@ -29,7 +30,7 @@ mod keys_more; mod layer; mod layer_menu; mod layer_time; -mod link; +pub(crate) mod link; mod liquify; mod lottie; mod markers; @@ -38,6 +39,7 @@ pub mod mask_interp; pub(crate) mod model3d; pub mod paint; pub mod panels_cmds; +pub(crate) mod path_nulls; mod paths; mod project_items; mod prop; @@ -61,7 +63,10 @@ pub mod time; mod track; mod view; pub mod viewer_cmds; +pub(crate) mod vpe; +pub(crate) mod vr_editor; mod warp_cmds; +pub(crate) mod watch_folder; #[cfg(test)] pub(crate) use app_more::report_text as report_text_for_tests; pub(crate) use app_more::view_command; @@ -162,6 +167,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(file_more::specs()); v.extend(lottie::specs()); v.extend(comp_more::specs()); + v.extend(frame_export::specs()); + v.extend(watch_folder::specs()); + v.extend(vpe::specs()); + v.extend(path_nulls::specs()); + v.extend(vr_editor::specs()); v.extend(frontend::specs()); v.extend(track::specs()); v.extend(mask_interp::specs()); diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs index eb0568781..325e0da02 100644 --- a/crates/engine/src/commands/panels_cmds.rs +++ b/crates/engine/src/commands/panels_cmds.rs @@ -29,38 +29,61 @@ fn jobs_wait(s: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- Media Browser -fn browser_dir(s: &Session, p: &Value) -> String { - str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(mb::home) +fn browser(s: &Session) -> Result> { + s.media_browser().ok_or_else(|| EngineError::Other("the Media Browser is not available in this build".into())) +} + +fn browser_dir(s: &Session, b: &dyn mb::Browser, p: &Value) -> String { + str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(|| b.home()) } fn browser_list(s: &mut Session, p: &Value) -> Result { - let dir = browser_dir(s, p); + let b = browser(s)?; + let dir = browser_dir(s, b.as_ref(), p); let only = b_p(p, "importableOnly").unwrap_or(s.state.media_browser.importable_only); - let entries = mb::list(&dir, only).map_err(EngineError::Other)?; - Ok(json!({"path": dir, "parent": mb::parent(&dir), "entries": entries, "favorites": s.state.media_browser.favorites})) + let entries = b.list(&dir, only).map_err(EngineError::Other)?; + let places: Vec = b.places().into_iter().map(|(name, path)| json!({"name": name, "path": path})).collect(); + let actions: Vec = b.actions().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect(); + Ok(json!({ + "path": dir, + "parent": b.parent(&dir), + "entries": entries, + "favorites": s.state.media_browser.favorites, + "places": places, + "actions": actions, + })) } -fn browser_enabled(_: &Session) -> std::result::Result<(), String> { - if mb::available() { Ok(()) } else { Err("the Media Browser needs the desktop app".into()) } +fn browser_enabled(s: &Session) -> std::result::Result<(), String> { + if s.media_browser().is_some() { Ok(()) } else { Err("the Media Browser is not available in this build".into()) } } fn browser_go(s: &mut Session, p: &Value) -> Result { + let b = browser(s)?; let dir = match str_p(p, "path") { - Some("..") => s.state.media_browser.folder.clone().and_then(|d| mb::parent(&d)).unwrap_or_else(mb::home), + Some("..") => s.state.media_browser.folder.clone().and_then(|d| b.parent(&d)).unwrap_or_else(|| b.home()), Some(d) => d.to_string(), - None => mb::home(), + None => b.home(), }; - if let Some(b) = b_p(p, "importableOnly") { - s.state.media_browser.importable_only = b; + if let Some(v) = b_p(p, "importableOnly") { + s.state.media_browser.importable_only = v; } // Validate before switching. - mb::list(&dir, false).map_err(EngineError::Other)?; + b.list(&dir, false).map_err(EngineError::Other)?; s.state.media_browser.folder = Some(dir.clone()); browser_list(s, &json!({"path": dir})) } +/// A browser-specific toolbar action (web: Open Folder…, Add Files…). +fn browser_action(s: &mut Session, p: &Value) -> Result { + let b = browser(s)?; + let id = str_p(p, "action").ok_or_else(|| bad("mediaBrowser.action", "missing `action`"))?; + b.action(id).map_err(|e| bad("mediaBrowser.action", e)) +} + fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result { - let dir = browser_dir(s, p); + let b = browser(s)?; + let dir = browser_dir(s, b.as_ref(), p); let favs = &mut s.state.media_browser.favorites; favs.retain(|f| f != &dir); if add { @@ -71,6 +94,16 @@ fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result { fn browser_import(s: &mut Session, p: &Value) -> Result { let paths = p.get("paths").or(p.get("path")).cloned().ok_or_else(|| bad("mediaBrowser.import", "missing `paths`"))?; + // Files the browser still has to read (the web app's opened folders): imported when they + // arrive. + let list: Vec = match &paths { + Value::String(one) => vec![one.clone()], + Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), + _ => vec![], + }; + if !browser(s)?.fetch(&list, p) { + return Ok(json!({"pending": true, "paths": list})); + } let r = s.execute("file.import", json!({"paths": paths}))?; // Add to the active comp (drag into the timeline). if b_p(p, "addToComp").unwrap_or(false) @@ -201,6 +234,15 @@ pub fn specs() -> Vec { cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)), cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)), cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import), + cmd!( + "mediaBrowser.action", + "Media Browser Action", + [], + None, + "{action}: an action from mediaBrowser.list's `actions` (web: openFolder, addFiles, uploadFolder)", + browser_enabled, + browser_action + ), query!("mediaBrowser.fileInfo", "File Info", "{path}", file_info), query!("item.metadata", "Metadata", "{item?: id|name}", metadata), cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment), diff --git a/crates/engine/src/commands/path_nulls.rs b/crates/engine/src/commands/path_nulls.rs new file mode 100644 index 000000000..1034f3d50 --- /dev/null +++ b/crates/engine/src/commands/path_nulls.rs @@ -0,0 +1,217 @@ +//! Window ▸ Create Nulls From Paths: our own panel (and these commands) linking a mask or shape +//! path to null layers with expressions, in three ways: +//! +//! - **Points Follow Nulls** (`paths.pointsFollowNulls`): one null per vertex, placed on it; the +//! path's expression rebuilds it from the nulls (`createPath` of each null's position brought +//! into the path's layer with `fromComp`), so moving a null moves its vertex. +//! - **Nulls Follow Points** (`paths.nullsFollowPoints`): one null per vertex whose Position +//! expression follows the vertex (`toComp(path.points()[i])`), for attaching things to an +//! animated path. +//! - **Trace Path** (`paths.tracePath`): one null with a Progress slider (keyframed 0 → 100 % over +//! the layer's span, optionally looping) whose Position follows `pointOnPath(progress)`. +//! +//! The path is `path`/`prop` of `layer`, else the selected path property, else the layer's first +//! mask or shape path. Each command is one undo step. + +use effectcraft_geom::vec2; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{ItemId, LayerId, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, comp_id, has_layers}; +use crate::{EngineError, Result, Session, cmd}; + +fn quote(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\"")) +} + +/// The target path property: (comp, layer, uid). +fn target(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + if p.get("path").is_some() || p.get("prop").is_some() { + let r = super::prop::prop_ref(s, p, cmd)?; + let is_path = s.project.comp(r.0).and_then(|c| c.layer(r.1)).and_then(|l| l.props.find(r.2)).is_some_and(|pr| matches!(pr.value, KV::Path(_))); + return if is_path { Ok(r) } else { Err(bad(cmd, "that property is not a mask or shape path")) }; + } + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + // A selected path property. + for (l, u) in super::selected_leaf_props(s) { + if comp.layer(l).and_then(|ly| ly.props.find(u)).is_some_and(|pr| matches!(pr.value, KV::Path(_))) { + return Ok((cid, l, u)); + } + } + // The first path of `layer` (or of the first selected layer). + let lid = match p.get("layer") { + Some(_) => super::layer_p(s, p, cmd)?.1, + None => *s.state.selected_layers.first().ok_or_else(|| bad(cmd, "select a layer with a mask or shape path"))?, + }; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut found = None; + layer.props.walk("", &mut |_, pr| { + if found.is_none() && matches!(pr.value, KV::Path(_)) && !matches!(pr.ui, effectcraft_project::ParamUi::Hidden) { + found = Some(pr.uid); + } + }); + found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, format!("`{}` has no mask or shape path", layer.name))) +} + +/// What the commands need about the path: its vertices in comp space (now), the path value, the +/// expression reference to it and its layer's name. +struct PathInfo { + comp_pts: Vec<[f64; 2]>, + path: effectcraft_keyframe::ShapePath, + reference: String, + layer_name: String, + prop_name: String, + span: (f64, f64), +} + +fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?; + let t = s.time(); + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) }; + if path.vertices.is_empty() { + return Err(bad(cmd, "the path has no vertices")); + } + let m = ctx.layer_to_comp(layer).0; + let comp_pts = path + .vertices + .iter() + .map(|v| { + let q = m.apply(vec2(v[0], v[1])); + [q.x, q.y] + }) + .collect(); + // Seen from another layer (the nulls). + let mut other = layer.clone(); + other.id = LayerId(u64::MAX); + other.name = "\u{0}".into(); + let reference = super::link::reference(comp, &other, layer, uid, true).ok_or_else(|| bad(cmd, "can't reference that path"))?; + let prop_name = layer.props.name_path_of(uid).and_then(|n| n.split('/').rev().nth(1).map(str::to_string)).unwrap_or_else(|| pr.name.clone()); + Ok(PathInfo { comp_pts, path, reference, layer_name: layer.name.clone(), prop_name, span: (layer.in_point.seconds(), layer.out_point.seconds()) }) +} + +/// A new null at `at` (comp space) named `name`, made unique in the comp (expressions find +/// layers by name). Returns (layer id, name). +fn new_null(s: &mut Session, cid: ItemId, name: &str, at: [f64; 2]) -> Result<(u64, String)> { + let taken = |n: &str| s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.name == n)); + let mut name = name.to_string(); + let base = name.clone(); + let mut k = 2; + while taken(&name) { + name = format!("{base} {k}"); + k += 1; + } + let r = s.execute("layer.newNull", json!({"comp": cid.0, "name": name}))?; + let id = r.get("layer").and_then(Value::as_u64).ok_or_else(|| EngineError::Other("no null layer".into()))?; + s.execute("prop.set", json!({"comp": cid.0, "layer": id, "path": "transform/position", "value": at}))?; + Ok((id, name)) +} + +fn nulls_follow_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "paths.nullsFollowPoints"; + let (cid, lid, uid) = target(s, p, C)?; + let pi = info(s, cid, lid, uid, C)?; + let nulls = super::app_more::grouped(s, "Nulls Follow Points", |s| { + let mut out = vec![]; + for (i, at) in pi.comp_pts.iter().enumerate() { + let (id, _) = new_null(s, cid, &format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1), *at)?; + let e = format!("var src = thisComp.layer({});\nvar p = src.toComp({}.points()[{i}]);\n[p[0], p[1]]", quote(&pi.layer_name), pi.reference); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?; + out.push(id); + } + Ok(out) + })?; + Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid})) +} + +fn points_follow_nulls(s: &mut Session, p: &Value) -> Result { + const C: &str = "paths.pointsFollowNulls"; + let (cid, lid, uid) = target(s, p, C)?; + let pi = info(s, cid, lid, uid, C)?; + let nulls = super::app_more::grouped(s, "Points Follow Nulls", |s| { + let mut out = vec![]; + let mut names = vec![]; + for (i, at) in pi.comp_pts.iter().enumerate() { + let name = format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1); + let (id, name) = new_null(s, cid, &name, *at)?; + out.push(id); + names.push(quote(&name)); + } + let arr = |v: &[[f64; 2]]| serde_json::to_string(v).unwrap_or_else(|_| "[]".into()); + let e = format!( + "var nulls = [{}];\nvar pts = [];\nfor (var i = 0; i < nulls.length; i++) {{\n var n = thisComp.layer(nulls[i]);\n var q = fromComp(n.toComp(n.transform.anchorPoint));\n pts.push([q[0], q[1]]);\n}}\ncreatePath(pts, {}, {}, {})", + names.join(", "), + arr(&pi.path.in_tangents), + arr(&pi.path.out_tangents), + pi.path.closed + ); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "expression": e}))?; + Ok(out) + })?; + Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid})) +} + +fn trace_path(s: &mut Session, p: &Value) -> Result { + const C: &str = "paths.tracePath"; + let (cid, lid, uid) = target(s, p, C)?; + let pi = info(s, cid, lid, uid, C)?; + let looping = b_p(p, "loop").unwrap_or(false); + let nid = super::app_more::grouped(s, "Trace Path", |s| { + let (id, _) = new_null(s, cid, &format!("Trace {}: {}", pi.layer_name, pi.prop_name), pi.comp_pts[0])?; + s.execute("effect.apply", json!({"comp": cid.0, "layers": [id], "effect": "ec.control.slider"}))?; + let fx = s.project.comp(cid).and_then(|c| c.layer(LayerId(id))).and_then(|l| l.props.group("effects/#1")).map(|g| g.uid); + if let Some(fx) = fx { + s.execute("prop.renameGroup", json!({"comp": cid.0, "layer": id, "prop": fx, "name": "Progress"}))?; + } + let (a, b) = pi.span; + s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": a, "value": 0.0}))?; + s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": b.max(a), "value": 100.0}))?; + if looping { + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "expression": "loopOut(\"cycle\")"}))?; + } + let e = format!( + "var src = thisComp.layer({});\nvar p = src.toComp({}.pointOnPath(effect(\"Progress\")(\"Slider\") / 100));\n[p[0], p[1]]", + quote(&pi.layer_name), + pi.reference + ); + s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?; + Ok(id) + })?; + Ok(json!({"null": nid, "layer": lid.0, "prop": uid})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "paths.pointsFollowNulls", + "Points Follow Nulls", + [], + None, + "{layer?, path?|prop?} → a null per vertex; the path follows them (expression)", + has_layers, + points_follow_nulls + ), + cmd!( + "paths.nullsFollowPoints", + "Nulls Follow Points", + [], + None, + "{layer?, path?|prop?} → a null per vertex following it (Position expressions)", + has_layers, + nulls_follow_points + ), + cmd!( + "paths.tracePath", + "Trace Path", + [], + None, + "{layer?, path?|prop?, loop?: bool} → a null moving along the path (Progress slider)", + has_layers, + trace_path + ), + ] +} diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 628533afe..49884bc0a 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -188,7 +188,7 @@ pub fn specs() -> Vec { crate::query!( "scriptui.get", "Script Window Controls", - "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, children…}}", + "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, draw (onDraw paint list), children…}}", scriptui::get ), cmd!( @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "Set Script Window Control", [], None, - "{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", + "{window?, widget, value: text | number | bool | item index | item text, changing?: bool (a live update: each keystroke / slider step; fires onChanging only)} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", always, scriptui::set ), diff --git a/crates/engine/src/commands/vpe.rs b/crates/engine/src/commands/vpe.rs new file mode 100644 index 000000000..f4932bc01 --- /dev/null +++ b/crates/engine/src/commands/vpe.rs @@ -0,0 +1,29 @@ +//! File ▸ Import ▸ Vanishing Point (.vpe): present in the menu like After Effects', but always +//! disabled. The `.vpe` exchange format Photoshop's Vanishing Point filter writes has no public +//! specification, and EffectCraft implements formats only from published specs (clean room), so +//! the entry explains that instead of importing. See docs/parity.md. + +use serde_json::Value; + +use super::{CommandSpec, bad}; +use crate::{Result, Session, cmd}; + +/// Why the entry is disabled (shown as its tooltip). +pub const WHY: &str = + "Vanishing Point Exchange (.vpe) files can't be imported: the format has no public specification, and EffectCraft only implements documented formats"; + +fn import(_: &mut Session, _: &Value) -> Result { + Err(bad("file.importVanishingPoint", WHY)) +} + +pub fn specs() -> Vec { + vec![cmd!( + "file.importVanishingPoint", + "Vanishing Point (.vpe)...", + ["File", "Import"], + None, + "{} (always disabled: undocumented format)", + |_| Err(WHY.into()), + import + )] +} diff --git a/crates/engine/src/commands/vr_editor.rs b/crates/engine/src/commands/vr_editor.rs new file mode 100644 index 000000000..e62281128 --- /dev/null +++ b/crates/engine/src/commands/vr_editor.rs @@ -0,0 +1,167 @@ +//! Window ▸ VR Comp Editor: our own panel over the VR environments Composition ▸ VR ▸ Create VR +//! Environment builds (an equirectangular output comp ← a 3:2 cube map ← six face comps, each +//! with a 90° camera). It lists the environments and edits the 360 view's camera orientation: +//! the six face cameras turn together, so the whole equirectangular output rotates (pan / tilt / +//! roll the viewer's starting direction) without touching the scene. +//! +//! The view orientation is not stored separately: it is read back from the Front camera +//! (`camera = view × face base`), so editing a camera by hand shows up here too. + +use effectcraft_geom::{Mat4, vec3}; +use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Project}; +use serde_json::{Value, json}; + +use super::app_more::{CELLS_3X2, FACES, ae, grouped, orientation_for_axes}; +use super::{CommandSpec, always, bad}; +use crate::{EngineError, Result, Session, cmd, query}; + +/// A VR environment: its output comp, cube map and the face cameras in [`FACES`] order. +pub struct VrEnv { + pub output: ItemId, + pub cube: ItemId, + pub faces: [(ItemId, LayerId); 6], +} + +/// The VR environments of a project. +pub fn environments(p: &Project) -> Vec { + let mut out = vec![]; + for (oid, oc) in p.comps() { + for l in &oc.layers { + let LayerSource::Comp { item: cube } = l.source else { continue }; + let vr = l.effects().is_some_and(|fx| fx.groups().any(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.vr.converter"))); + if !vr { + continue; + } + let Some(cc) = p.comp(cube) else { continue }; + let size = (cc.width / 3).max(1) as f64; + let mut faces: [Option<(ItemId, LayerId)>; 6] = [None; 6]; + for fl in &cc.layers { + let LayerSource::Comp { item: face } = fl.source else { continue }; + let pos = fl.props.prop("transform/position").map(|x| x.value.components()).unwrap_or_default(); + let (Some(x), Some(y)) = (pos.first(), pos.get(1)) else { continue }; + let cell = ((x / size).floor().max(0.0) as u32, (y / size).floor().max(0.0) as u32); + let Some(i) = CELLS_3X2.iter().position(|c| *c == cell) else { continue }; + let cam = p.comp(face).and_then(|fc| fc.layers.iter().find(|x| x.is_camera())).map(|x| x.id); + if let Some(cam) = cam { + faces[i] = Some((face, cam)); + } + } + if faces.iter().all(Option::is_some) { + out.push(VrEnv { output: *oid, cube, faces: faces.map(|f| f.unwrap_or((ItemId(0), LayerId(0)))) }); + } + } + } + out +} + +fn base(i: usize) -> [f64; 3] { + let (_, fw, r, u) = FACES[i]; + orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw)) +} + +fn orient(o: [f64; 3]) -> Mat4 { + Mat4::orientation(vec3(o[0], o[1], o[2])) +} + +fn transpose(m: Mat4) -> Mat4 { + let mut t = Mat4::IDENTITY; + for r in 0..3 { + for c in 0..3 { + t.0[r][c] = m.0[c][r]; + } + } + t +} + +fn norm(a: f64) -> f64 { + let r = a.rem_euclid(360.0); + let r = if (r - 360.0).abs() < 1e-7 { 0.0 } else { r }; + (r * 1e6).round() / 1e6 +} + +/// Orientation angles (X, Y, Z degrees, applied X then Y then Z) of a rotation matrix. +pub fn euler(m: Mat4) -> [f64; 3] { + let m = m.0; + let y = (-m[2][0]).clamp(-1.0, 1.0).asin(); + let (x, z) = if y.cos().abs() > 1e-6 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) }; + [norm(x.to_degrees()), norm(y.to_degrees()), norm(z.to_degrees())] +} + +/// The current view orientation of an environment (from its Front camera). +fn view_of(p: &Project, env: &VrEnv) -> [f64; 3] { + let (fc, cam) = env.faces[4]; + let o = p.comp(fc).and_then(|c| c.layer(cam)).and_then(|l| l.props.prop("transform/orientation")).map(|x| x.value.components()).unwrap_or_default(); + let o = [o.first().copied().unwrap_or(0.0), o.get(1).copied().unwrap_or(0.0), o.get(2).copied().unwrap_or(0.0)]; + euler(orient(o) * transpose(orient(base(4)))) +} + +fn env_json(p: &Project, e: &VrEnv) -> Value { + let name = |i: ItemId| p.item(i).map(|x| x.name.clone()).unwrap_or_default(); + json!({ + "output": e.output.0, "name": name(e.output), "cubeMap": e.cube.0, + "faces": e.faces.iter().enumerate().map(|(i, (c, cam))| json!({"face": FACES[i].0, "comp": c.0, "camera": cam.0})).collect::>(), + "view": view_of(p, e), + }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!(environments(&s.project).iter().map(|e| env_json(&s.project, e)).collect::>())) +} + +/// The environment `comp` (its output, cube map or a face comp), else the active comp's, else +/// the only one. +fn env_p(s: &Session, p: &Value, cmd: &str) -> Result { + let envs = environments(&s.project); + let want = p.get("comp").and_then(Value::as_u64).map(ItemId).or(s.state.active_comp); + let hit = |e: &VrEnv, c: ItemId| e.output == c || e.cube == c || e.faces.iter().any(|f| f.0 == c); + let i = want.and_then(|c| envs.iter().position(|e| hit(e, c))).or((envs.len() == 1).then_some(0)); + let i = i.ok_or_else(|| { + bad(cmd, if envs.is_empty() { "no VR environment (Composition ▸ VR ▸ Create VR Environment…)" } else { "give `comp`: a VR environment's output comp" }) + })?; + envs.into_iter().nth(i).ok_or(EngineError::NoComp) +} + +fn set_view(s: &mut Session, p: &Value) -> Result { + const C: &str = "comp.vr.setView"; + let env = env_p(s, p, C)?; + let mut o = view_of(&s.project, &env); + match p.get("orientation") { + Some(Value::Array(a)) => { + for (k, v) in a.iter().take(3).enumerate() { + o[k] = v.as_f64().ok_or_else(|| bad(C, "orientation: [x, y, z] degrees"))?; + } + } + Some(_) => return Err(bad(C, "orientation: [x, y, z] degrees")), + None => {} + } + // Pan (Y), tilt (X) and roll (Z) by name. + for (k, i) in [("tilt", 0), ("pan", 1), ("roll", 2)] { + if let Some(v) = p.get(k).and_then(Value::as_f64) { + o[i] = v; + } + } + let v = orient(o); + grouped(s, "VR View Orientation", |s| { + for (i, (fc, cam)) in env.faces.iter().enumerate() { + let e = euler(v * orient(base(i))); + s.execute("prop.set", json!({"comp": fc.0, "layer": cam.0, "path": "transform/orientation", "value": e}))?; + } + Ok(()) + })?; + Ok(json!({"output": env.output.0, "view": view_of(&s.project, &env)})) +} + +pub fn specs() -> Vec { + vec![ + query!("comp.vr.environments", "VR Environments", "{} → [{output, name, cubeMap, faces: [{face, comp, camera}], view: [x, y, z]}]", list), + cmd!( + "comp.vr.setView", + "VR View Orientation", + [], + None, + "{comp? (an environment's output / cube map / face comp), orientation?: [x, y, z] degrees, pan?, tilt?, roll?} → turns the six face cameras together", + always, + set_view + ), + ] +} diff --git a/crates/engine/src/commands/watch_folder.rs b/crates/engine/src/commands/watch_folder.rs new file mode 100644 index 000000000..b10cf8d50 --- /dev/null +++ b/crates/engine/src/commands/watch_folder.rs @@ -0,0 +1,154 @@ +//! File ▸ Watch Folder…: render farm without a farm. EffectCraft watches a folder for project +//! files (`.ecproj`, at the top level or one folder down, e.g. the folders File ▸ Dependencies ▸ +//! Collect Files writes) whose Render Queue has queued items, renders them, and writes a status +//! file next to each project (`.status.json`: `rendering`, then `done` or `failed`, with +//! every item's status and output path). A project with a status file is not picked up again; +//! delete the status file to render it once more. +//! +//! [`poll`] does one pass and is what the frontends call on a timer while a folder is watched +//! (`file.watchFolder.poll`); it is headless and synchronous, so tests and the CLI use it +//! directly. + +use std::path::{Path, PathBuf}; + +use effectcraft_project::render_queue::RenderStatus; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, str_p}; +use crate::{Result, Session, cmd}; + +/// The status file of a project. +pub fn status_path(project: &Path) -> PathBuf { + let mut s = project.as_os_str().to_owned(); + s.push(".status.json"); + PathBuf::from(s) +} + +/// Projects in `folder` (and its direct subfolders) without a status file, sorted. +pub fn pending(folder: &Path) -> std::io::Result> { + let mut out = vec![]; + let scan = |dir: &Path, out: &mut Vec, subdirs: &mut Vec| -> std::io::Result<()> { + for e in std::fs::read_dir(dir)? { + let p = e?.path(); + if p.is_dir() { + subdirs.push(p); + } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ecproj")) && !status_path(&p).exists() { + out.push(p); + } + } + Ok(()) + }; + let mut subdirs = vec![]; + scan(folder, &mut out, &mut subdirs)?; + for d in subdirs { + let mut ignore = vec![]; + let _ = scan(&d, &mut out, &mut ignore); + } + out.sort(); + Ok(out) +} + +fn write_status(path: &Path, v: &Value) { + let _ = std::fs::write(status_path(path), serde_json::to_vec_pretty(v).unwrap_or_default()); +} + +fn now() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// Render one project (in its own session sharing `host`'s services) and write its status. +fn render_project(host: &Session, path: &Path) -> Value { + let name = path.to_string_lossy().to_string(); + write_status(path, &json!({"project": name, "state": "rendering", "started": now()})); + let mut s = Session { services: host.services.clone(), footage: host.footage.clone(), expr: host.expr.clone(), ..Default::default() }; + s.importer = host.importer.clone(); + s.exporter = host.exporter.clone(); + s.accel = host.accel.clone(); + s.prefs = host.prefs.clone(); + let result = (|| -> std::result::Result { + s.execute("file.open", json!({"path": name})).map_err(|e| e.to_string())?; + let queued = s.project.render_queue.iter().filter(|i| i.render && i.status == RenderStatus::Queued).count(); + if queued == 0 { + return Ok(json!({"state": "done", "items": [], "note": "nothing queued"})); + } + s.start_render(true)?; + let items: Vec = s + .project + .render_queue + .iter() + .map(|i| { + json!({ + "comp": s.project.item(i.comp).map(|c| c.name.clone()), + "status": i.status.label(), + "error": if let RenderStatus::Failed(e) = &i.status { Some(e.clone()) } else { None }, + "output": i.last_output, + }) + }) + .collect(); + let failed = s.project.render_queue.iter().any(|i| matches!(i.status, RenderStatus::Failed(_))); + Ok(json!({"state": if failed { "failed" } else { "done" }, "items": items})) + })(); + let mut v = match result { + Ok(v) => v, + Err(e) => json!({"state": "failed", "error": e, "items": []}), + }; + v["project"] = json!(name); + v["finished"] = json!(now()); + write_status(path, &v); + v +} + +/// One pass over `folder`: render every new project; returns their statuses. +pub fn poll(s: &mut Session, folder: &str) -> std::result::Result, String> { + let projects = pending(Path::new(folder)).map_err(|e| format!("cannot read {folder}: {e}"))?; + Ok(projects.iter().map(|p| render_project(s, p)).collect()) +} + +fn watch(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.watchFolder"; + if p.get("stop").and_then(Value::as_bool) == Some(true) { + s.state.watch_folder = None; + s.bump(); + return Ok(json!({"watching": null})); + } + let folder = str_p(p, "folder").or(str_p(p, "path")).ok_or_else(|| bad(C, "missing `folder`"))?.to_string(); + if !Path::new(&folder).is_dir() { + return Err(bad(C, format!("{folder} is not a folder"))); + } + s.state.watch_folder = Some(folder.clone()); + s.bump(); + // The first pass right away. + let done = poll(s, &folder).map_err(|e| bad(C, e))?; + s.toast(format!("Watching {folder}")); + Ok(json!({"watching": folder, "rendered": done})) +} + +fn poll_cmd(s: &mut Session, p: &Value) -> Result { + const C: &str = "file.watchFolder.poll"; + let folder = str_p(p, "folder").map(str::to_string).or_else(|| s.state.watch_folder.clone()).ok_or_else(|| bad(C, "no folder is being watched"))?; + let done = poll(s, &folder).map_err(|e| bad(C, e))?; + Ok(json!({"watching": folder, "rendered": done})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.watchFolder", + "Watch Folder...", + ["File"], + None, + "{folder, stop?: bool} → watches the folder for .ecproj files with queued renders, renders them and writes `.status.json`", + always, + watch + ), + cmd!( + "file.watchFolder.poll", + "Poll Watch Folder", + [], + None, + "{folder? (default: the watched one)} → renders new projects now: [{project, state: done|failed, items: [{comp, status, output}]}]", + |s: &Session| if s.state.watch_folder.is_some() { Ok(()) } else { Err("no folder is being watched".into()) }, + poll_cmd + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64733d440..9213ff8db 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,6 +26,7 @@ pub mod offload; pub mod prefs; pub mod preview; pub mod psd_import; +pub mod remote; pub mod render_queue; pub mod roto; pub mod scriptui; @@ -219,6 +220,10 @@ pub struct EditorState { /// Essential Graphics can expose). #[serde(default)] pub essential_solo: bool, + /// File ▸ Watch Folder: the folder being watched for projects to render (frontends call + /// `file.watchFolder.poll` periodically while it is set). + #[serde(default)] + pub watch_folder: Option, /// The Footage panel: the footage shown, its time and In/Out marks (source time). #[serde(default)] pub footage_panel: Option, @@ -349,6 +354,11 @@ pub struct Session { pub offload: Option>, /// Analyses running in the [`Session::offload`] worker. pub offloaded: Vec, + /// What the Media Browser browses (`None`: the local file system where there is one). + pub browser: Option>, + /// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one + /// (`wait: true` over the control channel in the browser) learns its outcome. + pub offload_log: Vec<(offload::JobKind, Option)>, /// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`]. pub tasks: Vec, /// Finished tasks, newest last (Progress panel, `jobs.list`). @@ -421,6 +431,8 @@ impl Default for Session { applied_nested_switches: None, offload: None, offloaded: vec![], + offload_log: vec![], + browser: None, tasks: vec![], job_log: vec![], next_task_id: 1, @@ -786,12 +798,18 @@ mod tests_effects; #[cfg(test)] mod tests_essential; #[cfg(test)] +mod tests_essential_more; +#[cfg(test)] mod tests_face; #[cfg(test)] mod tests_fidelity; #[cfg(test)] +mod tests_frame_export; +#[cfg(test)] mod tests_lottie; #[cfg(test)] +mod tests_m137; +#[cfg(test)] mod tests_m145; #[cfg(test)] mod tests_markers; diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index afc7dc342..9b0a01da7 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -1,10 +1,16 @@ -//! Media Browser (Window ▸ Media Browser): browse the local file system for footage, keep -//! favourite folders, read file metadata, and import (`file.import`). Desktop only: the web build -//! has no file system to browse ([`available`] is false there). +//! Media Browser (Window ▸ Media Browser): browse for footage, keep favourite folders, read file +//! metadata, and import (`file.import`). +//! +//! What it browses is a [`Browser`]: the local file system on the desktop ([`FsBrowser`]); in the +//! web app, a virtual tree ([`VirtualTree`]) of the browser's storage and of folders the user +//! opened through the File System Access API, whose files are read only when imported +//! ([`Browser::fetch`]). //! //! Also the metadata the Metadata panel shows for project items and files (codec, size, rate, //! duration, colour profile, dates). +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -25,11 +31,153 @@ pub struct BrowserState { pub importable_only: bool, } -/// Whether this build can browse files. +/// Whether this build can browse the local file system ([`FsBrowser`]). pub fn available() -> bool { !cfg!(target_arch = "wasm32") } +/// Where the Media Browser browses. +pub trait Browser: Send + Sync { + /// The folder shown first. + fn home(&self) -> String; + /// Places listed above the favourites: (name, folder). The default is Home. + fn places(&self) -> Vec<(String, String)> { + vec![("Home".into(), self.home())] + } + /// A folder's entries: folders first, then files, by name. + fn list(&self, dir: &str, importable_only: bool) -> Result, String>; + /// The folder above `dir`. + fn parent(&self, dir: &str) -> Option { + parent(dir) + } + /// Make `paths` readable by the importer. `false`: they are being fetched, and the host runs + /// `mediaBrowser.import` with `params` again once they are (the web app reads opened folders' + /// files on demand). + fn fetch(&self, paths: &[String], params: &Value) -> bool { + let _ = (paths, params); + true + } + /// Extra toolbar actions: (id, label), run by `mediaBrowser.action`. + fn actions(&self) -> Vec<(String, String)> { + vec![] + } + fn action(&self, id: &str) -> Result { + Err(format!("unknown Media Browser action `{id}`")) + } +} + +/// The local file system (desktop). +pub struct FsBrowser; + +impl Browser for FsBrowser { + fn home(&self) -> String { + home() + } + fn list(&self, dir: &str, importable_only: bool) -> Result, String> { + list(dir, importable_only) + } +} + +impl crate::Session { + /// What the Media Browser browses: [`crate::Session::browser`], else the local file system + /// where this build has one. + pub fn media_browser(&self) -> Option> { + self.browser.clone().or_else(|| available().then(|| std::sync::Arc::new(FsBrowser) as std::sync::Arc)) + } +} + +/// One entry of a [`VirtualTree`]. +#[derive(Clone, Debug, PartialEq)] +struct VNode { + /// What importing it reads (`None`: a folder). + import: Option, + size: u64, + modified: Option, +} + +/// A browsable tree of virtual paths (`/Folder/sub/file.mov`), each file standing for an +/// importable path (the web app's file table, or a file of an opened folder). +#[derive(Clone, Debug, Default)] +pub struct VirtualTree { + nodes: BTreeMap, +} + +fn vparent(path: &str) -> Option<&str> { + if path == "/" || path.is_empty() { + return None; + } + let p = path.trim_end_matches('/'); + match p.rfind('/') { + Some(0) => Some("/"), + Some(i) => Some(&p[..i]), + None => None, + } +} + +impl VirtualTree { + /// Add a folder (and the folders above it). + pub fn add_dir(&mut self, path: &str) { + let mut cur = Some(path.trim_end_matches('/')); + while let Some(p) = cur.filter(|p| !p.is_empty() && *p != "/") { + self.nodes.entry(p.to_string()).or_insert(VNode { import: None, size: 0, modified: None }); + cur = vparent(p); + } + } + + /// Add a file shown at `path` that imports `import`. + pub fn add_file(&mut self, path: &str, import: &str, size: u64, modified: Option) { + if let Some(p) = vparent(path) { + self.add_dir(p); + } + self.nodes.insert(path.to_string(), VNode { import: Some(import.to_string()), size, modified }); + } + + /// Remove `path` and everything under it. + pub fn remove(&mut self, path: &str) { + let pre = format!("{}/", path.trim_end_matches('/')); + self.nodes.retain(|k, _| k != path && !k.starts_with(&pre)); + } + + pub fn is_dir(&self, path: &str) -> bool { + path == "/" || self.nodes.get(path.trim_end_matches('/')).is_some_and(|n| n.import.is_none()) + } + + /// What a file shown at `path` imports. + pub fn import_path(&self, path: &str) -> Option<&str> { + self.nodes.get(path).and_then(|n| n.import.as_deref()) + } + + pub fn list(&self, dir: &str, importable_only: bool) -> Result, String> { + let dir = if dir.len() > 1 { dir.trim_end_matches('/') } else { dir }; + if !self.is_dir(dir) { + return Err(format!("{dir}: no such folder")); + } + let mut out: Vec = self + .nodes + .iter() + .filter(|(k, _)| vparent(k) == Some(dir)) + .filter_map(|(k, n)| { + let name = k.rsplit('/').next().unwrap_or(k).to_string(); + let kind = n.import.as_deref().and_then(kind_of); + if importable_only && n.import.is_some() && kind.is_none() { + return None; + } + Some(Entry { + name, + // Folders browse by their virtual path; files import their own path. + path: n.import.clone().unwrap_or_else(|| k.clone()), + is_dir: n.import.is_none(), + size: n.size, + modified: n.modified, + kind, + }) + }) + .collect(); + out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(out) + } +} + /// One directory entry. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -261,6 +409,32 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn virtual_tree_lists_like_a_folder() { + let mut t = VirtualTree::default(); + t.add_file("/Browser Storage/clip.mov", "/clip.mov", 10, Some(5)); + t.add_file("/Folders/Shoot/B/b.png", "/Folders/Shoot/B/b.png", 3, None); + t.add_file("/Folders/Shoot/a.wav", "/Folders/Shoot/a.wav", 4, None); + t.add_file("/Folders/Shoot/notes.txt", "/Folders/Shoot/notes.txt", 1, None); + t.add_dir("/Folders/Empty"); + let names = |t: &VirtualTree, d: &str, only: bool| t.list(d, only).unwrap().into_iter().map(|e| e.name).collect::>(); + assert_eq!(names(&t, "/", false), ["Browser Storage", "Folders"]); + assert_eq!(names(&t, "/Folders", false), ["Empty", "Shoot"]); + assert_eq!(names(&t, "/Folders/Shoot", false), ["B", "a.wav", "notes.txt"]); + assert_eq!(names(&t, "/Folders/Shoot/", true), ["B", "a.wav"]); + let e = &t.list("/Browser Storage", false).unwrap()[0]; + assert_eq!((e.path.as_str(), e.size, e.modified, e.kind, e.is_dir), ("/clip.mov", 10, Some(5), Some("video"), false)); + let d = &t.list("/Folders", false).unwrap()[1]; + assert_eq!((d.path.as_str(), d.is_dir), ("/Folders/Shoot", true)); + assert_eq!(t.import_path("/Browser Storage/clip.mov"), Some("/clip.mov")); + assert!(t.list("/Folders/Shoot/a.wav", false).is_err(), "a file is not a folder"); + assert!(t.list("/Nope", false).is_err()); + t.remove("/Folders/Shoot"); + assert_eq!(names(&t, "/Folders", false), ["Empty"]); + assert_eq!(vparent("/a"), Some("/")); + assert_eq!(vparent("/"), None); + } + #[test] fn iso_dates() { assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index c286f5b94..b991be9b0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -505,6 +505,7 @@ File Placeholder... | file.importPlaceholder Solid... | file.importSolid Lottie... | file.importLottie + Vanishing Point (.vpe)... | file.importVanishingPoint Essential Graphics Template... | essential.importTemplate Import Recent Footage @dynamic:recentFootage @@ -528,6 +529,7 @@ File Find Missing Effects | file.findMissing {"what":"effects"} Find Missing Fonts | file.findMissing {"what":"fonts"} Find Missing Footage | file.findMissing {"what":"footage"} + Watch Folder... | file.watchFolder --- Scripts @dynamic:scripts @@ -664,9 +666,12 @@ Composition Audio | playback.audio Save Frame As File... | comp.saveFrameAs + Photoshop Layers... | comp.saveFrameAsPsd + ProEXR... | comp.saveFrameAsExr Pre-render... | render.preRender Save Current Preview... | render.saveCurrentPreview --- + Open in Essential Graphics | comp.openInEssentialGraphics Responsive Design — Time Create Intro | comp.responsiveTime {"op":"intro"} Create Outro | comp.responsiveTime {"op":"outro"} @@ -1165,6 +1170,8 @@ Window Timeline | window.panel {"panel":"timeline"} @dynamic:openViewers --- + Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"} + VR Comp Editor | window.panel {"panel":"vrCompEditor"} @dynamic:scriptPanels Help EffectCraft Help... | help.docs {"page":"help"} | F1 @@ -1174,6 +1181,9 @@ Help Animation Presets... | anim.browsePresets Keyboard Shortcuts... | app.keyboardShortcuts --- + In-App Tutorials... | help.inAppTutorials + Online Tutorials... | help.onlineTutorials + --- System Compatibility Report... | help.systemReport Enable Logging | help.enableLogging Reveal Logging File | help.revealLogFile diff --git a/crates/engine/src/offload.rs b/crates/engine/src/offload.rs index 3e92fbd45..712549bde 100644 --- a/crates/engine/src/offload.rs +++ b/crates/engine/src/offload.rs @@ -16,10 +16,11 @@ //! | 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 | +//! | Roto Brush propagation | the propagation | segmentations ([`WorkerReply::Segs`], streamed while it runs) go into the page's segmentation cache | //! | 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. +//! Viewer frames go to a separate, long-lived frame worker fed with project diffs +//! ([`crate::remote`]). //! //! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render //! Queue item being rendered becomes "User Stopped". @@ -76,6 +77,13 @@ pub enum WorkerJob { layer: LayerId, effect: Uid, }, + /// Roto Brush propagation: the segmentations come back as [`WorkerReply::Segs`]. + RotoPropagate { + comp: ItemId, + layer: LayerId, + effect: Uid, + direction: crate::roto::Direction, + }, Track { comp: ItemId, layer: LayerId, @@ -103,6 +111,7 @@ impl WorkerJob { WorkerJob::Warp { .. } => JobKind::Warp, WorkerJob::Camera { .. } => JobKind::Camera, WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze, + WorkerJob::RotoPropagate { .. } => JobKind::RotoPropagate, WorkerJob::Track { .. } => JobKind::Track, WorkerJob::MaskTrack { .. } => JobKind::MaskTrack, } @@ -112,9 +121,10 @@ impl WorkerJob { 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::Warp { comp, layer, effect } + | WorkerJob::Camera { comp, layer, effect } + | WorkerJob::RotoFreeze { comp, layer, effect } + | WorkerJob::RotoPropagate { 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)), } @@ -128,6 +138,7 @@ pub enum JobKind { Warp, Camera, RotoFreeze, + RotoPropagate, Track, MaskTrack, } @@ -140,6 +151,7 @@ impl JobKind { JobKind::Warp => "Warp Stabilizer Analysis", JobKind::Camera => "3D Camera Tracker Analysis", JobKind::RotoFreeze => "Freeze", + JobKind::RotoPropagate => "Roto Brush Propagation", JobKind::Track => "Analyze Track", JobKind::MaskTrack => "Track Mask", } @@ -170,6 +182,10 @@ pub enum WorkerReply { group: Box, message: String, }, + /// Roto Brush segmentations computed so far (propagation), not sent before. + Segs { + segs: Vec, + }, Failed { error: String, }, @@ -177,6 +193,41 @@ pub enum WorkerReply { Done, } +/// One full-resolution Roto Brush segmentation: its chain key and the base64 of +/// [`effectcraft_track::roto::FrameSeg::to_bytes`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SegData { + pub key: u64, + pub data: String, +} + +impl SegData { + pub fn new(key: u64, seg: &effectcraft_track::roto::FrameSeg) -> SegData { + SegData { key, data: effectcraft_track::roto::rle::base64_encode(&seg.to_bytes()) } + } + + /// Put the segmentation into this instance's cache (what the Roto Brush effect renders from). + pub fn store(&self) -> bool { + let Some(seg) = effectcraft_track::roto::rle::base64_decode(&self.data).and_then(|b| effectcraft_track::roto::FrameSeg::from_bytes(&b)) else { + return false; + }; + effectcraft_effects::roto::store(self.key, 1.0, Arc::new(seg)); + true + } +} + +/// The full-resolution segmentations of `keys` that are cached here and not in `sent` yet. +pub fn segs_to_send(keys: &[u64], sent: &mut std::collections::HashSet) -> Vec { + let new: Vec<(u64, Arc)> = + keys.iter().filter(|k| !sent.contains(k)).filter_map(|k| effectcraft_effects::roto::cached(*k, 1.0).map(|s| (*k, s))).collect(); + new.into_iter() + .map(|(k, s)| { + sent.insert(k); + SegData::new(k, &s) + }) + .collect() +} + /// Replies waiting for the UI thread. #[derive(Default)] pub struct Inbox(Mutex>); @@ -292,6 +343,7 @@ fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> { 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::RotoPropagate { comp, layer, effect, direction } => propagate(s, comp, layer, effect, direction, post), WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| { let work = crate::tracking::Work { project: s.project.clone(), @@ -323,6 +375,38 @@ fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> { } } +/// Roto Brush propagation in a worker: the segmentations stream back (at most every 250 ms) +/// as they are computed, so stopping the job keeps what was propagated. +fn propagate(s: &mut Session, comp: ItemId, layer: LayerId, effect: Uid, dir: crate::roto::Direction, post: Post) -> Result<(), String> { + let keys: Vec = s.roto_chain(comp, layer, effect).map(|c| c.keys.values().copied().collect()).ok_or("no Roto Brush strokes")?; + let sent = Rc::new(RefCell::new(std::collections::HashSet::new())); + let (p, k2, s2) = (post.clone(), keys.clone(), sent.clone()); + let mut last = None::; + let hook = Box::new(move |done: u64, total: u64| { + if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 250) { + last = Some(web_time::Instant::now()); + let segs = segs_to_send(&k2, &mut s2.borrow_mut()); + if !segs.is_empty() { + p(WorkerReply::Segs { segs }); + } + p(WorkerReply::Progress { done, total }); + } + }); + s.events.clear(); + with_hook(hook, || s.start_roto(comp, layer, effect, crate::roto::RotoTask::Propagate, dir, true))?; + let segs = segs_to_send(&keys, &mut sent.borrow_mut()); + if !segs.is_empty() { + post(WorkerReply::Segs { segs }); + } + if let Some(e) = s.events.iter().find_map(|e| match e { + Event::Toast { message, error: true } => Some(message.clone()), + _ => None, + }) { + return Err(e); + } + Ok(()) +} + fn analysis( s: &mut Session, kind: JobKind, @@ -398,6 +482,8 @@ pub struct OffloadedJob { pub uid: Uid, pub inbox: Arc, pub progress: OffloadProgress, + /// Segmentations received (Roto Brush propagation). + pub segs: u64, started: web_time::Instant, } @@ -432,7 +518,17 @@ impl Session { 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.offloaded.push(OffloadedJob { + id, + kind, + comp, + layer, + uid, + inbox, + progress: OffloadProgress::default(), + segs: 0, + started: web_time::Instant::now(), + }); self.events.push(Event::ProjectChanged { revision: self.revision }); Ok(()) } @@ -469,6 +565,12 @@ impl Session { j.progress.total = total; } WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)), + WorkerReply::Segs { segs } => { + for sd in &segs { + sd.store(); + } + j.segs += segs.len() as u64; + } WorkerReply::Failed { error } => j.progress.error = Some(error), WorkerReply::Done => j.progress.finished = true, WorkerReply::Render { .. } | WorkerReply::Item { .. } => {} @@ -495,17 +597,25 @@ impl Session { let mut done = vec![]; self.offloaded.retain(|j| { if j.progress.finished { - done.push((j.kind, j.progress.clone())); + done.push((j.kind, j.progress.clone(), j.segs)); } !j.progress.finished }); - for (kind, p) in done { + for (kind, p, segs) in done { changed = true; + self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string())))); + if self.offload_log.len() > 32 { + self.offload_log.remove(0); + } 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 }); + let kept = if segs > 0 { format!(" ({segs} frame(s) kept)") } else { String::new() }; + self.events.push(Event::Toast { message: format!("{} stopped{kept}", kind.label()), error: false }); + } else if kind == JobKind::RotoPropagate { + self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", segs, p.elapsed), error: false }); } + // Rendered frames depend on the result (an analysis' group, the segmentation cache). self.bump(); } changed @@ -567,6 +677,7 @@ mod tests { 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::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward }, WorkerJob::Track { comp, layer: LayerId(1), @@ -606,6 +717,7 @@ mod tests { 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::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] }, WorkerReply::Failed { error: "boom".into() }, WorkerReply::Done, ] { diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index 555dd97b2..efd63a9d6 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -158,7 +158,7 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> ( 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(), embedded: None }), + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: retain && !l.rect.is_empty(), embedded: None, placed: false }), ..Default::default() }; let id = proj.add_item(&format!("{name}/{file}"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); @@ -191,8 +191,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz let pl = &cx.psd.layers[index]; let so = pl.smart_object.clone()?; let data = cx.psd.linked_data(&so.uuid)?; - let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?; + let (cw, ch) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?; let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default(); + // Perspective quads and warps are baked over the placed bounds, at up to 4× the document + // resolution when the embedded content has that much detail. + let bake = so.needs_bake(); + let (w, h, k, bounds) = if bake { + let bb = so.placed_bounds(cw as f64, ch as f64); + let area = ((bb[2] - bb[0]) * (bb[3] - bb[1])).max(1.0); + let k = ((cw as f64 * ch as f64) / area).sqrt().clamp(1.0, 4.0); + let (w, h) = so.placed_size(cw as f64, ch as f64, k); + (w, h, k, bb) + } else { + (cw, ch, 1.0, [0.0; 4]) + }; let f = Footage { path: cx.path.to_string(), kind: FootageKind::Still, @@ -204,13 +216,20 @@ fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usiz alpha: AlphaMode::Straight, loop_count: 1, codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() }, - layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }), + layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()), placed: bake }), ..Default::default() }; let label = if file.is_empty() { name.to_string() } else { file }; let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f)); cx.items.push(id); let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None); + if bake { + // The baked pixels cover the placed bounds: centred there, scaled back to 1×. + let c = [bounds[0] + w as f64 / k / 2.0, bounds[1] + h as f64 / k / 2.0]; + set_vec2(&mut l, "transform/position", c); + set_vec2(&mut l, "transform/scale", [100.0 / k, 100.0 / k]); + return Some(l); + } let (c, sx, sy, rot) = so.placement(w as f64, h as f64); set_vec2(&mut l, "transform/position", c); set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]); diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs new file mode 100644 index 000000000..ec0640ffa --- /dev/null +++ b/crates/engine/src/remote.rs @@ -0,0 +1,384 @@ +//! Viewer frames rendered by another engine instance (the browser's frame worker). +//! +//! The page keeps no frame threads (a wasm build without shared memory has one thread per +//! instance), so the web app renders viewer frames in a long-lived Web Worker that holds a +//! replica of the project. The page sends the replica **project diffs** rather than the whole +//! project per edit, then render requests; the worker answers each with the frame's pixels. +//! +//! - [`diff`] / [`patch`]: a structural JSON diff of the serialized project (object members +//! added, changed or removed; arrays element-wise when their length is unchanged, else +//! replaced). Lossless (unlike JSON Merge Patch, `null` values survive). +//! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a +//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards). +//! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render +//! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]). +//! +//! The transport (structured-clone messages, transferable pixel buffers) is the web crate's; +//! everything here is portable and tested natively. + +use std::sync::Arc; + +use effectcraft_project::{ItemId, Project}; +use effectcraft_render::{ExprHost, FootageSource, Image, LayerCache, RenderOpts, Renderer}; +use effectcraft_time::Tick; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::offload::SegData; + +/// One step of a path into a JSON document. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Step { + Key(String), + Index(usize), +} + +/// One change: set the value at `path` (creating or replacing the member/element), or remove +/// the object member at `path` (`value` absent). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Op { + pub path: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +/// The changes that turn `old` into `new` (empty when equal). +pub fn diff(old: &Value, new: &Value) -> Vec { + let mut ops = vec![]; + diff_into(old, new, &mut vec![], &mut ops); + ops +} + +fn diff_into(old: &Value, new: &Value, path: &mut Vec, ops: &mut Vec) { + if old == new { + return; + } + match (old, new) { + (Value::Object(a), Value::Object(b)) => { + for (k, va) in a { + path.push(Step::Key(k.clone())); + match b.get(k) { + Some(vb) => diff_into(va, vb, path, ops), + None => ops.push(Op { path: path.clone(), value: None }), + } + path.pop(); + } + for (k, vb) in b { + if !a.contains_key(k) { + path.push(Step::Key(k.clone())); + ops.push(Op { path: path.clone(), value: Some(vb.clone()) }); + path.pop(); + } + } + } + (Value::Array(a), Value::Array(b)) if a.len() == b.len() => { + for (i, (va, vb)) in a.iter().zip(b).enumerate() { + path.push(Step::Index(i)); + diff_into(va, vb, path, ops); + path.pop(); + } + } + _ => ops.push(Op { path: path.clone(), value: Some(new.clone()) }), + } +} + +/// Apply [`diff`]'s changes to `doc`. +pub fn patch(doc: &mut Value, ops: &[Op]) -> Result<(), String> { + for op in ops { + let Some((last, parents)) = op.path.split_last() else { + *doc = op.value.clone().ok_or("cannot remove the document")?; + continue; + }; + let mut cur = &mut *doc; + for st in parents { + cur = match (st, cur) { + (Step::Key(k), Value::Object(m)) => m.get_mut(k).ok_or_else(|| format!("no member `{k}`"))?, + (Step::Index(i), Value::Array(a)) => a.get_mut(*i).ok_or_else(|| format!("no element {i}"))?, + _ => return Err("path does not match the document".into()), + }; + } + match (last, cur, &op.value) { + (Step::Key(k), Value::Object(m), Some(v)) => { + m.insert(k.clone(), v.clone()); + } + (Step::Key(k), Value::Object(m), None) => { + m.remove(k); + } + (Step::Index(i), Value::Array(a), Some(v)) => *a.get_mut(*i).ok_or_else(|| format!("no element {i}"))? = v.clone(), + _ => return Err("path does not match the document".into()), + } + } + Ok(()) +} + +/// Page → frame worker. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FrameMsg { + /// The whole project (JSON) at `revision`. + Project { revision: u64, project: String }, + /// Changes from the worker's revision `from` to `revision`. + Patch { from: u64, revision: u64, ops: Vec }, + /// Render a frame of the current project. + Render { id: u64, revision: u64, comp: ItemId, time: Tick, opts: Box }, + /// Roto Brush segmentations computed elsewhere (a propagation worker). + Segs { segs: Vec }, + /// Drop cached layer buffers (Edit ▸ Purge). + Purge, +} + +/// Frame worker → page. A [`FrameReply::Frame`]'s pixels travel next to the message (a +/// transferable buffer in the browser). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FrameReply { + /// Premultiplied RGBA8 pixels of request `id`, rendered in `ms` milliseconds. + Frame { id: u64, width: u32, height: u32, ms: f64 }, + /// Request `id` was not rendered (`stale`: the worker holds another revision). + Failed { id: u64, error: String, stale: bool }, + /// A sync could not be applied: the page must send the whole project again. + Resync { error: String }, +} + +/// The page's record of a worker's replica. +#[derive(Default)] +pub struct Mirror { + sent: Option<(u64, Value)>, +} + +impl Mirror { + /// The revision the worker holds. + pub fn revision(&self) -> Option { + self.sent.as_ref().map(|s| s.0) + } + + /// Forget the replica (the next sync sends the whole project). + pub fn reset(&mut self) { + self.sent = None; + } + + /// The message that brings the worker to `revision` of `project` (`None` when it has it). + pub fn sync(&mut self, revision: u64, project: &Project) -> Option { + if self.revision() == Some(revision) { + return None; + } + let v = serde_json::to_value(project).unwrap_or(Value::Null); + let msg = match self.sent.take() { + Some((from, old)) => FrameMsg::Patch { from, revision, ops: diff(&old, &v) }, + None => FrameMsg::Project { revision, project: v.to_string() }, + }; + self.sent = Some((revision, v)); + Some(msg) + } +} + +/// Premultiplied f32 → premultiplied RGBA8 (colour clamped to alpha), as the viewer shows it. +pub fn rgba8_premultiplied(img: &Image) -> Vec { + let mut out = Vec::with_capacity(img.data.len() * 4); + for p in &img.data { + let a = p[3].clamp(0.0, 1.0); + let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8; + out.extend_from_slice(&[c(p[0]), c(p[1]), c(p[2]), (a * 255.0 + 0.5) as u8]); + } + out +} + +/// The worker's side: a project replica and what rendering needs. +pub struct FrameServer { + pub footage: Arc, + pub expr: Option>, + pub cache: Arc, + doc: Value, + project: Option>, + revision: Option, +} + +impl FrameServer { + pub fn new(footage: Arc, expr: Option>) -> FrameServer { + FrameServer { footage, expr, cache: Arc::new(LayerCache::default()), doc: Value::Null, project: None, revision: None } + } + + pub fn revision(&self) -> Option { + self.revision + } + + pub fn project(&self) -> Option<&Arc> { + self.project.as_ref() + } + + fn load(&mut self, revision: u64) -> Result<(), String> { + let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?; + self.project = Some(Arc::new(p)); + self.revision = Some(revision); + Ok(()) + } + + /// Handle one message; returns the replies with their pixels. + pub fn handle(&mut self, msg: FrameMsg) -> Vec<(FrameReply, Option>)> { + match msg { + FrameMsg::Project { revision, project } => { + let r = serde_json::from_str(&project).map_err(|e| e.to_string()).and_then(|v| { + self.doc = v; + self.load(revision) + }); + match r { + Ok(()) => vec![], + Err(e) => self.lost(e), + } + } + FrameMsg::Patch { from, revision, ops } => { + if self.revision != Some(from) { + return self.lost(format!("the replica is at {:?}, the patch starts at {from}", self.revision)); + } + match patch(&mut self.doc, &ops).and_then(|_| self.load(revision)) { + Ok(()) => vec![], + Err(e) => self.lost(e), + } + } + FrameMsg::Segs { segs } => { + for s in &segs { + s.store(); + } + vec![] + } + FrameMsg::Purge => { + self.cache.clear(); + vec![] + } + FrameMsg::Render { id, revision, comp, time, opts } => { + let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)]; + if self.revision != Some(revision) { + return fail(format!("the replica is at {:?}, not {revision}", self.revision), true); + } + let Some(p) = self.project.clone() else { return fail("no project".into(), true) }; + if p.comp(comp).is_none() { + return fail(format!("no composition {}", comp.0), false); + } + let t0 = web_time::Instant::now(); + let mut r = Renderer::new(&p, self.footage.as_ref(), *opts); + r.expr = self.expr.as_deref(); + r.cache = Some(&self.cache); + let img = r.comp_frame(comp, time); + let ms = t0.elapsed().as_secs_f64() * 1000.0; + vec![(FrameReply::Frame { id, width: img.width, height: img.height, ms }, Some(rgba8_premultiplied(&img)))] + } + } + } + + fn lost(&mut self, error: String) -> Vec<(FrameReply, Option>)> { + self.doc = Value::Null; + self.project = None; + self.revision = None; + vec![(FrameReply::Resync { error }, None)] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + use serde_json::json; + + fn round Deserialize<'de>>(v: &T) -> T { + serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap() + } + + #[test] + fn diff_and_patch_round_trip() { + let a = json!({"a": 1, "b": [1, 2, {"c": null}], "d": {"e": "x", "f": [1]}, "g": null}); + let b = json!({"a": 2, "b": [1, 3, {"c": 4}], "d": {"f": [1, 2]}, "g": null, "h": {"i": null}}); + let ops = diff(&a, &b); + assert!(!ops.is_empty()); + let mut x = a.clone(); + patch(&mut x, &round(&ops)).unwrap(); + assert_eq!(x, b); + // Removal and the reverse direction. + let mut y = b.clone(); + patch(&mut y, &diff(&b, &a)).unwrap(); + assert_eq!(y, a); + assert!(diff(&a, &a).is_empty()); + // A whole-document change. + let mut z = json!(1); + patch(&mut z, &diff(&json!(1), &json!([1]))).unwrap(); + assert_eq!(z, json!([1])); + // A patch for another document fails cleanly. + assert!(patch(&mut json!(3), &diff(&a, &b)).is_err()); + } + + fn server(s: &Session) -> FrameServer { + FrameServer::new(s.footage.clone(), s.expr.clone()) + } + + /// Pixels the page itself would render. + fn local(s: &Session, comp: ItemId, t: Tick, opts: RenderOpts) -> Vec { + let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts); + r.expr = s.expr.as_deref(); + rgba8_premultiplied(&r.comp_frame(comp, t)) + } + + #[test] + fn replica_follows_edits_through_diffs_and_renders_the_same_frames() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() }; + let mut m = Mirror::default(); + let mut w = server(&s); + let send = |m: FrameMsg, w: &mut FrameServer| w.handle(round(&m)); + // First sync: the whole project. + let first = m.sync(s.revision, &s.project).unwrap(); + assert!(matches!(first, FrameMsg::Project { .. })); + assert!(send(first, &mut w).is_empty()); + assert!(m.sync(s.revision, &s.project).is_none(), "nothing new"); + let t = Tick::from_seconds_f64(1.0); + let render = |w: &mut FrameServer, rev: u64, id: u64| w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts) })); + let out = render(&mut w, s.revision, 1); + let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) }; + assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len())); + assert_eq!(*px, local(&s, comp, t, opts)); + // An edit travels as a small diff; the replica renders the edited frame. + s.execute("layer.newSolid", json!({"color": "#ff8800", "name": "Orange"})).unwrap(); + let msg = m.sync(s.revision, &s.project).unwrap(); + let FrameMsg::Patch { ops, .. } = &msg else { panic!("expected a patch") }; + assert!(!ops.is_empty() && serde_json::to_string(ops).unwrap().len() < s.project.to_json().len() / 2); + assert!(send(msg, &mut w).is_empty()); + assert_eq!(**w.project().unwrap(), *s.project); + let out = render(&mut w, s.revision, 2); + assert_eq!(out[0].1.as_deref(), Some(&local(&s, comp, t, opts)[..])); + // A request for another revision is refused as stale. + let out = render(&mut w, s.revision + 7, 3); + assert!(matches!(out[0].0, FrameReply::Failed { id: 3, stale: true, .. })); + // A patch from the wrong base asks for a resync; the mirror then sends the project. + let bad = FrameMsg::Patch { from: 999, revision: 1000, ops: vec![] }; + assert!(matches!(send(bad, &mut w)[0].0, FrameReply::Resync { .. })); + m.reset(); + assert!(matches!(m.sync(s.revision, &s.project), Some(FrameMsg::Project { .. }))); + } + + #[test] + fn frame_messages_round_trip() { + for m in [ + FrameMsg::Purge, + FrameMsg::Segs { segs: vec![SegData { key: 7, data: "AAAA".into() }] }, + FrameMsg::Render { + id: 3, + revision: 4, + comp: ItemId(2), + time: Tick::from_seconds_f64(0.5), + opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }), + }, + ] { + let a = serde_json::to_string(&m).unwrap(); + assert_eq!(serde_json::to_string(&round(&m)).unwrap(), a); + assert!(a.starts_with("{\"type\":"), "{a}"); + } + for r in [ + FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5 }, + FrameReply::Failed { id: 1, error: "x".into(), stale: true }, + FrameReply::Resync { error: "y".into() }, + ] { + assert_eq!(round(&r), r); + } + } +} diff --git a/crates/engine/src/roto.rs b/crates/engine/src/roto.rs index b6086d4ed..2c1828f85 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/roto.rs @@ -3,7 +3,8 @@ //! //! Segmentations are derived data in `effectcraft_effects::roto`'s content-keyed cache (see that //! module). `roto.propagate` fills it at full resolution in the background (or blocking with -//! `wait`), frame by frame outward from the base frame, rendering the effect's input +//! `wait`; in the browser the background is a Web Worker whose segmentations stream back, +//! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input //! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same //! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made @@ -403,16 +404,21 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction) } impl Session { + /// The Roto Brush job running in a worker (Freeze or propagation), with its task. + fn offloaded_roto(&self) -> Option<(&crate::offload::OffloadedJob, RotoTask)> { + self.offloaded(JobKind::RotoFreeze).map(|j| (j, RotoTask::Freeze)).or_else(|| self.offloaded(JobKind::RotoPropagate).map(|j| (j, RotoTask::Propagate))) + } + pub fn is_roto_running(&self) -> bool { - self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some() + self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded_roto().is_some() } pub fn roto_progress(&self) -> Option { self.roto_job.as_ref().map(|j| j.progress()).or_else(|| { - self.offloaded(JobKind::RotoFreeze).map(|j| { + self.offloaded_roto().map(|(j, task)| { let p = &j.progress; RotoProgress { - task: Some(RotoTask::Freeze), + task: Some(task), done: p.done, total: p.total, elapsed: p.elapsed, @@ -425,14 +431,22 @@ impl Session { /// (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)) - .or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze))) + self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task)).or_else(|| self.offloaded_roto().map(|(j, task)| (j.comp, j.layer, j.uid, task))) + } + + /// The segmentation chain a propagation of `effect` computes (its frames' cache keys). + pub fn roto_chain(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option { + let c = self.project.comp(comp)?; + let l = c.layer(layer)?; + let g = l.effects()?.groups().find(|g| g.uid == effect && is_roto(g))?; + let d = fx::data(¶ms_static(g)); + let t = comp_time_of(c, l, d.base?); + let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref()); + Some(fx::Chain::new(¶ms, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0)) } pub fn stop_roto(&mut self) -> bool { - if self.cancel_offloaded(JobKind::RotoFreeze) { + if self.cancel_offloaded(JobKind::RotoFreeze) || self.cancel_offloaded(JobKind::RotoPropagate) { return true; } match &self.roto_job { @@ -491,9 +505,12 @@ impl Session { task, }; let strokes = str_of(g, fx::STROKES); - if !wait && task == RotoTask::Freeze && self.offloads() { + if !wait && self.offloads() { drop(work); - self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?; + self.offload_analysis(match task { + RotoTask::Freeze => crate::offload::WorkerJob::RotoFreeze { comp, layer, effect }, + RotoTask::Propagate => crate::offload::WorkerJob::RotoPropagate { comp, layer, effect, direction: dir }, + })?; return Ok(n); } let wait = wait || cfg!(target_arch = "wasm32"); @@ -567,7 +584,7 @@ impl Session { /// instances in the background. pub fn poll_roto(&mut self, auto: bool) -> bool { let changed = self.poll_roto_job(); - if auto && self.roto_job.is_none() && self.state.roto.auto_propagate { + if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate { while let Some((c, l, e)) = self.roto_pending.first().copied() { self.roto_pending.remove(0); if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() { diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 2a76ba04c..1b88bce74 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -112,9 +112,103 @@ pub struct Widget { pub handlers: Vec, /// The active tab of a tabbed panel (child index). pub active_tab: usize, + /// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates. + /// Frontends paint it instead of the control's default look, unless it contains + /// [`DrawOp::Os`] (`drawOSControl()`), which draws the default look first. + pub draw: Vec, pub children: Vec, } +/// One segment of a ScriptUIGraphics path (control coordinates, pixels). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "k")] +pub enum PathSeg { + /// `moveTo(x, y)`: starts a new subpath. + M { x: f64, y: f64 }, + /// `lineTo(x, y)`. + L { x: f64, y: f64 }, + /// `rectPath(x, y, w, h)`: a closed rectangle subpath. + R { x: f64, y: f64, w: f64, h: f64 }, + /// `ellipsePath(x, y, w, h)`: a closed ellipse inscribed in the rectangle. + E { x: f64, y: f64, w: f64, h: f64 }, + /// `closePath()`. + Z, +} + +/// One paint call of an `onDraw` handler. Colours are straight RGBA in 0..1; `None` is a theme +/// colour (a brush or pen made from a theme colour name). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "camelCase")] +pub enum DrawOp { + /// `fillPath(brush, path)`. + Fill { color: Option<[f32; 4]>, path: Vec }, + /// `strokePath(pen, path)`. + Stroke { color: Option<[f32; 4]>, width: f64, path: Vec }, + /// `drawString(text, pen, x, y, font)`: `(x, y)` is the text's top-left. + Text { + text: String, + color: Option<[f32; 4]>, + x: f64, + y: f64, + size: f64, + #[serde(default)] + style: String, + }, + /// `drawOSControl()`: the control's default look. + Os, + /// `drawImage(image, x, y, w?, h?)` (drawn as a placeholder frame). + Image { x: f64, y: f64, w: f64, h: f64 }, +} + +impl PathSeg { + /// The subpaths of `path` as polylines (ellipses flattened to `n` segments), each with + /// whether it is closed. + pub fn polylines(path: &[PathSeg], n: usize) -> Vec<(Vec<[f64; 2]>, bool)> { + fn flush(cur: &mut Vec<[f64; 2]>, out: &mut Vec<(Vec<[f64; 2]>, bool)>, closed: bool) { + if cur.len() > 1 { + out.push((std::mem::take(cur), closed)); + } else { + cur.clear(); + } + } + let mut out = vec![]; + let mut cur: Vec<[f64; 2]> = vec![]; + for seg in path { + match *seg { + PathSeg::M { x, y } => { + flush(&mut cur, &mut out, false); + cur.push([x, y]); + } + PathSeg::L { x, y } => cur.push([x, y]), + PathSeg::Z => { + let start = cur.first().copied(); + flush(&mut cur, &mut out, true); + // Drawing continues from the subpath's start. + cur.extend(start); + } + PathSeg::R { x, y, w, h } => { + flush(&mut cur, &mut out, false); + out.push((vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]], true)); + } + PathSeg::E { x, y, w, h } => { + flush(&mut cur, &mut out, false); + let (cx, cy, rx, ry) = (x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0); + let n = n.max(8); + let pts = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + [cx + rx * a.cos(), cy + ry * a.sin()] + }) + .collect(); + out.push((pts, true)); + } + } + } + flush(&mut cur, &mut out, false); + out + } +} + impl Widget { pub fn find(&self, id: u32) -> Option<&Widget> { if self.id == id { @@ -493,7 +587,8 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result { let i = w.items.iter().position(|it| it == t).ok_or_else(|| crate::commands::bad(c, format!("no item `{t}` (items: {:?})", w.items)))?; value = json!(i); } - dispatch(s, ScriptUiEvent { window, widget, kind: "change".into(), value }) + let kind = if p.get("changing").and_then(Value::as_bool) == Some(true) { "changing" } else { "change" }; + dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value }) } /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). @@ -557,6 +652,31 @@ mod tests { assert_eq!(win.root.lookup(&json!(1)).unwrap().id, 1); } + #[test] + fn draw_lists_flatten_to_polylines_and_round_trip() { + let path = vec![ + PathSeg::M { x: 0.0, y: 0.0 }, + PathSeg::L { x: 10.0, y: 0.0 }, + PathSeg::L { x: 10.0, y: 10.0 }, + PathSeg::Z, + PathSeg::R { x: 1.0, y: 2.0, w: 3.0, h: 4.0 }, + PathSeg::E { x: 0.0, y: 0.0, w: 20.0, h: 10.0 }, + ]; + let lines = PathSeg::polylines(&path, 16); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], (vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], true)); + assert_eq!(lines[1].0[2], [4.0, 6.0]); + assert_eq!(lines[2].0.len(), 16); + assert!((lines[2].0[0][0] - 20.0).abs() < 1e-9 && (lines[2].0[4][1] - 10.0).abs() < 1e-9); + let op = DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path }; + let j = serde_json::to_value(&op).unwrap(); + assert_eq!(j["op"], "fill"); + assert_eq!(j["path"][0], json!({"k": "M", "x": 0.0, "y": 0.0})); + assert_eq!(serde_json::from_value::(j).unwrap(), op); + let t: DrawOp = serde_json::from_value(json!({"op": "text", "text": "Hi", "color": null, "x": 1, "y": 2, "size": 14})).unwrap(); + assert!(matches!(t, DrawOp::Text { size, .. } if size == 14.0)); + } + #[test] fn publish_replaces_a_hosts_windows() { let mut ui = ScriptUi::default(); diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 96cd5a298..ecb7f44ae 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -48,8 +48,12 @@ impl Session { if !s.is_empty() { return Some(std::path::PathBuf::from(s)); } + // (the browser has no file system: no default folder there) + if self.config.is_none() || cfg!(target_arch = "wasm32") { + return None; + } let base = effectcraft_render::disk_cache::default_folder(); - self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name))) + Some(base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name))) } /// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries). diff --git a/crates/engine/src/tests_essential_more.rs b/crates/engine/src/tests_essential_more.rs new file mode 100644 index 000000000..a8d8dc808 --- /dev/null +++ b/crates/engine/src/tests_essential_more.rs @@ -0,0 +1,104 @@ +//! Essential Graphics Font and uniform Scale controls, Composition ▸ Open in Essential Graphics. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::essential::{self, ControlType}; +use serde_json::json; + +use crate::{Event, Session}; + +#[test] +fn font_and_scale_controls_override_per_instance() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + // Source Text: as text and as font; Scale as one slider. + let t = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let f = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + let sc = s.execute("essential.addProperty", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["controls"][0].as_u64().unwrap(); + // Adding the same font control again adds nothing with `mirror: false` (else a mirror of + // it); opacity can't be a font. + assert!( + s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font", "mirror": false})).unwrap()["controls"] + .as_array() + .unwrap() + .is_empty() + ); + let m = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap(); + assert_eq!(m["mirrors"].as_array().unwrap().len(), 1); + let ml = s.execute("essential.list", json!({})).unwrap(); + let mirror = ml["controls"].as_array().unwrap().iter().find(|c| c["kind"] == "mirror").unwrap().clone(); + assert_eq!((mirror["of"].as_u64(), mirror["type"].as_str()), (Some(f), Some("font"))); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.execute("essential.addProperty", json!({"layer": text, "path": "transform/opacity", "as": "font"})).is_err()); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/opacity", "as": "font"})).unwrap()["ok"], false); + assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["type"], "scale"); + let l = s.execute("essential.list", json!({})).unwrap(); + let types: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["text", "font", "scale"]); + assert!(l["controls"][1]["value"]["size"].as_f64().unwrap() > 0.0); + let eg = s.project.comp(title).unwrap().essential.clone().unwrap(); + assert_eq!(eg.find(f).unwrap().as_type, Some(ControlType::Font)); + // Survives save / load (serde). + let j = serde_json::to_value(&eg).unwrap(); + assert_eq!(serde_json::from_value::(j).unwrap(), eg); + + // An instance: change the text, then the font and the scale. + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [inst]})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": t, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": f, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": sc, "value": 50})).unwrap(); + let i = s.execute("essential.instance", json!({"layer": inst})).unwrap(); + assert_eq!(i["controls"][1]["value"]["font"], "Serif Test"); + assert_eq!(i["controls"][1]["value"]["size"], 40.0); + assert_eq!(i["controls"][1]["overridden"], true); + assert_eq!(i["controls"][2]["type"], "scale"); + assert_eq!(i["controls"][2]["value"][0], 50.0); + assert_eq!(i["controls"][2]["value"][1], 50.0); + // What the instance renders: the overridden text with the overridden font, scaled. + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(inst)).unwrap().clone(); + let over: Vec = { + let g = essential::group(&layer).unwrap(); + let mut v = vec![]; + g.walk("", &mut |_, p| { + if let Some(c) = essential::control_of(&p.match_id) { + v.push(essential::Override { control: c, value: p.value.clone() }); + } + }); + v + }; + let p = essential::with_overrides(&s.project, title, &over).unwrap(); + let src = p.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match src.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } + // Push to Comp with only the font: the source keeps its own text. + s.execute("essential.revert", json!({"layer": inst, "control": t})).unwrap(); + s.execute("essential.pushToComp", json!({"layer": inst, "control": f})).unwrap(); + let srcl = s.project.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap(); + match srcl.props.prop("text/sourceText").map(|x| &x.value) { + Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str()), ("Hello", "Serif Test")), + o => panic!("{o:?}"), + } +} + +#[test] +fn open_in_essential_graphics_sets_the_primary_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 1})).unwrap(); + let a = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 1})).unwrap(); + s.drain_events(); + let r = s.execute("comp.openInEssentialGraphics", json!({"comp": a.0})).unwrap(); + assert_eq!(r["name"], "A"); + assert_eq!(s.state.essential_primary, Some(a)); + assert!( + s.drain_events() + .iter() + .any(|e| matches!(e, Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "essentialGraphics")) + ); + assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); +} diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs new file mode 100644 index 000000000..4866619de --- /dev/null +++ b/crates/engine/src/tests_frame_export.rs @@ -0,0 +1,78 @@ +//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…. + +use serde_json::json; + +use crate::Session; + +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 32×32 comp: a red full-frame solid under a 16×16 blue Multiply solid at 50%. +fn frame() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000"})).unwrap(); + let blue = s.execute("layer.newSolid", json!({"name": "Blue.Box", "color": "#0000ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setBlendMode", json!({"layers": [blue], "mode": "Multiply"})).unwrap(); + s.execute("prop.set", json!({"layer": blue, "path": "transform/opacity", "value": 50})).unwrap(); + s +} + +#[test] +fn photoshop_layers_writes_one_psd_layer_per_comp_layer() { + let mut s = frame(); + let dir = out_dir("frame-psd"); + let path = dir.join("frame.psd").to_string_lossy().to_string(); + let r = s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 2); + let psd = effectcraft_psd::Psd::parse(std::fs::read(&path).unwrap()).unwrap(); + assert_eq!((psd.width, psd.height), (32, 32)); + let names: Vec<&str> = psd.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Red", "Blue.Box"], "bottom first"); + let blue = &psd.layers[1]; + assert_eq!(blue.blend_key(), "mul "); + assert_eq!(blue.opacity, 128); + // Cropped to its pixels (16×16, centred) and rendered at full opacity. + assert_eq!((blue.rect.left, blue.rect.top, blue.rect.width(), blue.rect.height()), (8, 8, 16, 16)); + assert_eq!(psd.layers[0].blend_key(), "norm"); + assert_eq!(psd.layers[0].opacity, 255); + // The merged image is the comp frame: red × (50% blue multiply) in the middle. + let m = psd.composite().unwrap(); + let mid = m.data[16 * 32 + 16]; + assert!(mid[0] > 0.4 && mid[0] < 0.6 && mid[2] < 0.05, "{mid:?}"); + let corner = m.data[0]; + assert!(corner[0] > 0.99 && corner[2] < 0.01, "{corner:?}"); + // The menu has it. + assert!(crate::commands::find("comp.saveFrameAsPsd").is_some()); +} + +#[test] +fn proexr_writes_layer_prefixed_channels_and_the_composite() { + use exr::prelude::*; + let mut s = frame(); + let dir = out_dir("frame-exr"); + let path = dir.join("frame.exr").to_string_lossy().to_string(); + let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap(); + let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect(); + assert_eq!(names.len(), 12); + assert!(names.contains(&"R") && names.contains(&"A") && names.contains(&"Red.R") && names.contains(&"Blue_Box.B"), "{names:?}"); + let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(&path).unwrap(); + let layer = &img.layer_data[0]; + assert_eq!((layer.size.0, layer.size.1), (32, 32)); + let ch = |n: &str| layer.channel_data.list.iter().find(|c| c.name.to_string() == n).unwrap_or_else(|| panic!("no channel {n}")); + let at = |n: &str, x: usize, y: usize| match &ch(n).sample_data { + FlatSamples::F32(v) => v[y * 32 + x], + o => panic!("{o:?}"), + }; + // The blue layer alone: blue inside its box, transparent outside. + assert!((at("Blue_Box.B", 16, 16) - 1.0).abs() < 1e-3); + assert_eq!(at("Blue_Box.A", 0, 0), 0.0); + assert!((at("Red.R", 0, 0) - 1.0).abs() < 1e-3); + // Composite: opaque everywhere. + assert!((at("A", 16, 16) - 1.0).abs() < 1e-3); + assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)"); +} diff --git a/crates/engine/src/tests_m137.rs b/crates/engine/src/tests_m137.rs new file mode 100644 index 000000000..13abe9c64 --- /dev/null +++ b/crates/engine/src/tests_m137.rs @@ -0,0 +1,167 @@ +//! M13.7 menu items: File ▸ Watch Folder, Import ▸ Vanishing Point (disabled), Create Nulls From +//! Paths, VR Comp Editor and Help ▸ In-App / Online Tutorials. + +use std::sync::Arc; + +use serde_json::json; + +use crate::rq_tests::MockExporter; +use crate::{Event, Session}; + +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 +} + +#[test] +fn watch_folder_renders_queued_projects_and_writes_status() { + let dir = out_dir("watch-folder"); + let ex = Arc::new(MockExporter { log: Default::default() }); + // A project with a queued render, and one (in a subfolder) with nothing queued. + let mut a = Session { exporter: Some(ex.clone()), ..Default::default() }; + a.execute("comp.new", json!({"name": "Shot", "width": 32, "height": 32, "frameRate": 10, "duration": 0.5})).unwrap(); + a.execute("renderQueue.add", json!({})).unwrap(); + let pa = dir.join("a.ecproj"); + a.execute("file.saveAs", json!({"path": pa.to_string_lossy()})).unwrap(); + std::fs::create_dir_all(dir.join("collected")).unwrap(); + let mut b = Session::default(); + b.execute("comp.new", json!({"name": "Idle", "width": 32, "height": 32, "duration": 1})).unwrap(); + let pb = dir.join("collected/b.ecproj"); + b.execute("file.saveAs", json!({"path": pb.to_string_lossy()})).unwrap(); + + let mut host = Session { exporter: Some(ex.clone()), ..Default::default() }; + assert!(!host.is_enabled("file.watchFolder.poll")); + let r = host.execute("file.watchFolder", json!({"folder": dir.to_string_lossy()})).unwrap(); + let done = r["rendered"].as_array().unwrap(); + assert_eq!(done.len(), 2, "{r}"); + assert_eq!(host.state.watch_folder.as_deref(), Some(dir.to_string_lossy().as_ref())); + let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("a.ecproj.status.json")).unwrap()).unwrap(); + assert_eq!(st["state"], "done", "{st}"); + assert_eq!(st["items"][0]["comp"], "Shot"); + assert_eq!(st["items"][0]["status"], "Done"); + assert_eq!(ex.log.lock().unwrap().len(), 1); + let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("collected/b.ecproj.status.json")).unwrap()).unwrap(); + assert_eq!(st["note"], "nothing queued"); + // Rendered projects are not picked up again; deleting the status file re-renders. + assert!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().is_empty()); + std::fs::remove_file(dir.join("a.ecproj.status.json")).unwrap(); + assert_eq!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().len(), 1); + assert_eq!(ex.log.lock().unwrap().len(), 2); + // A broken project fails with a status, not an error. + std::fs::write(dir.join("broken.ecproj"), b"not a project").unwrap(); + let r = host.execute("file.watchFolder.poll", json!({})).unwrap(); + assert_eq!(r["rendered"][0]["state"], "failed"); + host.execute("file.watchFolder", json!({"stop": true})).unwrap(); + assert!(host.state.watch_folder.is_none()); + assert!(host.execute("file.watchFolder", json!({"folder": "/no/such/folder"})).is_err()); +} + +#[test] +fn vanishing_point_import_is_disabled_with_a_reason() { + let mut s = Session::default(); + assert!(!s.is_enabled("file.importVanishingPoint")); + let spec = crate::commands::find("file.importVanishingPoint").unwrap(); + assert!((spec.enabled)(&s).unwrap_err().contains("public specification")); + assert!(s.execute("file.importVanishingPoint", json!({})).is_err()); +} + +#[test] +fn help_tutorials() { + let mut s = Session::default(); + s.drain_events(); + s.execute("help.onlineTutorials", json!({})).unwrap(); + s.execute("help.inAppTutorials", json!({})).unwrap(); + let ev = s.drain_events(); + assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::APP_PAGE))); + assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "help.inAppTutorials"))); +} + +/// A comp with a 2D solid at (50, 40) carrying a square mask. +fn masked() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap(); + (s, l) +} + +#[test] +fn nulls_follow_points_and_points_follow_nulls() { + let (mut s, l) = masked(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + let r = s.execute("paths.nullsFollowPoints", json!({})).unwrap(); + let nulls: Vec = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(nulls.len(), 4); + let comp = s.active_comp().unwrap().clone(); + let n2 = comp.layer(effectcraft_project::LayerId(nulls[2])).unwrap(); + assert_eq!(n2.name, "Plate: Mask 1 [3]"); + // Placed on the vertex in comp space (the 40×40 solid is centred at (50, 40)). + let pos = n2.props.prop("transform/position").unwrap(); + assert_eq!(&pos.value.components()[..2], &[70.0, 60.0]); + let e = pos.expr.as_ref().unwrap().text.clone(); + assert!(e.contains("thisComp.layer(\"Plate\")") && e.contains(".points()[2]"), "{e}"); + // One undo step. + assert!(s.undo()); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + + let r = s.execute("paths.pointsFollowNulls", json!({"layer": l, "path": "masks/#1/path"})).unwrap(); + assert_eq!(r["nulls"].as_array().unwrap().len(), 4); + let comp = s.active_comp().unwrap().clone(); + let plate = comp.layer(effectcraft_project::LayerId(l)).unwrap(); + let e = plate.props.prop("masks/#1/path").unwrap().expr.as_ref().unwrap().text.clone(); + assert!(e.contains("createPath(pts") && e.contains("\"Plate: Mask 1 [1]\"") && e.contains("fromComp"), "{e}"); + // Not a path → error. + assert!(s.execute("paths.nullsFollowPoints", json!({"layer": l, "path": "transform/opacity"})).is_err()); +} + +#[test] +fn trace_path_adds_a_progress_null() { + let (mut s, l) = masked(); + let r = s.execute("paths.tracePath", json!({"layer": l, "loop": true})).unwrap(); + let n = r["null"].as_u64().unwrap(); + let comp = s.active_comp().unwrap().clone(); + let null = comp.layer(effectcraft_project::LayerId(n)).unwrap(); + let slider = null.props.prop("effects/#1/slider").unwrap(); + assert_eq!(slider.keys.len(), 2); + assert_eq!(slider.keys[1].value.as_f64(), 100.0); + assert!(slider.expr.as_ref().unwrap().text.contains("loopOut")); + assert_eq!(null.props.group("effects/#1").unwrap().name, "Progress"); + assert!(null.props.prop("transform/position").unwrap().expr.as_ref().unwrap().text.contains("pointOnPath(effect(\"Progress\")(\"Slider\") / 100)")); +} + +#[test] +fn vr_comp_editor_turns_the_face_cameras_together() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 64, "height": 64, "frameRate": 10, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Wall", "color": "#ff0000"})).unwrap(); + s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap(); + let envs = s.execute("comp.vr.environments", json!({})).unwrap(); + assert_eq!(envs.as_array().unwrap().len(), 1, "{envs}"); + let env = &envs[0]; + assert_eq!(env["name"], "Scene VR Output"); + assert_eq!(env["faces"].as_array().unwrap().len(), 6); + let view = env["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!(view.iter().all(|v| v.abs() < 1e-6 || (v - 360.0).abs() < 1e-6), "{view:?}"); + let out = env["output"].as_u64().unwrap(); + let r = s.execute("comp.vr.setView", json!({"comp": out, "pan": 30.0})).unwrap(); + let v = r["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!((v[1] - 30.0).abs() < 1e-6 && v[0].abs() < 1e-6 && v[2].abs() < 1e-6, "{v:?}"); + // Every face camera turned: Front by 30° about Y. + let front = &env["faces"][4]; + let fc = s.project.comp(effectcraft_project::ItemId(front["comp"].as_u64().unwrap())).unwrap(); + let cam = fc.layer(effectcraft_project::LayerId(front["camera"].as_u64().unwrap())).unwrap(); + let o = cam.props.prop("transform/orientation").unwrap().value.components(); + assert!((o[1] - 30.0).abs() < 1e-6, "{o:?}"); + // One undo step back to the start. + assert!(s.undo()); + let envs = s.execute("comp.vr.environments", json!({})).unwrap(); + assert!(envs[0]["view"][1].as_f64().unwrap().abs() < 1e-6); + // Round trip of the Euler decomposition. + for o in [[10.0, 20.0, 30.0], [350.0, 45.0, 5.0], [0.0, 0.0, 270.0]] { + let m = effectcraft_geom::Mat4::orientation(effectcraft_geom::vec3(o[0], o[1], o[2])); + let e = crate::commands::vr_editor::euler(m); + assert!(e.iter().zip(o).all(|(a, b)| (a - b).abs() < 1e-6), "{o:?} → {e:?}"); + } +} diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs index d989b28e2..8f5cb7f1b 100644 --- a/crates/engine/src/tests_panels.rs +++ b/crates/engine/src/tests_panels.rs @@ -516,3 +516,64 @@ fn panels_commands_are_wired() { assert!(s.is_enabled(id), "{id}"); } } + +/// A browser like the web app's: a virtual tree whose files are read on demand. +#[derive(Default)] +struct VirtualBrowser { + tree: Mutex, + fetched: Mutex>, + actions: Mutex>, +} + +impl crate::media_browser::Browser for VirtualBrowser { + fn home(&self) -> String { + "/Browser Storage".into() + } + fn places(&self) -> Vec<(String, String)> { + vec![("Browser Storage".into(), self.home()), ("Shoot".into(), "/Folders/Shoot".into())] + } + fn list(&self, dir: &str, only: bool) -> Result, String> { + self.tree.lock().unwrap().list(dir, only) + } + fn fetch(&self, paths: &[String], _: &Value) -> bool { + // Opened folders' files arrive later. + let pending: Vec = paths.iter().filter(|p| p.starts_with("/Folders/")).cloned().collect(); + self.fetched.lock().unwrap().extend(pending.iter().cloned()); + pending.is_empty() + } + fn actions(&self) -> Vec<(String, String)> { + vec![("openFolder".into(), "Open Folder…".into())] + } + fn action(&self, id: &str) -> Result { + self.actions.lock().unwrap().push(id.into()); + Ok(json!({"pending": true})) + } +} + +#[test] +fn media_browser_over_a_virtual_tree() { + let b = Arc::new(VirtualBrowser::default()); + { + let mut t = b.tree.lock().unwrap(); + t.add_dir("/Browser Storage"); + t.add_file("/Folders/Shoot/a.csv", "/Folders/Shoot/a.csv", 12, Some(1)); + } + let mut s = Session { browser: Some(b.clone()), ..Default::default() }; + let r = s.execute("mediaBrowser.list", json!({})).unwrap(); + assert_eq!(r["path"], "/Browser Storage"); + assert_eq!(r["places"][1]["path"], "/Folders/Shoot"); + assert_eq!(r["actions"][0]["id"], "openFolder"); + let r = s.execute("mediaBrowser.go", json!({"path": "/Folders/Shoot"})).unwrap(); + assert_eq!(r["entries"][0]["name"], "a.csv"); + assert_eq!(r["parent"], "/Folders"); + assert!(s.execute("mediaBrowser.go", json!({"path": "/Nope"})).is_err()); + // Importing a file that is not read yet: pending (the host imports it once it arrives). + let r = s.execute("mediaBrowser.import", json!({"paths": ["/Folders/Shoot/a.csv"]})).unwrap(); + assert_eq!(r["pending"], true); + assert_eq!(*b.fetched.lock().unwrap(), ["/Folders/Shoot/a.csv"]); + assert!(s.project.items.values().all(|i| !matches!(i.kind, ItemKind::Footage(_)))); + let r = s.execute("mediaBrowser.action", json!({"action": "openFolder"})).unwrap(); + assert_eq!(r["pending"], true); + assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]); + assert!(s.execute("mediaBrowser.action", json!({})).is_err()); +} diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index 794036269..41d8e86ea 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -286,3 +286,52 @@ fn propagation_is_deterministic_and_runs_in_background() { let r = s.execute("roto.stroke", json!({"layer": clip.0, "points": [[90, 110]]})).unwrap(); assert_eq!(r["radius"], 15.0); } + +/// Runs offloaded jobs at once in a second session sharing the footage, through JSON (what the +/// browser's Web Worker does, minus the thread). +struct InlineWorker(std::sync::Arc); + +impl crate::offload::Offload for InlineWorker { + fn start(&self, req: crate::offload::WorkerRequest, inbox: std::sync::Arc) -> Result<(), String> { + let req: crate::offload::WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap(); + let mut w = Session { footage: self.0.clone(), ..Default::default() }; + let post: crate::offload::Post = std::rc::Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap())); + crate::offload::run_request(&mut w, req, &post); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +#[test] +fn offloaded_propagation_streams_segmentations_back() { + let (mut s, clip, uid) = painted(center, "disk-offload.mov"); + s.execute("roto.span", json!({"layer": clip.0, "end": 5})).unwrap(); + s.offload = Some(std::sync::Arc::new(InlineWorker(s.footage.clone()))); + let r = s.execute("roto.propagate", json!({"layer": clip.0})).unwrap(); + assert_eq!(r["frames"], 6, "{r}"); + assert!(s.roto_job.is_none(), "offloaded, not on a thread"); + assert!(s.is_roto_running()); + assert_eq!(s.roto_progress().unwrap().task, Some(crate::roto::RotoTask::Propagate)); + assert_eq!(s.roto_target().map(|t| (t.1, t.2)), Some((clip, uid))); + assert!(s.jobs().iter().any(|j| j.id == "roto")); + // The worker has its own segmentation cache: here nothing is computed until its replies + // are applied. + forget(&s, clip, uid); + assert_eq!(s.execute("roto.status", json!({"layer": clip.0})).unwrap()["computed"], json!([])); + let rev = s.revision; + assert!(s.poll_offload()); + assert!(!s.is_roto_running()); + assert!(s.revision > rev, "frames are redrawn"); + let st = s.execute("roto.status", json!({"layer": clip.0})).unwrap(); + assert_eq!(st["computed"], json!([0, 1, 2, 3, 4, 5]), "{st}"); + assert!(s.events.iter().any(|e| matches!(e, crate::Event::Toast { message, error: false } if message.contains("propagated 6 frame")))); + // The matte matches one computed here. + let a = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone(); + forget(&s, clip, uid); + let b = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone(); + assert!(a.is_string()); + assert_eq!(a, b); + // `wait` still runs here. + s.execute("roto.propagate", json!({"layer": clip.0, "wait": true})).unwrap(); + assert!(s.offloaded.is_empty()); +} diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index da6c6c3ac..705425c02 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -326,6 +326,51 @@ fn variable_font_axes_animator() { let _ = info; } +#[test] +fn variable_font_axes_in_the_character_style() { + let mut s = comp(600, 200); + let t = s.execute("layer.newText", json!({"text": "Hamburg", "size": 60})).unwrap()["layer"].as_u64().unwrap(); + // The attribute round-trips and is undoable even for static fonts (it is kept, unused). + s.execute("layer.setText", json!({"layer": t, "variations": {"wght": 650}})).unwrap(); + let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap(); + assert_eq!(d.style_at(0).variations, vec![("wght".to_string(), 650.0)]); + assert_eq!(effectcraft_keyframe::text_doc::char_style_json(&d.style_at(0))["variations"], json!({"wght": 650.0})); + s.execute("layer.setText", json!({"layer": t, "range": [0, 3], "variations": {"wght": null}})).unwrap(); + let d = crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap(); + assert!(d.style_at(0).variations.is_empty() && !d.style_at(5).variations.is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(crate::commands::text_edit::layer_doc(&s, LayerId(t)).unwrap().style_at(0).variations.is_empty()); + assert!(s.execute("layer.setText", json!({"layer": t, "variations": 3})).is_err()); + let Some((face, axes)) = effectcraft_text::variable::find_variable_face() else { + eprintln!("no variable font installed: rendering check skipped"); + return; + }; + let info = effectcraft_text::fonts::face(face).info.clone(); + if effectcraft_text::resolve(&info.family, &info.style).face != face { + eprintln!("variable face not reachable by family/style: skipped"); + return; + } + s.execute("layer.setText", json!({"layer": t, "font": info.family, "style": info.style})).unwrap(); + let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).or_else(|| axes.iter().find(|a| a.max > a.min)).unwrap().clone(); + let geom = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time()); + let l = comp.layer(LayerId(t)).unwrap(); + let paths = effectcraft_render::text::glyph_paths(&ctx, l); + let area: f64 = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::area(p).abs()).sum(); + let right = paths.iter().map(|(p, _)| effectcraft_text::kurbo::Shape::bounding_box(p).x1).fold(f64::MIN, f64::max); + (area, right) + }; + s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.min}})).unwrap(); + let lo = geom(&s); + s.execute("layer.setText", json!({"layer": t, "variations": {a.tag.trim_end(): a.max}})).unwrap(); + let hi = geom(&s); + assert!((hi.0 - lo.0).abs() > 1.0, "{} outlines {lo:?} → {hi:?}", a.tag); + assert!((hi.1 - lo.1).abs() > 0.5, "{} advances {lo:?} → {hi:?}", a.tag); +} + #[test] fn camera_focus_commands_undo_and_serde() { let mut s = comp(400, 300); diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 3465d3855..23fc3f24c 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -122,48 +122,147 @@ fn group(ids: &mut Ids, name: &str, items: Vec, opacity: f64) -> Prop g } +/// A Merge Paths item in `mode` (0 Merge, 3 Intersect). +fn merge_item(ids: &mut Ids, mode: u32) -> Option { + let mut g = build::shape_simple_op(ids, "merge")?; + set(&mut g, "mode", Value::Enum(mode)); + Some(g) +} + +/// A shape inside clipping groups: its geometry merged into one compound path (an inner group +/// with Merge Paths), the clip paths, and Merge Paths ▸ Intersect before its paint, so the fill +/// is exactly the clipped area. Open stroked paths stay unclipped (an intersection would close +/// them). +fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[BezPath]) -> Vec { + let items = shape_items(ids, s, m); + let path = s.geom.to_path(); + let closed = path.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)) || s.fill.is_some(); + if clips.is_empty() || !closed { + return items; + } + let (geom, paint): (Vec, Vec) = + items.into_iter().partition(|g| !matches!(g.match_id.as_str(), "fill" | "stroke" | "gfill" | "gstroke")); + let mut inner = geom; + inner.extend(merge_item(ids, 0)); + let mut out = vec![build::shape_group(ids, "Clipped Path", inner)]; + for (i, c) in clips.iter().enumerate() { + let mut parts = shape_paths(ids, c); + parts.extend(merge_item(ids, 0)); + out.push(build::shape_group(ids, &format!("Clip Path {}", i + 1), parts)); + } + out.extend(merge_item(ids, 3)); + out.extend(paint); + out +} + /// 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. +/// geometry; group and element opacity go to the group transforms, blend modes to the groups' +/// Blend Mode). Top of the paint order first. +/// +/// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with +/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the +/// whole document are layer masks instead ([`vector_clip_masks`], used by +/// [`shapes_from_vector`]). Images and soft masks are not converted. pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec { - fn walk(ids: &mut Ids, nodes: &[Node], m: Affine) -> Vec { + fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec { let mut out = vec![]; for n in nodes.iter().rev() { match n { Node::Group(g) => { - let items = walk(ids, &g.children, m * g.transform); + let gm = m * g.transform; + let mut inner_clips = clips.to_vec(); + inner_clips.extend(g.clip.iter().map(|(p, _)| gm * p.clone())); + let items = walk(ids, &g.children, gm, &inner_clips); if !items.is_empty() { - out.push(group(ids, &g.name, items, g.opacity)); + let mut grp = group(ids, &g.name, items, g.opacity); + if g.blend != effectcraft_svg::BlendMode::Normal { + set(&mut grp, "blend", Value::Enum(blend_index(g.blend))); + } + out.push(grp); } } Node::Shape(s) => { - let items = shape_items(ids, s, m * s.transform); + let sm = m * s.transform; + let items = if clips.is_empty() { shape_items(ids, s, sm) } else { clipped_items(ids, s, sm, clips) }; if !items.is_empty() { out.push(group(ids, &s.name, items, s.opacity)); } } + Node::Image(_) => {} } } out } let root = &doc.root; - let items = walk(ids, &root.children, root.transform); + let mut clips = vec![]; + clips.extend(root.clip.iter().map(|(p, _)| root.transform * p.clone())); + let items = walk(ids, &root.children, root.transform, &clips); if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items } } +/// The shape-layer Blend Mode index of an SVG / PDF blend mode. +fn blend_index(b: effectcraft_svg::BlendMode) -> u32 { + use effectcraft_color::BlendMode as P; + use effectcraft_svg::BlendMode as S; + let p = match b { + S::Normal => P::Normal, + S::Multiply => P::Multiply, + S::Screen => P::Screen, + S::Overlay => P::Overlay, + S::Darken => P::Darken, + S::Lighten => P::Lighten, + S::ColorDodge => P::ColorDodge, + S::ColorBurn => P::ColorBurn, + S::HardLight => P::HardLight, + S::SoftLight => P::SoftLight, + S::Difference => P::Difference, + S::Exclusion => P::Exclusion, + S::Hue => P::Hue, + S::Saturation => P::Saturation, + S::Color => P::Color, + S::Luminosity => P::Luminosity, + }; + P::ALL.iter().position(|x| *x == p).unwrap_or(0) as u32 +} + +/// Clips that enclose the whole document (on a chain of single-child groups from the root, as +/// an Illustrator artboard clip or a PDF page clip), in document pixels, and the document with +/// those clips removed: Create Shapes from Vector Layer makes them layer masks. +pub fn vector_clip_masks(doc: &Doc) -> (Vec, Doc) { + let mut out = doc.clone(); + let mut masks = vec![]; + let mut m = Affine::IDENTITY; + let mut g = &mut out.root; + loop { + m *= g.transform; + for (p, _) in g.clip.drain(..) { + masks.push(m * p); + } + if g.children.len() != 1 { + break; + } + match &mut g.children[0] { + Node::Group(sub) if sub.mask.is_none() && sub.blend == effectcraft_svg::BlendMode::Normal => g = sub, + _ => break, + } + } + (masks, out) +} + /// Whether footage is a vector file (SVG / PDF / AI / EPS). pub fn is_vector(f: &effectcraft_project::Footage) -> bool { effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg") } -/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted -/// to one layer when `layer` names it. -pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page +/// `page` of a PDF, restricted to one layer when `layer` names it. +pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option> { if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) { return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); } effectcraft_pdf::sniff(bytes)?; Some( - effectcraft_pdf::parse(bytes) + effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), None => d, @@ -172,20 +271,21 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: ) } -/// Import a PDF / Illustrator / EPS document as a composition the size of its page: one footage -/// item per file layer (each showing only that layer, continuously rasterisable), top layer -/// first. Returns (comp, folder, items). +/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the +/// page: one footage item per file layer (each showing only that layer, continuously +/// rasterisable), top layer first. Returns (comp, folder, items). pub fn import_vector_comp( proj: &mut Project, path: &str, bytes: &[u8], name: &str, + page: u32, rate: effectcraft_time::FrameRate, duration: effectcraft_time::Tick, ) -> Result<(ItemId, ItemId, Vec), String> { use effectcraft_color::Label; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; - let doc = effectcraft_pdf::parse(bytes).map_err(|e| format!("{path}: {e}"))?; + let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?; let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF"); let (w, h) = doc.pixel_size(); let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); @@ -206,6 +306,7 @@ pub fn import_vector_comp( loop_count: 1, codec: codec.into(), layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + page, ..Default::default() }; let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); @@ -239,13 +340,28 @@ fn like_source(dst: &mut Layer, src: &Layer) { dst.switches.three_d = src.switches.three_d; } -/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. +/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip +/// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips +/// are kept with Merge Paths (see [`svg_contents`]). 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); + let (clips, doc) = vector_clip_masks(doc); + let mut ids = Ids(&mut proj.next_id); + let items = svg_contents(&mut ids, &doc); if let Some(c) = l.props.sub_mut("contents") { c.children.extend(items.into_iter().map(Into::into)); } + let mut n = 0; + for (k, clip) in clips.iter().enumerate() { + for sp in effectcraft_path::from_kurbo(clip) { + n += 1; + let mode = if k == 0 { MaskMode::Add } else { MaskMode::Intersect }; + let mk = build::mask(&mut ids, &format!("Mask {n}"), sp, mode, [255, 255, 0]); + if let Some(masks) = l.props.sub_mut("masks") { + masks.children.push(mk.into()); + } + } + } copy_transform(proj, src, &mut l); like_source(&mut l, src); l.name = comp.unique_layer_name(&format!("{} Outlines", src.name)); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 39770ee1e..fe95d0974 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -351,6 +351,52 @@ __Path.prototype.points = function () { return this.__pts; }; __Path.prototype.inTangents = function () { return this.__in; }; __Path.prototype.outTangents = function () { return this.__out; }; __Path.prototype.isClosed = function () { return this.__closed; }; +// pointOnPath / tangentOnPath: a point (or unit tangent) at a fraction of the path's arc length. +__Path.prototype.pointOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, false); }; +__Path.prototype.tangentOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, true); }; +__Path.prototype.normalOnPath = function (pct) { var t = this.tangentOnPath(pct); return [t[1], -t[0]]; }; + +function __pathSegs(p) { + var n = p.__pts.length, out = []; + var m = p.__closed ? n : n - 1; + for (var i = 0; i < m; i++) { + var j = (i + 1) % n, a = p.__pts[i], b = p.__pts[j]; + var o = p.__out[i] || [0, 0], ii = p.__in[j] || [0, 0]; + out.push([a, [a[0] + o[0], a[1] + o[1]], [b[0] + ii[0], b[1] + ii[1]], b]); + } + return out; +} +function __bezAt(s, u) { + var v = 1 - u; + return [0, 1].map(function (k) { return v * v * v * s[0][k] + 3 * v * v * u * s[1][k] + 3 * v * u * u * s[2][k] + u * u * u * s[3][k]; }); +} +function __bezDer(s, u) { + var v = 1 - u; + return [0, 1].map(function (k) { return 3 * v * v * (s[1][k] - s[0][k]) + 6 * v * u * (s[2][k] - s[1][k]) + 3 * u * u * (s[3][k] - s[2][k]); }); +} +function __pathAt(p, pct, tangent) { + var segs = __pathSegs(p); + if (!segs.length) return tangent ? [1, 0] : p.__pts.length ? [p.__pts[0][0], p.__pts[0][1]] : [0, 0]; + var N = 32, table = [], total = 0; + for (var k = 0; k < segs.length; k++) { + var prev = __bezAt(segs[k], 0); + for (var s = 1; s <= N; s++) { + var q = __bezAt(segs[k], s / N); + total += Math.sqrt((q[0] - prev[0]) * (q[0] - prev[0]) + (q[1] - prev[1]) * (q[1] - prev[1])); + table.push([total, k, s / N]); + prev = q; + } + } + var target = Math.max(0, Math.min(1, pct)) * total; + var i = 0; + while (i < table.length - 1 && table[i][0] < target) i++; + var e = table[i], seg = e[1]; + var l0 = i > 0 ? table[i - 1][0] : 0, u0 = i > 0 && table[i - 1][1] === seg ? table[i - 1][2] : 0; + var u = e[0] > l0 ? u0 + (e[2] - u0) * (target - l0) / (e[0] - l0) : e[2]; + if (!tangent) return __bezAt(segs[seg], u); + var d = __bezDer(segs[seg], u), len = Math.sqrt(d[0] * d[0] + d[1] * d[1]); + return len > 0 ? [d[0] / len, d[1] / len] : [1, 0]; +} function createPath(points, inTangents, outTangents, isClosed) { points = points || []; @@ -734,6 +780,9 @@ Prop.prototype = { inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); }, outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); }, isClosed: function () { return this.value.isClosed(); }, + pointOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).pointOnPath(pct); }, + tangentOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).tangentOnPath(pct); }, + normalOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).normalOnPath(pct); }, // Source Text style API. __docAt: function (t) { var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d75972f76..ac06485ed 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1116,6 +1116,10 @@ fn project_and_time_globals() { 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("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[1]", 0.0), 5.0); + assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[0]", 0.0), 10.0); + assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).tangentOnPath(0.25)[0]", 0.0), 1.0); + assert_close!(f.num("mask(\"Mask 1\").maskPath.pointOnPath(0)[0] - mask(\"Mask 1\").maskPath.points()[0][0]", 0.0), 0.0); assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0); // Marker keys carry protected regions and cue points. { diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 93352fe34..677db3e5f 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -625,12 +625,12 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): if hi < 0.5 { return Some(()); } - let Some(levels) = bokeh::progressive_levels(lo, hi) else { return Some(()) }; + let Some(levels) = bokeh::blur_levels(&[lo, hi]) else { return Some(()) }; // Level headers, then every level's weighted spans. let mut heads: Vec<[f32; 4]> = vec![]; let mut spans: Vec<[f32; 4]> = vec![]; for &r in &levels { - match bokeh::kernel(&dof.iris, r as f64) { + match bokeh::kernel_spans(&dof.iris, r as f64) { Some((kernels, norm)) => { let first = levels.len() + spans.len(); for (wt, sp) in &kernels { diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs new file mode 100644 index 000000000..db7ca9f7e --- /dev/null +++ b/crates/gpu/src/bokeh.rs @@ -0,0 +1,59 @@ +//! Classic 3D depth of field on the GPU (`bokeh_*` kernels): the plane buffer padded, its +//! highlights boosted, row prefix sums, then one span gather per blur level +//! ([`effectcraft_render::three_d::bokeh::kernel_spans`]) accumulated with each pixel's level +//! weight — the CPU's `progressive_blur` step for step. + +use effectcraft_render::three_d::PlaneDof; +use effectcraft_render::three_d::bokeh::kernel_spans; + +use crate::context::{Enc, GpuImage, Params}; + +/// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. +pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { + let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); + if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + return None; + } + let padded = e.image(w, h); + e.copy_into(src, &padded, d.pad, d.pad); + let boosted = if d.highlight.gain > 0.0 { + let out = e.image(w, h); + let mut p = Params::default(); + p.f[0] = [d.highlight.gain as f32, d.highlight.threshold as f32, d.highlight.saturation as f32, 0.0]; + e.pixels("bokeh_boost", &p, &padded, None, &out, None); + out + } else { + padded + }; + let prefix = e.image(w + 1, h); + e.dispatch("bokeh_prefix", &Params::default(), &boosted, None, &prefix, None, (h.div_ceil(64), 1)); + // Radius map, then every level's spans. + let mut data: Vec = d.radius.as_ref().clone(); + let mut levels = vec![]; + for &r in &d.levels { + let (kernels, norm) = kernel_spans(&d.iris, r as f64).unwrap_or_else(|| (vec![(1.0, vec![(0, -0.5, 0.5)])], 1.0)); + let off = data.len() as u32; + let mut count = 0u32; + for (wt, spans) in &kernels { + for &(dy, x0, x1) in spans { + data.extend_from_slice(&[dy as f32, x0 as f32, x1 as f32, *wt]); + count += 1; + } + } + levels.push((off, count, norm)); + } + let buf = e.data(&data); + let n = d.levels.len() as u32; + let (lo, hi) = (d.levels[0], d.levels[d.levels.len() - 1]); + let mut acc = e.image(w, h); + for (k, (off, count, norm)) in levels.into_iter().enumerate() { + let out = e.image(w, h); + let mut p = Params::default(); + p.u[0] = [off, count, k as u32, n]; + p.u[1] = [u32::from(k == 0), 0, 0, 0]; + p.f[0] = [1.0 / norm, lo, hi, 0.0]; + e.pixels("bokeh_gather", &p, &prefix, Some(&acc), &out, Some(&buf)); + acc = out; + } + Some(acc) +} diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index 0c72dd601..99d429f95 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -136,11 +136,18 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option = Vec::with_capacity(run.planes.len()); for p in &run.planes { - texs.push((e.g.upload_buf(&p.buf)?, p.buf.offset)); + let up = e.g.upload_buf(&p.buf)?; + match &p.dof { + Some(d) => { + let pad = d.pad as f64; + texs.push((crate::bokeh::apply(e, &up, d)?, [p.buf.offset[0] + pad, p.buf.offset[1] + pad])); + } + None => texs.push((up, p.buf.offset)), + } } let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect(); let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?; diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index eb32115a5..677e76da7 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -27,6 +27,9 @@ const ENTRIES: &[&str] = &[ "shadow_combine", "pointwise", "adjust_mix", + "bokeh_boost", + "bokeh_prefix", + "bokeh_gather", ]; /// Entry points that also bind group 1 (four read-only storage buffers; see @@ -151,6 +154,10 @@ impl GpuContext { include_str!("shaders/fx_color.wgsl"), include_str!("shaders/fx_distort.wgsl"), include_str!("shaders/fx_generate.wgsl"), + include_str!("shaders/fx_key.wgsl"), + include_str!("shaders/fx_stylize.wgsl"), + include_str!("shaders/fx_noise.wgsl"), + include_str!("shaders/fx_tone.wgsl"), ] .concat(); let module = @@ -204,6 +211,10 @@ impl GpuContext { .chain(crate::fx_color::KERNELS) .chain(crate::fx_distort::KERNELS) .chain(crate::fx_generate::KERNELS) + .chain(crate::fx_key::KERNELS) + .chain(crate::fx_stylize::KERNELS) + .chain(crate::fx_noise::KERNELS) + .chain(crate::fx_tone::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 3691ae17e..0b97bc7a6 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -113,14 +113,24 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.blur.gaussian" => gaussian(e, ctx, b), "ec.blur.fastbox" => box_blur(e, ctx, b), "ec.blur.directional" => directional(e, ctx, b), + "ec.stylize.glow" if crate::fx_stylize::glow_extra(ctx) => crate::fx_stylize::glow(e, ctx, b), + "ec.distort.transform" if crate::fx_stylize::transform_blur(ctx) => crate::fx_stylize::transform(e, ctx, b), "ec.stylize.glow" => glow(e, ctx, b), "ec.perspective.dropshadow" => drop_shadow(e, ctx, b), "ec.distort.transform" => transform(e, ctx, b), "ec.color.curves" => curves(e, ctx, b), + "ec.color.huesaturation" if !effectcraft_effects::huesat_ranges_identity(ctx) => crate::fx_tone::hue_saturation(e, ctx, b), + "ec.color.levels" if !effectcraft_effects::levels_channels_identity(ctx) => crate::fx_tone::levels(e, ctx, b), + "ec.channel.invert" if (4..=11).contains(&ctx.params.e("channel")) => crate::fx_tone::invert(e, ctx, b), + "ec.noise.fractal" if crate::fx_noise::fractal_extra(ctx) => crate::fx_noise::fractal(e, ctx, b, false), _ if id.starts_with("ec.control.") => Some(b), _ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b), _ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b), _ if crate::fx_generate::IDS.contains(&id) => crate::fx_generate::apply(e, id, ctx, b), + _ if crate::fx_key::IDS.contains(&id) => crate::fx_key::apply(e, id, ctx, b), + _ if crate::fx_stylize::IDS.contains(&id) => crate::fx_stylize::apply(e, id, ctx, b), + _ if crate::fx_noise::IDS.contains(&id) => crate::fx_noise::apply(e, id, ctx, b), + _ if crate::fx_tone::IDS.contains(&id) => crate::fx_tone::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index ee6ab7e8f..f411e97e2 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -235,7 +235,7 @@ fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage { } /// util::morph_frac (blend between the two nearest integer radii). -fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { +pub(crate) fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { if radius <= 0.0 { return img.clone(); } @@ -253,8 +253,8 @@ fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuI out } -const ERODE_X: [u32; 4] = [0, 2, 2, 2]; -const DILATE_X: [u32; 4] = [1, 2, 2, 2]; +pub(crate) const ERODE_X: [u32; 4] = [0, 2, 2, 2]; +pub(crate) const DILATE_X: [u32; 4] = [1, 2, 2, 2]; // ---------------------------------------------------------------- Key Light diff --git a/crates/gpu/src/fx_key.rs b/crates/gpu/src/fx_key.rs new file mode 100644 index 000000000..8aedbd3d2 --- /dev/null +++ b/crates/gpu/src/fx_key.rs @@ -0,0 +1,630 @@ +//! GPU effects, keying, matte and channel family (kernels in `shaders/fx_key.wgsl`, every entry +//! point prefixed `fxk_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! +//! The keyers (Color Key, Luma Key, Color Range, Extract, Difference Matte, Color Difference +//! Key, Screen Key) build their matte in a plane pass, run the CPU's edge thin (morphology) and +//! feather (Gaussian) on it, and apply it in a second pass; spill suppression, Unmult and the +//! channel effects are one per-pixel pass (`fxk_point`). Simple / Matte Choker and Key Cleaner +//! work on the alpha plane and put it back with `util::set_alpha` (straight colour kept, the +//! transparent pixels' colour from a small blur). Advanced Spill Suppressor's Standard method +//! finds the dominant key primary with a GPU sum reduction (`fxk_reduce`). Other layers (layer +//! parameters) are resampled into the buffer like `util::fit_layer` (`fxk_fit`). + +use effectcraft_color::rgb_to_hsl; +use effectcraft_effects::EffectCtx; +use effectcraft_effects::util::{SRC_ORDER, Src}; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, box_passes, gaussian_blur}; +use crate::fx_color::{DILATE_X, ERODE_X, morph_frac}; + +/// Compute entry points in `fx_key.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxk_point", "fxk_fit", "fxk_reduce"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.key.colorkey", + "ec.key.luma", + "ec.key.colorrange", + "ec.key.extract", + "ec.key.differencematte", + "ec.key.colordifference", + "ec.key.screen", + "ec.key.spill", + "ec.key.advancedspill", + "ec.key.keycleaner", + "ec.key.unmult", + "ec.matte.simplechoker", + "ec.matte.mattechoker", + "ec.matte.refinesoft", + "ec.matte.refinehard", + "ec.channel.setmatte", + "ec.channel.setchannels", + "ec.channel.shiftchannels", + "ec.channel.removecolormatting", + "ec.channel.arithmetic", + "ec.channel.solidcomposite", + "ec.channel.combiner", + "ec.channel.blend", + "ec.channel.calculations", + "ec.channel.compoundarithmetic", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.key.colorkey" | "ec.key.luma" | "ec.key.colorrange" | "ec.key.extract" => matte_key(e, id, ctx, b), + "ec.key.differencematte" => difference_matte(e, ctx, b), + "ec.key.screen" => screen_key(e, ctx, b), + "ec.key.advancedspill" => advanced_spill(e, ctx, b), + "ec.key.keycleaner" => key_cleaner(e, ctx, b), + "ec.matte.simplechoker" => simple_choker(e, ctx, b), + "ec.matte.mattechoker" => matte_choker(e, ctx, b), + "ec.matte.refinesoft" => refine(e, ctx, b, false), + "ec.matte.refinehard" => refine(e, ctx, b, true), + "ec.channel.setchannels" => set_channels(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +// ---------------------------------------------------------------- helpers + +/// One `fxk_point` pass over `src`'s size. +fn run(e: &mut Enc, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.image(src.width, src.height); + e.pixels("fxk_point", p, src, aux, &out, data); + out +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn src_index(s: Src) -> u32 { + SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32 +} + +/// Layer parameter `id` resampled into the buffer's pixel grid (`util::fit_layer`): +/// `Some(None)` when no layer is chosen (the effect then uses its own layer). +fn fitted(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, masks_and_effects: bool, stretch: bool) -> Option> { + let Some(o) = ctx.layer_param(id, masks_and_effects) else { return Some(None) }; + let (w, h) = (b.img.width, b.img.height); + if o.buf.img.is_empty() { + return Some(Some(e.image(w, h))); + } + let ls = ctx.layer_size; + let os = o.size; + let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) }; + let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) }; + let inv = 1.0 / b.scale.max(1e-9); + // Per column / row: the other layer's buffer coordinate. + let mut data = Vec::with_capacity((w + h) as usize); + data.extend((0..w).map(|x| (((x as f64 + 0.5 - b.offset[0]) * inv * sx + dx) * o.buf.scale + o.buf.offset[0]) as f32)); + data.extend((0..h).map(|y| (((y as f64 + 0.5 - b.offset[1]) * inv * sy + dy) * o.buf.scale + o.buf.offset[1]) as f32)); + let src = e.g.upload_image(&o.buf.img)?; + let buf = e.data(&data); + let out = e.image(w, h); + e.pixels("fxk_fit", &Params::default(), &src, None, &out, Some(&buf)); + Some(Some(out)) +} + +/// `util::layer_or_self`. +fn layer_or_self(e: &mut Enc, ctx: &EffectCtx, b: &GBuf, id: &str, stretch: bool) -> Option { + Some(fitted(e, ctx, b, id, true, stretch)?.unwrap_or_else(|| b.img.clone())) +} + +/// keying::thin_feather on a matte plane (x channel). +fn thin_feather(e: &mut Enc, m: GpuImage, thin: f64, feather: f64) -> GpuImage { + let m = if thin > 0.0 { + morph_frac(e, &m, thin, ERODE_X) + } else if thin < 0.0 { + morph_frac(e, &m, -thin, DILATE_X) + } else { + m + }; + if feather > 0.0 { gaussian_blur(e, &m, feather * 0.5, feather * 0.5, true) } else { m } +} + +/// keying::apply_matte (view 0 final, 1 source, 2 matte). +fn apply_matte(e: &mut Enc, b: GBuf, m: &GpuImage, view: u32) -> GBuf { + if view == 1 { + return b; + } + let mut p = op(5); + p.u[0][1] = view; + let img = run(e, &p, &b.img, Some(m), None); + GBuf { img, ..b } +} + +/// keying::primary. +fn primary(c: [f32; 4]) -> [u32; 3] { + if c[1] >= c[0] && c[1] >= c[2] { + [1, 0, 2] + } else if c[2] >= c[0] && c[2] >= c[1] { + [2, 0, 1] + } else { + [0, 1, 2] + } +} + +/// util::set_alpha with the new alpha in `na`'s x channel. +fn set_alpha(e: &mut Enc, img: &GpuImage, na: &GpuImage) -> GpuImage { + let fill = gaussian_blur(e, img, 3.0, 3.0, true); + let (rows, row) = e.image_rows(&fill); + let mut p = op(27); + p.u[3][0] = row; + run(e, &p, img, Some(na), Some(&rows)) +} + +// ---------------------------------------------------------------- keyers + +fn matte_key(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut thin = 0.0; + let mut feather = 0.0; + match id { + "ec.key.colorkey" => { + let k = pr.color("keyColor"); + p.u[0][0] = 1; + p.f[0] = [k[0], k[1], k[2], f("colorTolerance") / 255.0]; + thin = pr.f("edgeThin") * b.scale; + feather = pr.f("edgeFeather") * b.scale; + } + "ec.key.luma" => { + p.u[0] = [2, pr.e("keyType"), 0, 0]; + p.f[0] = [f("threshold") / 255.0, f("tolerance") / 255.0, 0.0, 0.0]; + thin = pr.f("edgeThin") * b.scale; + feather = pr.f("edgeFeather") * b.scale; + } + "ec.key.colorrange" => { + p.u[0] = [3, pr.e("colorSpace"), 0, 0]; + p.f[0] = [f("minL"), f("minA"), f("minB"), 0.0]; + p.f[1] = [f("maxL"), f("maxA"), f("maxB"), 0.0]; + p.f[2][0] = f("fuzziness"); + } + _ => { + p.u[0] = [4, pr.e("channel"), pr.b("invert") as u32, 0]; + p.f[0] = [f("blackPoint"), f("whitePoint"), f("blackSoftness"), f("whiteSoftness")]; + } + } + let m = run(e, &p, &b.img, None, None); + let m = thin_feather(e, m, thin, feather); + Some(apply_matte(e, b, &m, 0)) +} + +fn difference_matte(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 1 { + return Some(b); + } + let blur = pr.f("blurBeforeDifference") * b.scale; + let other = layer_or_self(e, ctx, &b, "differenceLayer", pr.e("ifLayerSizesDiffer") == 1)?; + let (src, other) = if blur > 0.05 { + (gaussian_blur(e, &b.img, blur * 0.5, blur * 0.5, true), gaussian_blur(e, &other, blur * 0.5, blur * 0.5, true)) + } else { + (b.img.clone(), other) + }; + let mut p = op(6); + p.f[0] = [(pr.f("matchingTolerance") / 100.0) as f32, (pr.f("matchingSoftness") / 100.0) as f32, 0.0, 0.0]; + let m = run(e, &p, &src, Some(&other), None); + Some(apply_matte(e, b, &m, if view == 2 { 3 } else { 4 })) +} + +/// keying::secondary's screen-key matte and composite. +fn screen_key(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 3 { + return Some(b); + } + let sc = pr.color("screenColor"); + let ix = primary(sc); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let gain = pr.f("screenGain") as f32 / 100.0; + let bal = pr.f("screenBalance") as f32 / 100.0; + let cb = pr.f("clipBlack") as f32 / 100.0; + let cw = pr.f("clipWhite") as f32 / 100.0; + let preblur = pr.f("screenPreblur") * b.scale; + let sg = pr.f("screenShrinkGrow") * b.scale; + let soft = pr.f("screenSoftness") * b.scale; + let (hi, lo) = if sc[o1] > sc[o2] { (sc[o1], sc[o2]) } else { (sc[o2], sc[o1]) }; + let sd = (sc[pi] - (bal * hi + (1.0 - bal) * lo)).max(1e-3); + let key_src = if preblur > 0.0 { gaussian_blur(e, &b.img, preblur * 0.5, preblur * 0.5, true) } else { b.img.clone() }; + let mut p = op(22); + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [sd, gain, bal, cb]; + p.f[1][0] = cw; + let mut m = run(e, &p, &key_src, None, None); + if sg < 0.0 { + m = morph_frac(e, &m, -sg, ERODE_X); + } else if sg > 0.0 { + m = morph_frac(e, &m, sg, DILATE_X); + } + if soft > 0.0 { + m = gaussian_blur(e, &m, soft * 0.5, soft * 0.5, true); + } + let mut p = op(23); + p.u[0] = [23, view, (pr.e("edgeColor") == 1) as u32, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [sc[0], sc[1], sc[2], bal]; + p.f[1][0] = pr.f("despill") as f32 / 100.0; + let img = run(e, &p, &b.img, Some(&m), None); + Some(GBuf { img, ..b }) +} + +fn advanced_spill(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let ultra = pr.e("method") == 1; + let key = pr.color("ultraSettings/keyColor"); + let mut p = op(8); + let sums = if ultra { + let ix = primary(key); + p.u[1] = [ix[0], ix[1], ix[2], 0]; + None + } else { + // Standard: the dominant key primary over the whole buffer (sum reduction). + p.u[1][3] = 1; + let mut cur = b.img.clone(); + let mut first = true; + while first || cur.width > 1 || cur.height > 1 { + let out = e.image(cur.width.div_ceil(16), cur.height.div_ceil(16)); + let mut rp = Params::default(); + rp.u[0][0] = first as u32; + e.pixels("fxk_reduce", &rp, &cur, None, &out, None); + cur = out; + first = false; + } + Some(cur) + }; + let tol = if ultra { pr.get("ultraSettings/tolerance").map(|v| v.as_f64()).unwrap_or(100.0) as f32 / 100.0 } else { 1.0 }; + let f = |k: &str| pr.f(k) as f32 / 100.0; + let (desat, neutral) = if ultra { (f("ultraSettings/desaturate"), f("ultraSettings/spillColorCorrection")) } else { (0.0, 0.0) }; + p.f[0] = [f("suppression"), f("ultraSettings/spillRange"), f("ultraSettings/lumaCorrection"), tol]; + p.f[1] = [desat, neutral, rgb_to_hsl(key[0], key[1], key[2]).0, 0.0]; + let img = run(e, &p, &b.img, sums.as_ref(), None); + Some(GBuf { img, ..b }) +} + +fn key_cleaner(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let r = (pr.f("additionalEdgeRadius") * b.scale).max(0.0); + let strength = pr.f("strength") as f32 / 100.0; + let chatter = pr.b("reduceChatter"); + let mut a = run(e, &op(24), &b.img, None, None); + if chatter { + a = gaussian_blur(e, &a, 1.0, 1.0, true); + } + let est = (r > 0.0 && strength > 0.0).then(|| gaussian_blur(e, &b.img, r * 0.5, r * 0.5, true)); + let rows = est.as_ref().map(|img| e.image_rows(img)); + let mut p = op(25); + p.u[0][1] = chatter as u32; + p.u[3] = [rows.as_ref().map_or(0, |r| r.1), rows.is_some() as u32, 0, 0]; + p.f[0] = [pr.f("alphaContrast") as f32 / 100.0, strength, 0.0, 0.0]; + let img = run(e, &p, &b.img, Some(&a), rows.as_ref().map(|r| &r.0)); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- mattes + +fn simple_choker(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let c = ctx.params.f("chokeMatte") * b.scale; + if c.abs() > 1e-6 { + if c < 0.0 && !ctx.adjustment { + b.pad(e, (-c).ceil() as u32 + 1)?; + } + let a = run(e, &op(24), &b.img, None, None); + let na = if c > 0.0 { morph_frac(e, &a, c, ERODE_X) } else { morph_frac(e, &a, -c, DILATE_X) }; + b.img = set_alpha(e, &b.img, &na); + } + if ctx.params.e("view") == 1 { + b.img = run(e, &op(28), &b.img, None, None); + } + Some(b) +} + +fn matte_choker(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let pr = ctx.params; + let g1 = pr.f("geometricSoftness1") * b.scale; + let c1 = pr.f("choke1") as f32; + let s1 = pr.f("grayLevelSoftness1") as f32 / 100.0; + let g2 = pr.f("geometricSoftness2") * b.scale; + let c2 = pr.f("choke2") as f32; + let s2 = pr.f("grayLevelSoftness2") as f32 / 100.0; + let it = pr.f("iterations").round().clamp(1.0, 100.0) as usize; + if (c1 < 0.0 || c2 < 0.0 || g1 > 0.0 || g2 > 0.0) && !ctx.adjustment { + b.pad(e, ((g1 + g2) * 1.5 * it as f64).ceil() as u32 + 1)?; + } + let mut a = run(e, &op(24), &b.img, None, None); + let stage = |e: &mut Enc, a: GpuImage, geo: f64, choke: f32, gray: f32| -> GpuImage { + let a = if geo > 0.0 { gaussian_blur(e, &a, geo * 0.5, geo * 0.5, true) } else { a }; + let t = 0.5 + choke / 255.0 * 0.5; + let w = gray.max(1e-3); + if (t - 0.5).abs() < 1e-6 && (w - 1.0).abs() < 1e-6 { + return a; + } + let mut p = op(26); + p.f[0] = [t, w, 0.0, 0.0]; + run(e, &p, &a, None, None) + }; + for _ in 0..it { + a = stage(e, a, g1, c1, s1); + a = stage(e, a, g2, c2, s2); + } + b.img = set_alpha(e, &b.img, &a); + Some(b) +} + +/// matte::refine (Refine Soft / Hard Matte) for one frame: Reduce Chatter, the matte motion +/// blur (they read neighbouring frames) and Invert run on the CPU. +fn refine(e: &mut Enc, ctx: &EffectCtx, b: GBuf, hard: bool) -> Option { + let pr = ctx.params; + // Invert gives alpha to the empty areas, whose colour then comes from a blur of nearly + // transparent pixels (util::set_alpha): ill-conditioned, so it renders on the CPU. + if pr.f("reduceChatter") > 0.0 || pr.b("useMotionBlur") || pr.b("invert") { + return None; + } + let dk = |id: &str| if hard { id.to_string() } else { format!("decontamination/{id}") }; + let r = (pr.f("radius") * b.scale).round().max(1.0) as usize; + let smooth = pr.f("smooth") as f32 / 100.0; + let feather = pr.f("feather") / 100.0 * r as f64 * 0.5; + let choke = (pr.f("choke") - if hard { 0.0 } else { pr.f("shiftEdge") }).clamp(-100.0, 100.0) as f32 / 100.0 * 0.5; + let decon = pr.b("decontaminate"); + let decon_amt = pr.f(&dk("decontaminationAmount")) as f32 / 100.0; + let extend = pr.b(&dk("extendWhereSmoothed")); + let decon_r = r as f64 + pr.f(&dk("increaseDecontaminationRadius")).max(0.0) * b.scale; + let view_map = pr.b(&dk("viewDecontaminationMap")); + let contrast = if hard { pr.f("alphaContrast") as f32 / 100.0 } else { 1.0 + pr.f("contrast").max(0.0) as f32 / 100.0 * 3.0 }; + let edge_details = hard || pr.b("calculateEdgeDetails"); + let view_edges = !hard && pr.b("viewEdgeRegion"); + let (w, h) = (b.img.width, b.img.height); + + // (luminance guide, alpha, guide × alpha, guide²); the edge band from the alpha's range. + let pack = run(e, &op(29), &b.img, None, None); + let hi = morph_frac(e, &pack, r as f64, [2, 1, 2, 2]); + let lo = morph_frac(e, &pack, r as f64, [2, 0, 2, 2]); + let band = run(e, &op(32), &hi, Some(&lo), None); + let mut na = if edge_details { + let eps = 1e-4 + smooth * smooth * 0.05; + let means = box_passes(e, &pack, &[r], &[r], true); + let mut p = op(30); + p.f[0][0] = eps; + let coef = run(e, &p, &means, None, None); + let coef = box_passes(e, &coef, &[r], &[r], true); + let (rows, row) = e.image_rows(&band); + let mut p = op(31); + p.u[3][0] = row; + run(e, &p, &coef, Some(&pack), Some(&rows)) + } else { + let a = run(e, &op(24), &b.img, None, None); + if smooth > 0.0 { + let s = smooth as f64 * r as f64 * 0.5; + gaussian_blur(e, &a, s, s, true) + } else { + a + } + }; + if feather > 0.0 { + na = gaussian_blur(e, &na, feather, feather, true); + } + let mut p = op(33); + p.u[0][1] = pr.b("invert") as u32; + p.f[0] = [choke, contrast, 0.0, 0.0]; + let na = run(e, &p, &na, None, None); + if view_edges { + let img = run(e, &op(34), &na, Some(&band), None); + return Some(GBuf { img, ..b }); + } + let est = (decon && decon_amt > 0.0 && !view_map).then(|| gaussian_blur(e, &b.img, decon_r * 0.5, decon_r * 0.5, true)); + let rows = est.as_ref().map(|img| e.image_rows(img)); + let mut p = op(35); + p.u[0] = [35, view_map as u32, (decon && (view_map || decon_amt > 0.0)) as u32, extend as u32]; + p.u[3] = [rows.as_ref().map_or(0, |r| r.1), rows.is_some() as u32, 0, 0]; + p.f[0][0] = decon_amt; + let img = run(e, &p, &b.img, Some(&na), rows.as_ref().map(|r| &r.0)); + if view_map { + return Some(GBuf { img, ..b }); + } + debug_assert_eq!((img.width, img.height), (w, h)); + let img = set_alpha(e, &img, &na); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- channel + +const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"]; + +const SHIFT_ORDER: [Src; 11] = + [Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off]; + +const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off]; + +fn set_channels(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let s = ["setRedTo", "setGreenTo", "setBlueTo", "setAlphaTo"].map(|id| src_index(effectcraft_effects::util::src_at(pr.e(id)))); + let stretch = pr.b("stretchLayersToFit"); + let mut layers = vec![]; + for id in SET_CHANNEL_LAYERS { + layers.push(fitted(e, ctx, &b, id, true, stretch)?); + } + if layers.iter().all(Option::is_none) { + return route(e, b, s); + } + let mut vals = e.image(b.img.width, b.img.height); + for (k, layer) in layers.iter().enumerate() { + let mut p = op(20); + p.u[0] = [20, k as u32, s[k], 0]; + vals = run(e, &p, &vals, Some(layer.as_ref().unwrap_or(&b.img)), None); + } + let img = run(e, &op(21), &vals, None, None); + Some(GBuf { img, ..b }) +} + +/// channel::route. +fn route(e: &mut Enc, b: GBuf, s: [u32; 4]) -> Option { + if s == [0, 1, 2, 3] { + return Some(b); + } + let mut p = op(11); + p.u[1] = s; + let img = run(e, &p, &b.img, None, None); + Some(GBuf { img, ..b }) +} + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut aux = None; + match id { + "ec.key.spill" => { + let k = pr.color("colorToSuppress"); + let amt = f("suppression") / 100.0; + if pr.e("colorAccuracy") == 1 { + let kg = (k[0] + k[1] + k[2]) / 3.0; + let kc = [k[0] - kg, k[1] - kg, k[2] - kg]; + let kn = (kc[0] * kc[0] + kc[1] * kc[1] + kc[2] * kc[2]).sqrt(); + if kn < 1e-5 { + return Some(b); + } + p.u[0] = [7, 1, 0, 0]; + p.f[0] = [kc[0] / kn, kc[1] / kn, kc[2] / kn, amt]; + } else { + let ix = primary(k); + p.u[0] = [7, 0, 0, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0][3] = amt; + } + } + "ec.key.colordifference" => { + let view = pr.e("view"); + if view == 0 { + return Some(b); + } + let accurate = pr.e("colorMatchingAccuracy") == 1; + let lin = |c: [f32; 3]| if accurate { c.map(|v| effectcraft_color::srgb_to_linear(v.clamp(0.0, 1.0))) } else { c }; + let k = pr.color("keyColor"); + let key = lin([k[0], k[1], k[2]]); + let ix = primary([key[0], key[1], key[2], 1.0]); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let lv = |pre: &str| ["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| f(&format!("{pre}{s}"))); + let (pa, pb) = (lv("partialA"), lv("partialB")); + p.u[0] = [9, view, accurate as u32, 0]; + p.u[1] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [(key[pi] - key[o1]).max(1e-3), (key[pi] - key[o2]).max(1e-3), f("blackLevel"), f("whiteLevel")]; + p.f[1] = [pa[0], pa[1], pa[2], pa[3]]; + p.f[2] = [pa[4], pb[4], f("gamma"), 0.0]; + p.f[3] = [pb[0], pb[1], pb[2], pb[3]]; + } + "ec.key.unmult" => { + let white = pr.e("backgroundColor") == 1; + let level = if white { pr.f("whiteLevel") } else { pr.f("blackLevel") }.clamp(0.0, 100.0) as f32 / 100.0; + p.u[0] = [10, white as u32, pr.b("removeColorMatting") as u32, pr.b("clipHdrResults") as u32]; + p.f[0] = [level, pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0, 0.0, 0.0]; + } + "ec.channel.shiftchannels" => { + let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(10)])); + return route(e, b, s); + } + "ec.channel.removecolormatting" => { + p.u[0] = [12, pr.b("clipHdr") as u32, 0, 0]; + p.f[0] = pr.color("backgroundColor"); + } + "ec.channel.arithmetic" => { + p.u[0] = [13, pr.e("operator"), pr.b("clip") as u32, 0]; + p.f[0] = [f("redValue") / 255.0, f("greenValue") / 255.0, f("blueValue") / 255.0, 0.0]; + } + "ec.channel.solidcomposite" => { + const MODES: [effectcraft_color::BlendMode; 12] = { + use effectcraft_color::BlendMode::*; + [Normal, Add, Multiply, Screen, Overlay, SoftLight, HardLight, Darken, Lighten, Difference, Color, Luminosity] + }; + let c = pr.color("color"); + let opa = (f("opacity") / 100.0 * c[3]).clamp(0.0, 1.0); + p.u[0] = [14, crate::ops::mode_id(MODES[(pr.e("blendingMode") as usize).min(11)]), 0, 0]; + p.f[0] = [c[0] * opa, c[1] * opa, c[2] * opa, opa]; + p.f[1][0] = f("sourceOpacity") / 100.0; + } + "ec.channel.setmatte" => { + let src = MATTE_ORDER[(pr.e("takeMatteFrom") as usize).min(9)]; + aux = fitted(e, ctx, &b, "takeMatteFromLayer", true, pr.b("stretchMatteToFit"))?; + let pre_ok = !matches!(src, Src::Alpha | Src::Full | Src::Off); + let flags = pr.b("invertMatte") as u32 + | (pr.b("compositeMatteWithOriginal") as u32) << 1 + | ((pr.b("premultiplyMatteLayer") && pre_ok) as u32) << 2 + | (aux.is_none() as u32) << 3 + | ((src == Src::Alpha) as u32) << 4; + p.u[0] = [15, src_index(src), flags, 0]; + } + "ec.channel.combiner" => { + if pr.b("useSecondLayer") { + aux = Some(layer_or_self(e, ctx, &b, "sourceLayer", true)?); + } + let flags = pr.b("invert") as u32 | (pr.b("solidAlpha") as u32) << 1 | (aux.is_some() as u32) << 2; + p.u[0] = [16, pr.e("from"), pr.e("to"), flags]; + } + "ec.channel.blend" => { + let orig = (f("blendWithOriginal") / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return Some(b); + } + aux = Some(layer_or_self(e, ctx, &b, "blendWithLayer", pr.e("ifLayerSizesDiffer") == 1)?); + p.u[0] = [17, pr.e("mode"), 0, 0]; + p.f[0][0] = orig; + } + "ec.channel.calculations" => { + const MODES: [effectcraft_color::BlendMode; 22] = { + use effectcraft_color::BlendMode::*; + [ + Normal, + Darken, + Multiply, + ColorBurn, + LinearBurn, + Add, + Lighten, + Screen, + ColorDodge, + LinearDodge, + Overlay, + SoftLight, + HardLight, + LinearLight, + VividLight, + PinLight, + HardMix, + Difference, + Exclusion, + Hue, + Saturation, + Luminosity, + ] + }; + if pr.get("secondLayer").and_then(|v| v.as_layer()).is_some() { + aux = Some(layer_or_self(e, ctx, &b, "secondLayer", pr.b("stretchSecondLayerToFit"))?); + } + let flags = + pr.b("invertInput") as u32 | (pr.b("invertSecondLayer") as u32) << 1 | (pr.b("preserveTransparency") as u32) << 2 | (aux.is_some() as u32) << 3; + let mode = MODES[(pr.e("blendingMode") as usize).min(MODES.len() - 1)]; + p.u[0] = [18, pr.e("inputChannel"), pr.e("secondLayerChannel"), crate::ops::mode_id(mode)]; + p.u[1][0] = flags; + p.f[0][0] = (f("secondLayerOpacity") / 100.0).clamp(0.0, 1.0); + } + "ec.channel.compoundarithmetic" => { + let orig = (f("blendWithOriginal") / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return Some(b); + } + aux = Some(layer_or_self(e, ctx, &b, "secondSource", pr.b("stretchSecondSourceToFit"))?); + p.u[0] = [19, pr.e("operator"), pr.e("operateOnChannels"), pr.e("overflowBehavior")]; + p.f[0][0] = orig; + } + _ => return None, + } + let img = run(e, &p, &b.img, aux.as_ref(), None); + Some(GBuf { img, ..b }) +} diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs new file mode 100644 index 000000000..eaa7324a6 --- /dev/null +++ b/crates/gpu/src/fx_noise.rs @@ -0,0 +1,581 @@ +//! GPU effects, noise, blur and time family (kernels in `shaders/fx_noise.wgsl`, every entry point prefixed +//! `fxn_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! +//! Median, Median (Legacy) and Dust & Scratches take the median of the 512-level quantised +//! channels by bisection over the window (radius ≤ [`MEDIAN_MAX_R`] on the GPU). Fractal and +//! Turbulent Noise resolve every per-octave term on the CPU in the CPU's f32 order +//! (`effectcraft_effects::fractal_gpu`) and evaluate the pixels on the GPU. Echo and Posterize +//! Time fetch the layer's other frames through the effect host like the CPU does, then +//! accumulate on the GPU. Remove Grain measures the grain level in its sample boxes on the CPU +//! (one readback) and runs the guided filters, Texture, Unsharp Mask and Preview on the GPU; +//! Temporal Filtering and the Noise Samples / Blending Matte views render on the CPU. + +use effectcraft_color::BlendMode; +use effectcraft_effects::{Buf, EffectCtx}; +use effectcraft_keyframe::Value; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, box_passes, gaussian_blur}; + +/// Compute entry points in `fx_noise.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "fxn_quant", + "fxn_median", + "fxn_median_out", + "fxn_edge", + "fxn_smart_edges", + "fxn_unsharp", + "fxn_pick", + "fxn_cblur_out", + "fxn_straight", + "fxn_premul", + "fxn_morph", + "fxn_crop", + "fxn_cb_map", + "fxn_cb_level", + "fxn_echo", + "fxn_echo_fin", + "fxn_fractal", + "fxn_noise_alpha", + "fxn_noise_hls", + "fxn_gf_guide", + "fxn_gf", + "fxn_rg_texture", + "fxn_rg_unsharp", + "fxn_rg_out", +]; + +/// Effect ids implemented here (Fractal Noise's other modes come through [`fractal_extra`]). +pub(crate) const IDS: &[&str] = &[ + "ec.noise.turbulent", + "ec.noise.median", + "ec.noise.medianlegacy", + "ec.noise.dustscratches", + "ec.noise.removegrain", + "ec.noise.noisealpha", + "ec.noise.noisehls", + "ec.noise.noisehlsauto", + "ec.blur.smart", + "ec.blur.bilateral", + "ec.blur.sharpen", + "ec.blur.unsharp", + "ec.blur.compound", + "ec.blur.channel", + "ec.channel.minimax", + "ec.time.echo", + "ec.time.posterizetime", +]; + +/// Largest median radius (buffer pixels) the GPU takes; larger ones render on the CPU. +pub(crate) const MEDIAN_MAX_R: usize = 16; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.noise.turbulent" => fractal(e, ctx, b, true), + "ec.noise.median" | "ec.noise.medianlegacy" | "ec.noise.dustscratches" => median(e, id, ctx, b), + "ec.noise.removegrain" => remove_grain(e, ctx, b), + "ec.noise.noisealpha" => noise_alpha(e, ctx, b), + "ec.noise.noisehls" => noise_hls(e, ctx, b, ctx.params.f("noisePhase") as f32 / 360.0), + "ec.noise.noisehlsauto" => noise_hls(e, ctx, b, ctx.time as f32 * ctx.params.f("noiseAnimationSpeed") as f32), + "ec.blur.smart" => smart_blur(e, ctx, b), + "ec.blur.bilateral" => bilateral(e, ctx, b), + "ec.blur.sharpen" => { + let s = b.scale; + unsharp(e, b, ctx.params.f("amount") as f32 / 50.0, s, 0.0) + } + "ec.blur.unsharp" => { + let s = (ctx.params.f("radius") * b.scale).max(0.1); + unsharp(e, b, ctx.params.f("amount") as f32 / 100.0, s, ctx.params.f("threshold") as f32) + } + "ec.blur.compound" => compound_blur(e, ctx, b), + "ec.blur.channel" => channel_blur(e, ctx, b), + "ec.channel.minimax" => minimax(e, ctx, b), + "ec.time.echo" => echo(e, ctx, b), + "ec.time.posterizetime" => posterize_time(e, ctx, b), + _ => None, + } +} + +/// A same-size per-pixel kernel. +fn run(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.image(src.width, src.height); + e.pixels(entry, p, src, aux, &out, data); + out +} + +fn with_u0(u: [u32; 4]) -> Params { + let mut p = Params::default(); + p.u[0] = u; + p +} + +// ---------------------------------------------------------------- Fractal / Turbulent Noise (noise3.rs) + +/// Fractal Noise settings the `pointwise` kernel (effects.rs) leaves to the CPU, which this +/// family's kernel covers: every Noise and Fractal Type, Cycle Evolution, Perspective Offset, +/// Sub Rotation / Offset and all Blending Modes. +pub(crate) fn fractal_extra(ctx: &EffectCtx) -> bool { + let pr = ctx.params; + pr.e("noiseType") != 2 + || pr.e("fractalType") > 2 + || pr.e("blendingMode") > 1 + || pr.b("evolutionOptions/cycleEvolution") + || pr.b("transform/perspectiveOffset") + || pr.f("subSettings/subRotation") != 0.0 + || (!pr.b("subSettings/centerSubscale") && pr.v2("subSettings/subOffset") != [0.0, 0.0]) +} + +/// noise3::render. +pub(crate) fn fractal(e: &mut Enc, ctx: &EffectCtx, b: GBuf, turbulent: bool) -> Option { + let fr = effectcraft_effects::fractal_gpu(ctx, b.offset, b.scale, turbulent); + let mode = match fr.mode { + None => 255, + Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32, + }; + let data: Vec = fr.octaves.iter().flatten().copied().collect(); + let cycle = fr.cycle.unwrap_or([0.0; 3]); + let mut p = Params::default(); + p.u[0] = [fr.kind, fr.noise_type, fr.octaves.len() as u32, fr.seed]; + p.u[1] = [fr.sub_rotation as u32, fr.cycle.is_some() as u32, fr.invert as u32, fr.overflow]; + p.u[2][0] = mode; + p.f[0] = [fr.rot.0, fr.rot.1, fr.size[0], fr.size[1]]; + p.f[1] = [fr.contrast, fr.brightness, fr.opacity, fr.blend]; + p.f[2] = [cycle[0], cycle[1], cycle[2], fr.norm]; + let buf = e.data(&data); + let img = run(e, "fxn_fractal", &p, &b.img, None, Some(&buf)); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- Median family (noise.rs, noise2.rs) + +/// noise::median_image (of the straight colour with alpha 1 when `straight`). +fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool) -> GpuImage { + let q = run(e, "fxn_quant", &with_u0([straight as u32, 0, 0, 0]), img, None, None); + run(e, "fxn_median", &with_u0([r as u32, 0, 0, 0]), &q, None, None) +} + +fn median(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return Some(b); + } + if r > MEDIAN_MAX_R { + return None; + } + let (mode, on_alpha, straight) = match id { + "ec.noise.medianlegacy" => { + let on = ctx.params.b("operateOnAlphaChannel"); + (if on { 2 } else { 3 }, on, !on) + } + "ec.noise.dustscratches" => (1, ctx.params.b("operateOnAlpha"), false), + _ => (0, ctx.params.b("operateOnAlpha"), false), + }; + let m = median_image(e, &b.img, r, straight); + let mut p = with_u0([mode, on_alpha as u32, 0, 0]); + if mode == 1 { + p.f[0][0] = ctx.params.f("threshold") as f32 / 255.0; + } + let img = run(e, "fxn_median_out", &p, &b.img, Some(&m), None); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- Remove Grain (noise.rs) + +fn remove_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let amt = pr.f("noiseReductionSettings/noiseReduction") as f32; + let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0; + let view = pr.get("viewingMode").map(Value::as_enum).unwrap_or(3); + // Noise Samples / Blending Matte views and Temporal Filtering render on the CPU. + if view == 1 || view == 2 || pr.b("temporalFiltering/enabled") { + return None; + } + if amt <= 0.0 && um_amount <= 0.0 { + return Some(b); + } + // The grain level measured in the sample boxes (CPU; only the smoothing uses it). + let level = if amt > 0.0 { + let img = e.download(&b.img)?; + effectcraft_effects::remove_grain_level(ctx, &Buf { img, offset: b.offset, scale: b.scale }) + } else { + 1.0 + }; + let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize; + let multichannel = pr.e("noiseReductionSettings/mode") == 0; + let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0); + let r = (2.0 * b.scale).round().max(1.0) as usize; + let eps = (0.02 * amt * level).powi(2); + let orig = b.img.clone(); + let mut ch = orig.clone(); + if amt > 0.0 { + for _ in 0..passes { + let g = run(e, "fxn_gf_guide", &with_u0([!multichannel as u32, 0, 0, 0]), &ch, None, None); + ch = guided_filter(e, &g, &ch, r, eps); + } + if texture > 0.0 { + let mut p = Params::default(); + p.f[0][0] = texture; + ch = run(e, "fxn_rg_texture", &p, &ch, Some(&orig), None); + } + } + if um_amount > 0.0 { + let radius = (pr.f("unsharpMask/radius") * b.scale).max(0.1); + let low = gaussian_blur(e, &ch, radius, radius, true); + let mut p = Params::default(); + p.f[0] = [um_amount, pr.f("unsharpMask/threshold") as f32 / 255.0, 0.0, 0.0]; + ch = run(e, "fxn_rg_unsharp", &p, &ch, Some(&low), None); + } + let mut p = Params::default(); + if view == 0 { + let c = b.to_px(pr.v2("previewRegion/center")); + let (hw, hh) = (pr.f("previewRegion/width") * b.scale * 0.5, pr.f("previewRegion/height") * b.scale * 0.5); + let col = pr.color("previewRegion/boxColor"); + p.u[0] = [1, pr.b("previewRegion/showBox") as u32, 0, 0]; + p.f[0] = [(c.0 - hw) as f32, (c.0 + hw) as f32, (c.1 - hh) as f32, (c.1 + hh) as f32]; + p.f[1] = [col[0], col[1], col[2], 1.0]; + } + let img = run(e, "fxn_rg_out", &p, &ch, Some(&orig), None); + Some(GBuf { img, ..b }) +} + +/// One guided-filter arithmetic pass (`fxn_gf`), `d` = an image read through `data`. +fn gf(e: &mut Enc, mode: u32, s: &GpuImage, a: &GpuImage, d: Option<&GpuImage>, eps: f32) -> GpuImage { + let mut p = with_u0([mode, 0, 0, 0]); + p.f[0][0] = eps; + let rows = d.map(|d| e.image_rows(d)); + if let Some((_, row)) = &rows { + p.u[0][1] = *row; + } + run(e, "fxn_gf", &p, s, Some(a), rows.as_ref().map(|r| &r.0)) +} + +/// util::guided_filter on every channel at once (guide `g`, input `p`). +fn guided_filter(e: &mut Enc, g: &GpuImage, p: &GpuImage, r: usize, eps: f32) -> GpuImage { + let bx = |e: &mut Enc, img: &GpuImage| box_passes(e, img, &[r], &[r], true); + let mean_g = bx(e, g); + let mean_p = bx(e, p); + let gp = gf(e, 5, g, p, None, 0.0); + let gg = gf(e, 6, g, g, None, 0.0); + let corr_gp = bx(e, &gp); + let corr_gg = bx(e, &gg); + let cov = gf(e, 0, &corr_gp, &mean_p, Some(&mean_g), 0.0); + let var = gf(e, 1, &corr_gg, &mean_g, None, 0.0); + let a = gf(e, 2, &cov, &var, None, eps); + let bb = gf(e, 3, &a, &mean_p, Some(&mean_g), 0.0); + let ma = bx(e, &a); + let mb = bx(e, &bb); + gf(e, 4, &ma, &mb, Some(g), 0.0) +} + +// ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs) + +/// noise::phased_cycle's two seeds and smoothed fraction for `phase`. +fn phase_terms(seed: u32, phase: f32, cycle: i64) -> (u32, u32, f32) { + let k = phase.floor(); + let t = phase - k; + let k = k as i64; + let wrap = |k: i64| if cycle > 0 { k.rem_euclid(cycle) } else { k } as u32; + (seed.wrapping_add(wrap(k).wrapping_mul(0x632b)), seed.wrapping_add(wrap(k + 1).wrapping_mul(0x632b)), t * t * (3.0 - 2.0 * t)) +} + +fn noise_alpha(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let k = ctx.params.f("amount") as f32 / 100.0; + if k <= 0.0 { + return Some(b); + } + let kind = ctx.params.e("noise"); + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x2f1); + let phase = ctx.params.f("noisePhase") as f32 / 360.0 + if kind >= 2 { ctx.time as f32 } else { 0.0 }; + let cycle = if ctx.params.b("noiseOptions/cycleNoise") { ctx.params.f("noiseOptions/cycle").round().max(1.0) as i64 } else { 0 }; + let ph = if kind >= 2 { phase } else { phase.floor() }; + let (sa, sb, t) = phase_terms(seed, ph, cycle); + let mut p = with_u0([kind % 2, ctx.params.e("originalAlpha"), ctx.params.e("overflow"), sa]); + p.u[1][0] = sb; + p.f[0] = [k, t, 0.0, 0.0]; + let img = run(e, "fxn_noise_alpha", &p, &b.img, None, None); + Some(GBuf { img, ..b }) +} + +fn noise_hls(e: &mut Enc, ctx: &EffectCtx, b: GBuf, phase: f32) -> Option { + let hue = ctx.params.f("hue") as f32 / 100.0; + let light = ctx.params.f("lightness") as f32 / 100.0; + let satv = ctx.params.f("saturation") as f32 / 100.0; + if hue == 0.0 && light == 0.0 && satv == 0.0 { + return Some(b); + } + let gs = (ctx.params.f("grainSize") * b.scale).max(0.1) as f32; + let seed = ctx.seed.wrapping_mul(0x51f3); + let mut seeds = [0u32; 6]; + let mut t = 0.0; + for k in 0..3u32 { + let (sa, sb, tt) = phase_terms(seed.wrapping_add(k * 7919), phase, 0); + seeds[2 * k as usize] = sa; + seeds[2 * k as usize + 1] = sb; + t = tt; + } + let mut p = with_u0([ctx.params.e("noise"), seed, 0, 0]); + p.u[1] = [seeds[0], seeds[1], seeds[2], seeds[3]]; + p.u[2] = [seeds[4], seeds[5], 0, 0]; + p.f[0] = [hue, light, satv, gs]; + p.f[1] = [phase, t, 0.0, 0.0]; + let img = run(e, "fxn_noise_hls", &p, &b.img, None, None); + Some(GBuf { img, ..b }) +} + +// ---------------------------------------------------------------- blurs (blur2.rs, misc.rs) + +/// blur2::edge_preserving (+ Bilateral's grey output with `grey`). +fn edge_preserving(e: &mut Enc, img: &GpuImage, r: f64, range: f32, hard: bool, grey: bool) -> GpuImage { + let ri = r.ceil() as u32; + let stride = ((r / 6.0).ceil() as u32).max(1); + let sig_s2 = 2.0 * (r / 2.0).max(0.5).powi(2); + let sig_r2 = 2.0 * range.max(1e-3).powi(2); + let d2max = (r * r + 0.5).floor() as u32; + let mut p = with_u0([ri, stride, hard as u32, d2max]); + p.u[1][0] = grey as u32; + p.f[0] = [sig_s2 as f32, sig_r2, range, 0.0]; + run(e, "fxn_edge", &p, img, None, None) +} + +fn bilateral(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let r = ctx.params.f("radius") * b.scale; + if r < 0.5 { + return Some(b); + } + let range = ctx.params.f("threshold") as f32 / 100.0; + let img = edge_preserving(e, &b.img, r, range, false, !ctx.params.b("colorize")); + Some(GBuf { img, ..b }) +} + +fn smart_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let r = ctx.params.f("radius") * b.scale; + let thr = ctx.params.f("threshold") as f32 / 100.0; + let mode = ctx.params.e("mode"); + let blurred = if r >= 0.5 { edge_preserving(e, &b.img, r, thr, true, false) } else { b.img.clone() }; + if mode == 0 { + return Some(GBuf { img: blurred, ..b }); + } + let mut p = with_u0([mode, 0, 0, 0]); + p.f[0][0] = thr.max(0.01) * 0.5; + let img = run(e, "fxn_smart_edges", &p, &blurred, None, None); + Some(GBuf { img, ..b }) +} + +/// misc::unsharp_core (Sharpen, Unsharp Mask). +fn unsharp(e: &mut Enc, b: GBuf, amount: f32, sigma: f64, threshold: f32) -> Option { + let blurred = gaussian_blur(e, &b.img, sigma, sigma, true); + let mut p = Params::default(); + p.f[0] = [amount, threshold, 0.0, 0.0]; + let img = run(e, "fxn_unsharp", &p, &b.img, Some(&blurred), None); + Some(GBuf { img, ..b }) +} + +fn dims_xy(dim: u32) -> (f64, f64) { + match dim { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + } +} + +fn pick(e: &mut Enc, acc: &GpuImage, from: &GpuImage, mask: [bool; 4], alpha: bool) -> GpuImage { + let mut p = with_u0([alpha as u32, 0, 0, 0]); + p.u[1] = mask.map(|m| m as u32); + run(e, "fxn_pick", &p, acc, Some(from), None) +} + +fn channel_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let sig = ["redBlurriness", "greenBlurriness", "blueBlurriness", "alphaBlurriness"].map(|id| ctx.params.f(id).max(0.0) * 0.5 * b.scale); + if sig.iter().all(|&s| s < 0.05) { + return Some(b); + } + let (kx, ky) = dims_xy(ctx.params.e("dimensions")); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + let maxs = sig.iter().cloned().fold(0.0, f64::max); + if !repeat { + b.pad(e, (maxs * 3.0).ceil() as u32 + 1)?; + } + // n: each colour channel blurred by its own amount; a: the alpha blurred by the same amount + // (its divisor), alpha = the Alpha Blurriness result. + let mut n = b.img.clone(); + let mut a = pick(e, &b.img, &b.img, [true; 4], true); + let mut done: Vec = Vec::new(); + for &s in &sig { + if s < 0.05 || done.contains(&s.to_bits()) { + continue; + } + done.push(s.to_bits()); + let bl = gaussian_blur(e, &b.img, s * kx, s * ky, true); + let on = |c: usize| sig[c] >= 0.05 && sig[c].to_bits() == s.to_bits(); + n = pick(e, &n, &bl, [on(0), on(1), on(2), false], false); + a = pick(e, &a, &bl, [on(0), on(1), on(2), on(3)], true); + } + b.img = run(e, "fxn_cblur_out", &Params::default(), &n, Some(&a), None); + Some(b) +} + +fn compound_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let max_sigma = ctx.params.f("maximumBlur").max(0.0) * 0.5 * b.scale; + if max_sigma < 0.05 { + return Some(b); + } + let invert = ctx.params.b("invertBlur"); + if !ctx.adjustment { + b.pad(e, (max_sigma * 3.0).ceil() as u32 + 1)?; + } + let (w, h) = (b.img.width as usize, b.img.height as usize); + // The blur map: the Blur Layer fitted to the buffer (util::fit_layer), else the layer itself. + let map = match ctx.layer_param("blurLayer", true) { + Some(o) => { + if o.buf.img.is_empty() { + return None; + } + let stretch = ctx.params.b("stretchMapToFit"); + let (ls, os) = (ctx.layer_size, o.size); + let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) }; + let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) }; + let inv = 1.0 / b.scale.max(1e-9); + let col = |x: usize| (((x as f64 + 0.5 - b.offset[0]) * inv) * sx + dx) * o.buf.scale + o.buf.offset[0]; + let row = |y: usize| (((y as f64 + 0.5 - b.offset[1]) * inv) * sy + dy) * o.buf.scale + o.buf.offset[1]; + let data: Vec = (0..w).map(|x| col(x) as f32).chain((0..h).map(|y| row(y) as f32)).collect(); + let layer = e.g.upload_image(&o.buf.img)?; + let buf = e.data(&data); + let mut p = with_u0([1, invert as u32, 0, 0]); + p.u[1][0] = w as u32; + let out = e.image(b.img.width, b.img.height); + e.pixels("fxn_cb_map", &p, &layer, None, &out, Some(&buf)); + out + } + None => run(e, "fxn_cb_map", &with_u0([0, invert as u32, 0, 0]), &b.img, None, None), + }; + const N: usize = 5; + let levels: Vec = (0..N) + .map(|k| { + if k == 0 { + b.img.clone() + } else { + let s = max_sigma * k as f64 / (N - 1) as f64; + gaussian_blur(e, &b.img, s, s, ctx.adjustment) + } + }) + .collect(); + let (rows, row) = e.image_rows(&map); + let out = e.image(b.img.width, b.img.height); + for k in 0..N - 1 { + e.pixels("fxn_cb_level", &with_u0([k as u32, row, 0, 0]), &levels[k], Some(&levels[k + 1]), &out, Some(&rows)); + } + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- Minimax (channel.rs) + +/// util::morph_plane per channel (`modes`: 0 min, 1 max, 2 keep) with radii (`rx`, `ry`). +fn morph(e: &mut Enc, img: &GpuImage, rx: usize, ry: usize, modes: [u32; 4]) -> GpuImage { + let mut cur = img.clone(); + for (vertical, r) in [(0, rx), (1, ry)] { + if r == 0 { + continue; + } + let mut p = with_u0([r as u32, vertical, 0, 0]); + p.u[1] = modes; + cur = run(e, "fxn_morph", &p, &cur, None, None); + } + cur +} + +fn minimax(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return Some(b); + } + let op = ctx.params.e("operation"); + let dont_shrink = ctx.params.b("dontShrinkEdges"); + let (rx, ry) = match ctx.params.e("direction") { + 1 => (r, 0), + 2 => (0, r), + _ => (r, r), + }; + let sel: [bool; 4] = match ctx.params.e("channel") { + 1 => [false, false, false, true], + 2 => [true, true, true, true], + 3 => [true, false, false, false], + 4 => [false, true, false, false], + 5 => [false, false, true, false], + _ => [true, true, true, false], + }; + if sel[3] && op != 0 && !ctx.adjustment { + b.pad(e, rx.max(ry) as u32)?; + } + let (w, h) = (b.img.width, b.img.height); + let mut planes = run(e, "fxn_straight", &Params::default(), &b.img, None, None); + // Beyond the layer edge is empty (0) unless Don't Shrink Edges repeats the edge pixels. + if !dont_shrink { + let (pw, ph) = (w + 2 * rx as u32, h + 2 * ry as u32); + if !e.g.fits(pw, ph) { + return None; + } + let padded = e.image(pw, ph); + e.copy_into(&planes, &padded, rx as u32, ry as u32); + planes = padded; + } + let modes = |max: bool| sel.map(|s| if s { max as u32 } else { 2 }); + let stages: &[bool] = match op { + 0 => &[false], + 1 => &[true], + 2 => &[false, true], + _ => &[true, false], + }; + for &max in stages { + planes = morph(e, &planes, rx, ry, modes(max)); + } + if !dont_shrink { + let out = e.image(w, h); + e.pixels("fxn_crop", &with_u0([rx as u32, ry as u32, 0, 0]), &planes, None, &out, None); + planes = out; + } + b.img = run(e, "fxn_premul", &Params::default(), &planes, None, None); + Some(b) +} + +// ---------------------------------------------------------------- Echo / Posterize Time (time_fx.rs) + +fn echo(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let n = ctx.params.f("numberOfEchoes").round().clamp(0.0, 255.0) as usize; + let dt = ctx.params.f("echoTime"); + let start = ctx.params.f("startingIntensity").clamp(0.0, 1.0) as f32; + let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32; + let op = ctx.params.e("echoOperator"); + let times: Vec = (0..=n).map(|i| ctx.time + i as f64 * dt).collect(); + // Without the effect host (or the frames) the layer passes through, as on the CPU. + let Some((grid, imgs)) = effectcraft_effects::time_frames(ctx, ×) else { return Some(b) }; + let (w, h) = (grid.img.width, grid.img.height); + let mut acc = [e.image(w, h), e.image(w, h)]; + for (i, img) in imgs.iter().enumerate() { + let frame = e.g.upload_image(img)?; + let mut p = with_u0([op, (i == 0) as u32, 0, 0]); + p.f[0][0] = start * decay.powi(i as i32); + let [a, o] = &acc; + e.pixels("fxn_echo", &p, a, Some(&frame), o, None); + acc.swap(0, 1); + } + let mut p = with_u0([op, 0, 0, 0]); + p.f[0][0] = (n + 1) as f32; + let img = run(e, "fxn_echo_fin", &p, &acc[0], None, None); + Some(GBuf { img, offset: grid.offset, scale: grid.scale }) +} + +fn posterize_time(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + if ctx.env.host.is_none() { + return Some(b); + } + let tt = effectcraft_effects::posterized_time(ctx.time, ctx.params.f("frameRate")); + match effectcraft_effects::time_frames(ctx, &[tt]) { + Some((grid, mut imgs)) => { + let img = e.g.upload_image(&imgs.pop().unwrap_or_default())?; + Some(GBuf { img, offset: grid.offset, scale: grid.scale }) + } + None => Some(b), + } +} diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs new file mode 100644 index 000000000..6e83c7c8e --- /dev/null +++ b/crates/gpu/src/fx_stylize.rs @@ -0,0 +1,1002 @@ +//! GPU effects, stylize and distort family (kernels in `shaders/fx_stylize.wgsl`, every entry point prefixed +//! `fxs_`): the CPU effects' exact steps with the pixel loops as compute kernels. +//! +//! Parameter conversions, padding, output geometry and anything the CPU computes in f64 with a +//! floor or a wrap (tile indices, wrapped coordinates, brush cells) are computed here exactly as +//! the CPU does and uploaded as per-column / per-row / per-cell tables; the kernels do the +//! per-pixel work. Glow's other Glow Operations / Arbitrary Map and Transform's shutter-angle +//! motion blur extend the base kernels in `effects.rs` (see [`glow_extra`], [`transform_blur`]). + +use std::f64::consts::{FRAC_PI_2, TAU}; + +use effectcraft_color::BlendMode; +use effectcraft_effects::EffectCtx; +use effectcraft_geom::{Mat3, vec2}; +use effectcraft_raster::Sampling; + +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; +use crate::ops; + +/// Compute entry points in `fx_stylize.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "fxs_point", + "fxs_warp", + "fxs_table", + "fxs_griddler", + "fxs_motiontile", + "fxs_repetile", + "fxs_magnify", + "fxs_scatter", + "fxs_brush", + "fxs_roughen", + "fxs_tex_luma", + "fxs_texturize", + "fxs_glow_map", + "fxs_glow_op", + "fxs_add", + "fxs_div", + "fxs_flomotion", +]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.stylize.posterize", + "ec.stylize.threshold", + "ec.stylize.ccthreshold", + "ec.stylize.ccthresholdrgb", + "ec.stylize.strobe", + "ec.stylize.ccvignette", + "ec.stylize.scatter", + "ec.stylize.brushstrokes", + "ec.stylize.roughenedges", + "ec.stylize.texturize", + "ec.stylize.motiontile", + "ec.stylize.cckaleida", + "ec.stylize.ccrepetile", + "ec.distort.mirror", + "ec.distort.offset", + "ec.distort.polar", + "ec.distort.spherize", + "ec.distort.cornerpin", + "ec.distort.opticscompensation", + "ec.distort.magnify", + "ec.distort.ccslant", + "ec.distort.ccsmear", + "ec.distort.ccsplit", + "ec.distort.ccsplit2", + "ec.distort.cctiler", + "ec.distort.ccgriddler", + "ec.distort.liquify", + "ec.distort.twirllegacy", + "ec.distort.ccripplepulse", + "ec.distort.ccpowerpin", + "ec.distort.ccflomotion", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.stylize.posterize" => posterize(e, ctx, b), + "ec.stylize.threshold" => threshold(e, ctx, b), + "ec.stylize.ccthreshold" => cc_threshold(e, ctx, b), + "ec.stylize.ccthresholdrgb" => cc_threshold_rgb(e, ctx, b), + "ec.stylize.strobe" => strobe(e, ctx, b), + "ec.stylize.ccvignette" => vignette(e, ctx, b), + "ec.stylize.scatter" => scatter(e, ctx, b), + "ec.stylize.brushstrokes" => brush_strokes(e, ctx, b), + "ec.stylize.roughenedges" => roughen_edges(e, ctx, b), + "ec.stylize.texturize" => texturize(e, ctx, b), + "ec.stylize.motiontile" => motion_tile(e, ctx, b), + "ec.stylize.cckaleida" => kaleida(e, ctx, b), + "ec.stylize.ccrepetile" => repetile(e, ctx, b), + "ec.distort.mirror" => mirror(e, ctx, b), + "ec.distort.offset" => offset(e, ctx, b), + "ec.distort.polar" => polar(e, ctx, b), + "ec.distort.spherize" => spherize(e, ctx, b), + "ec.distort.cornerpin" => corner_pin(e, ctx, b), + "ec.distort.opticscompensation" => optics_compensation(e, ctx, b), + "ec.distort.magnify" => magnify(e, ctx, b), + "ec.distort.ccslant" => cc_slant(e, ctx, b), + "ec.distort.ccsmear" => cc_smear(e, ctx, b), + "ec.distort.ccsplit" => { + let s = ctx.params.f("split") / 100.0; + cc_split(e, ctx, b, s, s) + } + "ec.distort.ccsplit2" => cc_split(e, ctx, b, ctx.params.f("split1") / 100.0, ctx.params.f("split2") / 100.0), + "ec.distort.cctiler" => cc_tiler(e, ctx, b), + "ec.distort.ccgriddler" => cc_griddler(e, ctx, b), + "ec.distort.liquify" => liquify(e, ctx, b), + "ec.distort.twirllegacy" => twirl_legacy(e, ctx, b), + "ec.distort.ccripplepulse" => ripple_pulse(e, ctx, b), + "ec.distort.ccpowerpin" => power_pin(e, ctx, b), + "ec.distort.ccflomotion" => flo_motion(e, ctx, b), + _ => None, + } +} + +/// Run a same-size per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option { + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, None, &out, data); + b.img = out; + Some(b) +} + +/// util::layer_rect. +fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> (f64, f64, f64, f64) { + (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale) +} + +// ---------------------------------------------------------------- per-pixel (fxs_point) + +fn point(e: &mut Enc, mode: u32, f: impl FnOnce(&mut Params), b: GBuf) -> Option { + let mut p = Params::default(); + p.u[0][0] = mode; + f(&mut p); + run(e, "fxs_point", &p, b, None) +} + +fn posterize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let lv = ctx.params.f("level").round().clamp(2.0, 255.0) as f32 - 1.0; + point(e, 1, |p| p.f[0][0] = lv, b) +} + +fn threshold(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let t = ctx.params.f("level") as f32 / 255.0; + point(e, 2, |p| p.f[0][0] = t, b) +} + +fn cc_threshold(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let t = ctx.params.f("threshold") as f32 / 255.0; + let blend = ctx.params.f("blend") as f32 / 100.0; + let (ch, inv) = (ctx.params.e("channel"), ctx.params.b("invert") as u32); + point( + e, + 3, + |p| { + p.u[0][1] = ch; + p.u[0][2] = inv; + p.f[0] = [t, blend, 0.0, 0.0]; + }, + b, + ) +} + +fn cc_threshold_rgb(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let t = [ctx.params.f("redThreshold"), ctx.params.f("greenThreshold"), ctx.params.f("blueThreshold")].map(|v| v as f32 / 255.0); + let inv = [ctx.params.b("invertRed"), ctx.params.b("invertGreen"), ctx.params.b("invertBlue")].map(u32::from); + let blend = ctx.params.f("blend") as f32 / 100.0; + point( + e, + 4, + |p| { + p.u[0] = [4, inv[0], inv[1], inv[2]]; + p.f[0] = [t[0], t[1], t[2], blend]; + }, + b, + ) +} + +fn strobe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let on = effectcraft_effects::strobe_on( + ctx.time, + ctx.params.f("strobeDuration"), + ctx.params.f("strobePeriod"), + ctx.params.f("randomStrobeProbability") / 100.0, + ctx.seed, + ); + if !on { + return Some(b); + } + let blend = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0); + if ctx.params.e("strobe") == 1 { + // Image::scale_alpha. + if (blend - 1.0).abs() < 1e-7 { + return Some(b); + } + return point(e, 6, |p| p.f[0][0] = blend, b); + } + let s = ctx.params.color("strobeColor"); + let op = ctx.params.e("strobeOperator"); + point( + e, + 5, + |p| { + p.u[0][1] = op; + p.f[0] = [s[0], s[1], s[2], blend]; + }, + b, + ) +} + +fn vignette(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") as f32 / 100.0; + let fov = ctx.params.f("angleOfView").clamp(0.0, 179.0).to_radians(); + if amt == 0.0 || fov <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let pin = ctx.params.f("pinHighlights") as f32 / 100.0; + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let half_diag = (lw * lw + lh * lh).sqrt() * 0.5; + let dist = half_diag.max(1.0) / (fov * 0.5).tan(); + point( + e, + 7, + |p| { + p.f[0] = [c.0 as f32, c.1 as f32, dist as f32, amt]; + p.f[1][0] = pin; + }, + b, + ) +} + +// ---------------------------------------------------------------- warps (fxs_warp) + +fn warp(e: &mut Enc, mode: u32, f: impl FnOnce(&mut Params), b: GBuf, data: Option<&wgpu::Buffer>) -> Option { + let mut p = Params::default(); + p.u[0][0] = mode; + f(&mut p); + run(e, "fxs_warp", &p, b, data) +} + +fn mirror(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = b.to_px(ctx.params.v2("center")); + let a = ctx.params.f("angle").to_radians(); + warp(e, 1, |p| p.f[0] = [c.0 as f32, c.1 as f32, a.cos() as f32, a.sin() as f32], b, None) +} + +fn spherize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let r = (ctx.params.f("radius") * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + warp(e, 2, |p| p.f[0] = [c.0 as f32, c.1 as f32, r as f32, 0.0], b, None) +} + +fn polar(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("interpolation") / 100.0; + let to_polar = ctx.params.e("conversion") == 1; + let (w, h) = (b.img.width as f64, b.img.height as f64); + let (cx, cy) = (w / 2.0, h / 2.0); + let rmax = cx.min(cy); + warp( + e, + 3, + |p| { + p.u[0][1] = to_polar as u32; + p.f[0] = [w as f32, h as f32, cx as f32, cy as f32]; + p.f[1] = [rmax as f32, amt as f32, 0.0, 0.0]; + }, + b, + None, + ) +} + +fn cc_slant(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let k = ctx.params.f("slant") / 100.0; + let height = ctx.params.f("height") / 100.0; + if k == 0.0 && (height - 1.0).abs() < 1e-12 { + return Some(b); + } + let stretch = ctx.params.b("stretching"); + let xs = if stretch { 1.0 / (1.0 + k * k).sqrt() } else { 1.0 }; + let hinv = if height.abs() < 1e-6 { 1e6 } else { 1.0 / height }; + if !ctx.adjustment { + let lean = (k * ctx.layer_size[1] * b.scale * height.max(1.0)).abs(); + let grow = ((height - 1.0).max(0.0) * ctx.layer_size[1] * b.scale).max(lean); + b.pad(e, grow.ceil().min(4096.0) as u32)?; + } + let fl = b.to_px(ctx.params.v2("floor")); + warp( + e, + 4, + |p| { + p.f[0] = [fl.0 as f32, fl.1 as f32, hinv as f32, k as f32]; + p.f[1][0] = xs as f32; + }, + b, + None, + ) +} + +fn cc_smear(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let from = b.to_px(ctx.params.v2("from")); + let to = b.to_px(ctx.params.v2("to")); + let reach = ctx.params.f("reach") / 100.0; + let radius = (ctx.params.f("radius") * b.scale).max(0.5); + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let l2 = dx * dx + dy * dy; + if l2 < 1e-12 || reach == 0.0 { + return Some(b); + } + warp( + e, + 5, + |p| { + p.f[0] = [from.0 as f32, from.1 as f32, dx as f32, dy as f32]; + p.f[1] = [l2 as f32, reach as f32, radius as f32, 0.0]; + }, + b, + None, + ) +} + +/// distort3::split_impl (CC Split / CC Split 2). +fn cc_split(e: &mut Enc, ctx: &EffectCtx, b: GBuf, s1: f64, s2: f64) -> Option { + if s1 == 0.0 && s2 == 0.0 { + return Some(b); + } + let a = b.to_px(ctx.params.v2("pointA")); + let c = b.to_px(ctx.params.v2("pointB")); + let (dx, dy) = (c.0 - a.0, c.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + return Some(b); + } + let (ux, uy) = (dx / len, dy / len); + warp( + e, + 6, + |p| { + p.f[0] = [a.0 as f32, a.1 as f32, ux as f32, uy as f32]; + p.f[1] = [len as f32, s1 as f32, s2 as f32, (len * 0.5) as f32]; + }, + b, + None, + ) +} + +fn optics_compensation(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let fov = ctx.params.f("fieldOfView").clamp(0.0, 179.0).to_radians(); + if fov < 1e-6 { + return Some(b); + } + let reverse = ctx.params.b("reverseLensDistortion"); + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let r_ref = match ctx.params.e("fovOrientation") { + 1 => lh * 0.5, + 2 => (lw * lw + lh * lh).sqrt() * 0.5, + _ => lw * 0.5, + } + .max(1.0); + let half = fov * 0.5; + let f = r_ref / half.tan(); + let optimal = ctx.params.b("optimalPixels"); + let resize = if optimal { 0 } else { ctx.params.e("resize") }; + let corners = [(lx, ly), (lx + lw, ly), (lx, ly + lh), (lx + lw, ly + lh)]; + if reverse && resize > 0 && !ctx.adjustment { + let c = b.to_px(ctx.params.v2("viewCenter")); + let rc = corners.iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(0.0, f64::max); + let th = rc / r_ref * half; + let cap = lw.max(lh) * [0.0, 0.5, 1.5, 3.5][resize.min(3) as usize]; + let grow = if th < FRAC_PI_2 - 1e-3 { (f * th.tan() - rc).max(0.0) } else { cap }; + b.pad(e, grow.min(cap).min(4096.0).ceil() as u32)?; + } + let c = b.to_px(ctx.params.v2("viewCenter")); + let fit = if optimal { + let rc = corners.iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(1.0, f64::max); + if reverse { + let th = (rc / r_ref * half).min(FRAC_PI_2 - 1e-3); + (f * th.tan() / rc).max(1.0) + } else { + ((rc / f).atan() / half * r_ref / rc).min(1.0) + } + } else { + 1.0 + }; + warp( + e, + 7, + |p| { + p.u[0][1] = reverse as u32; + p.f[0] = [c.0 as f32, c.1 as f32, fit as f32, f as f32]; + p.f[1] = [r_ref as f32, half as f32, 0.0, 0.0]; + }, + b, + None, + ) +} + +fn kaleida(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = b.to_px(ctx.params.v2("center")); + let size = ctx.params.f("size").max(1.0) / 100.0; + let mode = ctx.params.e("mirroring"); + let rot = ctx.params.f("rotation").to_radians(); + let n = [6.0, 8.0, 4.0, 12.0, 3.0][mode.min(4) as usize]; + let seg = TAU / n; + warp( + e, + 8, + |p| { + p.u[0][1] = (mode != 3) as u32; + p.f[0] = [c.0 as f32, c.1 as f32, size as f32, rot as f32]; + p.f[1][0] = seg as f32; + }, + b, + None, + ) +} + +fn liquify(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + // View Freeze Area Mask / View Mesh overlays render on the CPU (catalog CPU_ONLY_CONTROLS). + if ctx.params.b("viewFreezeAreaMask") || ctx.params.b("viewMesh") { + return None; + } + if ctx.params.s("distortionMesh").trim().is_empty() { + return Some(b); + } + let pct = ctx.params.f("distortionPercentage") / 100.0; + let mesh = effectcraft_effects::liquify_mesh(ctx); + if mesh.nx < 2 || mesh.ny < 2 { + return None; + } + let off = ctx.params.v2("distortionMeshOffset"); + let data: Vec = mesh.d.iter().flat_map(|d| [d[0] as f32, d[1] as f32]).collect(); + let buf = e.data(&data); + let (scale, o) = (b.scale, b.offset); + warp( + e, + 9, + |p| { + p.u[0] = [9, mesh.nx as u32, mesh.ny as u32, 0]; + p.f[0] = [mesh.cell as f32, off[0] as f32, off[1] as f32, pct as f32]; + p.f[1] = [scale as f32, o[0] as f32, o[1] as f32, 0.0]; + }, + b, + Some(&buf), + ) +} + +fn twirl_legacy(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("angle").to_radians(); + if ang == 0.0 { + return Some(b); + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("radius") / 100.0 * lw.min(lh) * 0.5).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + warp(e, 10, |p| p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32], b, None) +} + +fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let level = ctx.params.f("pulseLevel") / 100.0; + let amp = ctx.params.f("amplitude") / 100.0; + if level == 0.0 || amp == 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let span = ctx.params.f("timeSpan").max(0.01); + let size = ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale; + let speed = size * 0.5 / span; + let lambda = (size * 0.06).max(2.0); + let height = level * amp * lambda * 0.5; + let front = speed * ctx.time.max(0.0); + let bump = ctx.params.b("renderBump"); + warp( + e, + 11, + |p| { + p.u[0][1] = bump as u32; + p.f[0] = [c.0 as f32, c.1 as f32, front as f32, speed as f32]; + p.f[1] = [span as f32, lambda as f32, height as f32, 0.0]; + }, + b, + None, + ) +} + +fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // Perspective below 100 % (a bilinear inverse by Newton iterations with finite + // differences) renders on the CPU. + if ctx.params.f("perspective") / 100.0 < 1.0 { + return None; + } + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; + let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); + let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); + let corners = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; + if !ctx.adjustment { + let mut need = 0.0f64; + for c in &corners { + let (px, py) = b.to_px(*c); + need = need.max(-px).max(-py).max(px - b.img.width as f64).max(py - b.img.height as f64); + } + if need > 0.0 { + b.pad(e, need.ceil().min(4096.0) as u32 + 1)?; + } + } + let q = corners.map(|c| { + let p = b.to_px(c); + vec2(p.0, p.1) + }); + let Some(inv) = Mat3::square_to_quad(q).inverse() else { return Some(b) }; + let r = inv.0.map(|r| [r[0] as f32, r[1] as f32, r[2] as f32, 0.0]); + let (sc, off) = (b.scale, b.offset); + warp( + e, + 12, + |p| { + p.f[2] = r[0]; + p.f[3] = r[1]; + p.f[4] = r[2]; + p.f[5] = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; + p.f[6] = [sc as f32, off[0] as f32, off[1] as f32, 0.0]; + }, + b, + None, + ) +} + +fn flo_motion(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)]; + if knots.iter().all(|k| k.1 == 0.0) { + return Some(b); + } + let ss = match ctx.params.e("antialiasing") { + 0 => 1, + 1 => 2, + _ => 3, + }; + let falloff = ctx.params.f("falloff").max(0.01); + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let radius = (lw * lw + lh * lh).sqrt() * 0.5; + let mut p = Params::default(); + p.u[0] = [ss, ctx.params.b("tileEdges") as u32, 0, 0]; + for (i, (k, a)) in knots.iter().enumerate() { + p.f[i] = [k[0] as f32, k[1] as f32, *a as f32, 0.0]; + } + p.f[2] = [falloff as f32, radius as f32, lw as f32, lh as f32]; + p.f[3] = [b.scale as f32, b.offset[0] as f32, b.offset[1] as f32, 0.0]; + run(e, "fxs_flomotion", &p, b, None) +} + +// ---------------------------------------------------------------- separable tables + +/// Per-column `fx(x + 0.5)` then per-row `fy(y + 0.5)`, `k` values each. +fn axis_table(w: u32, h: u32, fx: impl Fn(f64) -> [f64; K], fy: impl Fn(f64) -> [f64; K]) -> Vec { + let mut v = Vec::with_capacity((w + h) as usize * K); + v.extend((0..w).flat_map(|x| fx(x as f64 + 0.5).map(|t| t as f32))); + v.extend((0..h).flat_map(|y| fy(y as f64 + 0.5).map(|t| t as f32))); + v +} + +fn table(e: &mut Enc, b: GBuf, data: &[f32], repeat: bool, blend: f32) -> Option { + let mut p = Params::default(); + p.u[0] = [b.img.width, repeat as u32, 0, 0]; + p.f[0][0] = blend; + let buf = e.data(data); + run(e, "fxs_table", &p, b, Some(&buf)) +} + +fn offset(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let shift = ctx.params.v2("shift"); + let (w, h) = (b.img.width as f64, b.img.height as f64); + let blend = ctx.params.f("blend") as f32 / 100.0; + let (cx, cy) = (w / 2.0, h / 2.0); + let (sx, sy) = b.to_px(shift); + let data = axis_table(b.img.width, b.img.height, |x| [(x - (sx - cx)).rem_euclid(w)], |y| [(y - (sy - cy)).rem_euclid(h)]); + table(e, b, &data, false, blend.max(0.0)) +} + +fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = (ctx.params.f("scale") / 100.0).max(0.001); + let c = b.to_px(ctx.params.v2("center")); + let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; + let (x0, y0, w, h) = layer_rect(ctx, &b); + let (w, h) = (w.max(1.0), h.max(1.0)); + let data = axis_table(b.img.width, b.img.height, |x| [x0 + (c.0 + (x - c.0) / s - x0).rem_euclid(w)], |y| [y0 + (c.1 + (y - c.1) / s - y0).rem_euclid(h)]); + table(e, b, &data, true, blend) +} + +fn cc_griddler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let hs = (ctx.params.f("horizontalScale") / 100.0).max(0.001); + let vs = (ctx.params.f("verticalScale") / 100.0).max(0.001); + let rot = ctx.params.f("rotation").to_radians(); + let cut = ctx.params.b("cutTiles"); + let (x0, y0, lw, _) = layer_rect(ctx, &b); + let t = (ctx.params.f("tileSize") / 100.0 * lw).max(1.0); + if (hs - 1.0).abs() < 1e-9 && (vs - 1.0).abs() < 1e-9 && rot.abs() < 1e-9 && !cut { + return Some(b); + } + let (sr, cr) = rot.sin_cos(); + let centre = |p: f64, o: f64| { + let tc = o + (((p - o) / t).floor() + 0.5) * t; + [tc, p - tc] + }; + let data = axis_table(b.img.width, b.img.height, |x| centre(x, x0), |y| centre(y, y0)); + let mut p = Params::default(); + p.u[0] = [b.img.width, cut as u32, 0, 0]; + p.f[0] = [cr as f32, sr as f32, hs as f32, vs as f32]; + p.f[1][0] = (0.45 * t) as f32; + let buf = e.data(&data); + run(e, "fxs_griddler", &p, b, Some(&buf)) +} + +fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = b.scale; + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + if lw <= 0.0 || lh <= 0.0 { + return Some(b); + } + let tc = ctx.params.v2("tileCenter"); + let tw = (lw * ctx.params.f("tileWidth") / 100.0).max(0.01); + let th = (lh * ctx.params.f("tileHeight") / 100.0).max(0.01); + let mirror = ctx.params.b("mirrorEdges"); + let phase = ctx.params.f("phase") / 360.0; + let hshift = ctx.params.b("horizontalPhaseShift"); + let (nw, nh, noff) = if ctx.adjustment { + (b.img.width, b.img.height, b.offset) + } else { + let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); + let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); + ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) + }; + if !e.g.fits(nw, nh) { + return None; + } + let split = |t: f64| [t.floor(), t - t.floor()]; + let data = axis_table(nw, nh, |x| split(((x - noff[0]) / s - tc[0]) / tw + 0.5), |y| split(((y - noff[1]) / s - tc[1]) / th + 0.5)); + let mut p = Params::default(); + p.u[0] = [nw, hshift as u32, mirror as u32, 0]; + p.f[0] = [phase as f32, (lw * s) as f32, (lh * s) as f32, 0.0]; + p.f[1] = [b.offset[0] as f32, b.offset[1] as f32, 0.0, 0.0]; + let buf = e.data(&data); + let out = e.image(nw, nh); + e.pixels("fxs_motiontile", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, offset: noff, scale: s }) +} + +fn repetile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = b.scale; + let (r, l, d, u) = ( + ctx.params.f("expandRight").max(0.0) * s, + ctx.params.f("expandLeft").max(0.0) * s, + ctx.params.f("expandDown").max(0.0) * s, + ctx.params.f("expandUp").max(0.0) * s, + ); + if ctx.adjustment || r + l + d + u < 0.5 { + return Some(b); + } + let (x0, y0, w, h) = layer_rect(ctx, &b); + if w < 1.0 || h < 1.0 { + return Some(b); + } + let (nw, nh) = ((w + l + r).round().max(1.0) as u32, (h + u + d).round().max(1.0) as u32); + if !e.g.fits(nw, nh) { + return None; + } + // stylize2::tile_coord. + let tile = |v: f64, size: f64| { + let i = (v / size).floor(); + [i, v - i * size] + }; + let data = axis_table(nw, nh, |x| tile(x - l, w), |y| tile(y - u, h)); + let mut p = Params::default(); + p.u[0] = [nw, ctx.params.e("tiling"), 0, 0]; + p.f[0] = [x0 as f32, y0 as f32, w as f32, h as f32]; + let buf = e.data(&data); + let out = e.image(nw, nh); + e.pixels("fxs_repetile", &p, &b.img, None, &out, Some(&buf)); + Some(GBuf { img: out, offset: [l, u], scale: s }) +} + +fn magnify(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let square = ctx.params.e("shape") == 1; + let c = b.to_px(ctx.params.v2("center")); + let mag = (ctx.params.f("magnification") / 100.0).max(0.01); + let link = ctx.params.e("link"); + let mut size = ctx.params.f("size") * b.scale; + let mut feather = ctx.params.f("feather") * b.scale; + if link >= 1 { + size *= mag; + } + if link == 2 { + feather *= mag; + } + let op = (ctx.params.f("opacity") / 100.0) as f32; + let mode = ctx.params.e("blendingMode") as usize; + let blend = effectcraft_effects::MAGNIFY_MODES.get(mode.wrapping_sub(1)).copied(); + if ctx.params.b("resizeLayer") && link == 0 && !ctx.adjustment { + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let reach = size.max(0.0); + let over = [lx - (c.0 - reach), (c.0 + reach) - (lx + lw), ly - (c.1 - reach), (c.1 + reach) - (ly + lh)].into_iter().fold(0.0, f64::max); + b.pad(e, over.min(4096.0).ceil() as u32)?; + } + let c = b.to_px(ctx.params.v2("center")); + let axis = |p: f64, c: f64| { + let d = p - c; + let sp = c + d / mag; + [d, sp, sp.floor() + 0.5] + }; + let data = axis_table(b.img.width, b.img.height, |x| axis(x, c.0), |y| axis(y, c.1)); + let (kind, mode_id) = match blend { + None => (0, 0), + Some(BlendMode::Normal) => (1, 0), + Some(m) => (2, ops::mode_id(m)), + }; + let mut p = Params::default(); + p.u[0] = [b.img.width, square as u32, ctx.params.e("scaling"), kind]; + p.u[1] = [mode_id, ctx.seed ^ 0x3a61, 0, 0]; + p.f[0] = [size as f32, feather as f32, op, mag as f32]; + p.f[1][0] = (size - feather) as f32; + let buf = e.data(&data); + run(e, "fxs_magnify", &p, b, Some(&buf)) +} + +// ---------------------------------------------------------------- stylize2.rs + +fn scatter(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") * b.scale; + if amt <= 0.0 { + return Some(b); + } + let seed = if ctx.params.b("randomizeEveryFrame") { ctx.seed ^ ((ctx.time * 1000.0) as u32).wrapping_mul(0x9e37_79b9) } else { ctx.seed }; + let mut p = Params::default(); + p.u[0] = [ctx.params.e("grain"), seed, 0, 0]; + p.f[0][0] = amt as f32; + run(e, "fxs_scatter", &p, b, None) +} + +fn brush_strokes(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("strokeAngle"); + let size = (ctx.params.f("brushSize") * b.scale).max(0.5); + let len = ctx.params.f("strokeLength") * b.scale; + let density = ctx.params.f("strokeDensity") as f32; + let rnd = ctx.params.f("strokeRandomness"); + let blend = ctx.params.f("blendWithOriginal") as f32 / 100.0; + let seed = ctx.seed ^ 0xb705; + let cell = (size * 2.0).max(1.0); + let (w, h) = (b.img.width, b.img.height); + let cell_of = |i: u32| (i as f64 / cell).floor() as i64 as u32; + let (ncx, ncy) = (cell_of(w.saturating_sub(1)) + 1, cell_of(h.saturating_sub(1)) + 1); + if (ncx as u64 * ncy as u64 * 6 + (w + h) as u64) >= 1 << 24 { + return None; + } + let mut data: Vec = (0..w).map(|x| cell_of(x) as f32).chain((0..h).map(|y| cell_of(y) as f32)).collect(); + data.reserve((ncx * ncy * 6) as usize); + for cy in 0..ncy { + for cx in 0..ncx { + let hh = |k: u32| effectcraft_raster::hash_noise(cx.wrapping_add(k.wrapping_mul(7919)), cy, seed) as f64; + let a = (ang + (hh(0) - 0.5) * rnd * 60.0).to_radians(); + let l = len * (0.5 + hh(1)); + let (dx, dy) = (a.sin(), -a.cos()); + let jit = (hh(2) - 0.5) * size; + let n = l.ceil().clamp(1.0, 32.0); + let m = if (hh(3) as f32) < density.min(1.0) { 1.0 } else { 0.0 }; + data.extend([dx as f32, dy as f32, l as f32, jit as f32, n as f32, m]); + } + } + let mut p = Params::default(); + p.u[0] = [w, ncx, ctx.params.e("paintSurface"), h]; + p.f[0][0] = blend; + let buf = e.data(&data); + run(e, "fxs_brush", &p, b, Some(&buf)) +} + +fn roughen_edges(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let kind = ctx.params.e("edgeType"); + let ec = ctx.params.color("edgeColor"); + let border = ctx.params.f("border") * b.scale; + let sharp = ctx.params.f("edgeSharpness").max(0.01) as f32; + let infl = ctx.params.f("fractalInfluence") as f32; + let scale = (ctx.params.f("scale") * b.scale * 0.1).max(0.5) as f32; + let (ox, oy) = b.to_px(ctx.params.v2("offset")); + let oct = ctx.params.f("complexity") as f32; + let evo = ctx.params.f("evolution") as f32 / 360.0; + let st = ctx.params.f("stretch") as f32; + let (sx, sy) = (1.0 + st.max(0.0), 1.0 + (-st).max(0.0)); + let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0) as f32); + let (evo, evo_w, c) = match cycle { + Some(c) => { + let ev = evo.rem_euclid(c); + (ev, ev / c, c) + } + None => (evo, 0.0, 0.0), + }; + let (fm, im, sm, colored) = match kind { + 1 => (1.0, 1.0, 1.0, true), + 2 => (1.0, 1.0, 3.0, false), + 3 => (3.0, 1.0, 1.5, false), + 4 => (1.5, 1.5, 1.0, false), + 5 => (1.5, 1.5, 1.0, true), + 6 => (0.6, 1.2, 2.5, false), + 7 => (0.6, 1.2, 2.5, true), + _ => (1.0, 1.0, 1.0, false), + }; + let extra = if kind == 4 || kind == 5 { 1.0 } else { 0.0 }; + let seed = ctx.seed ^ 0x40f1 ^ (ctx.params.f("evolutionOptions/randomSeed").round() as i64 as u32).wrapping_mul(0x9e37_79b9); + // util::gauss_plane of the alpha (edges repeated); the kernel reads .w. + let d = if border > 0.05 { gaussian_blur(e, &b.img, border * 0.5, border * 0.5, true) } else { b.img.clone() }; + let mut p = Params::default(); + p.u[0] = [seed, cycle.is_some() as u32, colored as u32, 0]; + p.f[0] = [ox as f32, oy as f32, scale * sx, scale * sy]; + p.f[1] = [fm, evo, c, evo_w]; + p.f[2] = [oct + extra, infl, im, sharp]; + p.f[3] = [ec[0], ec[1], ec[2], sm]; + let out = e.image(b.img.width, b.img.height); + e.pixels("fxs_roughen", &p, &b.img, Some(&d), &out, None); + b.img = out; + Some(b) +} + +fn texturize(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let ang = ctx.params.f("lightDirection").to_radians(); + let k = ctx.params.f("textureContrast") as f32; + if k == 0.0 { + return Some(b); + } + let Some(tex) = ctx.layer_param("textureLayer", true) else { return Some(b) }; + if tex.buf.img.is_empty() { + return None; + } + // transition::place_layer, per column / row in the texture buffer's pixels. + let (ls, os) = (ctx.layer_size, tex.size); + let inv = 1.0 / b.scale.max(1e-9); + let mode = ctx.params.e("texturePlacement"); + let place = |v: f64, k: usize| { + let p = (v - b.offset[k]) * inv; + let q = match mode { + 0 if os[0] >= 1.0 && os[1] >= 1.0 => p.rem_euclid(os[k]), + 1 => p + (os[k] - ls[k]) * 0.5, + _ if ls[0] > 0.0 && ls[1] > 0.0 => p * os[k] / ls[k], + _ => p, + }; + [q * tex.buf.scale + tex.buf.offset[k]] + }; + let data = axis_table(b.img.width, b.img.height, |x| place(x, 0), |y| place(y, 1)); + let timg = e.g.upload_image(&tex.buf.img)?; + let mut p = Params::default(); + p.u[0][0] = b.img.width; + let buf = e.data(&data); + let luma = e.image(b.img.width, b.img.height); + e.pixels("fxs_tex_luma", &p, &b.img, Some(&timg), &luma, Some(&buf)); + let s = 0.7 * b.scale; + let hgt = gaussian_blur(e, &luma, s, s, true); + let mut p = Params::default(); + p.f[0] = [ang.cos() as f32, -ang.sin() as f32, k, 0.0]; + let out = e.image(b.img.width, b.img.height); + e.pixels("fxs_texturize", &p, &b.img, Some(&hgt), &out, None); + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- distort.rs + +fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let (w, h) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let q = ["ul", "ur", "lr", "ll"].map(|k| { + let p = b.to_px(ctx.params.v2(k)); + vec2(p.0, p.1) + }); + let quad = Mat3::square_to_quad(q); + let o = b.to_px([0.0, 0.0]); + let m = quad * Mat3::scale(vec2(1.0 / (w * b.scale), 1.0 / (h * b.scale))) * Mat3::translate(vec2(-o.0, -o.1)); + let empty = e.image(b.img.width, b.img.height); + b.img = ops::warp(e, &empty, &b.img, &m, Sampling::Bilinear, BlendMode::Normal, 1.0, 0, None); + Some(b) +} + +// ---------------------------------------------------------------- Glow / Transform extras + +/// Glow settings the base kernel in `effects.rs` leaves to the CPU (another Glow Operation or +/// the Arbitrary Map) and [`glow`] implements. +pub(crate) fn glow_extra(ctx: &EffectCtx) -> bool { + ctx.params.e("colors") == 2 || effectcraft_effects::glow_operation(ctx) != BlendMode::Add +} + +fn dims_xy(dim: u32) -> (f64, f64) { + match dim { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + } +} + +/// misc::glow with any Glow Operation and the Arbitrary Map. +pub(crate) fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let thr = ctx.params.f("threshold") as f32 / 100.0; + let radius = ctx.params.f("radius") * b.scale; + let intensity = ctx.params.f("intensity") as f32; + let colors = ctx.params.e("colors"); + let (kx, ky) = dims_xy(ctx.params.e("glowDimensions")); + if !ctx.adjustment { + b.pad(e, (radius * 1.5).ceil() as u32 + 2)?; + } + let mut p = Params::default(); + p.u[0] = [(colors == 1) as u32, (ctx.params.e("based") == 0) as u32, ctx.params.e("colorLooping"), 0]; + p.f[0] = [thr, ctx.params.f("colorLoops") as f32, (ctx.params.f("colorPhase") / 360.0) as f32, ctx.params.f("abMidpoint") as f32 / 100.0]; + let bright = e.image(b.img.width, b.img.height); + if colors == 2 { + // Arbitrary Map: Curves tables per channel (see effects::curves). + let mut parts = ctx.params.s("arbitraryMap").split('|'); + let curves: Vec> = (0..3).map(|_| parts.next().and_then(effectcraft_effects::Curve::parse)).collect(); + let mut data = vec![0.0f32]; + let mut offs = [-1.0f32; 3]; + for (i, c) in curves.iter().enumerate() { + let Some(c) = c else { continue }; + let (xs, ys, ms, lut) = c.tables(); + offs[i] = data.len() as f32; + data.push(xs.len() as f32); + data.extend_from_slice(xs); + data.extend_from_slice(ys); + data.extend_from_slice(ms); + data.extend_from_slice(lut); + } + p.f[1] = [offs[0], offs[1], offs[2], 0.0]; + let buf = e.data(&data); + e.pixels("fxs_glow_map", &p, &b.img, None, &bright, Some(&buf)); + } else { + p.f[1] = ctx.params.color("colorA"); + p.f[2] = ctx.params.color("colorB"); + e.pixels("glow_bright", &p, &b.img, None, &bright, None); + } + let s = (radius / 2.0).max(0.5); + let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false); + let operation = ctx.params.e("operation"); + let glow_op = effectcraft_effects::glow_operation(ctx); + let mut p = Params::default(); + p.f[0][0] = intensity; + let out = e.image(b.img.width, b.img.height); + if operation == 0 && glow_op != BlendMode::Add { + p.u[0][0] = ops::mode_id(glow_op); + e.pixels("fxs_glow_op", &p, &b.img, Some(&blurred), &out, None); + } else { + p.u[0][0] = operation.min(2); + e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None); + } + b.img = out; + Some(b) +} + +/// Transform renders with motion blur (a shutter and a host to read the parameters at other +/// times): [`transform`] implements it, the base kernel in `effects.rs` the sharp case. +pub(crate) fn transform_blur(ctx: &EffectCtx) -> bool { + ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() +} + +/// distort::transform_matrix. +fn transform_matrix(pr: &effectcraft_effects::Params, b: &GBuf) -> Mat3 { + let anchor = b.to_px(pr.v2("anchor")); + let pos = b.to_px(pr.v2("position")); + let sh = pr.f("scaleHeight"); + let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; + Mat3::translate(vec2(pos.0, pos.1)) + * Mat3::rotate_deg(pr.f("rotation")) + * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) + * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) + * Mat3::translate(vec2(-anchor.0, -anchor.1)) +} + +/// distort::transform's motion blur: the transform at Samples instants across the shutter, +/// averaged. +pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let (angle, phase, n) = effectcraft_effects::transform_shutter(ctx)?; + let host = ctx.env.host?; + let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear }; + let fd = 1.0 / ctx.fps(); + let (w, h) = (b.img.width, b.img.height); + let mut acc = e.image(w, h); + let mut count = 0u32; + for k in 0..n { + let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; + let Some(pr) = host.params_at(t) else { continue }; + let op = pr.f("opacity") as f32 / 100.0; + let empty = e.image(w, h); + let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), sampling, BlendMode::Normal, op, 0, None); + let sum = e.image(w, h); + e.pixels("fxs_add", &Params::default(), &acc, Some(&one), &sum, None); + acc = sum; + count += 1; + } + if count == 0 { + // The CPU renders the frame sharp. + return None; + } + let mut p = Params::default(); + p.f[0][0] = count as f32; + let out = e.image(w, h); + e.pixels("fxs_div", &p, &acc, None, &out, None); + b.img = out; + Some(b) +} diff --git a/crates/gpu/src/fx_tone.rs b/crates/gpu/src/fx_tone.rs new file mode 100644 index 000000000..8025ffdd8 --- /dev/null +++ b/crates/gpu/src/fx_tone.rs @@ -0,0 +1,381 @@ +//! GPU effects, tonal and colour correction family (kernels in `shaders/fx_tone.wgsl`, every +//! entry point prefixed `fxt_`): one per-pixel pass each (`fxt_point`) with the CPU effect's +//! exact maths. +//! +//! Levels (Individual Controls), Gamma/Pedestal/Gain, Photo Filter, Change Color, Change to +//! Color, Leave Color, Broadcast Colors, Color Balance (HLS), Video Limiter, PS Arbitrary Map, +//! CC Toner, CC Color Offset and CC Kernel, plus Hue/Saturation's colour ranges and Levels' +//! per-channel controls (the parts the base kernels in `kernels.wgsl` leave to the CPU). Auto +//! Levels / Contrast / Color and Equalize measure the frame's histograms on the CPU (the image +//! is read back for that) and apply the correction on the GPU. + +use effectcraft_color::rgb_to_hsl; +use effectcraft_effects::EffectCtx; + +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_tone.wgsl`. +pub(crate) const KERNELS: &[&str] = &["fxt_point"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.color.levelsic", + "ec.color.gammapedestalgain", + "ec.color.photofilter", + "ec.color.changecolor", + "ec.color.changetocolor", + "ec.color.leavecolor", + "ec.color.broadcast", + "ec.color.colorbalancehls", + "ec.color.videolimiter", + "ec.color.psarbitrarymap", + "ec.color.cctoner", + "ec.color.cccoloroffset", + "ec.color.cckernel", + "ec.color.autolevels", + "ec.color.autocontrast", + "ec.color.autocolor", + "ec.color.equalize", + "ec.color.shadowhighlight", + "ec.color.cccolorneutralizer", + "ec.color.colorstabilizer", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.color.shadowhighlight" => shadow_highlight(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +/// Invert in the HLS and YIQ channels (the base `pointwise` kernel does RGB and Alpha). +pub(crate) fn invert(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = op(18); + p.u[0][1] = ctx.params.e("channel"); + p.f[0][0] = 1.0 - ctx.params.f("blend") as f32 / 100.0; + run(e, &p, b, None) +} + +/// Shadow/Highlight: the amounts (Auto Amounts) and the Black / White Clip ranges are measured +/// on the CPU; the luminance bases are blurred and the image adjusted on the GPU. +fn shadow_highlight(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let src = e.download(&b.img)?; + let (s_amt, h_amt) = effectcraft_effects::shadow_highlight_amounts(ctx, &src); + let (bc, wc) = (pr.f("moreOptions/blackClip").clamp(0.0, 49.0) / 100.0, pr.f("moreOptions/whiteClip").clamp(0.0, 49.0) / 100.0); + let clip0 = (bc > 0.0 || wc > 0.0).then(|| effectcraft_effects::luma_clip_points(&src, bc, wc)); + let s_r = pr.f("moreOptions/shadowRadius").max(0.0) * b.scale / 2.0; + let h_r = pr.f("moreOptions/highlightRadius").max(0.0) * b.scale / 2.0; + let (w, h) = (b.img.width, b.img.height); + let luma = e.image(w, h); + e.pixels("fxt_point", &op(19), &b.img, None, &luma, None); + let base_s = gaussian_blur(e, &luma, s_r, s_r, true); + let base_h = if (h_r - s_r).abs() < 1e-9 { base_s.clone() } else { gaussian_blur(e, &luma, h_r, h_r, true) }; + let bases = e.image(w, h); + e.pixels("fxt_point", &op(21), &base_s, Some(&base_h), &bases, None); + let mut p = op(20); + p.f[0] = + [s_amt, h_amt, (pr.f("moreOptions/shadowTonalWidth") as f32 / 100.0).max(0.01), (pr.f("moreOptions/highlightTonalWidth") as f32 / 100.0).max(0.01)]; + p.f[1] = [ + pr.f("moreOptions/colorCorrection") as f32 / 100.0, + pr.f("moreOptions/midtoneContrast") as f32 / 100.0, + (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32, + 0.0, + ]; + let out = e.image(w, h); + e.pixels("fxt_point", &p, &b.img, Some(&bases), &out, None); + let Some((lo0, hi0)) = clip0 else { return Some(GBuf { img: out, ..b }) }; + let adjusted = e.download(&out)?; + let (lo1, hi1) = effectcraft_effects::luma_clip_points(&adjusted, bc, wc); + if (lo0, hi0) == (lo1, hi1) || hi1 - lo1 <= 1e-3 { + return Some(GBuf { img: out, ..b }); + } + let mut p = op(22); + p.f[0] = [lo0, lo1, (hi0 - lo0) / (hi1 - lo1), 0.0]; + run(e, &p, GBuf { img: out, ..b }, None) +} + +fn rgb(c: [f32; 4]) -> [f32; 4] { + [c[0], c[1], c[2], 0.0] +} + +/// Hue/Saturation with colour ranges (Channel Control); Master and Colorize alone run in the +/// base `pointwise` kernel. +pub(crate) fn hue_saturation(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |id: String, d: f64| pr.get(&id).map(|v| v.as_f64()).unwrap_or(d) as f32; + let mut data = vec![]; + for (r, _, c) in effectcraft_effects::HUESAT_RANGES { + let range = [ + f(format!("{r}RangeStart"), c - 15.0), + f(format!("{r}RangeEnd"), c + 15.0), + f(format!("{r}StartFalloff"), 30.0).max(0.0), + f(format!("{r}EndFalloff"), 30.0).max(0.0), + f(format!("{r}Hue"), 0.0) / 360.0, + f(format!("{r}Saturation"), 0.0) / 100.0, + f(format!("{r}Lightness"), 0.0) / 100.0, + ]; + if range[4] != 0.0 || range[5] != 0.0 || range[6] != 0.0 { + data.extend(range); + } + } + let g = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + p.u[0] = [14, pr.b("colorize") as u32, (data.len() / 7) as u32, 0]; + p.f[0] = [g("hue") / 360.0, g("saturation") / 100.0, g("lightness") / 100.0, 0.0]; + p.f[1] = [g("colorizeHue") / 360.0, g("colorizeSaturation") / 100.0, g("colorizeLightness") / 100.0, 0.0]; + run(e, &p, b, Some(data)) +} + +/// Levels with Red / Green / Blue / Alpha controls (the RGB controls alone run in the base +/// `pointwise` kernel). +pub(crate) fn levels(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let (cb, cw) = effectcraft_effects::levels_clip(ctx); + let mut data = vec![ + pr.f("inBlack") as f32, + pr.f("inWhite") as f32, + pr.f("gamma").max(0.01) as f32, + pr.f("outBlack") as f32, + pr.f("outWhite") as f32, + cb as u32 as f32, + cw as u32 as f32, + ]; + for (v, (cb, cw)) in effectcraft_effects::levels_channel_settings(ctx) { + data.extend(v); + data.extend([cb as u32 as f32, cw as u32 as f32]); + } + run(e, &op(15), b, Some(data)) +} + +fn op(code: u32) -> Params { + let mut p = Params::default(); + p.u[0][0] = code; + p +} + +fn run(e: &mut Enc, p: &Params, b: GBuf, data: Option>) -> Option { + let buf = data.map(|d| e.data(&d)); + let out = e.image(b.img.width, b.img.height); + e.pixels("fxt_point", p, &b.img, None, &out, buf.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// color3::parse_map. +fn parse_map(s: &str) -> Vec { + let vals: Vec = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::().ok()).collect(); + if vals.len() < 2 { + return (0..256).map(|i| i as f32 / 255.0).collect(); + } + let max = vals.iter().cloned().fold(0.0f32, f32::max); + let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 }; + vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect() +} + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut data = None; + match id { + "ec.color.levelsic" => { + const IDS: [[&str; 5]; 5] = [ + ["rgb/rgbInBlack", "rgb/rgbInWhite", "rgb/rgbGamma", "rgb/rgbOutBlack", "rgb/rgbOutWhite"], + ["red/redInBlack", "red/redInWhite", "red/redGamma", "red/redOutBlack", "red/redOutWhite"], + ["green/greenInBlack", "green/greenInWhite", "green/greenGamma", "green/greenOutBlack", "green/greenOutWhite"], + ["blue/blueInBlack", "blue/blueInWhite", "blue/blueGamma", "blue/blueOutBlack", "blue/blueOutWhite"], + ["alpha/alphaInBlack", "alpha/alphaInWhite", "alpha/alphaGamma", "alpha/alphaOutBlack", "alpha/alphaOutWhite"], + ]; + let lv: Vec<[f32; 5]> = IDS.iter().map(|ids| ids.map(f)).collect(); + let ident = |l: &[f32; 5]| *l == [0.0, 1.0, 1.0, 0.0, 1.0]; + if lv.iter().all(ident) { + return Some(b); + } + let (cb, cw) = effectcraft_effects::levels_clip(ctx); + let mut d = vec![]; + for l in &lv { + d.extend(l); + d.push(ident(l) as u32 as f32); + } + p.u[0] = [1, cb as u32, cw as u32, 0]; + data = Some(d); + } + "ec.color.gammapedestalgain" => { + let ch = |n: &str| [f(&format!("{n}Gamma")).max(0.01), f(&format!("{n}Pedestal")), f(&format!("{n}Gain")), 0.0]; + p.u[0][0] = 2; + p.f[0] = ch("red"); + p.f[1] = ch("green"); + p.f[2] = ch("blue"); + p.f[3][0] = f("blackStretch").max(0.0); + } + "ec.color.photofilter" => { + let fi = pr.e("filter"); + let c = match effectcraft_effects::PHOTO_FILTERS.get(fi as usize) { + Some((_, rgb)) if fi != effectcraft_effects::PHOTO_FILTER_CUSTOM => rgb.map(|v| v as f32 / 255.0), + _ => { + let c = pr.color("color"); + [c[0], c[1], c[2]] + } + }; + p.u[0] = [3, pr.b("preserveLuminosity") as u32, 0, 0]; + p.f[0] = [c[0], c[1], c[2], f("density") / 100.0]; + } + "ec.color.changetocolor" => { + let from = pr.color("from"); + let to = pr.color("to"); + let (fh, fs, fl) = rgb_to_hsl(from[0], from[1], from[2]); + let (th, ts, tl) = rgb_to_hsl(to[0], to[1], to[2]); + let change = pr.e("change"); + let flags = matches!(change, 1 | 3) as u32 + | (matches!(change, 2 | 3) as u32) << 1 + | ((pr.e("changeBy") == 1) as u32) << 2 + | (pr.b("viewCorrectionMatte") as u32) << 3; + p.u[0] = [4, flags, 0, 0]; + p.f[0] = [fh, fs, fl, f("softness") / 100.0]; + p.f[1] = [th, ts, tl, 0.0]; + p.f[2] = [f("toleranceGroup/hue") / 100.0, f("toleranceGroup/lightness") / 100.0, f("toleranceGroup/saturation") / 100.0, 0.0]; + } + "ec.color.leavecolor" => { + let amt = f("amount") / 100.0; + if amt <= 0.0 { + return Some(b); + } + let k = pr.color("color"); + p.u[0] = [5, (pr.e("matchColors") == 1) as u32, 0, 0]; + p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0]; + p.f[1] = [amt, f("tolerance") / 100.0, f("edgeSoftness") / 100.0, 0.0]; + } + "ec.color.changecolor" => { + let view_mask = pr.e("view") == 1; + let (dh, dl, ds) = (f("hueTransform") / 360.0, f("lightnessTransform") / 100.0, f("saturationTransform") / 100.0); + if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 { + return Some(b); + } + let k = pr.color("colorToChange"); + p.u[0] = [6, pr.e("matchColors"), view_mask as u32 | (pr.b("invertColorCorrectionMask") as u32) << 1, 0]; + p.f[0] = [k[0], k[1], k[2], rgb_to_hsl(k[0], k[1], k[2]).0]; + p.f[1] = [dh, dl, ds, 0.0]; + p.f[2] = [f("matchingTolerance") / 100.0, f("matchingSoftness") / 100.0, 0.0, 0.0]; + } + "ec.color.broadcast" => { + let pal = pr.e("locale") == 1; + let (setup, span) = if pal { (0.0, 100.0) } else { (7.5, 92.5) }; + p.u[0] = [7, pal as u32, pr.e("howToMakeSafe"), 0]; + p.f[0][0] = (f("maxSignal") - setup) / span; + } + "ec.color.colorbalancehls" => { + let (dh, dl, ds) = (f("hue") / 360.0, f("lightness") / 100.0, f("saturation") / 100.0); + if dh == 0.0 && dl == 0.0 && ds == 0.0 { + return Some(b); + } + p.u[0][0] = 8; + p.f[0] = [dh, dl, ds, 0.0]; + } + "ec.color.cctoner" => { + let g = |id: &str| rgb(pr.color(id)); + let stops = match pr.e("tones") { + 0 => vec![g("shadows"), g("highlights")], + 2 => vec![g("shadows"), g("darktones"), g("midtones"), g("brights"), g("highlights")], + _ => vec![g("shadows"), g("midtones"), g("highlights")], + }; + p.u[0] = [9, stops.len() as u32 - 1, 0, 0]; + p.f[0][0] = (f("blend") / 100.0).clamp(0.0, 1.0); + for (i, s) in stops.iter().enumerate() { + p.f[1 + i] = *s; + } + } + "ec.color.cccoloroffset" => { + let offs = ["redPhase", "greenPhase", "bluePhase"].map(|id| (pr.f(id) / 360.0).rem_euclid(1.0) as f32); + if offs.iter().all(|o| *o == 0.0) { + return Some(b); + } + p.u[0] = [10, pr.e("overflow"), 0, 0]; + p.f[0] = [offs[0], offs[1], offs[2], 0.0]; + } + "ec.color.cckernel" => { + let k = ["k1", "k2", "k3", "k4", "k5", "k6", "k7", "k8", "k9"].map(f); + let (scale, offset) = (f("scale"), f("offset")); + let identity = k.iter().enumerate().all(|(i, v)| if i == 4 { *v == 1.0 } else { *v == 0.0 }); + if identity && scale == 1.0 && offset == 0.0 { + return Some(b); + } + p.u[0] = [11, pr.b("absolute") as u32, 0, 0]; + p.f[0] = [k[0], k[1], k[2], k[3]]; + p.f[1] = [k[4], k[5], k[6], k[7]]; + p.f[2] = [k[8], scale, offset, 0.0]; + } + "ec.color.psarbitrarymap" => { + let map = parse_map(pr.s("map")); + p.u[0] = [12, pr.b("applyPhaseMapToAlpha") as u32, map.len() as u32, 0]; + p.f[0][0] = (pr.f("phase") * (map.len() - 1) as f64 / 255.0) as f32; + data = Some(map); + } + "ec.color.videolimiter" => { + const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09]; + let level = CLIP_LEVELS[(pr.e("clipLevel") as usize).min(4)]; + let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(pr.e("compressionBeforeClipping") as usize).min(4)]; + p.u[0] = [13, pr.e("clipMethod"), pr.b("gamutWarning") as u32, 0]; + p.f[0] = [level, level * (1.0 - comp), 0.0, 0.0]; + p.f[1] = rgb(pr.color("gamutWarningColor")); + } + "ec.color.colorstabilizer" => { + // The sample means are measured on the CPU (the frame is read back). + if ctx.env.host.is_none() { + return Some(b); + } + let img = e.download(&b.img)?; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let Some(maps) = effectcraft_effects::color_stabilizer_maps(ctx, &cpu) else { return Some(b) }; + let mut d = vec![]; + for m in &maps { + d.push(m.len() as f32); + for i in 0..5 { + let (x, y) = m.get(i).copied().unwrap_or((0.0, 0.0)); + d.extend([x as f32, y as f32]); + } + } + p.u[0][0] = 24; + data = Some(d); + } + "ec.color.cccolorneutralizer" => { + let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")]; + for (i, (u, bal)) in pairs.iter().enumerate() { + let (u, v) = (pr.color(u), pr.color(bal)); + p.f[i] = [v[0] - u[0], v[1] - u[1], v[2] - u[2], 0.0]; + } + p.u[0][0] = 23; + p.f[3] = [f("pinning") / 100.0, f("blendWOriginal") / 100.0, f("contrast") / 100.0, 0.0]; + p.f[4] = [f("darks") / 100.0, f("brights") / 100.0, 0.0, 0.0]; + } + "ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" | "ec.color.equalize" => { + // The histograms are measured on the CPU. + let img = e.download(&b.img)?; + if id == "ec.color.equalize" { + let amt = (pr.f("amount") / 100.0).clamp(0.0, 1.0) as f32; + if amt <= 0.0 { + return Some(b); + } + let style = pr.e("style"); + let tables = effectcraft_effects::equalize_tables(&img, style); + p.u[0] = [17, style, tables.first().map_or(0, |t| t.len()) as u32, 0]; + p.f[0][0] = amt; + data = Some(tables.concat()); + } else { + let kind = match id { + "ec.color.autolevels" => 0, + "ec.color.autocontrast" => 1, + _ => 2, + }; + let (ranges, gam) = effectcraft_effects::auto_correct_settings(ctx, &img, kind); + p.u[0][0] = 16; + p.f[0] = [ranges[0].0, ranges[1].0, ranges[2].0, 0.0]; + p.f[1] = [ranges[0].1, ranges[1].1, ranges[2].1, 0.0]; + p.f[2] = [gam[0], gam[1], gam[2], (pr.f("blend") / 100.0).clamp(0.0, 1.0) as f32]; + } + } + _ => return None, + } + run(e, &p, b, data) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index b1c21b7c1..fd20971b8 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -19,7 +19,10 @@ //! //! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are -//! uploaded and read back once. +//! uploaded and read back once. Each family lives in its own module with its own WGSL file +//! (`fx_color`, `fx_distort`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`, `fx_tone`); +//! settings a kernel cannot match fall back to the CPU (`catalog::gpu_supported`, or `None` +//! from the family's `apply`). //! //! GPU particles (`particles`): the stepped particle effects hand their simulation to //! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle @@ -31,12 +34,17 @@ //! egui-wgpu to draw. mod adv3d; +mod bokeh; mod classic3d; mod context; mod effects; mod fx_color; mod fx_distort; mod fx_generate; +mod fx_key; +mod fx_noise; +mod fx_stylize; +mod fx_tone; mod ops; mod particles; mod walk; @@ -180,4 +188,12 @@ mod tests_fx_distort; #[cfg(test)] mod tests_fx_generate; #[cfg(test)] +mod tests_fx_key; +#[cfg(test)] +mod tests_fx_noise; +#[cfg(test)] +mod tests_fx_stylize; +#[cfg(test)] +mod tests_fx_tone; +#[cfg(test)] mod tests_particles; diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl new file mode 100644 index 000000000..261e8aca7 --- /dev/null +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -0,0 +1,867 @@ +// GPU effects (keying, matte and channel family): see src/fx_key.rs. Each entry point mirrors +// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed +// `fxk_` (the family files share one module). + +// util::unpremul: (straight colour, alpha); colour 0 when alpha ≤ 1e-6. +fn fxk_straight(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +fn fxk_premul(c: vec3, a: f32) -> vec4 { + return vec4(c * a, a); +} + +// Pixel `p` of the third image, copied into `data` as RGBA f32 rows of P.u[3].x pixels. +fn fxk_rows(p: vec2) -> vec4 { + let i = (u32(p.y) * P.u[3].x + u32(p.x)) * 4u; + return vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); +} + +// keying::to_lab. +fn fxk_cbrt(t: f32) -> f32 { + return sign(t) * pow(abs(t), 1.0 / 3.0); +} + +fn fxk_lab_f(t: f32) -> f32 { + if (t > 0.008856) { + return fxk_cbrt(t); + } + return 7.787 * t + 16.0 / 116.0; +} + +// keying::range_components. +fn fxk_range_components(space: u32, c: vec3) -> vec3 { + if (space == 1u) { + let y = 0.299 * c.x + 0.587 * c.y + 0.114 * c.z; + let u = -0.14713 * c.x - 0.28886 * c.y + 0.436 * c.z; + let v = 0.615 * c.x - 0.51499 * c.y - 0.10001 * c.z; + return vec3(y * 255.0, u * 255.0 + 128.0, v * 255.0 + 128.0); + } + if (space == 2u) { + return c * 255.0; + } + let cl = clamp(c, vec3(0.0), vec3(1.0)); + let l = vec3(srgb_to_linear(cl.x), srgb_to_linear(cl.y), srgb_to_linear(cl.z)); + let x = (0.4124 * l.x + 0.3576 * l.y + 0.1805 * l.z) / 0.95047; + let yy = 0.2126 * l.x + 0.7152 * l.y + 0.0722 * l.z; + let z = (0.0193 * l.x + 0.1192 * l.y + 0.9505 * l.z) / 1.08883; + let fx = fxk_lab_f(x); + let fy = fxk_lab_f(yy); + let fz = fxk_lab_f(z); + return vec3((116.0 * fy - 16.0) * 2.55, 500.0 * (fx - fy) + 128.0, 200.0 * (fy - fz) + 128.0); +} + +// keying::matte_levels. +fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { + var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); + t = powz(t, 1.0 / max(gamma, 0.01)); + return clamp(out_b / 255.0 + t * (out_w - out_b) / 255.0, 0.0, 1.0); +} + +// keying::secondary. +fn fxk_secondary(c: vec3, o1: u32, o2: u32, bal: f32) -> f32 { + var hi = c[o2]; + var lo = c[o1]; + if (c[o1] > c[o2]) { + hi = c[o1]; + lo = c[o2]; + } + return bal * hi + (1.0 - bal) * lo; +} + +// channel::arithmetic's 8-bit quantisation. +fn fxk_q(v: f32) -> u32 { + return u32(round_away(clamp(v, 0.0, 1.0) * 255.0)); +} + +// channel2::extract. +fn fxk_extract(px: vec4, ch: u32, inv: bool) -> vec4 { + let c = fxk_straight(px); + let a = max(px.w, 0.0); + var g: f32; + switch ch { + case 0u: { + if (inv) { + return fxk_premul(1.0 - c, a); + } + return px; + } + case 1u: { g = luminance(c) * a; } + case 2u: { g = c.x * a; } + case 3u: { g = c.y * a; } + case 4u: { g = c.z * a; } + default: { g = a; } + } + if (inv) { + g = 1.0 - g; + } + return vec4(g, g, g, 1.0); +} + +// channel2::arith. +fn fxk_arith(op: u32, a: f32, b: f32) -> f32 { + switch op { + case 0u: { return b; } + case 1u: { return a + b; } + case 2u: { return a - b; } + case 3u: { return a * b; } + case 4u: { return abs(a - b); } + case 5u: { return f32(fxk_q(a) & fxk_q(b)) / 255.0; } + case 6u: { return f32(fxk_q(a) | fxk_q(b)) / 255.0; } + case 7u: { return f32(fxk_q(a) ^ fxk_q(b)) / 255.0; } + case 8u, 11u: { return max(a, b); } + case 9u, 10u: { return min(a, b); } + case 12u: { return a + b - a * b; } + case 13u: { + if (a < 0.5) { + return 2.0 * a * b; + } + return 1.0 - 2.0 * (1.0 - a) * (1.0 - b); + } + default: { + if (b < 0.5) { + return 2.0 * a * b; + } + return 1.0 - 2.0 * (1.0 - a) * (1.0 - b); + } + } +} + +// channel2::overflow. +fn fxk_overflow(op: u32, v: f32, how: u32) -> f32 { + if (how == 0u) { + return clamp(v, 0.0, 1.0); + } + if (how == 1u) { + let m = 1.0001; + let r = v - m * floor(v / m); + return min(select(r, 0.0, r >= m || r < 0.0), 1.0); + } + if (op == 1u) { + return v * 0.5; + } + if (op == 2u) { + return (v + 1.0) * 0.5; + } + return clamp(v, 0.0, 1.0); +} + +// Per-pixel keying / matte / channel operations. u[0].x = op (see src/fx_key.rs for each +// effect's parameter layout): +// 1 Color Key matte 2 Luma Key matte 3 Color Range matte 4 Extract matte +// 5 apply a matte (aux.x; u[0].y = 0 final, 2 matte view, 3 Difference Matte's matte view, +// 4 Difference Matte's output) +// 6 Difference Matte matte (src and aux blurred) +// 7 Spill Suppressor 8 Advanced Spill Suppressor (aux = 1×1 excess sums when u[1].w) +// 9 Color Difference Key 10 Unmult 11 route channels (u[1] = sources) +// 12 Remove Color Matting 13 Arithmetic 14 Solid Composite 15 Set Matte +// 16 Channel Combiner 17 Blend 18 Calculations 19 Compound Arithmetic +// 20 Set Channels: channel u[0].y of the value image from aux; 21 Set Channels: premultiply +// 22 Screen Key matte 23 Screen Key output 24 alpha plane 25 Key Cleaner output +// 26 Matte Choker ramp 27 util::set_alpha (aux.x = alpha, data = fill) +// 28 matte view (alpha as opaque grey) +// Refine Soft / Hard Matte: 29 guide pack (luminance, alpha, products); 30 guided-filter +// coefficients from the box means (f[0].x = eps); 31 refined alpha (src = boxed coefficients, +// aux = pack, data = edge band); 32 edge band (src.y − aux.y > 1e-3); 33 choke / contrast / +// invert; 34 View Edge Region; 35 decontamination (aux.x = refined alpha, data = colour estimate) +@compute @workgroup_size(16, 16) +fn fxk_point(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let c = fxk_straight(px); + let a = max(px.w, 0.0); + let u0 = P.u[0]; + let u1 = P.u[1]; + let f0 = P.f[0]; + var o = px; + switch u0.x { + case 1u: { + let k = f0.xyz; + let d = max(max(abs(c.x - k.x), abs(c.y - k.y)), abs(c.z - k.z)); + o = vec4(select(1.0, 0.0, d <= f0.w)); + } + case 2u: { + let l = luminance(c); + let thr = f0.x; + let tol = f0.y; + var m: f32; + switch u0.y { + case 0u: { + if (tol > 0.0) { + m = clamp((thr + tol - l) / tol, 0.0, 1.0); + } else { + m = select(1.0, 0.0, l > thr); + } + } + case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); } + case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); } + default: { + if (tol > 0.0) { + m = clamp((l - (thr - tol)) / tol, 0.0, 1.0); + } else { + m = select(1.0, 0.0, l < thr); + } + } + } + o = vec4(m); + } + case 3u: { + let v = fxk_range_components(u0.y, c); + var out_d = 0.0; + for (var i = 0u; i < 3u; i++) { + out_d = max(max(out_d, P.f[0][i] - v[i]), v[i] - P.f[1][i]); + } + let fuzz = P.f[2].x; + var m = 1.0; + if (out_d <= 0.0) { + m = 0.0; + } else if (fuzz > 0.0) { + m = clamp(out_d / fuzz, 0.0, 1.0); + } + o = vec4(m); + } + case 4u: { + var v: f32; + switch u0.y { + case 1u: { v = c.x; } + case 2u: { v = c.y; } + case 3u: { v = c.z; } + case 4u: { v = a; } + default: { v = luminance(c); } + } + v = 255.0 * v; + let bp = f0.x; + let wp = f0.y; + let bs = f0.z; + let ws = f0.w; + var k = 1.0; + if (v < bp) { + k = select(0.0, clamp(1.0 - (bp - v) / bs, 0.0, 1.0), bs > 0.0); + } else if (v > wp) { + k = select(0.0, clamp(1.0 - (v - wp) / ws, 0.0, 1.0), ws > 0.0); + } + if (u0.z != 0u) { + k = 1.0 - k; + } + o = vec4(k); + } + case 5u: { + let m = textureLoad(aux, p, 0).x; + switch u0.y { + case 2u: { + let k = clamp(m, 0.0, 1.0); + let v = px.w * k; + o = vec4(v, v, v, 1.0); + } + case 3u: { + let v = m * px.w; + o = vec4(v, v, v, px.w); + } + case 4u: { o = px * m; } + default: { o = px * clamp(m, 0.0, 1.0); } + } + } + case 6u: { + let q = textureLoad(aux, p, 0); + let oc = fxk_straight(q); + let oa = max(q.w, 0.0); + let d = max(max(max(abs(c.x - oc.x), abs(c.y - oc.y)), abs(c.z - oc.z)), abs(a - oa)); + var m: f32; + if (d <= f0.x) { + m = 0.0; + } else if (f0.y <= 1e-6) { + m = 1.0; + } else { + m = clamp((d - f0.x) / f0.y, 0.0, 1.0); + } + o = vec4(m); + } + case 7u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + var s = px.xyz / px.w; + if (u0.y == 1u) { + let g = (s.x + s.y + s.z) / 3.0; + let dir = f0.xyz; + let proj = (s.x - g) * dir.x + (s.y - g) * dir.y + (s.z - g) * dir.z; + if (proj > 0.0) { + s = s - proj * dir * f0.w; + } + } else { + let spill = max(s[u1.x] - max(s[u1.y], s[u1.z]), 0.0); + s[u1.x] = s[u1.x] - spill * f0.w; + } + o = vec4(s * px.w, px.w); + } + case 8u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + var pi = u1.x; + var o1 = u1.y; + var o2 = u1.z; + if (u1.w != 0u) { + let ex = textureLoad(aux, vec2(0, 0), 0); + if (ex.y >= ex.x && ex.y >= ex.z) { + pi = 1u; + o1 = 0u; + o2 = 2u; + } else if (ex.z >= ex.x) { + pi = 2u; + o1 = 0u; + o2 = 1u; + } else { + pi = 0u; + o1 = 1u; + o2 = 2u; + } + } + var s = px.xyz / px.w; + let amt = f0.x; + let spill_range = f0.y; + let luma = f0.z; + let tol = f0.w; + let desat = P.f[1].x; + let neutral = P.f[1].y; + let key_hue = P.f[1].z; + var w = 1.0; + if (tol < 1.0) { + let h = rgb_to_hsl(s).x; + let d0 = abs(h - key_hue); + let d = min(d0, 1.0 - d0); + w = clamp(1.0 - d / max(tol * 0.5, 1e-4), 0.0, 1.0); + } + let mx = max(s[o1], s[o2]); + let avg = 0.5 * (s[o1] + s[o2]); + let lim = mx + (avg - mx) * spill_range; + let spill = max(s[pi] - lim, 0.0) * amt * w; + s[pi] = s[pi] - spill; + let lw = vec3(0.2126, 0.7152, 0.0722); + let back = spill * lw[pi] * luma + spill * neutral / 3.0; + s = s + back; + if (desat > 0.0 && spill > 0.0) { + let l = luminance(s); + let k = min(desat * min(spill * 4.0, 1.0), 1.0); + s = s + (vec3(l) - s) * k; + } + o = vec4(s * px.w, px.w); + } + case 9u: { + var cc = c; + if (u0.z != 0u) { + let cl = clamp(cc, vec3(0.0), vec3(1.0)); + cc = vec3(srgb_to_linear(cl.x), srgb_to_linear(cl.y), srgb_to_linear(cl.z)); + } + let pa = P.f[1]; + let pb = P.f[3]; + let ua = 1.0 - clamp((cc[u1.x] - cc[u1.y]) / f0.x, 0.0, 1.0); + let ub = 1.0 - clamp((cc[u1.x] - cc[u1.z]) / f0.y, 0.0, 1.0); + let ca = fxk_matte_levels(ua, pa.x, pa.y, pa.z, pa.w, P.f[2].x); + let cb = fxk_matte_levels(ub, pb.x, pb.y, pb.z, pb.w, P.f[2].y); + let um = max(ua, ub); + let cm = fxk_matte_levels(max(ca, cb), f0.z, f0.w, P.f[2].z, 0.0, 255.0); + var view = u0.y; + if (view == 8u) { + let right = p.x * 2 >= dims.x; + let bottom = p.y * 2 >= dims.y; + if (!right && !bottom) { + view = 2u; + } else if (right && !bottom) { + view = 4u; + } else if (!right && bottom) { + view = 6u; + } else { + view = 7u; + } + } + var g = -1.0; + switch view { + case 1u: { g = ua; } + case 2u: { g = ca; } + case 3u: { g = ub; } + case 4u: { g = cb; } + case 5u: { g = um; } + case 6u: { g = cm; } + default: {} + } + if (g >= 0.0) { + let m = g * a; + o = vec4(m, m, m, 1.0); + } else { + o = px * cm; + } + } + case 10u: { + let white = u0.y != 0u; + var d: f32; + var start: f32; + if (white) { + d = 1.0 - min(min(c.x, c.y), c.z); + start = 1.0 - f0.x; + } else { + d = max(max(c.x, c.y), c.z); + start = f0.x; + } + d = clamp(d, 0.0, 1.0); + let width = f0.y * (1.0 - start); + var m: f32; + if (width > 1e-6) { + m = clamp((d - start) / width, 0.0, 1.0); + } else { + m = select(0.0, 1.0, d > start); + } + if (m <= 1e-6) { + o = vec4(0.0); + } else { + let bg = select(0.0, 1.0, white); + var oc = c; + if (u0.z != 0u) { + oc = (c - bg * (1.0 - m)) / m; + } + if (u0.w != 0u) { + oc = clamp(oc, vec3(0.0), vec3(1.0)); + } + o = fxk_premul(oc, a * m); + } + } + case 11u: { + let v = vec4(dst_pick(u1.x, c, a), dst_pick(u1.y, c, a), dst_pick(u1.z, c, a), dst_pick(u1.w, c, a)); + o = fxk_premul(v.xyz, clamp(v.w, 0.0, 1.0)); + } + case 12u: { + if (px.w <= 1e-6) { + o = vec4(0.0); + } else { + for (var i = 0; i < 3; i++) { + let s = px[i] / px.w; + var v = max(s - f0[i] * (1.0 - px.w), 0.0); + if (u0.y != 0u) { + v = min(v, px.w); + } + o[i] = v; + } + } + } + case 13u: { + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + let s = px.xyz / px.w; + var r = vec3(0.0); + for (var i = 0u; i < 3u; i++) { + let v = s[i]; + let kv = f0[i]; + let iv = fxk_q(v); + let ik = u32(round_away(kv * 255.0)); + var x: f32; + switch u0.y { + case 0u: { x = f32(iv & ik) / 255.0; } + case 1u: { x = f32(iv | ik) / 255.0; } + case 2u: { x = f32(iv ^ ik) / 255.0; } + case 3u: { x = v + kv; } + case 4u: { x = v - kv; } + case 5u: { x = abs(v - kv); } + case 6u: { x = min(v, kv); } + case 7u: { x = max(v, kv); } + case 8u: { x = select(v, 0.0, v > kv); } + case 9u: { x = select(v, 0.0, v < kv); } + case 10u: { x = select(0.0, 1.0, v >= kv); } + case 11u: { x = v * kv; } + default: { x = 1.0 - (1.0 - v) * (1.0 - kv); } + } + if (u0.z != 0u) { + r[i] = clamp(x, 0.0, 1.0); + } else { + r[i] = max(x, 0.0); + } + } + o = vec4(r * px.w, px.w); + } + case 14u: { + var b = blend_pixel(u0.y, f0, px * P.f[1].x, 0.5); + b.w = clamp(b.w, 0.0, 1.0); + o = vec4(max(b.xyz, vec3(0.0)), b.w); + } + case 15u: { + var mc = c; + var ma = a; + if ((u0.z & 8u) == 0u) { + let q = textureLoad(aux, p, 0); + mc = fxk_straight(q); + ma = max(q.w, 0.0); + } + var m = dst_pick(u0.y, mc, ma); + if ((u0.z & 4u) != 0u) { + m = m * ma; + } + m = clamp(m, 0.0, 1.0); + let inv = (u0.z & 1u) != 0u; + if (inv) { + m = 1.0 - m; + } + let own_m = (u0.z & 8u) != 0u; + let is_alpha = (u0.z & 16u) != 0u; + var na = m; + if ((u0.z & 2u) != 0u && (!own_m || !is_alpha || inv)) { + na = a * m; + } + o = fxk_premul(c, clamp(na, 0.0, 1.0)); + } + case 16u: { + let ch_from = u0.y; + let ch_to = u0.z; + let inv = (u0.w & 1u) != 0u; + let solid = (u0.w & 2u) != 0u; + let second = (u0.w & 4u) != 0u; + var sc = c; + var sa = a; + if (second) { + let q = textureLoad(aux, p, 0); + sc = fxk_straight(q); + sa = max(q.w, 0.0); + } + if (sa <= 0.0 && ch_from != 7u && !second) { + if (solid) { + o = fxk_premul(c, 1.0); + } + textureStore(out, p, o); + return; + } + var na = a; + var r: vec3; + if (ch_from <= 3u) { + switch ch_from { + case 0u: { + let hsl = rgb_to_hsl(sc); + r = vec3(hsl.x, hsl.z, hsl.y); + } + case 1u: { r = hsl_to_rgb(sc.x, sc.z, sc.y); } + case 2u: { + let y = 0.299 * sc.x + 0.587 * sc.y + 0.114 * sc.z; + r = vec3(y, 0.492 * (sc.z - y) + 0.5, 0.877 * (sc.x - y) + 0.5); + } + default: { + let y = sc.x; + let uu = sc.y - 0.5; + let vv = sc.z - 0.5; + let rr = y + vv / 0.877; + let bb = y + uu / 0.492; + let gg = (y - 0.299 * rr - 0.114 * bb) / 0.587; + r = vec3(rr, gg, bb); + } + } + if (inv) { + r = 1.0 - r; + } + } else { + var v: f32; + switch ch_from { + case 4u: { v = sc.x; } + case 5u: { v = sc.y; } + case 6u: { v = sc.z; } + case 7u: { v = sa; } + case 8u: { v = rgb_to_hsl(sc).z; } + case 9u: { v = rgb_to_hsl(sc).x; } + case 10u: { v = rgb_to_hsl(sc).y; } + case 11u: { v = luminance(sc); } + case 12u: { v = max(max(sc.x, sc.y), sc.z); } + default: { v = min(min(sc.x, sc.y), sc.z); } + } + if (inv) { + v = 1.0 - v; + } + switch ch_to { + case 0u: { r = vec3(v, 0.0, 0.0); } + case 1u: { r = vec3(0.0, v, 0.0); } + case 2u: { r = vec3(0.0, 0.0, v); } + case 3u: { + na = clamp(v, 0.0, 1.0); + r = vec3(1.0); + } + case 5u: { r = hsl_to_rgb(v, 1.0, 0.5); } + case 6u: { r = hsl_to_rgb(0.0, clamp(v, 0.0, 1.0), 0.5); } + case 8u: { + let hsl = rgb_to_hsl(c); + r = hsl_to_rgb(hsl.x, clamp(hsl.y * v, 0.0, 1.0), hsl.z); + } + default: { r = vec3(v); } + } + } + if (solid) { + na = 1.0; + } + o = fxk_premul(max(r, vec3(0.0)), na); + } + case 17u: { + let q = textureLoad(aux, p, 0); + var bl: vec4; + switch u0.y { + case 0u: { bl = q; } + case 1u, 2u: { + let oa = q.w; + if (px.w <= 0.0 || oa <= 0.0) { + bl = px; + } else { + let hsl = rgb_to_hsl(c); + let ohsl = rgb_to_hsl(fxk_straight(q)); + var h2 = ohsl.x; + var s2 = ohsl.y; + if (u0.y == 2u) { + if (hsl.y > 1e-4) { + s2 = hsl.y; + } else { + h2 = 0.0; + s2 = 0.0; + } + } + let rgb = hsl_to_rgb(h2, s2, hsl.z); + bl = px + (fxk_premul(rgb, a) - px) * oa; + } + } + case 3u: { bl = vec4(min(px.xyz, q.xyz), px.w); } + default: { bl = max(px, q); } + } + o = bl + (px - bl) * f0.x; + } + case 18u: { + let flags = u1.x; + var r = fxk_extract(px, u0.y, (flags & 1u) != 0u); + if ((flags & 8u) != 0u) { + let s = fxk_extract(textureLoad(aux, p, 0), u0.z, (flags & 2u) != 0u) * f0.x; + r = blend_pixel(u0.w, r, s, 0.5); + } + if ((flags & 4u) != 0u && r.w > 1e-6) { + r = fxk_premul(fxk_straight(r), px.w); + } + r.w = clamp(r.w, 0.0, 1.0); + o = r; + } + case 19u: { + let q = textureLoad(aux, p, 0); + let oc = fxk_straight(q); + let oa = max(q.w, 0.0); + var rc = c; + var ra = a; + if (u0.z != 2u) { + for (var i = 0u; i < 3u; i++) { + rc[i] = fxk_overflow(u0.y, fxk_arith(u0.y, c[i], oc[i]), u0.w); + } + } + if (u0.z != 0u) { + ra = fxk_overflow(u0.y, fxk_arith(u0.y, a, oa), u0.w); + } + let res = fxk_premul(rc, ra); + o = res + (px - res) * f0.x; + } + case 20u: { + let q = textureLoad(aux, p, 0); + o = px; + o[u0.y] = dst_pick(u0.z, fxk_straight(q), max(q.w, 0.0)); + } + case 21u: { + o = fxk_premul(px.xyz, clamp(px.w, 0.0, 1.0)); + } + case 22u: { + let d = c[u1.x] - fxk_secondary(c, u1.y, u1.z, f0.z); + let t = clamp(d / f0.x * f0.y, 0.0, 1.0); + o = vec4(clamp((1.0 - t - f0.w) / max(P.f[1].x - f0.w, 1e-3), 0.0, 1.0)); + } + case 23u: { + let k = clamp(textureLoad(aux, p, 0).x, 0.0, 1.0); + let alpha = a * k; + if (u0.y == 1u) { + o = vec4(alpha, alpha, alpha, 1.0); + } else if (u0.y == 2u) { + var v = 0.5; + if (alpha <= 1e-3) { + v = 0.0; + } else if (alpha >= 0.999) { + v = 1.0; + } + o = vec4(v, v, v, 1.0); + } else { + let screen = f0.xyz; + var fg = c; + if (!(u0.z != 0u || k <= 1e-4)) { + let s = 1.0 - k; + for (var i = 0; i < 3; i++) { + fg[i] = min(max(c[i] - s * screen[i], 0.0) / k, max(c[i], 1.0)); + } + } + let spill = max(fg[u1.x] - fxk_secondary(fg, u1.y, u1.z, f0.w), 0.0); + fg[u1.x] = fg[u1.x] - spill * P.f[1].x; + o = fxk_premul(fg, alpha); + } + } + case 24u: { + o = vec4(px.w); + } + case 25u: { + var na = textureLoad(aux, p, 0).x; + if (u0.y != 0u) { + na = clamp((na - 0.5) * 1.5 + 0.5, 0.0, 1.0); + } + var cc = c; + let a0 = a; + if (P.u[3].y != 0u && a0 > 1e-4 && a0 < 0.999) { + let q = fxk_rows(p); + if (q.w > 1e-6) { + let ea = q.w; + if (ea > 1e-4) { + let t = f0.y * (1.0 - a0); + cc = cc + (q.xyz / ea - cc) * t; + } + } + } + na = clamp((na - 0.5) * f0.x + 0.5, 0.0, 1.0); + if (a0 <= 1e-6) { + na = 0.0; + } + o = fxk_premul(cc, na); + } + case 26u: { + o = vec4(clamp((px.x - f0.x) / f0.y + 0.5, 0.0, 1.0)); + } + case 27u: { + let na = clamp(textureLoad(aux, p, 0).x, 0.0, 1.0); + var cc = c; + if (px.w <= 1e-4) { + cc = fxk_straight(fxk_rows(p)); + } + o = fxk_premul(cc, na); + } + case 29u: { + let g = luminance(px.xyz); + o = vec4(g, px.w, g * px.w, g * g); + } + case 30u: { + let var_g = px.w - px.x * px.x; + let cov = px.z - px.x * px.y; + let ca = cov / (var_g + f0.x); + o = vec4(ca, px.y - ca * px.x, 0.0, 0.0); + } + case 31u: { + let q = textureLoad(aux, p, 0); + if (fxk_rows(p).x != 0.0) { + o = vec4(clamp(px.x * q.x + px.y, 0.0, 1.0)); + } else { + o = vec4(q.y); + } + } + case 32u: { + o = vec4(select(0.0, 1.0, px.y - textureLoad(aux, p, 0).y > 1e-3)); + } + case 33u: { + var v = px.x; + let choke = f0.x; + if (choke > 0.0) { + v = (v - choke) / (1.0 - choke); + } else if (choke < 0.0) { + v = v / (1.0 + choke); + } + if (abs(f0.y - 1.0) > 1e-6) { + v = (v - 0.5) * f0.y + 0.5; + } + v = clamp(v, 0.0, 1.0); + if (u0.y != 0u) { + v = 1.0 - v; + } + o = vec4(v); + } + case 34u: { + let v = px.x; + if (textureLoad(aux, p, 0).x != 0.0) { + o = vec4(0.5 + 0.5 * v, 0.5 * v, 0.5 * v, 1.0); + } else { + o = vec4(v, v, v, 1.0); + } + } + case 35u: { + let n = textureLoad(aux, p, 0).x; + let a0 = px.w; + var t = 0.0; + if (a0 > 1e-4 && a0 < 0.999) { + t = 1.0 - a0; + } else if (u0.w != 0u && n > 1e-4 && n < 0.999) { + t = 1.0 - n; + } + let strength = f0.x * t; + if (u0.y != 0u) { + let v = select(0.0, strength, u0.z != 0u); + o = vec4(v, v, v, 1.0); + } else if (P.u[3].y != 0u && strength > 0.0 && a0 > 1e-4) { + let q = fxk_rows(p); + if (q.w > 1e-4) { + let cc = c + (q.xyz / q.w - c) * strength; + o = fxk_premul(cc, a0); + } + } + } + case 28u: { + let v = clamp(px.w, 0.0, 1.0); + o = vec4(v, v, v, 1.0); + } + default: {} + } + textureStore(out, p, o); +} + +// util::fit_layer: bilinear sample of the other layer (src) at the buffer coordinates in data +// (one per column, then one per row). +@compute @workgroup_size(16, 16) +fn fxk_fit(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let x = data[u32(p.x)]; + let y = data[u32(dims.x) + u32(p.y)]; + textureStore(out, p, sample_bilinear(src, x, y)); +} + +// Sum of 16×16 blocks; u[0].x = first pass: sum Advanced Spill Suppressor's per-channel excess +// of each primary over the other two (straight colour × alpha), else sum the values. +@compute @workgroup_size(16, 16) +fn fxk_reduce(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let sd = vec2(textureDimensions(src)); + var acc = vec4(0.0); + for (var j = 0; j < 16; j++) { + for (var i = 0; i < 16; i++) { + let q = vec2(p.x * 16 + i, p.y * 16 + j); + if (q.x >= sd.x || q.y >= sd.y) { + continue; + } + let v = textureLoad(src, q, 0); + if (P.u[0].x != 0u) { + let c = fxk_straight(v); + let a = max(v.w, 0.0); + acc += vec4( + max(c.x - max(c.y, c.z), 0.0) * a, + max(c.y - max(c.x, c.z), 0.0) * a, + max(c.z - max(c.x, c.y), 0.0) * a, + 0.0, + ); + } else { + acc += v; + } + } + } + textureStore(out, p, acc); +} diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl new file mode 100644 index 000000000..56281158b --- /dev/null +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -0,0 +1,825 @@ +// GPU effects (noise, blur and time family): see src/fx_noise.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation. Every name here is prefixed `fxn_` (the +// family files share one module). + +const FXN_MAX: f32 = 3.40282347e38; + +fn fxn_pixel(gid: vec3) -> vec2 { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return vec2(-1); + } + return p; +} + +// a / b rounded like the CPU's IEEE division (GPU division may be approximate): one +// residual correction with fma. Used where a threshold follows the quotient. +fn fxn_div4(a: vec4, b: vec4) -> vec4 { + let q = a / b; + let r = fma(-q, b, a); + return fma(r, 1.0 / b, q); +} + +// util::unpremul: (straight colour, alpha). +fn fxn_unpremul(p: vec4) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(0.0, 0.0, 0.0, max(p.w, 0.0)); +} + +// ---------------------------------------------------------------- Median family (noise::median_image) + +// Quantise to the median's 512 levels (0..511). u[0].x = 1: straight colour with alpha 1 +// (Median (Legacy) colour only). +@compute @workgroup_size(16, 16) +fn fxn_quant(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var v = textureLoad(src, p, 0); + if (P.u[0].x == 1u) { + v = vec4(fxn_unpremul(v).xyz, 1.0); + } + let q = clamp(v, vec4(0.0), vec4(1.0)) * 511.0; + textureStore(out, p, vec4(round_away(q.x), round_away(q.y), round_away(q.z), round_away(q.w))); +} + +// Per-channel median of the quantised levels over a (2r+1)² window (edges repeated): the +// smallest level m with more than half the window at or below m. u[0].x = r. +@compute @workgroup_size(16, 16) +fn fxn_median(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let r = i32(P.u[0].x); + let n = (2 * r + 1) * (2 * r + 1); + let half = f32(n / 2); + var lo = vec4(0.0); + var hi = vec4(511.0); + for (var it = 0; it < 9; it++) { + let mid = floor((lo + hi) * 0.5); + var cnt = vec4(0.0); + for (var dy = -r; dy <= r; dy++) { + for (var dx = -r; dx <= r; dx++) { + let q = tex_get_clamped(src, p.x + dx, p.y + dy); + cnt += select(vec4(0.0), vec4(1.0), q <= mid); + } + } + let above = cnt > vec4(half); + hi = select(hi, mid, above); + lo = select(mid + 1.0, lo, above); + } + textureStore(out, p, fxn_div4(lo, vec4(511.0))); +} + +// noise::median_px. +fn fxn_median_px(o: vec4, m: vec4, on_alpha: bool) -> vec4 { + if (on_alpha) { + let a = clamp(m.w, 0.0, 1.0); + return vec4(min(m.xyz, vec3(a)), a); + } + if (m.w <= 1e-4) { + return o; + } + return vec4(min(fxn_div4(m, vec4(m.w)).xyz, vec3(1.0)) * o.w, o.w); +} + +// src = the layer, aux = the median. u[0] = (mode, operate on alpha): 0 Median, 1 Dust & +// Scratches (f[0].x = threshold), 2 Median (Legacy) with alpha, 3 Median (Legacy) colour only. +@compute @workgroup_size(16, 16) +fn fxn_median_out(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let o = textureLoad(src, p, 0); + let m = textureLoad(aux, p, 0); + let on_alpha = P.u[0].y != 0u; + var r = o; + switch P.u[0].x { + case 0u: { + r = fxn_median_px(o, m, on_alpha); + } + case 1u: { + let cand = fxn_median_px(o, m, on_alpha); + let d = abs(cand - o); + if (max(max(d.x, d.y), max(d.z, d.w)) > P.f[0].x) { + r = cand; + } + } + case 2u: { + r = m; + } + default: { + r = vec4(m.xyz * o.w, o.w); + } + } + textureStore(out, p, r); +} + +// ---------------------------------------------------------------- Bilateral / Smart Blur (blur2.rs) + +// blur2::edge_preserving. u[0] = (ceil r, stride, hard, largest d²); u[1].x = grey (Bilateral +// with Colorize off); f[0] = (2σs², 2σr², range). +@compute @workgroup_size(16, 16) +fn fxn_edge(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let ri = i32(P.u[0].x); + let stride = i32(P.u[0].y); + let hard = P.u[0].z != 0u; + let d2max = i32(P.u[0].w); + let sig_s2 = P.f[0].x; + let sig_r2 = P.f[0].y; + let range = P.f[0].z; + let c = textureLoad(src, p, 0); + let cl = luminance(c.xyz); + var acc = vec4(0.0); + var wsum = 0.0; + for (var dy = -ri; dy <= ri; dy += stride) { + for (var dx = -ri; dx <= ri; dx += stride) { + let d2 = dx * dx + dy * dy; + if (d2 > d2max) { + continue; + } + let q = tex_get_clamped(src, p.x + dx, p.y + dy); + var w = 0.0; + if (hard) { + w = select(0.0, 1.0, abs(luminance(q.xyz) - cl) <= range); + } else { + let d = q - c; + let dc = d.x * d.x + d.y * d.y + d.z * d.z + d.w * d.w; + w = exp(-f32(d2) / sig_s2 - dc / sig_r2); + } + if (w > 0.0) { + acc += q * w; + wsum += w; + } + } + } + var o = c; + if (wsum > 0.0) { + o = acc / wsum; + } + if (P.u[1].x != 0u && o.w > 0.0) { + let l = luminance(o.xyz / o.w); + o = vec4(vec3(l * o.w), o.w); + } + textureStore(out, p, o); +} + +// Smart Blur's Edge Only / Overlay Edge (blur2::sobel on the blurred layer's premultiplied +// luminance). u[0].x = mode; f[0].x = edge threshold. +@compute @workgroup_size(16, 16) +fn fxn_smart_edges(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var l: array; + for (var j = 0; j < 3; j++) { + for (var i = 0; i < 3; i++) { + l[j * 3 + i] = luminance(tex_get_clamped(src, p.x + i - 1, p.y + j - 1).xyz); + } + } + let gx = l[2] + 2.0 * l[5] + l[8] - l[0] - 2.0 * l[3] - l[6]; + let gy = l[6] + 2.0 * l[7] + l[8] - l[0] - 2.0 * l[1] - l[2]; + let e = sqrt(gx * gx + gy * gy) * 0.25; + let px = textureLoad(src, p, 0); + let on = e > P.f[0].x; + var o = px; + if (P.u[0].x == 1u) { + o = vec4(vec3(select(0.0, 1.0, on) * px.w), px.w); + } else if (on) { + o = vec4(vec3(px.w), px.w); + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- Sharpen / Unsharp Mask (misc::unsharp_core) + +// src = the layer, aux = its blur. f[0] = (amount, threshold in 0..255). +@compute @workgroup_size(16, 16) +fn fxn_unsharp(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var o = textureLoad(src, p, 0); + let bl = textureLoad(aux, p, 0); + for (var c = 0; c < 3; c++) { + let d = o[c] - bl[c]; + if (abs(d) * 255.0 >= P.f[0].y) { + o[c] = max(o[c] + d * P.f[0].x, 0.0); + } + } + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- planes + +// Channel select: channel c of `src` becomes aux[c] (u[0].x = 0) or aux's alpha (1) where +// u[1][c] != 0. +@compute @workgroup_size(16, 16) +fn fxn_pick(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var o = textureLoad(src, p, 0); + let a = textureLoad(aux, p, 0); + for (var c = 0; c < 4; c++) { + if (P.u[1][c] != 0u) { + o[c] = select(a[c], a.w, P.u[0].x == 1u); + } + } + textureStore(out, p, o); +} + +// Channel Blur's result: src = blurred premultiplied channels, aux = the alpha blurred like +// each channel (alpha = the Alpha Blurriness result). +@compute @workgroup_size(16, 16) +fn fxn_cblur_out(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + let ba = textureLoad(aux, p, 0); + var s = vec3(0.0); + for (var c = 0; c < 3; c++) { + if (ba[c] > 1e-6) { + s[c] = v[c] / ba[c]; + } + } + let a = clamp(ba.w, 0.0, 1.0); + textureStore(out, p, vec4(s * a, a)); +} + +// Straight colour planes with the raw alpha (Minimax). +@compute @workgroup_size(16, 16) +fn fxn_straight(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + textureStore(out, p, vec4(fxn_unpremul(v).xyz, v.w)); +} + +// Premultiply straight planes by the clamped alpha. +@compute @workgroup_size(16, 16) +fn fxn_premul(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + let a = clamp(v.w, 0.0, 1.0); + textureStore(out, p, vec4(v.xyz * a, a)); +} + +// util::morph_plane along one axis (edges repeated). u[0] = (r, vertical); u[1] = per-channel +// operation (0 min, 1 max, 2 keep). +@compute @workgroup_size(16, 16) +fn fxn_morph(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let r = i32(P.u[0].x); + let vertical = P.u[0].y != 0u; + let centre = textureLoad(src, p, 0); + var lo = centre; + var hi = centre; + for (var k = -r; k <= r; k++) { + var v: vec4; + if (vertical) { + v = tex_get_clamped(src, p.x, p.y + k); + } else { + v = tex_get_clamped(src, p.x + k, p.y); + } + lo = min(lo, v); + hi = max(hi, v); + } + var o = centre; + for (var c = 0; c < 4; c++) { + if (P.u[1][c] == 0u) { + o[c] = lo[c]; + } else if (P.u[1][c] == 1u) { + o[c] = hi[c]; + } + } + textureStore(out, p, o); +} + +// Crop: out(p) = src(p + u[0].xy). +@compute @workgroup_size(16, 16) +fn fxn_crop(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + textureStore(out, p, textureLoad(src, p + vec2(P.u[0].xy), 0)); +} + +// ---------------------------------------------------------------- Compound Blur (blur2::compound_blur) + +// The blur map (straight luminance × alpha, inverted with u[0].y) in every channel. u[0].x = 1: +// `src` is the Blur Layer sampled at the per-column / per-row coordinates in `data` (u[1].x = +// width); 0: `src` is the layer itself. +@compute @workgroup_size(16, 16) +fn fxn_cb_map(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var v: vec4; + if (P.u[0].x == 1u) { + let w = P.u[1].x; + v = sample_bilinear(src, data[u32(p.x)], data[w + u32(p.y)]); + } else { + v = textureLoad(src, p, 0); + } + let s = fxn_unpremul(v); + var l = luminance(s.xyz) * s.w; + if (P.u[0].y != 0u) { + l = 1.0 - l; + } + textureStore(out, p, vec4(l)); +} + +// Writes the pixels whose blur level falls between levels k (src) and k + 1 (aux). The map is +// `data` (RGBA f32 rows of u[0].y pixels); u[0].x = k. +@compute @workgroup_size(16, 16) +fn fxn_cb_level(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let m = data[(u32(p.y) * P.u[0].y + u32(p.x)) * 4u]; + let t = clamp(m, 0.0, 1.0) * 4.0; + let k = min(u32(floor(t)), 3u); + if (k != P.u[0].x) { + return; + } + let f = t - f32(k); + let a = textureLoad(src, p, 0); + let c = textureLoad(aux, p, 0); + textureStore(out, p, a + (c - a) * f); +} + +// ---------------------------------------------------------------- Echo (time_fx::echo) + +// acc (src) ⊕ frame (aux) × f[0].x. u[0] = (operator, first frame). +@compute @workgroup_size(16, 16) +fn fxn_echo(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let op = P.u[0].x; + var acc = textureLoad(src, p, 0); + if (P.u[0].y != 0u) { + acc = select(vec4(0.0), vec4(FXN_MAX), op == 2u); + } + let k = P.f[0].x; + let q = textureLoad(aux, p, 0) * k; + switch op { + case 1u: { + acc = max(acc, q); + } + case 2u: { + acc = min(acc, q); + } + case 3u: { + acc = acc + q - acc * q; + } + case 4u: { + acc += q * (1.0 - acc.w); + } + case 5u: { + acc = q + acc * (1.0 - q.w); + } + default: { + acc += q; + } + } + textureStore(out, p, acc); +} + +// Echo's finish. u[0].x = operator; f[0].x = number of frames. +@compute @workgroup_size(16, 16) +fn fxn_echo_fin(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let op = P.u[0].x; + var acc = textureLoad(src, p, 0); + if (op == 6u) { + acc = acc / P.f[0].x; + } + if (op == 2u && acc.x == FXN_MAX) { + acc = vec4(0.0); + } + textureStore(out, p, vec4(max(acc.xyz, vec3(0.0)), clamp(acc.w, 0.0, 1.0))); +} + +// ---------------------------------------------------------------- Fractal / Turbulent Noise (noise3.rs) + +fn fxn_catmull(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 { + return 0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t * t + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t * t * t); +} + +fn fxn_slice(ix: i32, iy: i32, iz: i32, tx: f32, ty: f32, seed: u32, nt: u32) -> f32 { + if (nt == 0u) { + return lattice(ix, iy, iz, seed); + } + if (nt == 1u) { + let a = lattice(ix, iy, iz, seed); + let b = lattice(ix + 1, iy, iz, seed); + let c = lattice(ix, iy + 1, iz, seed); + let d = lattice(ix + 1, iy + 1, iz, seed); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; + } + var rows: array; + for (var j = 0; j < 4; j++) { + let y = iy + j - 1; + rows[j] = fxn_catmull(lattice(ix - 1, y, iz, seed), lattice(ix, y, iz, seed), lattice(ix + 1, y, iz, seed), lattice(ix + 2, y, iz, seed), tx); + } + return fxn_catmull(rows[0], rows[1], rows[2], rows[3], ty); +} + +// noise3::typed_noise. +fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 { + if (nt == 2u) { + return value_noise(x, y, z, seed); + } + let x0 = floor(x); + let y0 = floor(y); + let z0 = floor(z); + let tx = x - x0; + let ty = y - y0; + let fz = fade(z - z0); + let a = fxn_slice(i32(x0), i32(y0), i32(z0), tx, ty, seed, nt); + let b = fxn_slice(i32(x0), i32(y0), i32(z0) + 1, tx, ty, seed, nt); + return a + (b - a) * fz; +} + +// noise3::shape. +fn fxn_shape(kind: u32, n: f32) -> f32 { + let s = n * 2.0 - 1.0; + switch kind { + case 1u: { + return 1.0 - s * s; + } + case 2u: { + return 1.0 - abs(s); + } + case 3u: { + let t = 1.0 - abs(s); + return t * t; + } + case 8u: { + let t = clamp(abs(s) / 0.12, 0.0, 1.0); + return 1.0 - t * t; + } + default: { + return n; + } + } +} + +// Fractal::layer. `o` = octave data base index. +fn fxn_layer(u: f32, v: f32, o: u32, z: f32, seed: u32) -> f32 { + let nt = P.u[0].y; + if (P.u[1].y != 0u) { + let t = P.f[2].x; + let c = P.f[2].y; + let w = P.f[2].z; + let a = fxn_typed_noise(u, v, t + z, seed, nt); + let b = fxn_typed_noise(u, v, t - c + z, seed, nt); + return a * (1.0 - w) + b * w; + } + return fxn_typed_noise(u, v, data[o + 7u] + z, seed, nt); +} + +// u[0] = (kind, noise type, octaves, seed); u[1] = (sub rotation, cycle, invert, overflow); +// u[2].x = blend mode (BlendMode::ALL index, 255 = None); f[0] = (sin, cos, size x, size y); +// f[1] = (contrast, brightness, opacity, blend with original); f[2] = (cycle t, cycle, t / cycle, +// norm); data = 10 floats per octave (noise3::FractalGpu). +@compute @workgroup_size(16, 16) +fn fxn_fractal(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let kind = P.u[0].x; + let n_oct = P.u[0].z; + let sr = P.f[0].x; + let cr = P.f[0].y; + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + var sum = 0.0; + var best = 0.0; + for (var oi = 0u; oi < n_oct; oi++) { + let o = oi * 10u; + let dx = x - data[o]; + let dy = y - data[o + 1u]; + var u = (dx * cr + dy * sr) / P.f[0].z; + var v = (-dx * sr + dy * cr) / P.f[0].w; + if (P.u[1].x != 0u) { + let s = data[o + 2u]; + let c = data[o + 3u]; + let u2 = u * c + v * s; + v = -u * s + v * c; + u = u2; + } + let f = data[o + 4u]; + u = u * f; + v = v * f; + let z = data[o + 8u]; + let seed = P.u[0].w + oi; + if (kind >= 4u && kind <= 6u) { + let k = data[o + 9u]; + let a = fxn_layer(u * 0.5, v * 0.5, o, z + 3.1, seed ^ 0x55u) - 0.5; + let bb = fxn_layer(u * 0.5, v * 0.5, o, z + 5.7, seed ^ 0xaau) - 0.5; + if (kind == 5u) { + let s = sin(a * 3.0); + let c = cos(a * 3.0); + let u2 = u * c - v * s; + v = u * s + v * c; + u = u2; + } else { + u += a * 1.5 * k; + v += bb * 1.5 * k; + } + } + let n = fxn_shape(kind, fxn_layer(u, v, o, z, seed)); + let amp = data[o + 5u]; + if (kind == 7u) { + best = max(best, n * amp); + } + sum += n * amp * data[o + 6u]; + } + var raw = sum / max(P.f[2].w, 1e-6); + if (kind == 7u) { + raw = best; + } + var val = (raw - 0.5) * P.f[1].x + 0.5 + P.f[1].y; + let ov = P.u[1].w; + if (ov == 0u) { + val = clamp(val, 0.0, 1.0); + } else if (ov == 1u) { + val = clamp(0.5 + 0.5 * tanh(2.0 * (val - 0.5)) / tanh(1.0), 0.0, 1.0); + } else if (ov == 2u) { + let t = val - 2.0 * floor(val / 2.0); + val = select(t, 2.0 - t, t > 1.0); + } + if (P.u[1].z != 0u) { + val = 1.0 - val; + } + let op = P.f[1].z; + let s = vec4(vec3(val * op), op); + let orig = textureLoad(src, p, 0); + var r = s; + if (P.u[2].x != 255u) { + r = blend_pixel(P.u[2].x, orig, s, 0.5); + } + let bo = P.f[1].w; + textureStore(out, p, orig * bo + r * (1.0 - bo)); +} + +// ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs) + +// noise::phased_cycle with the phase's two seeds and smoothed fraction resolved on the CPU. +fn fxn_phased(x: u32, y: u32, sa: u32, sb: u32, t: f32) -> f32 { + let a = hash_noise(x, y, sa); + let b = hash_noise(x, y, sb); + return a + (b - a) * t; +} + +// u[0] = (squared, original alpha mode, overflow, seed a); u[1].x = seed b; f[0] = (amount, t). +@compute @workgroup_size(16, 16) +fn fxn_noise_alpha(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let k = P.f[0].x; + var n = fxn_phased(u32(p.x), u32(p.y), P.u[0].w, P.u[1].x, P.f[0].y); + if (P.u[0].x != 0u) { + n = n * n; + } + let s = fxn_unpremul(textureLoad(src, p, 0)); + let a = s.w; + var na: f32; + switch P.u[0].y { + case 0u: { + na = a + n * k; + } + case 1u: { + na = select(0.0, a + n * k, a > 0.0); + } + case 2u: { + na = a * (1.0 + (2.0 * n - 1.0) * k); + } + default: { + na = a + (2.0 * n - 1.0) * k * 4.0 * a * (1.0 - a); + } + } + if (P.u[0].z == 1u) { + let t = na - 2.0 * floor(na / 2.0); + na = select(t, 2.0 - t, t > 1.0); + } else if (P.u[0].z == 2u && !(na >= 0.0 && na <= 1.0)) { + na = na - floor(na); + } + na = clamp(na, 0.0, 1.0); + textureStore(out, p, vec4(s.xyz * na, na)); +} + +// Noise HLS: u[0] = (noise kind, seed); u[1], u[2].xy = per channel (hue, lightness, +// saturation) the phase's two seeds; f[0] = (hue, lightness, saturation, grain size); f[1].x = +// phase (Grain), f[1].y = smoothed fraction. +fn fxn_hls_seed(i: u32) -> u32 { + if (i < 4u) { + return P.u[1][i]; + } + return P.u[2][i - 4u]; +} + +fn fxn_hls_n(x: u32, y: u32, k: u32) -> f32 { + let s = P.u[0].y + k * 7919u; + switch P.u[0].x { + case 2u: { + let gs = P.f[0].w; + return clamp((value_noise(f32(x) / gs, f32(y) / gs, P.f[1].x, s) - 0.5) * 3.0, -1.0, 1.0); + } + case 1u: { + let v = fxn_phased(x, y, fxn_hls_seed(2u * k), fxn_hls_seed(2u * k + 1u), P.f[1].y) * 2.0 - 1.0; + return v * abs(v); + } + default: { + return fxn_phased(x, y, fxn_hls_seed(2u * k), fxn_hls_seed(2u * k + 1u), P.f[1].y) * 2.0 - 1.0; + } + } +} + +@compute @workgroup_size(16, 16) +fn fxn_noise_hls(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + let s0 = fxn_unpremul(px); + let a = s0.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let x = u32(p.x); + let y = u32(p.y); + var hsl = rgb_to_hsl(s0.xyz); + if (P.f[0].x != 0.0) { + hsl.x = fract_euclid(hsl.x + fxn_hls_n(x, y, 0u) * P.f[0].x * 0.5); + } + if (P.f[0].y != 0.0) { + hsl.z = clamp(hsl.z + fxn_hls_n(x, y, 1u) * P.f[0].y, 0.0, 1.0); + } + if (P.f[0].z != 0.0) { + hsl.y = clamp(hsl.y + fxn_hls_n(x, y, 2u) * P.f[0].z, 0.0, 1.0); + } + let c = hsl_to_rgb(hsl.x, hsl.y, hsl.z); + textureStore(out, p, vec4(c * a, a)); +} + +// ---------------------------------------------------------------- Remove Grain (noise::remove_grain) + +// The guide (util::guided_filter's g): the luminance of the premultiplied channels in every +// channel (Multichannel), or the channels themselves (u[0].x, Single Channel). +@compute @workgroup_size(16, 16) +fn fxn_gf_guide(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let v = textureLoad(src, p, 0); + if (P.u[0].x != 0u) { + textureStore(out, p, v); + } else { + textureStore(out, p, vec4(luminance(v.xyz))); + } +} + +// Guided filter arithmetic, S = src, A = aux, D = `data` (RGBA f32 rows of u[0].y pixels): +// u[0].x = 0: S − A·D, 1: S − A·A, 2: S / (A + f[0].x), 3: A − S·D, 4: S·D + A, 5: S·A, 6: S·S. +@compute @workgroup_size(16, 16) +fn fxn_gf(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let s = textureLoad(src, p, 0); + let a = textureLoad(aux, p, 0); + var d = vec4(0.0); + let mode = P.u[0].x; + if (mode == 0u || mode == 3u || mode == 4u) { + let i = (u32(p.y) * P.u[0].y + u32(p.x)) * 4u; + d = vec4(data[i], data[i + 1u], data[i + 2u], data[i + 3u]); + } + var o: vec4; + switch mode { + case 0u: { + o = s - a * d; + } + case 1u: { + o = s - a * a; + } + case 2u: { + o = s / (a + P.f[0].x); + } + case 3u: { + o = a - s * d; + } + case 4u: { + o = s * d + a; + } + case 5u: { + o = s * a; + } + default: { + o = s * s; + } + } + textureStore(out, p, o); +} + +// Texture: f + (o − f) × f[0].x on colour (src = filtered, aux = original). +@compute @workgroup_size(16, 16) +fn fxn_rg_texture(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let f = textureLoad(src, p, 0); + let o = textureLoad(aux, p, 0); + textureStore(out, p, vec4(f.xyz + (o.xyz - f.xyz) * P.f[0].x, f.w)); +} + +// Unsharp Mask on the planes: src = channels, aux = their blur; f[0] = (amount, threshold). +@compute @workgroup_size(16, 16) +fn fxn_rg_unsharp(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + var v = textureLoad(src, p, 0); + let l = textureLoad(aux, p, 0); + for (var c = 0; c < 3; c++) { + if (abs(v[c] - l[c]) > P.f[0].y) { + v[c] = v[c] + (v[c] - l[c]) * P.f[0].x; + } + } + textureStore(out, p, v); +} + +// Remove Grain's result: colour clamped to 0..alpha (alpha from aux = the original); with +// u[0].x the Preview viewing mode (noise::preview_compose): the original (aux) outside the +// region f[0] = (x0, x1, y0, y1), the box outline in f[1] where u[0].y. +@compute @workgroup_size(16, 16) +fn fxn_rg_out(@builtin(global_invocation_id) gid: vec3) { + let p = fxn_pixel(gid); + if (p.x < 0) { + return; + } + let o = textureLoad(aux, p, 0); + let a = o.w; + var v = vec4(clamp(textureLoad(src, p, 0).xyz, vec3(0.0), vec3(max(a, 0.0))), a); + if (P.u[0].x != 0u) { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let r = P.f[0]; + let inside = x >= r.x && x < r.y && y >= r.z && y < r.w; + if (!inside) { + v = o; + } + if (P.u[0].y != 0u) { + let nx = abs(x - r.x) < 0.75 || abs(x - r.y) < 0.75; + let ny = abs(y - r.z) < 0.75 || abs(y - r.w) < 0.75; + if ((nx && y >= r.z - 0.75 && y < r.w + 0.75) || (ny && x >= r.x - 0.75 && x < r.y + 0.75)) { + v = vec4(P.f[1].xyz, 1.0); + } + } + } + textureStore(out, p, v); +} diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl new file mode 100644 index 000000000..bc119e8cd --- /dev/null +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -0,0 +1,925 @@ +// GPU effects (stylize and distort family): see src/fx_stylize.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation. Every name here is prefixed `fxs_` (the +// family files share one module). Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer +// pixels, as in util::remap. Per-column / per-row tables in `data` hold coordinates the CPU +// computes in f64 (anything with a floor or a wrap), so tile and wrap boundaries match exactly. + +const FXS_TAU: f32 = 6.2831855; +const FXS_PI: f32 = 3.1415927; +const FXS_HALF_PI: f32 = 1.5707964; + +fn fxs_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +fn fxs_centre(p: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); +} + +// util::unpremul: straight colour (zero below alpha 1e-6) and alpha (≥ 0). +fn fxs_unpremul(px: vec4) -> vec4 { + let a = px.w; + if (a > 1e-6) { + return vec4(fxs_qdiv(px.xyz, a), a); + } + return vec4(0.0, 0.0, 0.0, max(a, 0.0)); +} + +// util::smoothstep. +fn fxs_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +// x / a rounded like the CPU's IEEE division (GPU division may be off by an ulp, which flips +// thresholds on 8 / 16 bpc colours that sit exactly on them): one residual correction. +fn fxs_qdiv(x: vec3, a: f32) -> vec3 { + let q = x / a; + let r = fma(-q, vec3(a), x); + return q + r / a; +} + +fn fxs_rem2(i: i32) -> i32 { + return ((i % 2) + 2) % 2; +} + +// ---------------------------------------------------------------- per-pixel colour effects +// u[0].x = mode: +// 1 Posterize (misc::posterize): f[0].x = levels − 1 +// 2 Threshold (misc::threshold): f[0].x = level +// 3 CC Threshold: u[0].yz = (channel, invert); f[0].xy = (threshold, blend) +// 4 CC Threshold RGB: u[0].yzw = invert R, G, B; f[0] = (thresholds, blend) +// 5 Strobe Light on colour: u[0].y = operator; f[0] = (strobe colour, blend) +// 6 Strobe Light transparent (Image::scale_alpha): f[0].x = factor +// 7 CC Vignette: f[0] = (cx, cy, distance, amount); f[1].x = pin highlights + +fn fxs_bin(v: f32, t: f32, inv: bool) -> f32 { + return select(0.0, 1.0, (v >= t) != inv); +} + +fn fxs_strobe_op(op: u32, v: f32, k: f32) -> f32 { + switch op { + case 1u: { return v + k; } + case 2u: { return max(v - k, 0.0); } + case 3u: { return v * k; } + case 4u: { return abs(v - k); } + case 5u: { return 1.0 - (1.0 - v) * (1.0 - k); } + case 6u: { return max(v, k); } + case 7u: { return min(v, k); } + default: { return k; } + } +} + +@compute @workgroup_size(16, 16) +fn fxs_point(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let mode = P.u[0].x; + let a = px.w; + if (mode == 6u) { + textureStore(out, p, px * P.f[0].x); + return; + } + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + switch mode { + case 1u: { + let lv = P.f[0].x; + let c = clamp(fxs_qdiv(px.xyz, a), vec3(0.0), vec3(1.0)) * lv; + let o = vec3(round_away(c.x), round_away(c.y), round_away(c.z)) / lv; + textureStore(out, p, vec4(o * a, a)); + } + case 2u: { + let v = select(0.0, 1.0, luminance(fxs_qdiv(px.xyz, a)) >= P.f[0].x); + textureStore(out, p, vec4(vec3(v) * a, a)); + } + case 3u: { + let u = fxs_unpremul(px); + let c = u.xyz; + let t = P.f[0].x; + let blend = P.f[0].y; + let inv = P.u[0].z != 0u; + var o = c; + var na = u.w; + switch P.u[0].y { + case 1u: { + o = vec3(fxs_bin(c.x, t, inv), fxs_bin(c.y, t, inv), fxs_bin(c.z, t, inv)); + } + case 2u: { + o = vec3(fxs_bin(rgb_to_hsl(c).y, t, inv)); + } + case 3u: { + na = fxs_bin(u.w, t, inv); + } + default: { + o = vec3(fxs_bin(luminance(c), t, inv)); + } + } + o = o + (c - o) * blend; + na = na + (u.w - na) * blend; + textureStore(out, p, vec4(o * na, na)); + } + case 4u: { + let c = fxs_qdiv(px.xyz, a); + let t = P.f[0].xyz; + let o = vec3(fxs_bin(c.x, t.x, P.u[0].y != 0u), fxs_bin(c.y, t.y, P.u[0].z != 0u), fxs_bin(c.z, t.z, P.u[0].w != 0u)); + let r = o + (c - o) * P.f[0].w; + textureStore(out, p, vec4(r * a, a)); + } + case 5u: { + let c = px.xyz / a; + let k = P.f[0].xyz; + let op = P.u[0].y; + let o = vec3(fxs_strobe_op(op, c.x, k.x), fxs_strobe_op(op, c.y, k.y), fxs_strobe_op(op, c.z, k.z)); + let r = o + (c - o) * P.f[0].w; + textureStore(out, p, vec4(r * a, a)); + } + case 7u: { + let u = fxs_unpremul(px); + let q = fxs_centre(p); + let dx = q.x - P.f[0].x; + let dy = q.y - P.f[0].y; + let r = sqrt(dx * dx + dy * dy) / P.f[0].z; + let f0 = 1.0 / (1.0 + r * r); + let f = f0 * f0; + var k = 1.0 - P.f[0].w * (1.0 - f); + let pin = P.f[1].x; + if (pin > 0.0) { + let l = clamp(luminance(u.xyz), 0.0, 1.0); + k += (1.0 - k) * pin * l; + } + textureStore(out, p, vec4(max(u.xyz * k, vec3(0.0)) * u.w, u.w)); + } + default: { + textureStore(out, p, px); + } + } +} + +// ---------------------------------------------------------------- inverse-mapped warps +// u[0].x = mode (all sample bilinear, transparent outside, unless noted): +// 1 Mirror (distort::mirror): f[0] = (cx, cy, nx, ny) +// 2 Spherize (distort::spherize): f[0] = (cx, cy, radius) +// 3 Polar Coordinates (distort::polar, edges repeated): u[0].y = to polar; +// f[0] = (w, h, cx, cy); f[1] = (rmax, interpolation) +// 4 CC Slant (distort3::cc_slant): f[0] = (floor x, floor y, 1 / height, slant); f[1].x = x scale +// 5 CC Smear (distort3::cc_smear): f[0] = (from x, from y, dx, dy); f[1] = (len², reach, radius) +// 6 CC Split (distort3::split_impl): f[0] = (ax, ay, ux, uy); f[1] = (len, split 1, split 2, reach) +// 7 Optics Compensation (distort2::optics_compensation): u[0].y = reverse; +// f[0] = (cx, cy, fit, f); f[1] = (reference radius, half FOV) +// 8 CC Kaleida (stylize2::kaleida, edges repeated): u[0].y = mirror; +// f[0] = (cx, cy, size, rotation); f[1].x = segment angle +// 9 Liquify (distort4::liquify): u[0].xy = (_, mesh nx, mesh ny); f[0] = (cell, mesh offset x, y, +// percentage); f[1] = (scale, buffer offset x, y); data = mesh offsets (x, y) per node +// 10 Twirl (Legacy) (distort3::twirl_legacy): f[0] = (cx, cy, radius, angle) +// 11 CC Ripple Pulse (distort3::cc_ripple_pulse): u[0].y = render bump; f[0] = (cx, cy, front, +// speed); f[1] = (time span, wavelength, height) +// 12 CC Power Pin, full perspective (distort3::cc_power_pin): f[2..5] = inverse of the +// square → quad matrix (rows); f[5] = source rect in layer pixels (x0, y0, w, h); +// f[6] = (scale, buffer offset x, y) + +fn fxs_mesh_d(i: u32, j: u32) -> vec2 { + let k = (j * P.u[0].y + i) * 2u; + return vec2(data[k], data[k + 1u]); +} + +// distort3::cc_ripple_pulse's wave. +fn fxs_pulse(r: f32) -> f32 { + let front = P.f[0].z; + let speed = P.f[0].w; + let span = P.f[1].x; + let age = (front - r) / max(speed, 1e-9); + if (age < 0.0 || age > span) { + return 0.0; + } + let fade = 1.0 - age / span; + return sin(FXS_TAU * (r - front) / P.f[1].y) * fade; +} + +// distort4::Mesh::at. +fn fxs_mesh_at(x: f32, y: f32) -> vec2 { + let cell = P.f[0].x; + let nx = P.u[0].y; + let ny = P.u[0].z; + let fx = x / cell; + let fy = y / cell; + if (fx < 0.0 || fy < 0.0 || fx > f32(nx - 1u) || fy > f32(ny - 1u)) { + return vec2(0.0); + } + let x0 = min(u32(floor(fx)), nx - 2u); + let y0 = min(u32(floor(fy)), ny - 2u); + let x1 = min(x0 + 1u, nx - 1u); + let y1 = min(y0 + 1u, ny - 1u); + let tx = fx - f32(x0); + let ty = fy - f32(y0); + let a = fxs_mesh_d(x0, y0); + let b = fxs_mesh_d(x1, y0); + let c = fxs_mesh_d(x0, y1); + let d = fxs_mesh_d(x1, y1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +@compute @workgroup_size(16, 16) +fn fxs_warp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let q = fxs_centre(p); + let x = q.x; + let y = q.y; + var s = q; + var clamped = false; + switch P.u[0].x { + case 1u: { + let n = P.f[0].zw; + let d = (x - P.f[0].x) * n.x + (y - P.f[0].y) * n.y; + if (d > 0.0) { + s = vec2(x - 2.0 * d * n.x, y - 2.0 * d * n.y); + } + } + case 2u: { + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = x - c.x; + let dy = y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d < r && d != 0.0) { + let nd = d / r; + let k = (asin(nd) / FXS_HALF_PI) / nd; + s = vec2(c.x + dx * k, c.y + dy * k); + } + } + case 3u: { + clamped = true; + let w = P.f[0].x; + let h = P.f[0].y; + let cx = P.f[0].z; + let cy = P.f[0].w; + let rmax = P.f[1].x; + var pp: vec2; + if (P.u[0].y != 0u) { + let dx = x - cx; + let dy = y - cy; + let a0 = atan2(dx, -dy); + let a = a0 - floor(a0 / FXS_TAU) * FXS_TAU; + let r = sqrt(dx * dx + dy * dy) / rmax; + pp = vec2(a / FXS_TAU * w, h - r * h); + } else { + let a = x / w * FXS_TAU; + let r = (h - y) / h * rmax; + pp = vec2(cx + r * sin(a), cy - r * cos(a)); + } + let amt = P.f[1].y; + s = vec2(x + (pp.x - x) * amt, y + (pp.y - y) * amt); + } + case 4u: { + let fl = P.f[0].xy; + s = vec2(fl.x + (x - fl.x - (fl.y - y) * P.f[0].w) / P.f[1].x, fl.y + (y - fl.y) * P.f[0].z); + } + case 5u: { + let fr = P.f[0].xy; + let dx = P.f[0].z; + let dy = P.f[0].w; + let t = clamp(((x - fr.x) * dx + (y - fr.y) * dy) / P.f[1].x, 0.0, 1.0); + let px = fr.x + dx * t; + let py = fr.y + dy * t; + let d = sqrt((x - px) * (x - px) + (y - py) * (y - py)); + let w0 = max(1.0 - d / P.f[1].z, 0.0); + let w = w0 * w0 * (3.0 - 2.0 * w0); + let k = P.f[1].y * w * t; + s = vec2(x - dx * k, y - dy * k); + } + case 6u: { + let a = P.f[0].xy; + let ux = P.f[0].z; + let uy = P.f[0].w; + let nx = -uy; + let ny = ux; + let len = P.f[1].x; + let reach = P.f[1].w; + let rx = x - a.x; + let ry = y - a.y; + let t = (rx * ux + ry * uy) / len; + let sd = rx * nx + ry * ny; + if (t >= 0.0 && t <= 1.0) { + let amt = select(P.f[1].z, P.f[1].y, sd >= 0.0); + let g = amt * reach * sin(FXS_PI * t); + let ad = abs(sd); + if (ad < g) { + textureStore(out, p, vec4(0.0)); + return; + } + let push = g * max(1.0 - (ad - g) / reach, 0.0); + let ns = select(-1.0, 1.0, sd >= 0.0) * (ad - push); + s = vec2(x + nx * (ns - sd), y + ny * (ns - sd)); + } + } + case 7u: { + let c = P.f[0].xy; + let fit = P.f[0].z; + let f = P.f[0].w; + let r_ref = P.f[1].x; + let half = P.f[1].y; + let dx = x - c.x; + let dy = y - c.y; + let r = sqrt(dx * dx + dy * dy) * fit; + if (r >= 1e-9) { + var rs: f32; + if (P.u[0].y != 0u) { + rs = atan(r / f) / half * r_ref; + } else { + let th = r / r_ref * half; + if (th >= FXS_HALF_PI - 1e-4) { + textureStore(out, p, vec4(0.0)); + return; + } + rs = f * tan(th); + } + let k = rs / r * fit; + s = vec2(c.x + dx * k, c.y + dy * k); + } + } + case 8u: { + clamped = true; + let c = P.f[0].xy; + let rot = P.f[0].w; + let seg = P.f[1].x; + let dx = x - c.x; + let dy = y - c.y; + let r = sqrt(dx * dx + dy * dy) / P.f[0].z; + let a0 = atan2(dy, dx) - rot; + var a = a0 - floor(a0 / seg) * seg; + if (P.u[0].y != 0u && a > seg * 0.5) { + a = seg - a; + } + s = vec2(c.x + r * cos(a + rot), c.y + r * sin(a + rot)); + } + case 9u: { + let scale = P.f[1].x; + let o = P.f[1].yz; + let lx = (x - o.x) / scale; + let ly = (y - o.y) / scale; + let d = fxs_mesh_at(lx - P.f[0].y, ly - P.f[0].z); + if (d.x == 0.0 && d.y == 0.0) { + textureStore(out, p, textureLoad(src, p, 0)); + return; + } + let pct = P.f[0].w; + s = vec2((lx + d.x * pct) * scale + o.x, (ly + d.y * pct) * scale + o.y); + } + case 10u: { + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = x - c.x; + let dy = y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d < r) { + let a = -P.f[0].w * (1.0 - d / r); + let sn = sin(a); + let co = cos(a); + s = vec2(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co); + } + } + case 11u: { + let c = P.f[0].xy; + let dx = x - c.x; + let dy = y - c.y; + let r = sqrt(dx * dx + dy * dy); + let height = P.f[1].z; + let d = fxs_pulse(r) * height; + var u = vec2(0.0); + if (r > 1e-9) { + u = vec2(dx / r, dy / r); + } + var o = sample_bilinear(src, x - u.x * d, y - u.y * d); + if (P.u[0].y != 0u && d != 0.0) { + let slope = (fxs_pulse(r + 0.5) - fxs_pulse(r - 0.5)) * height; + let k = clamp(1.0 - slope * 0.5, 0.0, 2.0); + o = vec4(o.xyz * k, o.w); + } + textureStore(out, p, o); + return; + } + case 12u: { + let r0 = P.f[2]; + let r1 = P.f[3]; + let r2 = P.f[4]; + var u = r0.x * x + r0.y * y + r0.z; + var v = r1.x * x + r1.y * y + r1.z; + let w = r2.x * x + r2.y * y + r2.z; + if (w != 1.0 && w != 0.0) { + u = u / w; + v = v / w; + } + if (u < -1e-9 || u > 1.0 + 1e-9 || v < -1e-9 || v > 1.0 + 1e-9) { + textureStore(out, p, vec4(0.0)); + return; + } + let rc = P.f[5]; + let lx = rc.x + u * rc.z; + let ly = rc.y + v * rc.w; + let o = P.f[6]; + s = vec2(lx * o.x + o.y, ly * o.x + o.z); + } + default: {} + } + if (clamped) { + textureStore(out, p, sample_bilinear_clamped(src, s.x, s.y)); + } else { + textureStore(out, p, sample_bilinear(src, s.x, s.y)); + } +} + +// ---------------------------------------------------------------- CC Flo Motion (distort4::flo_motion) + +// One knot's pull at layer point q: f[k] = (knot x, knot y, amount, _). +fn fxs_flo_knot(q: vec2, k: vec4) -> vec2 { + if (k.z == 0.0) { + return q; + } + let d = q - k.xy; + let r = sqrt(d.x * d.x + d.y * d.y) / P.f[2].y; + let w = powz(1.0 - min(r, 1.0), P.f[2].x); + let s = max(1.0 - clamp(k.z, -4.0, 0.95) * w, 0.02); + return k.xy + d * s; +} + +// u[0] = (supersampling, tile edges); f[0], f[1] = knots; f[2] = (falloff, radius, layer w, +// layer h); f[3] = (scale, buffer offset x, y) +@compute @workgroup_size(16, 16) +fn fxs_flomotion(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let ss = P.u[0].x; + let scale = P.f[3].x; + let off = P.f[3].yz; + let lw = P.f[2].z; + let lh = P.f[2].w; + var acc = vec4(0.0); + for (var sy = 0u; sy < ss; sy++) { + for (var sx = 0u; sx < ss; sx++) { + let px = f32(p.x) + (f32(sx) + 0.5) / f32(ss); + let py = f32(p.y) + (f32(sy) + 0.5) / f32(ss); + var q = vec2((px - off.x) / scale, (py - off.y) / scale); + q = fxs_flo_knot(q, P.f[0]); + q = fxs_flo_knot(q, P.f[1]); + if (P.u[0].y != 0u) { + q = vec2(q.x - floor(q.x / lw) * lw, q.y - floor(q.y / lh) * lh); + } + acc += sample_bilinear(src, q.x * scale + off.x, q.y * scale + off.y); + } + } + textureStore(out, p, acc / f32(ss * ss)); +} + +// ---------------------------------------------------------------- separable table warps +// The source point is (data[x], data[w + y]) (computed in f64 per column and per row), then +// mixed with the original: out = sample + (orig − sample) · blend (Offset, CC Tiler). +// u[0] = (buffer width, repeat edges); f[0].x = blend +@compute @workgroup_size(16, 16) +fn fxs_table(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let sx = data[u32(p.x)]; + let sy = data[w + u32(p.y)]; + var s: vec4; + if (P.u[0].y != 0u) { + s = sample_bilinear_clamped(src, sx, sy); + } else { + s = sample_bilinear(src, sx, sy); + } + let o = textureLoad(src, p, 0); + textureStore(out, p, s + (o - s) * P.f[0].x); +} + +// CC Griddler (distort2::cc_griddler). data: per column (tile centre x, local x), per row +// (tile centre y, local y). u[0] = (buffer width, cut tiles); f[0] = (cos, sin, h scale, v scale); +// f[1].x = cut half size (0.45 · tile) +@compute @workgroup_size(16, 16) +fn fxs_griddler(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let tcx = data[2u * x]; + let lx = data[2u * x + 1u]; + let tcy = data[2u * w + 2u * y]; + let ly = data[2u * w + 2u * y + 1u]; + if (P.u[0].y != 0u && max(abs(lx), abs(ly)) > P.f[1].x) { + textureStore(out, p, vec4(0.0)); + return; + } + let cr = P.f[0].x; + let sr = P.f[0].y; + let rx = lx * cr + ly * sr; + let ry = -lx * sr + ly * cr; + textureStore(out, p, sample_bilinear(src, tcx + rx / P.f[0].z, tcy + ry / P.f[0].w)); +} + +// Motion Tile (stylize2::motion_tile) into a new output size. data: per output column (tile +// index, fraction) of tx, then per row of ty. u[0] = (output width, horizontal phase shift, +// mirror edges); f[0] = (phase, layer w · scale, layer h · scale); f[1].xy = source buffer offset +@compute @workgroup_size(16, 16) +fn fxs_motiontile(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let ixc = data[2u * x]; + let fxc = data[2u * x + 1u]; + let iyr = data[2u * w + 2u * y]; + let fyr = data[2u * w + 2u * y + 1u]; + let phase = P.f[0].x; + var i = ixc; + var fx = fxc; + var j = iyr; + var fy = fyr; + if (P.u[0].y != 0u) { + let t = fxc + phase * iyr; + i = ixc + floor(t); + fx = t - floor(t); + } else { + let t = fyr + phase * ixc; + j = iyr + floor(t); + fy = t - floor(t); + } + if (P.u[0].z != 0u) { + if (fxs_rem2(i32(i)) == 1) { + fx = 1.0 - fx; + } + if (fxs_rem2(i32(j)) == 1) { + fy = 1.0 - fy; + } + } + textureStore(out, p, sample_bilinear(src, fx * P.f[0].y + P.f[1].x, fy * P.f[0].z + P.f[1].y)); +} + +// CC RepeTile (stylize2::repetile) into a new output size. data: per output column (tile index, +// position in the tile), then per row. u[0] = (output width, tiling); f[0] = layer rect (x0, y0, w, h) +@compute @workgroup_size(16, 16) +fn fxs_repetile(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let ix = i32(data[2u * x]); + var fx = data[2u * x + 1u]; + let iy = i32(data[2u * w + 2u * y]); + var fy = data[2u * w + 2u * y + 1u]; + let odd = fxs_rem2(ix + iy) == 1; + var flip_x = false; + var flip_y = false; + switch P.u[0].y { + case 1u: { + flip_x = fxs_rem2(ix) == 1; + flip_y = fxs_rem2(iy) == 1; + } + case 2u: { + flip_x = odd; + } + case 3u: { + flip_y = odd; + } + case 4u: { + flip_x = odd; + flip_y = odd; + } + default: {} + } + let r = P.f[0]; + if (flip_x) { + fx = r.z - fx; + } + if (flip_y) { + fy = r.w - fy; + } + textureStore(out, p, sample_bilinear(src, r.x + fx, r.y + fy)); +} + +// ---------------------------------------------------------------- Magnify (distort2::magnify) +// data: per column (dx, sx, floor(sx) + 0.5), then per row (dy, sy, floor(sy) + 0.5). +// u[0] = (buffer width, square, scaling, blend: 0 none, 1 normal, 2 blend mode); +// u[1] = (blend mode id, seed); f[0] = (size, feather, opacity, magnification); f[1].x = size − feather +@compute @workgroup_size(16, 16) +fn fxs_magnify(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let bw = P.u[0].x; + let x = u32(p.x); + let y = u32(p.y); + let dx = data[3u * x]; + let sx = data[3u * x + 1u]; + let nx = data[3u * x + 2u]; + let dy = data[3u * bw + 3u * y]; + let sy = data[3u * bw + 3u * y + 1u]; + let ny = data[3u * bw + 3u * y + 2u]; + var d: f32; + if (P.u[0].y != 0u) { + d = max(abs(dx), abs(dy)); + } else { + d = sqrt(dx * dx + dy * dy); + } + let size = P.f[0].x; + let feather = P.f[0].y; + var w: f32; + if (feather > 0.01) { + w = 1.0 - fxs_smoothstep(P.f[1].x, size, d); + } else { + w = clamp(size - d + 0.5, 0.0, 1.0); + } + w = w * P.f[0].z; + let px = textureLoad(src, p, 0); + let blend = P.u[0].w; + if (w <= 0.0) { + if (blend == 0u) { + textureStore(out, p, vec4(0.0)); + } else { + textureStore(out, p, px); + } + return; + } + var m: vec4; + switch P.u[0].z { + case 1u: { + m = sample_bicubic(src, sx, sy); + } + case 2u: { + let seed = P.u[1].y; + let k = max(1.0 - 1.0 / P.f[0].w, 0.0); + let j1 = (hash_noise(x, y ^ (1u << 20u), seed) - 0.5) * k; + let j2 = (hash_noise(x, y ^ (2u << 20u), seed) - 0.5) * k; + m = sample_bilinear(src, nx + j1, ny + j2); + } + default: { + m = sample_bilinear(src, nx, ny); + } + } + if (blend == 0u) { + textureStore(out, p, m * w); + } else if (blend == 1u) { + textureStore(out, p, px + (m - px) * w); + } else { + textureStore(out, p, blend_pixel(P.u[1].x, px, m * w, 0.5)); + } +} + +// ---------------------------------------------------------------- Scatter (stylize2::scatter) +// u[0] = (grain, seed); f[0].x = amount +@compute @workgroup_size(16, 16) +fn fxs_scatter(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let grain = P.u[0].x; + let seed = P.u[0].y; + let amt = P.f[0].x; + let x = u32(p.x); + let y = u32(p.y); + var dx = 0.0; + var dy = 0.0; + if (grain != 2u) { + dx = (hash_noise(x, y, seed) - 0.5) * 2.0 * amt; + } + if (grain != 1u) { + dy = (hash_noise(x, y, seed ^ 0x5bd1u) - 0.5) * 2.0 * amt; + } + textureStore(out, p, tex_get(src, i32(round_away(f32(p.x) + dx)), i32(round_away(f32(p.y) + dy)))); +} + +// ---------------------------------------------------------------- Brush Strokes (stylize2::brush_strokes) +// data: cell column per x (w), cell row per y (h), then per cell (dx, dy, length, jitter, samples, +// painted). u[0] = (buffer width, cell columns, paint surface, buffer height); f[0].x = blend +@compute @workgroup_size(16, 16) +fn fxs_brush(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].w; + let cx = u32(data[u32(p.x)]); + let cy = u32(data[w + u32(p.y)]); + let base = w + h + (cy * P.u[0].y + cx) * 6u; + let dx = data[base]; + let dy = data[base + 1u]; + let l = data[base + 2u]; + let jit = data[base + 3u]; + let n = u32(data[base + 4u]); + let m = data[base + 5u]; + let q = fxs_centre(p); + let bx = q.x - dy * jit; + let by = q.y + dx * jit; + var acc = vec4(0.0); + for (var i = 0u; i < n; i++) { + var t = 0.0; + if (n != 1u) { + t = (f32(i) / f32(n - 1u) - 0.5) * l; + } + acc += sample_bilinear_clamped(src, bx + dx * t, by + dy * t); + } + let stroke = acc / f32(n); + let orig = textureLoad(src, p, 0); + let surface = P.u[0].z; + var base_px = orig; + if (surface == 1u) { + base_px = vec4(0.0); + } else if (surface == 2u) { + base_px = vec4(1.0); + } else if (surface == 3u) { + base_px = vec4(0.0, 0.0, 0.0, 1.0); + } + var painted: vec4; + if (surface == 2u || surface == 3u) { + let k = 1.0 - stroke.w * m; + painted = stroke * m + base_px * k; + } else { + painted = base_px + (stroke - base_px) * m; + } + textureStore(out, p, painted + (orig - painted) * P.f[0].x); +} + +// ---------------------------------------------------------------- Roughen Edges (stylize2::roughen_edges) + +// noise::fbm. +fn fxs_fbm(u: f32, v: f32, z: f32, seed: u32, octaves0: f32) -> f32 { + let octaves = clamp(octaves0, 1.0, 20.0); + let n = u32(ceil(octaves)); + let frac = octaves - floor(octaves); + var sum = 0.0; + var norm = 0.0; + var amp = 1.0; + var f = 1.0; + for (var o = 0u; o < n; o++) { + var w = 1.0; + if (o + 1u == n && frac > 0.0) { + w = frac; + } + sum += value_noise(u * f, v * f, z + f32(o) * 7.31, seed + o) * amp * w; + norm += amp * w; + amp *= 0.5; + f *= 2.0; + } + return sum / max(norm, 1e-6); +} + +// aux = the (blurred) alpha in .w. u[0] = (seed, cycle evolution, coloured); +// f[0] = (offset x, offset y, scale · stretch x, scale · stretch y); f[1] = (frequency, evolution, +// cycle, cycle weight); f[2] = (octaves, influence, influence multiplier, sharpness); +// f[3] = (edge colour, sharpness multiplier) +@compute @workgroup_size(16, 16) +fn fxs_roughen(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + if (px.w <= 0.0) { + textureStore(out, p, px); + return; + } + let dv = textureLoad(aux, p, 0).w; + let fm = P.f[1].x; + let nx = (f32(p.x) + 0.5 - P.f[0].x) / P.f[0].z * fm; + let ny = (f32(p.y) + 0.5 - P.f[0].y) / P.f[0].w * fm; + let seed = P.u[0].x; + let evo = P.f[1].y; + let oct = P.f[2].x; + var n = fxs_fbm(nx, ny, evo, seed, oct); + if (P.u[0].y != 0u) { + n += (fxs_fbm(nx, ny, evo - P.f[1].z, seed, oct) - n) * P.f[1].w; + } + let infl = P.f[2].y; + let s = dv - 0.5 + (n - 0.5) * infl * P.f[2].z * 0.5; + let m = clamp(s * P.f[2].w * P.f[3].w * 4.0 + 0.5, 0.0, 1.0); + let u = fxs_unpremul(px); + var c = u.xyz; + if (P.u[0].z != 0u) { + let band = 1.0 - fxs_smoothstep(0.5, 0.9, dv + (n - 0.5) * infl * 0.5); + c = c + (P.f[3].xyz - c) * band; + } + let a = u.w * m; + textureStore(out, p, vec4(c * a, a)); +} + +// ---------------------------------------------------------------- Texturize (stylize2::texturize) + +// The texture layer placed into the buffer (transition::place_layer) as luminance +// (Plane::luma). aux = the texture layer's buffer; data: per column x, then per row y in its +// pixels. u[0].x = buffer width +@compute @workgroup_size(16, 16) +fn fxs_tex_luma(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let w = P.u[0].x; + let t = sample_bilinear(aux, data[u32(p.x)], data[w + u32(p.y)]); + let l = luminance(fxs_unpremul(t).xyz); + textureStore(out, p, vec4(l)); +} + +// Relief shading. aux = the blurred texture luminance; f[0] = (light x, light y, contrast) +@compute @workgroup_size(16, 16) +fn fxs_texturize(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let gx = (tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x) * 0.5; + let gy = (tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x) * 0.5; + let shade = max(1.0 - P.f[0].z * 4.0 * (gx * P.f[0].x + gy * P.f[0].y), 0.0); + let px = textureLoad(src, p, 0); + textureStore(out, p, vec4(px.xyz * shade, px.w)); +} + +// ---------------------------------------------------------------- Glow (misc::glow) extras + +// Bright pass with the Arbitrary Map: as `glow_bright` (same f[0] / u[0] layout, see +// kernels.wgsl) with the red, green and blue curves at data offsets f[1].xyz (−1 = identity). +@compute @workgroup_size(16, 16) +fn fxs_glow_map(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let thr = P.f[0].x; + var l = luminance(px.xyz / a); + if (P.u[0].y != 0u) { + l = a; + } + let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0); + let t = glow_ab_t(l); + let c = vec3(curve_eval(P.f[1].x, t), curve_eval(P.f[1].y, t), curve_eval(P.f[1].z, t)); + textureStore(out, p, vec4(c * k * a, k * a)); +} + +// On Top with another Glow Operation: blend_pixel(mode, original, glow · intensity, 0). +// aux = the blurred glow; u[0].x = blend mode id; f[0].x = intensity +@compute @workgroup_size(16, 16) +fn fxs_glow_op(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + let g = textureLoad(aux, p, 0) * P.f[0].x; + var o = blend_pixel(P.u[0].x, textureLoad(src, p, 0), g, 0.0); + o.w = clamp(o.w, 0.0, 1.0); + textureStore(out, p, o); +} + +// ---------------------------------------------------------------- accumulation (Transform motion blur) + +// out = src + aux +@compute @workgroup_size(16, 16) +fn fxs_add(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + textureStore(out, p, textureLoad(src, p, 0) + textureLoad(aux, p, 0)); +} + +// out = src / f[0].x +@compute @workgroup_size(16, 16) +fn fxs_div(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!fxs_inside(p)) { + return; + } + textureStore(out, p, textureLoad(src, p, 0) / P.f[0].x); +} diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl new file mode 100644 index 000000000..39db34588 --- /dev/null +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -0,0 +1,729 @@ +// GPU effects (tonal and colour correction family): see src/fx_tone.rs. Each operation mirrors +// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed +// `fxt_` (the family files share one module). + +fn fxt_straight(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +fn fxt_mix(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// f32::rem_euclid(360.0). +fn fxt_rem360(x: f32) -> f32 { + let r = x - 360.0 * floor(x / 360.0); + return select(r, 0.0, r >= 360.0); +} + +// Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { + let o = i * 6u; + if (data[o + 5u] != 0.0) { + return v; + } + var t = (v - data[o]) / max(data[o + 1u] - data[o], 1e-6); + if (clip_b) { + t = max(t, 0.0); + } + if (clip_w) { + t = min(t, 1.0); + } + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); +} + +// color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). +fn fxt_level(o: u32, v: f32) -> f32 { + var t = (v - data[o]) / max(data[o + 1u] - data[o], 1e-6); + if (data[o + 5u] != 0.0) { + t = max(t, 0.0); + } + if (data[o + 6u] != 0.0) { + t = min(t, 1.0); + } + t = powz(max(t, 0.0), 1.0 / data[o + 2u]); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; +} + +fn fxt_level_ident(o: u32) -> bool { + return data[o] == 0.0 && data[o + 1u] == 1.0 && data[o + 2u] == 1.0 && data[o + 3u] == 0.0 && data[o + 4u] == 1.0; +} + +fn fxt_adjust_sat(s: f32, sat: f32) -> f32 { + if (sat >= 0.0) { + return s + (1.0 - s) * sat * min(s, 1.0); + } + return s * (1.0 + sat); +} + +fn fxt_adjust_light(l: f32, light: f32) -> f32 { + if (light >= 0.0) { + return l + (1.0 - l) * light; + } + return l * (1.0 + light); +} + +// HueRange::weight for range `r` (data: start, end, fall start, fall end, hue, sat, light). +fn fxt_range_weight(r: u32, h: f32) -> f32 { + let o = r * 7u; + let width = fxt_rem360(data[o + 1u] - data[o]); + let d = fxt_rem360(h - data[o]); + if (d <= width) { + return 1.0; + } + let after = d - width; + let before = 360.0 - d; + var wa = 0.0; + if (data[o + 3u] > 0.0) { + wa = 1.0 - after / data[o + 3u]; + } + var wb = 0.0; + if (data[o + 2u] > 0.0) { + wb = 1.0 - before / data[o + 2u]; + } + return clamp(max(wa, wb), 0.0, 1.0); +} + +// color3::colour_match. +fn fxt_colour_match(c: vec3, key: vec3, key_hue: f32, mode: u32, tol: f32, soft: f32) -> f32 { + var d: f32; + if (mode == 0u) { + d = sqrt(dot(c - key, c - key)) / sqrt(3.0); + } else if (mode == 1u) { + let hs = rgb_to_hsl(c); + let dh0 = abs(hs.x - key_hue); + let dh = min(dh0, 1.0 - dh0) * 2.0; + d = select(dh, 1.0, hs.y < 0.02); + } else { + let sa = max(c.x + c.y + c.z, 1e-6); + let sk = max(key.x + key.y + key.z, 1e-6); + let a = vec2(c.x / sa, c.y / sa); + let k = vec2(key.x / sk, key.y / sk); + d = sqrt(dot(a - k, a - k)) * 1.5; + } + if (d <= tol) { + return 1.0; + } + if (soft <= 1e-6) { + return 0.0; + } + return clamp(1.0 - (d - tol) / soft, 0.0, 1.0); +} + +// color3::knee. +fn fxt_knee(v: f32, start: f32, limit: f32) -> f32 { + if (v <= start || limit <= start) { + return min(v, limit); + } + let r = limit - start; + return start + r * (1.0 - exp(-(v - start) / r)); +} + +// PS Arbitrary Map lookup (map in data, n entries; f[0].x = phase offset in entries). +fn fxt_lookup(v: f32, n: u32) -> f32 { + let nf = f32(n); + var x = clamp(v, 0.0, 1.0) * (nf - 1.0) + P.f[0].x; + x = x - nf * floor(x / nf); + let fl = floor(x); + let i0 = u32(fl) % n; + let i1 = select(i0, i0 + 1u, i0 + 1u < n); + let t = x - fl; + return data[i0] + (data[i1] - data[i0]) * t; +} + +fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 { + let i = min(u32(round_away(clamp(v, 0.0, 1.0) * f32(n - 1u))), n - 1u); + return data[table * n + i]; +} + +// Per-pixel colour corrections. u[0].x = op (layouts in src/fx_tone.rs): +// 1 Levels (Individual Controls) 2 Gamma/Pedestal/Gain 3 Photo Filter 4 Change to Color +// 5 Leave Color 6 Change Color 7 Broadcast Colors 8 Color Balance (HLS) 9 CC Toner +// 10 CC Color Offset 11 CC Kernel 12 PS Arbitrary Map 13 Video Limiter +// 14 Hue/Saturation with colour ranges 15 Levels with channel controls +// 16 Auto Levels / Contrast / Color apply 17 Equalize apply 18 Invert (HLS / YIQ channels) +// 19 luminance plane 20 Shadow/Highlight (aux = (shadow base, highlight base)) +// 21 pack src.x, aux.x 22 Shadow/Highlight clip remap 23 CC Color Neutralizer +// 24 Color Stabilizer (data: per channel n, then 5 × (x, y)) +@compute @workgroup_size(16, 16) +fn fxt_point(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + let u0 = P.u[0]; + let f0 = P.f[0]; + // Image::map_straight: transparent pixels stay as they are. + let straight_skip = a <= 0.0; + let c = fxt_straight(px); + var o = px; + switch u0.x { + case 1u: { + let cb = u0.y != 0u; + let cw = u0.z != 0u; + // color2::map_ca. + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + let na = clamp(fxt_lv(4u, 0.0, cb, cw), 0.0, 1.0); + if (na > 0.0) { + o = vec4(0.0, 0.0, 0.0, clamp(na, 0.0, 1.0)); + } + } else { + let aa = max(a, 0.0); + var nc: vec3; + for (var i = 0u; i < 3u; i++) { + nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + } + let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); + o = vec4(nc * na, na); + } + } + case 2u: { + if (straight_skip) { + break; + } + let stretch = P.f[3].x; + var r: vec3; + for (var i = 0; i < 3; i++) { + var v = c[i]; + if (stretch > 0.0 && v > 0.0 && v < 1.0) { + let q = 1.0 - v; + v = v + stretch * 0.25 * v * (q * q * q * q); + } + let g = P.f[i]; + r[i] = max(g.y + (g.z - g.y) * powz(max(v, 0.0), g.x), 0.0); + } + o = vec4(r * a, a); + } + case 3u: { + if (straight_skip) { + break; + } + var r = fxt_mix(c, c * f0.xyz, f0.w); + if (u0.y != 0u) { + let l0 = luminance(c); + let l1 = max(luminance(r), 1e-6); + r = r * l0 / l1; + } + o = vec4(r * a, a); + } + case 4u: { + if (straight_skip) { + break; + } + let fr = f0.xyz; + let tc = P.f[1].xyz; + let tol = P.f[2].xyz; + let soft = f0.w; + let hsl = rgb_to_hsl(c); + let d = vec3(abs(fract_euclid(hsl.x - fr.x + 0.5) - 0.5) * 2.0, abs(hsl.z - fr.z), abs(hsl.y - fr.y)); + var k = 1.0; + for (var i = 0; i < 3; i++) { + let over = d[i] - tol[i]; + if (over > 0.0) { + k = min(k, select(clamp(1.0 - over / soft, 0.0, 1.0), 0.0, soft <= 1e-6)); + } + } + let ch_l = (u0.y & 1u) != 0u; + let ch_s = (u0.y & 2u) != 0u; + var r = c; + if ((u0.y & 8u) != 0u) { + r = vec3(k); + } else if (k > 0.0) { + var nh: f32; + var ns = hsl.y; + var nl = hsl.z; + if ((u0.y & 4u) != 0u) { + let dh = (fract_euclid(tc.x - fr.x + 0.5) - 0.5) * k; + nh = fract_euclid(hsl.x + dh); + if (ch_s) { + ns = hsl.y + (tc.y - fr.y) * k; + } + if (ch_l) { + nl = hsl.z + (tc.z - fr.z) * k; + } + } else { + let dh = (fract_euclid(tc.x - hsl.x + 0.5) - 0.5) * k; + nh = fract_euclid(hsl.x + dh); + if (ch_s) { + ns = hsl.y + (tc.y - hsl.y) * k; + } + if (ch_l) { + nl = hsl.z + (tc.z - hsl.z) * k; + } + } + r = hsl_to_rgb(nh, clamp(ns, 0.0, 1.0), clamp(nl, 0.0, 1.0)); + } + o = vec4(r * a, a); + } + case 5u: { + if (straight_skip) { + break; + } + let keep = f0.xyz; + let amt = P.f[1].x; + let tol = P.f[1].y; + let soft = P.f[1].z; + var d: f32; + if (u0.y != 0u) { + let hsl = rgb_to_hsl(c); + d = select(abs(fract_euclid(hsl.x - f0.w + 0.5) - 0.5) * 2.0, 1.0, hsl.y < 0.02); + } else { + d = sqrt(dot(c - keep, c - keep)) / sqrt(3.0); + } + var keepk: f32; + if (tol >= 1.0 || d <= tol) { + keepk = 1.0; + } else if (soft <= 1e-6) { + keepk = 0.0; + } else { + keepk = clamp(1.0 - (d - tol) / soft, 0.0, 1.0); + } + let g = luminance(c); + let r = fxt_mix(c, vec3(g), amt * (1.0 - keepk)); + o = vec4(r * a, a); + } + case 6u: { + if (a <= 0.0) { + break; + } + var m = fxt_colour_match(c, f0.xyz, f0.w, u0.y, P.f[2].x, P.f[2].y); + if ((u0.z & 2u) != 0u) { + m = 1.0 - m; + } + var r = c; + if ((u0.z & 1u) != 0u) { + r = vec3(m); + } else if (m > 0.0) { + let hsl = rgb_to_hsl(c); + let dh = P.f[1].x; + let dl = P.f[1].y; + let ds = P.f[1].z; + let h2 = fract_euclid(hsl.x + dh); + let s2 = clamp(hsl.y + ds * select(hsl.y, 1.0 - hsl.y, ds > 0.0), 0.0, 1.0); + let l2 = clamp(hsl.z + dl * select(hsl.z, 1.0 - hsl.z, dl > 0.0), 0.0, 1.0); + r = fxt_mix(c, hsl_to_rgb(h2, s2, l2), m); + } + o = vec4(r * a, a); + } + case 7u: { + if (a <= 0.0) { + break; + } + let pal = u0.y != 0u; + let y = 0.299 * c.x + 0.587 * c.y + 0.114 * c.z; + var ch: f32; + if (pal) { + let uu = 0.492 * (c.z - y); + let vv = 0.877 * (c.x - y); + ch = sqrt(uu * uu + vv * vv); + } else { + let ii = 0.596 * c.x - 0.274 * c.y - 0.322 * c.z; + let qq = 0.211 * c.x - 0.523 * c.y + 0.312 * c.z; + ch = sqrt(ii * ii + qq * qq); + } + let max_norm = f0.x; + let unsafe_px = y + ch > max_norm + 1e-5; + switch u0.z { + case 2u: { + if (unsafe_px) { + o = vec4(0.0); + } + } + case 3u: { + if (!unsafe_px) { + o = vec4(0.0); + } + } + case 1u: { + if (unsafe_px) { + let target_v = max(max_norm - y, 0.0); + let s = select(1.0, target_v / ch, ch > 1e-6); + var r = y + (c - y) * s; + if (y > max_norm) { + r = r * max_norm / y; + } + o = vec4(r * a, a); + } + } + default: { + if (unsafe_px) { + let k = max_norm / (y + ch); + o = vec4(c * k * a, a); + } + } + } + } + case 8u: { + if (straight_skip) { + break; + } + let hsl = rgb_to_hsl(c); + let dh = f0.x; + let dl = f0.y; + let ds = f0.z; + var l = hsl.z; + if (dl > 0.0) { + l = l + (1.0 - l) * dl; + } else { + l = l + l * dl; + } + var s = hsl.y; + if (ds > 0.0) { + s = s + (1.0 - s) * ds; + } else { + s = s + s * ds; + } + let r = hsl_to_rgb(hsl.x + dh, clamp(s, 0.0, 1.0), clamp(l, 0.0, 1.0)); + o = vec4(r * a, a); + } + case 9u: { + if (straight_skip) { + break; + } + let n = u0.y; + let t = clamp(luminance(c), 0.0, 1.0) * f32(n); + let k = min(u32(floor(t)), n - 1u); + let tone = fxt_mix(P.f[1u + k].xyz, P.f[2u + k].xyz, t - f32(k)); + let r = fxt_mix(tone, c, f0.x); + o = vec4(r * a, a); + } + case 10u: { + if (straight_skip) { + break; + } + var r = c; + for (var k = 0; k < 3; k++) { + if (f0[k] == 0.0) { + continue; + } + let x = c[k] + f0[k]; + if (x <= 1.0) { + r[k] = x; + } else if (u0.y == 1u) { + r[k] = max(2.0 - x, 0.0); + } else if (u0.y == 2u) { + r[k] = select(1.0, 0.0, x - 1.0 < 0.5); + } else { + r[k] = fract_euclid(x); + } + } + o = vec4(r * a, a); + } + case 11u: { + if (a <= 0.0) { + break; + } + var acc = vec3(0.0); + for (var j = 0; j < 3; j++) { + for (var i = 0; i < 3; i++) { + let idx = j * 3 + i; + let w = P.f[idx / 4][idx % 4]; + if (w == 0.0) { + continue; + } + let q = tex_get_clamped(src, p.x + i - 1, p.y + j - 1); + acc += fxt_straight(q) * w; + } + } + var v = acc * P.f[2].y + P.f[2].z; + if (u0.y != 0u) { + v = abs(v); + } + o = vec4(max(v, vec3(0.0)) * a, a); + } + case 12u: { + let n = u0.z; + let aa = max(a, 0.0); + let r = vec3(fxt_lookup(c.x, n), fxt_lookup(c.y, n), fxt_lookup(c.z, n)); + var na = aa; + if (u0.y != 0u) { + na = fxt_lookup(aa, n); + } + o = vec4(r * na, na); + } + case 13u: { + if (a <= 0.0) { + break; + } + let level = f0.x; + let start = f0.y; + let y = luminance(c); + var r = c; + if (u0.y != 1u) { + let y2 = fxt_knee(max(y, 0.0), start, level); + r = r - y + y2; + } + if (u0.y != 0u) { + let yl = luminance(r); + var t = 1.0; + for (var i = 0; i < 3; i++) { + let v = r[i]; + if (v > level && v - yl > 1e-6) { + t = min(t, (level - yl) / (v - yl)); + } + if (v < 0.0 && yl - v > 1e-6) { + t = min(t, yl / (yl - v)); + } + } + t = clamp(t, 0.0, 1.0); + r = yl + (r - yl) * t; + } + r = clamp(r, vec3(0.0), vec3(level)); + if (u0.z != 0u && any(abs(c - r) > vec3(1e-4))) { + r = P.f[1].xyz; + } + o = vec4(r * a, a); + } + case 14u: { + if (straight_skip) { + break; + } + let hsl = rgb_to_hsl(c); + var h = hsl.x; + var s = hsl.y; + var l = hsl.z; + if (u0.y != 0u) { + let cl = P.f[1].z; + h = P.f[1].x; + s = P.f[1].y; + l = clamp(l + cl * select(l, 1.0 - l, cl > 0.0), 0.0, 1.0); + } else { + var s2 = fxt_adjust_sat(s, f0.y); + var l2 = fxt_adjust_light(l, f0.z); + var h2 = h + f0.x; + for (var r = 0u; r < u0.z; r++) { + let w = fxt_range_weight(r, hsl.x * 360.0) * min(hsl.y * 20.0, 1.0); + if (w > 0.0) { + let ro = r * 7u; + h2 += data[ro + 4u] * w; + s2 = fxt_adjust_sat(clamp(s2, 0.0, 1.0), data[ro + 5u] * w); + l2 = fxt_adjust_light(l2, data[ro + 6u] * w); + } + } + h = fract_euclid(h2); + s = clamp(s2, 0.0, 1.0); + l = l2; + } + o = vec4(hsl_to_rgb(h, s, l) * a, a); + } + case 15u: { + var r = c; + if (!straight_skip) { + for (var i = 0u; i < 3u; i++) { + r[i] = fxt_level(0u, c[i]); + } + } + // The master pass writes premultiplied values; the channel pass reads them back. + var q = px; + if (!straight_skip) { + q = vec4(r * a, a); + } + let qc = fxt_straight(q); + let qa = max(q.w, 0.0); + var any_ch = false; + for (var k = 0u; k < 4u; k++) { + if (!fxt_level_ident(7u + 7u * k)) { + any_ch = true; + } + } + if (!any_ch) { + o = q; + break; + } + var oc = qc; + for (var i = 0u; i < 3u; i++) { + let off = 7u + 7u * i; + if (!fxt_level_ident(off)) { + oc[i] = fxt_level(off, qc[i]); + } + } + var na = qa; + if (!fxt_level_ident(28u)) { + na = clamp(fxt_level(28u, qa), 0.0, 1.0); + } + o = vec4(oc * na, na); + } + case 16u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let lo = f0.xyz; + let hi = P.f[1].xyz; + let g = P.f[2]; + var r: vec3; + for (var k = 0; k < 3; k++) { + let st = clamp((c[k] - lo[k]) / (hi[k] - lo[k]), 0.0, 1.0); + r[k] = powz(st, g[k]); + } + r = fxt_mix(r, c, g.w); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 17u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let n = u0.z; + let amt = f0.x; + var r: vec3; + if (u0.y == 1u) { + let l = luminance(c); + let nl = fxt_eq_look(0u, n, l); + if (l > 1e-5) { + r = c * nl / l; + } else { + r = vec3(nl); + } + } else if (u0.y == 2u) { + r = vec3(fxt_eq_look(0u, n, c.x), fxt_eq_look(0u, n, c.y), fxt_eq_look(0u, n, c.z)); + } else { + r = vec3(fxt_eq_look(0u, n, c.x), fxt_eq_look(1u, n, c.y), fxt_eq_look(2u, n, c.z)); + } + r = fxt_mix(c, r, amt); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 18u: { + if (a <= 0.0) { + break; + } + let ch = u0.y; + var r: vec3; + if (ch <= 7u) { + var hsl = rgb_to_hsl(c); + if (ch == 4u || ch == 5u) { + hsl.x = 1.0 - hsl.x; + } + if (ch == 4u || ch == 6u) { + hsl.z = 1.0 - hsl.z; + } + if (ch == 4u || ch == 7u) { + hsl.y = 1.0 - hsl.y; + } + r = hsl_to_rgb(hsl.x, hsl.y, hsl.z); + } else { + var q = vec3( + 0.299 * c.x + 0.587 * c.y + 0.114 * c.z, + 0.596 * c.x - 0.274 * c.y - 0.322 * c.z, + 0.211 * c.x - 0.523 * c.y + 0.312 * c.z, + ); + if (ch == 8u || ch == 9u) { + q.x = 1.0 - q.x; + } + if (ch == 8u || ch == 10u) { + q.y = -q.y; + } + if (ch == 8u || ch == 11u) { + q.z = -q.z; + } + r = max( + vec3(q.x + 0.956 * q.y + 0.621 * q.z, q.x - 0.272 * q.y - 0.647 * q.z, q.x - 1.106 * q.y + 1.703 * q.z), + vec3(0.0), + ); + } + o = vec4((c + (r - c) * f0.x) * a, a); + } + case 19u: { + o = vec4(luminance(c)); + } + case 20u: { + if (a <= 0.0) { + break; + } + let bases = textureLoad(aux, p, 0); + let s_amt = f0.x; + let h_amt = f0.y; + let ws0 = clamp(1.0 - bases.x / f0.z, 0.0, 1.0); + let wh0 = clamp((bases.y - (1.0 - f0.w)) / f0.w, 0.0, 1.0); + let ws = ws0 * ws0; + let wh = wh0 * wh0; + let gain = (1.0 + s_amt * ws * 2.0) * (1.0 - h_amt * wh * 0.5); + let l = luminance(c); + let nl = l * gain; + let sat = pow(max(gain, 1e-3), P.f[1].x); + var r = nl + (c - l) * sat; + let mc = P.f[1].y; + if (mc != 0.0) { + let shift = (0.5 + (nl - 0.5) * (1.0 + mc)) - nl; + r = r + shift; + } + r = fxt_mix(max(r, vec3(0.0)), c, P.f[1].z); + o = vec4(r * a, a); + } + case 21u: { + o = vec4(px.x, textureLoad(aux, p, 0).x, 0.0, 0.0); + } + case 22u: { + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + break; + } + let r = max(f0.x + (c - f0.y) * f0.z, vec3(0.0)); + let na = clamp(max(a, 0.0), 0.0, 1.0); + o = vec4(r * na, na); + } + case 23u: { + if (a <= 0.0) { + break; + } + let l = clamp(luminance(c), 0.0, 1.0); + let ws = 1.0 - fxt_smoothstep(0.0, 0.5, l); + let wh = fxt_smoothstep(0.5, 1.0, l); + let wm = 1.0 - ws - wh; + let pin = P.f[3].x; + let keep = 1.0 - pin * clamp(1.0 - 4.0 * l * (1.0 - l), 0.0, 1.0); + let contrast = P.f[3].z; + let darks = P.f[4].x; + let brights = P.f[4].y; + var r: vec3; + for (var k = 0; k < 3; k++) { + let d = P.f[0][k] * ws + P.f[1][k] * wm + P.f[2][k] * wh; + var v = c[k] + d * keep; + v = 0.5 + (v - 0.5) * (1.0 + contrast); + v += darks * 0.25 * (1.0 - l) * (1.0 - l) + brights * 0.25 * l * l; + r[k] = max(v, 0.0); + } + r = fxt_mix(r, c, P.f[3].y); + o = vec4(r * a, a); + } + case 24u: { + if (a <= 0.0) { + break; + } + var r: vec3; + for (var k = 0u; k < 3u; k++) { + let base = k * 11u; + let n = u32(data[base]); + let v = c[k]; + // slice::partition_point(x < v), clamped to 1..n-1. + var i = 0u; + while (i < n && data[base + 1u + 2u * i] < v) { + i++; + } + i = clamp(i, 1u, n - 1u); + let ax = data[base + 1u + 2u * (i - 1u)]; + let ay = data[base + 2u + 2u * (i - 1u)]; + let cx = data[base + 1u + 2u * i]; + let cy = data[base + 2u + 2u * i]; + r[k] = ay + (cy - ay) * (v - ax) / max(cx - ax, 1e-9); + } + o = vec4(r * a, a); + } + default: {} + } + textureStore(out, p, o); +} + +// util::smoothstep. +fn fxt_smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + if (abs(e1 - e0) < 1e-9) { + return select(1.0, 0.0, x < e0); + } + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 443abcf9c..f471a86b8 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -844,3 +844,118 @@ fn adjust_mix(@builtin(global_invocation_id) gid: vec3) { let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x; textureStore(out, p, c + (a - c) * k); } + +// ---------------------------------------------------------------- Classic 3D bokeh depth of field +// (render::three_d::bokeh: boost_highlights, row prefix sums, span gathers per blur level +// blended by each pixel's radius.) + +// Highlight boost: f[0] = (gain, threshold, saturation). +@compute @workgroup_size(16, 16) +fn bokeh_boost(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var c = textureLoad(src, p, 0); + let gain = P.f[0].x; + let thr = clamp(P.f[0].y, 0.0, 1.0); + let satk = P.f[0].z; + if (c.w > 0.0 && gain > 0.0) { + let a = c.w; + let rgb = c.xyz / a; + let l = 0.2126 * rgb.x + 0.7152 * rgb.y + 0.0722 * rgb.z; + if (!(l < thr || (thr >= 1.0 && l < 1.0))) { + var over = 1.0; + if (thr < 1.0) { + over = clamp((l - thr) / (1.0 - thr), 0.0, 1.0); + } + let k = 1.0 + gain * 4.0 * max(over, 0.25); + let sat = 1.0 + satk; + let o = max(vec3(l) + (rgb - vec3(l)) * sat, vec3(0.0)) * k; + c = vec4(o * a, a); + } + } + textureStore(out, p, c); +} + +// Row prefix sums: out is (w + 1) × h, out[x] = sum of src[0..x]. One invocation per row. +@compute @workgroup_size(64, 1) +fn bokeh_prefix(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(src)); + let y = i32(gid.x); + if (y >= dims.y) { + return; + } + var acc = vec4(0.0); + textureStore(out, vec2(0, y), acc); + for (var x = 0; x < dims.x; x = x + 1) { + acc = acc + textureLoad(src, vec2(x, y), 0); + textureStore(out, vec2(x + 1, y), acc); + } +} + +fn bokeh_prefix_at(y: i32, t: f32) -> vec4 { + let n = i32(textureDimensions(src).x) - 1; + if (t <= 0.0) { + return vec4(0.0); + } + if (t >= f32(n)) { + return textureLoad(src, vec2(n, y), 0); + } + let i = i32(floor(t)); + let f = t - f32(i); + let a = textureLoad(src, vec2(i, y), 0); + let b = textureLoad(src, vec2(i + 1, y), 0); + return a + (b - a) * f; +} + +// One blur level added into the accumulator `aux`: src = prefix sums; data = the radius map +// (w·h) then spans (dy, x0, x1, weight). u[0] = (span offset, span count, level, levels), +// u[1].x = 1 for the first level (no accumulator yet); f[0] = (1 / norm, lo, hi). +@compute @workgroup_size(16, 16) +fn bokeh_gather(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var acc = vec4(0.0); + if (P.u[1].x == 0u) { + acc = textureLoad(aux, p, 0); + } + let r = data[u32(p.y) * u32(dims.x) + u32(p.x)]; + let k = P.u[0].z; + let n = P.u[0].w; + var wgt = 1.0; + if (n > 1u) { + let lo = P.f[0].y; + let hi = P.f[0].z; + let f = clamp((r - lo) / (hi - lo) * f32(n - 1u), 0.0, f32(n - 1u)); + let kk = min(u32(floor(f)), n - 2u); + let t = f - f32(kk); + if (k == kk) { + wgt = 1.0 - t; + } else if (k == kk + 1u) { + wgt = t; + } else { + wgt = 0.0; + } + } + if (wgt > 0.0) { + var g = vec4(0.0); + let off = P.u[0].x; + for (var i = 0u; i < P.u[0].y; i = i + 1u) { + let s = off + i * 4u; + let sy = p.y + i32(data[s]); + if (sy < 0 || sy >= dims.y) { + continue; + } + let hi = bokeh_prefix_at(sy, f32(p.x) + data[s + 2u] + 0.5); + let lo = bokeh_prefix_at(sy, f32(p.x) + data[s + 1u] + 0.5); + g = g + (hi - lo) * data[s + 3u]; + } + acc = acc + g * P.f[0].x * wgt; + } + textureStore(out, p, acc); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index f795b58d2..7a3a1712a 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -511,7 +511,7 @@ fn effect_chains_and_mixed_stacks() { let mut l = s.footage(80, 50); s.effect(&mut l, "ec.color.exposure", &[("master/exposure", n(0.7))]); s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]); - s.effect(&mut l, "ec.stylize.posterize", &[]); + s.effect(&mut l, "ec.stylize.coloremboss", &[]); s.effect(&mut l, "ec.stylize.glow", &[]); s.effect(&mut l, "ec.channel.invert", &[("blend", n(50.0))]); s.push(l); diff --git a/crates/gpu/src/tests_3d.rs b/crates/gpu/src/tests_3d.rs index f10368d20..bd9455b9e 100644 --- a/crates/gpu/src/tests_3d.rs +++ b/crates/gpu/src/tests_3d.rs @@ -218,3 +218,73 @@ fn the_gpu_draws_classic_3d_runs_itself() { let d = crate::tests::diff(&cpu, &img, 1e-3); assert!(d.over * 200 <= d.total, "{d:?}"); } + +/// Classic 3D bokeh depth of field runs on the GPU: iris shapes, highlights, fringe, Fast +/// Rectangle and a tilted plane (progressive blur) match the CPU. +#[test] +fn bokeh_depth_of_field_runs_on_the_gpu() { + let cases: [(&str, &[(&str, Value)]); 4] = [ + ("fast rectangle", &[]), + ("hexagon + highlights", &[("irisShape", Value::Enum(4)), ("irisRotation", n(15.0)), ("highlightGain", n(40.0)), ("highlightThreshold", n(150.0))]), + ("round + fringe", &[("irisShape", Value::Enum(6)), ("irisRoundness", n(60.0)), ("irisDiffractionFringe", n(80.0))]), + ("wide triangle", &[("irisShape", Value::Enum(1)), ("irisAspectRatio", n(2.0))]), + ]; + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (label, vals) in cases { + // The crossing planes are tilted (±35°): every plane blurs progressively. + let mut s = crossing(depth, BlendMode::Normal); + camera(&mut s, [80.0, 50.0, -220.0], Some((180.0, 40.0))); + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.name == "Camera" { + for (k, v) in vals { + set(l, &format!("cameraOptions/{k}"), v.clone()); + } + } + } + check(&format!("bokeh {label} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // The blur is the GPU's: the prepared planes carry it unapplied. + if depth == BitDepth::Bpc32 + && let Some(g) = gpu() + { + let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let run: Vec<_> = r.visible_layers(&ctx).into_iter().filter(|l| l.is_3d()).collect(); + let prep = r.prepare_3d_run(&ctx, &run, (W, H)).unwrap(); + assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU"); + let mut e = crate::context::Enc::new(g.context()); + let canvas = e.image(W, H); + assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU"); + } + } + } +} + +/// A 2D layer whose track matte is a 3D layer: the matte is drawn through the camera on the +/// GPU walk as on the CPU. +#[test] +fn two_d_layer_with_a_three_d_track_matte() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for kind in [MatteKind::Alpha, MatteKind::Luma] { + let mut s = scene(depth); + let bg = s.solid([0.1, 0.12, 0.2], W, H); + s.push(bg); + let fill = s.footage(W, H); + let fid = s.push(fill); + // The matte: a solid turned in 3D, seen through a camera. + let m = three_d(s.solid([0.9, 0.9, 0.9], 70, 50), [80.0, 50.0, 30.0], 40.0); + let mid = s.push(m); + camera(&mut s, [40.0, 30.0, -200.0], None); + let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; + let fi = layers.iter().position(|l| l.id == fid).unwrap(); + layers[fi].track_matte = Some(TrackMatte { layer: mid, kind }); + if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) { + ml.switches.video = false; + } + check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // The same with the Advanced 3D renderer (its CPU path draws 3D mattes through the + // camera with composite_iso_with). + s.p.comp_mut(s.cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D; + check(&format!("3d matte {kind:?} {depth:?} (Advanced 3D)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } + } +} diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs index ae7f88da3..bc6be0cf7 100644 --- a/crates/gpu/src/tests_adjust.rs +++ b/crates/gpu/src/tests_adjust.rs @@ -57,7 +57,7 @@ fn gpu_adjustment_chains_match_the_cpu() { #[test] fn mixed_adjustment_chains_fall_back_per_effect() { let mut stack = gpu_stack(); - stack.insert(1, ("ec.stylize.posterize", vec![])); + stack.insert(1, ("ec.stylize.coloremboss", vec![])); stack.push(("ec.stylize.glow", vec![("threshold", n(30.0))])); for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { let s = adjustment_scene(depth, &stack, true); @@ -93,7 +93,7 @@ impl FxTarget for Probe { fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() { let Some(g) = gpu() else { return }; let mut stack = gpu_stack(); - stack.insert(1, ("ec.stylize.posterize", vec![])); + stack.insert(1, ("ec.stylize.coloremboss", vec![])); let s = adjustment_scene(BitDepth::Bpc32, &stack, false); let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); r.accel = Some(g); diff --git a/crates/gpu/src/tests_fx_key.rs b/crates/gpu/src/tests_fx_key.rs new file mode 100644 index 000000000..76c7e831e --- /dev/null +++ b/crates/gpu/src/tests_fx_key.rs @@ -0,0 +1,230 @@ +//! GPU keying, matte and channel family effects vs the CPU effects (the oracle): direct on a +//! buffer at full and half resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +/// [`effect_case`] for hard thresholds (8-bit quantisation, bitwise operators): the composited +/// renders may flip a few pixels sitting on the threshold after the layer transform's float +/// rounding, so up to 0.1 % of them may differ by any amount. +fn discontinuous_case(id: &str, vals: &[(&str, Value)]) { + crate::tests::effect_direct(id, vals, false); + crate::tests::effect_direct(id, vals, true); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(70, 44); + s.effect(&mut l, id, vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.001); + check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.001); + } +} + +#[test] +fn colour_luma_range_extract_keys() { + effect_case("ec.key.colorkey", &[("keyColor", c(0.7, 0.6, 0.5)), ("colorTolerance", n(60.0))]); + effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(1.5)), ("edgeFeather", n(4.0))]); + effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(-2.0))]); + effect_case("ec.key.luma", &[("threshold", n(120.0)), ("tolerance", n(40.0))]); + effect_case("ec.key.luma", &[("keyType", e(0)), ("threshold", n(140.0)), ("tolerance", n(30.0)), ("edgeFeather", n(3.0))]); + effect_case("ec.key.luma", &[("keyType", e(2)), ("threshold", n(128.0)), ("tolerance", n(25.0)), ("edgeThin", n(1.0))]); + effect_case("ec.key.luma", &[("keyType", e(3)), ("threshold", n(100.0)), ("tolerance", n(50.0))]); + effect_case("ec.key.colorrange", &[("fuzziness", n(20.0))]); + effect_case("ec.key.colorrange", &[("colorSpace", e(1)), ("minL", n(60.0)), ("maxA", n(150.0)), ("minB", n(90.0)), ("fuzziness", n(15.0))]); + effect_case("ec.key.colorrange", &[("colorSpace", e(2)), ("minL", n(80.0)), ("maxA", n(180.0)), ("minB", n(40.0)), ("fuzziness", n(30.0))]); + effect_case("ec.key.extract", &[("blackPoint", n(60.0)), ("whitePoint", n(200.0)), ("blackSoftness", n(30.0)), ("whiteSoftness", n(20.0))]); + discontinuous_case("ec.key.extract", &[("channel", e(1)), ("blackPoint", n(90.0)), ("invert", on())]); + effect_case("ec.key.extract", &[("channel", e(4)), ("whitePoint", n(200.0)), ("whiteSoftness", n(40.0))]); +} + +#[test] +fn difference_colour_difference_and_screen_keys() { + effect_case("ec.key.differencematte", &[]); + effect_case("ec.key.differencematte", &[("view", e(2)), ("matchingTolerance", n(0.0)), ("blurBeforeDifference", n(3.0))]); + effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8))]); + effect_case( + "ec.key.colordifference", + &[ + ("keyColor", c(0.3, 0.8, 0.2)), + ("colorMatchingAccuracy", e(1)), + ("partialAInBlack", n(20.0)), + ("partialAGamma", n(1.4)), + ("partialBOutWhite", n(200.0)), + ("blackLevel", n(10.0)), + ("gamma", n(0.8)), + ], + ); + for view in [1, 2, 3, 4, 5, 6, 8] { + effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8)), ("view", e(view)), ("partialBInWhite", n(180.0))]); + } + effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3))]); + effect_case( + "ec.key.screen", + &[ + ("screenColor", c(0.3, 0.4, 0.8)), + ("screenGain", n(130.0)), + ("screenBalance", n(30.0)), + ("despill", n(60.0)), + ("clipBlack", n(10.0)), + ("clipWhite", n(85.0)), + ("screenPreblur", n(2.0)), + ("screenShrinkGrow", n(-1.5)), + ("screenSoftness", n(3.0)), + ], + ); + effect_case("ec.key.screen", &[("screenColor", c(0.8, 0.4, 0.3)), ("screenShrinkGrow", n(2.0)), ("edgeColor", e(1))]); + for view in [1, 2] { + effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3)), ("view", e(view))]); + } +} + +#[test] +fn spill_unmult_and_key_cleaner() { + effect_case("ec.key.spill", &[("colorToSuppress", c(0.2, 0.9, 0.1))]); + effect_case("ec.key.spill", &[("colorToSuppress", c(0.9, 0.8, 0.1)), ("colorAccuracy", e(1)), ("suppression", n(150.0))]); + effect_case("ec.key.advancedspill", &[]); + effect_case("ec.key.advancedspill", &[("suppression", n(80.0)), ("ultraSettings/spillRange", n(20.0)), ("ultraSettings/lumaCorrection", n(50.0))]); + effect_case( + "ec.key.advancedspill", + &[ + ("method", e(1)), + ("ultraSettings/keyColor", c(0.9, 0.2, 0.3)), + ("ultraSettings/tolerance", n(40.0)), + ("ultraSettings/desaturate", n(50.0)), + ("ultraSettings/spillColorCorrection", n(30.0)), + ], + ); + effect_case("ec.key.unmult", &[]); + effect_case("ec.key.unmult", &[("backgroundColor", e(1)), ("whiteLevel", n(90.0)), ("softness", n(40.0)), ("clipHdrResults", off())]); + effect_case("ec.key.unmult", &[("blackLevel", n(30.0)), ("softness", n(0.0)), ("removeColorMatting", off())]); + effect_case("ec.key.keycleaner", &[]); + effect_case("ec.key.keycleaner", &[("additionalEdgeRadius", n(6.0)), ("reduceChatter", on()), ("alphaContrast", n(250.0)), ("strength", n(60.0))]); +} + +#[test] +fn chokers() { + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(1.5))]); + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-2.5))]); + effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-1.0)), ("view", e(1))]); + effect_case("ec.matte.mattechoker", &[]); + effect_case( + "ec.matte.mattechoker", + &[ + ("geometricSoftness1", n(2.0)), + ("choke1", n(-40.0)), + ("geometricSoftness2", n(3.0)), + ("choke2", n(30.0)), + ("grayLevelSoftness2", n(40.0)), + ("iterations", n(2.0)), + ], + ); +} + +#[test] +fn refine_mattes() { + effect_case("ec.matte.refinesoft", &[]); + effect_case("ec.matte.refinesoft", &[("radius", n(4.0)), ("smooth", n(40.0)), ("feather", n(30.0)), ("contrast", n(20.0)), ("shiftEdge", n(-25.0))]); + effect_case("ec.matte.refinesoft", &[("calculateEdgeDetails", off()), ("smooth", n(50.0)), ("shiftEdge", n(30.0))]); + effect_case("ec.matte.refinesoft", &[("radius", n(3.0)), ("viewEdgeRegion", on())]); + effect_case("ec.matte.refinesoft", &[("decontamination/viewDecontaminationMap", on()), ("decontamination/extendWhereSmoothed", off())]); + effect_case("ec.matte.refinesoft", &[("decontamination/increaseDecontaminationRadius", n(4.0)), ("decontamination/decontaminationAmount", n(60.0))]); + effect_case("ec.matte.refinehard", &[]); + effect_case("ec.matte.refinehard", &[("choke", n(20.0)), ("feather", n(20.0)), ("decontaminate", off())]); +} + +#[test] +fn channel_effects() { + effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]); + effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]); + effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]); + effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]); + for op in [0, 2, 3, 4, 8, 10, 12] { + discontinuous_case("ec.channel.arithmetic", &[("operator", e(op)), ("redValue", n(120.0)), ("greenValue", n(60.0)), ("blueValue", n(200.0))]); + } + effect_case("ec.channel.arithmetic", &[("operator", e(3)), ("redValue", n(200.0)), ("clip", off())]); + for mode in [0, 2, 4, 9, 11] { + effect_case("ec.channel.solidcomposite", &[("blendingMode", e(mode)), ("color", c(0.8, 0.3, 0.2)), ("opacity", n(70.0)), ("sourceOpacity", n(80.0))]); + } + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(4))]); + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(3)), ("invertMatte", on())]); + effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(1)), ("compositeMatteWithOriginal", off()), ("premultiplyMatteLayer", off())]); + for (from, to) in [(0, 0), (1, 0), (2, 0), (3, 0), (8, 4), (9, 5), (10, 6), (11, 3), (12, 8), (13, 7), (7, 1)] { + effect_case("ec.channel.combiner", &[("from", e(from)), ("to", e(to))]); + } + effect_case("ec.channel.combiner", &[("from", e(4)), ("to", e(2)), ("invert", on()), ("solidAlpha", on())]); + effect_case("ec.channel.blend", &[("mode", e(1)), ("blendWithOriginal", n(30.0))]); + effect_case("ec.channel.calculations", &[("inputChannel", e(1))]); + effect_case("ec.channel.calculations", &[("inputChannel", e(0)), ("invertInput", on())]); + effect_case("ec.channel.compoundarithmetic", &[("operator", e(3)), ("operateOnChannels", e(1))]); +} + +/// The channel and keying effects that read another layer, composited. +#[test] +fn layer_inputs() { + let cases: Vec<(&str, Vec<(&str, Value)>)> = vec![ + ("ec.key.differencematte", vec![("matchingTolerance", n(10.0)), ("matchingSoftness", n(20.0))]), + ("ec.key.differencematte", vec![("ifLayerSizesDiffer", e(1)), ("blurBeforeDifference", n(2.0)), ("view", e(2))]), + ("ec.channel.setmatte", vec![("takeMatteFrom", e(4)), ("stretchMatteToFit", off())]), + ("ec.channel.setmatte", vec![("takeMatteFrom", e(3))]), + ("ec.channel.setchannels", vec![("setGreenTo", e(0)), ("setAlphaTo", e(4))]), + ("ec.channel.combiner", vec![("useSecondLayer", on()), ("from", e(11)), ("to", e(7))]), + ("ec.channel.blend", vec![("mode", e(0)), ("blendWithOriginal", n(40.0))]), + ("ec.channel.blend", vec![("mode", e(2))]), + ("ec.channel.blend", vec![("mode", e(3)), ("ifLayerSizesDiffer", e(1))]), + ("ec.channel.blend", vec![("mode", e(4))]), + ("ec.channel.calculations", vec![("secondLayerChannel", e(2)), ("blendingMode", e(2)), ("secondLayerOpacity", n(70.0))]), + ("ec.channel.calculations", vec![("inputChannel", e(4)), ("blendingMode", e(10)), ("preserveTransparency", on()), ("invertSecondLayer", on())]), + ("ec.channel.compoundarithmetic", vec![("operator", e(1)), ("overflowBehavior", e(2))]), + ("ec.channel.compoundarithmetic", vec![("operator", e(4)), ("operateOnChannels", e(1)), ("blendWithOriginal", n(25.0))]), + ("ec.channel.compoundarithmetic", vec![("operator", e(13)), ("operateOnChannels", e(2))]), + ]; + let layer_param = |id: &str| match id { + "ec.key.differencematte" => vec!["differenceLayer"], + "ec.channel.setmatte" => vec!["takeMatteFromLayer"], + "ec.channel.setchannels" => vec!["sourceLayer2", "sourceLayer4"], + "ec.channel.combiner" => vec!["sourceLayer"], + "ec.channel.blend" => vec!["blendWithLayer"], + "ec.channel.calculations" => vec!["secondLayer"], + _ => vec!["secondSource"], + }; + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (id, vals) in &cases { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut other = s.footage(50, 36); + other.switches.video = false; + let oid = s.push(other); + let mut l = s.footage(70, 44); + let mut vals = vals.clone(); + for p in layer_param(id) { + vals.push((p, Value::Layer(Some(oid.0)))); + } + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("{id} {vals:?} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} diff --git a/crates/gpu/src/tests_fx_noise.rs b/crates/gpu/src/tests_fx_noise.rs new file mode 100644 index 000000000..70f893e82 --- /dev/null +++ b/crates/gpu/src/tests_fx_noise.rs @@ -0,0 +1,193 @@ +//! GPU noise, blur and time family effects vs the CPU effects (the oracle): direct on a buffer at full and half +//! resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn medians() { + effect_case("ec.noise.median", &[("radius", n(2.0))]); + effect_case("ec.noise.median", &[("radius", n(5.0)), ("operateOnAlpha", on())]); + effect_case("ec.noise.medianlegacy", &[("radius", n(3.0))]); + effect_case("ec.noise.medianlegacy", &[("radius", n(2.0)), ("operateOnAlphaChannel", on())]); + effect_case("ec.noise.dustscratches", &[("radius", n(3.0)), ("threshold", n(10.0))]); + effect_case("ec.noise.dustscratches", &[("radius", n(2.0)), ("threshold", n(0.0)), ("operateOnAlpha", on())]); +} + +#[test] +fn edge_preserving_blurs() { + effect_case("ec.blur.bilateral", &[]); + effect_case("ec.blur.bilateral", &[("radius", n(9.0)), ("threshold", n(30.0)), ("colorize", Value::Bool(false))]); + effect_case("ec.blur.bilateral", &[("radius", n(14.0)), ("threshold", n(5.0))]); + effect_case("ec.blur.smart", &[]); + effect_case("ec.blur.smart", &[("radius", n(6.0)), ("threshold", n(15.0)), ("mode", e(1))]); + effect_case("ec.blur.smart", &[("radius", n(4.0)), ("threshold", n(40.0)), ("mode", e(2))]); +} + +#[test] +fn sharpen_and_unsharp_mask() { + effect_case("ec.blur.sharpen", &[("amount", n(60.0))]); + effect_case("ec.blur.sharpen", &[("amount", n(250.0))]); + effect_case("ec.blur.unsharp", &[("amount", n(120.0)), ("radius", n(4.0))]); + effect_case("ec.blur.unsharp", &[("amount", n(80.0)), ("radius", n(2.5)), ("threshold", n(12.0))]); +} + +#[test] +fn channel_and_compound_blur() { + effect_case("ec.blur.channel", &[("redBlurriness", n(8.0)), ("blueBlurriness", n(3.0))]); + effect_case("ec.blur.channel", &[("greenBlurriness", n(6.0)), ("alphaBlurriness", n(6.0)), ("repeatEdge", on())]); + effect_case("ec.blur.channel", &[("redBlurriness", n(5.0)), ("alphaBlurriness", n(10.0)), ("dimensions", e(1))]); + effect_case("ec.blur.compound", &[]); + effect_case("ec.blur.compound", &[("maximumBlur", n(12.0)), ("invertBlur", on())]); +} + +/// Compound Blur reading another layer (stretched and centred), composited. +#[test] +fn compound_blur_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for stretch in [true, false] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut map = s.footage(50, 36); + map.switches.video = false; + let mid = s.push(map); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.blur.compound", + &[("blurLayer", Value::Layer(Some(mid.0))), ("maximumBlur", n(10.0)), ("stretchMapToFit", Value::Bool(stretch))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("compound blur layer {stretch} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn minimax() { + effect_case("ec.channel.minimax", &[("radius", n(3.0))]); + effect_case("ec.channel.minimax", &[("radius", n(2.0)), ("operation", e(0)), ("channel", e(2))]); + effect_case("ec.channel.minimax", &[("radius", n(4.0)), ("operation", e(2)), ("channel", e(1)), ("direction", e(1))]); + effect_case("ec.channel.minimax", &[("radius", n(3.0)), ("operation", e(3)), ("channel", e(4)), ("dontShrinkEdges", on()), ("direction", e(2))]); +} + +#[test] +fn turbulent_noise() { + effect_case("ec.noise.turbulent", &[]); + effect_case( + "ec.noise.turbulent", + &[("fractalType", e(3)), ("noiseType", e(3)), ("evolution", n(120.0)), ("evolutionOptions/turbulenceFactor", n(1.5)), ("blendingMode", e(5))], + ); + effect_case( + "ec.noise.turbulent", + &[("fractalType", e(5)), ("noiseType", e(1)), ("complexity", n(3.5)), ("transform/scale", n(40.0)), ("overflow", e(1)), ("blendingMode", e(0))], + ); + effect_case("ec.noise.turbulent", &[("fractalType", e(7)), ("noiseType", e(0)), ("transform/perspectiveOffset", on()), ("opacity", n(70.0))]); +} + +#[test] +fn fractal_noise_other_modes() { + effect_case("ec.noise.fractal", &[("noiseType", e(3)), ("fractalType", e(4)), ("blendingMode", e(3))]); + effect_case( + "ec.noise.fractal", + &[ + ("fractalType", e(6)), + ("evolutionOptions/cycleEvolution", on()), + ("evolutionOptions/cycle", n(2.0)), + ("evolution", n(500.0)), + ("subSettings/subRotation", n(25.0)), + ("subSettings/subOffset", pt(4.0, -3.0)), + ], + ); + effect_case("ec.noise.fractal", &[("fractalType", e(8)), ("noiseType", e(1)), ("blendingMode", e(12)), ("contrast", n(160.0)), ("invert", on())]); +} + +#[test] +fn noise_alpha_and_hls() { + effect_case("ec.noise.noisealpha", &[("amount", n(40.0))]); + effect_case("ec.noise.noisealpha", &[("amount", n(70.0)), ("noise", e(3)), ("originalAlpha", e(2)), ("overflow", e(1)), ("noisePhase", n(100.0))]); + effect_case( + "ec.noise.noisealpha", + &[("amount", n(60.0)), ("noise", e(2)), ("originalAlpha", e(3)), ("overflow", e(2)), ("noiseOptions/cycleNoise", on()), ("randomSeed", n(9.0))], + ); + effect_case("ec.noise.noisehls", &[("hue", n(30.0)), ("lightness", n(20.0)), ("saturation", n(40.0))]); + effect_case("ec.noise.noisehls", &[("noise", e(1)), ("lightness", n(50.0)), ("noisePhase", n(200.0))]); + effect_case("ec.noise.noisehls", &[("noise", e(2)), ("hue", n(60.0)), ("saturation", n(25.0)), ("grainSize", n(3.0))]); + effect_case("ec.noise.noisehlsauto", &[("noise", e(0)), ("hue", n(20.0)), ("lightness", n(30.0)), ("noiseAnimationSpeed", n(7.0))]); +} + +#[test] +fn remove_grain() { + effect_case("ec.noise.removegrain", &[]); + effect_case( + "ec.noise.removegrain", + &[ + ("noiseReductionSettings/noiseReduction", n(2.5)), + ("noiseReductionSettings/passes", n(2.0)), + ("noiseReductionSettings/mode", e(1)), + ("fineTuning/texture", n(0.3)), + ], + ); + effect_case("ec.noise.removegrain", &[("unsharpMask/amount", n(80.0)), ("unsharpMask/radius", n(2.0)), ("unsharpMask/threshold", n(4.0))]); + effect_case("ec.noise.removegrain", &[("viewingMode", e(0)), ("previewRegion/showBox", on()), ("previewRegion/boxColor", c(1.0, 0.2, 0.1))]); +} + +/// Echo and Posterize Time fetch other frames through the effect host: a layer whose rotation +/// is keyframed, at a nonzero time (the direct cases have no host and pass the layer through). +#[test] +fn echo_and_posterize_time() { + effect_case("ec.time.echo", &[]); + effect_case("ec.time.posterizetime", &[]); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (id, vals) in [ + ("ec.time.echo", vec![("numberOfEchoes", n(3.0)), ("decay", n(0.7)), ("echoTime", n(-0.1))]), + ("ec.time.echo", vec![("numberOfEchoes", n(4.0)), ("echoOperator", e(2)), ("echoTime", n(0.05))]), + ("ec.time.echo", vec![("numberOfEchoes", n(2.0)), ("echoOperator", e(4)), ("startingIntensity", n(0.8))]), + ("ec.time.echo", vec![("numberOfEchoes", n(5.0)), ("echoOperator", e(6))]), + ("ec.time.posterizetime", vec![("frameRate", n(4.0))]), + ] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + // A precomp whose content moves, so the frames differ. + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut a = s.footage(30, 24); + let pos = a.props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))]; + let rot = a.props.prop_mut("transform/rotation").unwrap(); + rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let t = Tick::from_seconds_f64(0.4); + let label = format!("{id} {vals:?} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } + } +} diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs new file mode 100644 index 000000000..35e3e99ba --- /dev/null +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -0,0 +1,223 @@ +//! GPU stylize and distort family effects vs the CPU effects (the oracle): direct on a buffer at full and half +//! resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn posterize_thresholds_strobe_vignette() { + effect_case("ec.stylize.posterize", &[("level", n(4.0))]); + effect_case("ec.stylize.posterize", &[("level", n(9.0))]); + effect_case("ec.stylize.threshold", &[("level", n(100.0))]); + effect_case("ec.stylize.threshold", &[("level", n(181.0))]); + effect_case("ec.stylize.ccthreshold", &[]); + effect_case("ec.stylize.ccthreshold", &[("channel", e(1)), ("invert", on()), ("threshold", n(90.0))]); + effect_case("ec.stylize.ccthreshold", &[("channel", e(2)), ("blend", n(30.0))]); + effect_case("ec.stylize.ccthreshold", &[("channel", e(3)), ("threshold", n(60.0))]); + effect_case( + "ec.stylize.ccthresholdrgb", + &[("redThreshold", n(70.0)), ("greenThreshold", n(150.0)), ("blueThreshold", n(110.0)), ("invertGreen", on()), ("blend", n(25.0))], + ); + effect_case( + "ec.stylize.strobe", + &[("strobeDuration", n(0.5)), ("strobeOperator", e(1)), ("strobeColor", c(0.2, 0.5, 0.9)), ("blendWithOriginal", n(30.0))], + ); + effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.5)), ("strobeOperator", e(4)), ("strobeColor", c(0.6, 0.1, 0.3))]); + effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.5)), ("strobe", e(1)), ("blendWithOriginal", n(40.0))]); + effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.1)), ("strobePeriod", n(0.2)), ("randomStrobeProbability", n(50.0))]); + effect_case("ec.stylize.ccvignette", &[("amount", n(80.0))]); + effect_case("ec.stylize.ccvignette", &[("amount", n(-60.0)), ("pinHighlights", n(50.0)), ("angleOfView", n(120.0)), ("center", pt(20.0, 15.0))]); +} + +#[test] +fn scatter_brush_strokes() { + effect_case("ec.stylize.scatter", &[("amount", n(5.0))]); + effect_case("ec.stylize.scatter", &[("amount", n(3.0)), ("grain", e(1))]); + effect_case("ec.stylize.scatter", &[("amount", n(7.0)), ("grain", e(2)), ("randomizeEveryFrame", on())]); + effect_case("ec.stylize.brushstrokes", &[]); + effect_case( + "ec.stylize.brushstrokes", + &[("strokeAngle", n(40.0)), ("brushSize", n(3.0)), ("strokeLength", n(10.0)), ("paintSurface", e(2)), ("blendWithOriginal", n(20.0))], + ); + effect_case("ec.stylize.brushstrokes", &[("paintSurface", e(1)), ("strokeDensity", n(0.6)), ("strokeRandomness", n(2.0)), ("brushSize", n(1.3))]); + effect_case("ec.stylize.brushstrokes", &[("paintSurface", e(3)), ("strokeLength", n(25.0))]); +} + +#[test] +fn roughen_edges() { + effect_case("ec.stylize.roughenedges", &[]); + effect_case("ec.stylize.roughenedges", &[("edgeType", e(1)), ("border", n(4.0)), ("edgeColor", c(0.1, 0.6, 0.9))]); + effect_case( + "ec.stylize.roughenedges", + &[ + ("edgeType", e(5)), + ("evolutionOptions/cycleEvolution", on()), + ("evolutionOptions/cycle", n(2.0)), + ("evolution", n(500.0)), + ("stretch", n(2.0)), + ("complexity", n(3.0)), + ], + ); + effect_case("ec.stylize.roughenedges", &[("edgeType", e(7)), ("border", n(0.0)), ("scale", n(60.0)), ("evolutionOptions/randomSeed", n(9.0))]); +} + +#[test] +fn tiles_kaleida_repetile() { + effect_case("ec.stylize.motiontile", &[("tileWidth", n(50.0)), ("tileHeight", n(40.0))]); + effect_case( + "ec.stylize.motiontile", + &[("outputWidth", n(150.0)), ("outputHeight", n(130.0)), ("mirrorEdges", on()), ("phase", n(70.0)), ("tileWidth", n(45.0))], + ); + effect_case( + "ec.stylize.motiontile", + &[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))], + ); + effect_case("ec.stylize.cckaleida", &[]); + effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]); + effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]); + effect_case("ec.stylize.ccrepetile", &[("expandLeft", n(12.0)), ("expandDown", n(9.0)), ("tiling", e(1))]); + effect_case("ec.stylize.ccrepetile", &[("expandLeft", n(30.0)), ("expandRight", n(30.0)), ("expandUp", n(20.0)), ("tiling", e(4))]); + effect_case("ec.distort.cctiler", &[]); + effect_case("ec.distort.cctiler", &[("scale", n(70.0)), ("blendWithOriginal", n(30.0)), ("center", pt(30.0, 18.0))]); + effect_case("ec.distort.ccgriddler", &[("rotation", n(20.0))]); + effect_case("ec.distort.ccgriddler", &[("cutTiles", on()), ("horizontalScale", n(120.0)), ("verticalScale", n(80.0)), ("tileSize", n(17.0))]); +} + +#[test] +fn mirror_offset_polar_spherize_corner_pin() { + effect_case("ec.distort.mirror", &[("angle", n(30.0))]); + effect_case("ec.distort.mirror", &[("angle", n(200.0)), ("center", pt(27.0, 19.0))]); + effect_case("ec.distort.offset", &[("shift", pt(10.0, 5.0))]); + effect_case("ec.distort.offset", &[("shift", pt(50.3, 20.7)), ("blend", n(40.0))]); + effect_case("ec.distort.polar", &[("interpolation", n(100.0))]); + effect_case("ec.distort.polar", &[("interpolation", n(60.0)), ("conversion", e(1))]); + effect_case("ec.distort.spherize", &[("radius", n(20.0))]); + effect_case("ec.distort.spherize", &[("radius", n(35.0)), ("center", pt(30.0, 25.0))]); + effect_case("ec.distort.cornerpin", &[("ul", pt(5.0, 3.0)), ("ur", pt(60.0, 8.0)), ("lr", pt(66.0, 40.0)), ("ll", pt(2.0, 44.0))]); + effect_case("ec.distort.cornerpin", &[("ul", pt(-8.0, 6.0)), ("ur", pt(75.0, -4.0)), ("lr", pt(50.0, 30.0)), ("ll", pt(12.0, 50.0))]); +} + +#[test] +fn optics_magnify() { + effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(60.0))]); + effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(90.0)), ("reverseLensDistortion", on()), ("fovOrientation", e(2))]); + effect_case( + "ec.distort.opticscompensation", + &[("fieldOfView", n(110.0)), ("reverseLensDistortion", on()), ("resize", e(1)), ("viewCenter", pt(30.0, 20.0))], + ); + effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(120.0)), ("optimalPixels", on()), ("fovOrientation", e(1))]); + effect_case("ec.distort.magnify", &[("size", n(15.0))]); + effect_case( + "ec.distort.magnify", + &[("shape", e(1)), ("feather", n(6.0)), ("size", n(18.0)), ("scaling", e(1)), ("blendingMode", e(3)), ("magnification", n(270.0))], + ); + effect_case("ec.distort.magnify", &[("scaling", e(2)), ("blendingMode", e(0)), ("size", n(20.0)), ("opacity", n(70.0))]); + effect_case("ec.distort.magnify", &[("size", n(25.0)), ("center", pt(60.0, 10.0)), ("resizeLayer", on()), ("blendingMode", e(5))]); +} + +#[test] +fn cc_slant_smear_split() { + effect_case("ec.distort.ccslant", &[("slant", n(30.0))]); + effect_case("ec.distort.ccslant", &[("slant", n(-20.0)), ("stretching", on()), ("height", n(120.0))]); + effect_case("ec.distort.ccsmear", &[]); + effect_case("ec.distort.ccsmear", &[("reach", n(-40.0)), ("radius", n(20.0)), ("to", pt(40.0, 10.0))]); + effect_case("ec.distort.ccsplit", &[]); + effect_case("ec.distort.ccsplit2", &[("split1", n(30.0)), ("split2", n(80.0)), ("pointB", pt(60.0, 30.0))]); +} + +#[test] +fn twirl_legacy_ripple_pulse_power_pin() { + effect_case("ec.distort.twirllegacy", &[("angle", n(90.0))]); + effect_case("ec.distort.twirllegacy", &[("angle", n(-250.0)), ("radius", n(80.0)), ("center", pt(30.0, 18.0))]); + effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(80.0)), ("timeSpan", n(0.5))]); + effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(-60.0)), ("amplitude", n(150.0)), ("renderBump", on()), ("center", pt(25.0, 20.0))]); + effect_case("ec.distort.ccpowerpin", &[("topLeft", pt(5.0, 3.0)), ("bottomRight", pt(62.0, 38.0))]); + effect_case("ec.distort.ccpowerpin", &[("topRight", pt(80.0, -6.0)), ("bottomLeft", pt(-4.0, 50.0)), ("expandLeft", n(20.0)), ("expandBottom", n(-10.0))]); +} + +#[test] +fn flo_motion() { + effect_case("ec.distort.ccflomotion", &[]); + effect_case("ec.distort.ccflomotion", &[("antialiasing", e(2)), ("tileEdges", on()), ("amount1", n(-150.0)), ("falloff", n(0.7))]); + effect_case("ec.distort.ccflomotion", &[("antialiasing", e(0)), ("amount2", n(0.0)), ("knot1", pt(20.0, 30.0))]); +} + +#[test] +fn liquify_render() { + let mesh = "warp 20 50 0 0 0 10,10 20,15 30,20\ntwirlClockwise 24 80 0 0 0 40,25 42,27"; + effect_case("ec.distort.liquify", &[("distortionMesh", Value::Str(mesh.into()))]); + effect_case( + "ec.distort.liquify", + &[("distortionMesh", Value::Str(mesh.into())), ("distortionPercentage", n(60.0)), ("distortionMeshOffset", pt(3.0, -2.0))], + ); +} + +#[test] +fn glow_operations_and_arbitrary_map() { + effect_case("ec.stylize.glow", &[("glowOperation", e(2)), ("threshold", n(30.0)), ("radius", n(8.0))]); + effect_case("ec.stylize.glow", &[("glowOperation", e(3)), ("colors", e(1)), ("intensity", n(2.0))]); + effect_case("ec.stylize.glow", &[("colors", e(2)), ("arbitraryMap", Value::Str("0,0 0.5,0.8 1,1|0,0.2 1,0.6|".into())), ("threshold", n(20.0))]); + effect_case("ec.stylize.glow", &[("colors", e(2)), ("arbitraryMap", Value::Str("0,1 1,0||0,0 1,1".into())), ("operation", e(1)), ("glowOperation", e(4))]); +} + +/// Texturize with a texture layer (Tile / Center / Stretch), composited. +#[test] +fn texturize_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for placement in 0..3 { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut tex = s.footage(50, 36); + tex.switches.video = false; + let tid = s.push(tex); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.stylize.texturize", + &[("textureLayer", Value::Layer(Some(tid.0))), ("texturePlacement", e(placement)), ("textureContrast", n(1.5)), ("lightDirection", n(60.0))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("texturize {placement} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +/// Transform's own shutter angle: the effect's rotation and position animate. +#[test] +fn transform_motion_blur() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(70, 44); + s.effect(&mut l, "ec.distort.transform", &[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", n(180.0))]); + let rot = l.props.prop_mut("effects/#1/rotation").expect("transform rotation"); + rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(120.0))]; + let pos = l.props.prop_mut("effects/#1/position").expect("transform position"); + pos.keys = vec![Keyframe::new(Tick::ZERO, pt(35.0, 22.0)), Keyframe::new(Tick::from_seconds_f64(1.0), pt(95.0, 40.0))]; + s.push(l); + let label = format!("transform motion blur {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::from_seconds_f64(0.3)), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0); + } +} diff --git a/crates/gpu/src/tests_fx_tone.rs b/crates/gpu/src/tests_fx_tone.rs new file mode 100644 index 000000000..61c977d18 --- /dev/null +++ b/crates/gpu/src/tests_fx_tone.rs @@ -0,0 +1,185 @@ +//! GPU colour-correction family effects vs the CPU effects (the oracle): direct on a buffer at +//! full and half resolution, as adjustment, and composited at 8 and 32 bpc. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +fn off() -> Value { + Value::Bool(false) +} + +#[test] +fn levels_and_gamma() { + effect_case( + "ec.color.levelsic", + &[("rgb/rgbInBlack", n(0.1)), ("rgb/rgbGamma", n(1.3)), ("red/redOutWhite", n(0.8)), ("blue/blueInWhite", n(0.7)), ("alpha/alphaGamma", n(0.7))], + ); + effect_case("ec.color.levelsic", &[("green/greenInBlack", n(0.2)), ("green/greenInWhite", n(0.6)), ("clipToOutputWhite", e(0))]); + effect_case("ec.color.levelsic", &[("alpha/alphaOutBlack", n(0.3))]); + effect_case("ec.color.levels", &[("redInBlack", n(0.1)), ("greenGamma", n(1.5)), ("blueOutWhite", n(0.7)), ("inWhite", n(0.9))]); + effect_case("ec.color.levels", &[("alphaInWhite", n(0.6)), ("redInWhite", n(0.5)), ("redClipToOutputWhite", e(0))]); + effect_case("ec.color.gammapedestalgain", &[("redGamma", n(1.4)), ("greenPedestal", n(0.1)), ("blueGain", n(0.8))]); + effect_case("ec.color.gammapedestalgain", &[("blackStretch", n(2.0)), ("redGain", n(1.2))]); +} + +#[test] +fn hue_saturation_ranges() { + effect_case("ec.color.huesaturation", &[("redsHue", n(40.0)), ("bluesSaturation", n(-60.0)), ("greensLightness", n(30.0))]); + effect_case( + "ec.color.huesaturation", + &[ + ("hue", n(20.0)), + ("saturation", n(15.0)), + ("yellowsSaturation", n(50.0)), + ("yellowsRangeStart", n(30.0)), + ("yellowsStartFalloff", n(8.0)), + ("cyansLightness", n(-40.0)), + ], + ); +} + +#[test] +fn colour_replacement() { + effect_case("ec.color.photofilter", &[]); + effect_case( + "ec.color.photofilter", + &[("filter", e(effectcraft_effects::PHOTO_FILTER_CUSTOM)), ("color", c(0.2, 0.7, 0.4)), ("density", n(60.0)), ("preserveLuminosity", off())], + ); + effect_case("ec.color.changetocolor", &[("from", c(0.8, 0.4, 0.3)), ("to", c(0.2, 0.5, 0.9)), ("toleranceGroup/hue", n(15.0))]); + effect_case( + "ec.color.changetocolor", + &[("from", c(0.6, 0.6, 0.3)), ("to", c(0.9, 0.2, 0.6)), ("change", e(3)), ("changeBy", e(1)), ("toleranceGroup/hue", n(25.0)), ("softness", n(30.0))], + ); + effect_case("ec.color.changetocolor", &[("from", c(0.6, 0.6, 0.3)), ("change", e(2)), ("toleranceGroup/hue", n(25.0)), ("viewCorrectionMatte", on())]); + effect_case("ec.color.leavecolor", &[("amount", n(80.0)), ("color", c(0.7, 0.5, 0.4)), ("tolerance", n(10.0)), ("edgeSoftness", n(20.0))]); + effect_case( + "ec.color.leavecolor", + &[("amount", n(100.0)), ("color", c(0.8, 0.4, 0.3)), ("matchColors", e(1)), ("tolerance", n(5.0)), ("edgeSoftness", n(30.0))], + ); + effect_case("ec.color.changecolor", &[("hueTransform", n(90.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]); + effect_case( + "ec.color.changecolor", + &[ + ("lightnessTransform", n(-30.0)), + ("saturationTransform", n(40.0)), + ("matchColors", e(0)), + ("matchingTolerance", n(20.0)), + ("matchingSoftness", n(15.0)), + ], + ); + effect_case("ec.color.changecolor", &[("view", e(1)), ("matchColors", e(2)), ("matchingSoftness", n(25.0)), ("invertColorCorrectionMask", on())]); +} + +#[test] +fn broadcast_balance_limiter() { + effect_case("ec.color.broadcast", &[("maxSignal", n(95.0))]); + effect_case("ec.color.broadcast", &[("locale", e(1)), ("howToMakeSafe", e(1)), ("maxSignal", n(90.0))]); + effect_case("ec.color.colorbalancehls", &[("hue", n(40.0)), ("lightness", n(-20.0)), ("saturation", n(30.0))]); + effect_case("ec.color.colorbalancehls", &[("lightness", n(25.0)), ("saturation", n(-50.0))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(0))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(1)), ("clipMethod", e(1)), ("compressionBeforeClipping", e(4))]); + effect_case("ec.color.videolimiter", &[("clipLevel", e(0)), ("clipMethod", e(0)), ("gamutWarning", on())]); +} + +#[test] +fn cc_colour_and_arbitrary_map() { + effect_case("ec.color.cctoner", &[]); + effect_case("ec.color.cctoner", &[("tones", e(2)), ("blend", n(30.0))]); + effect_case("ec.color.cctoner", &[("tones", e(0)), ("shadows", c(0.1, 0.0, 0.3)), ("highlights", c(1.0, 0.9, 0.6))]); + effect_case("ec.color.cccoloroffset", &[("redPhase", n(90.0)), ("bluePhase", n(200.0))]); + effect_case("ec.color.cccoloroffset", &[("greenPhase", n(120.0)), ("overflow", e(1))]); + effect_case("ec.color.cckernel", &[("k1", n(-1.0)), ("k5", n(5.0)), ("k9", n(-1.0)), ("scale", n(0.5)), ("offset", n(0.05))]); + effect_case("ec.color.cckernel", &[("k2", n(1.0)), ("k5", n(0.0)), ("k8", n(-1.0)), ("absolute", on())]); + let map: String = (0..256).map(|i| format!("{}", ((i as f64 / 255.0).powf(0.6) * 255.0).round())).collect::>().join(","); + effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map.clone()))]); + effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map)), ("phase", n(40.0)), ("applyPhaseMapToAlpha", on())]); +} + +#[test] +fn automatic_corrections() { + effect_case("ec.color.autolevels", &[]); + effect_case("ec.color.autolevels", &[("blackClip", n(2.0)), ("whiteClip", n(3.0)), ("blend", n(20.0))]); + effect_case("ec.color.autocontrast", &[("blackClip", n(1.0))]); + effect_case("ec.color.autocolor", &[]); + effect_case("ec.color.autocolor", &[("snapNeutralMidtones", on()), ("whiteClip", n(2.0))]); + effect_case("ec.color.equalize", &[]); + effect_case("ec.color.equalize", &[("style", e(1)), ("amount", n(70.0))]); + effect_case("ec.color.equalize", &[("style", e(2))]); +} + +#[test] +fn shadow_highlight_neutralizer_invert() { + effect_case("ec.color.shadowhighlight", &[]); + effect_case( + "ec.color.shadowhighlight", + &[ + ("autoAmounts", off()), + ("shadowAmount", n(70.0)), + ("highlightAmount", n(40.0)), + ("moreOptions/shadowRadius", n(8.0)), + ("moreOptions/highlightRadius", n(15.0)), + ("moreOptions/midtoneContrast", n(30.0)), + ("moreOptions/blackClip", n(2.0)), + ("blend", n(20.0)), + ], + ); + effect_case("ec.color.shadowhighlight", &[("moreOptions/blackClip", n(0.0)), ("moreOptions/whiteClip", n(0.0)), ("moreOptions/colorCorrection", n(-40.0))]); + effect_case( + "ec.color.cccolorneutralizer", + &[("shadowsBalance", c(0.1, 0.0, 0.05)), ("midtonesUnbalance", c(0.6, 0.5, 0.4)), ("pinning", n(40.0)), ("contrast", n(20.0)), ("darks", n(-30.0))], + ); + effect_case("ec.color.cccolorneutralizer", &[("highlightsBalance", c(0.9, 1.0, 0.8)), ("brights", n(40.0)), ("blendWOriginal", n(25.0))]); + for ch in 4..=11 { + effect_case("ec.channel.invert", &[("channel", e(ch)), ("blend", n(20.0))]); + } +} + +/// Color Stabilizer reads its reference frame through the effect host: a precomp whose content +/// moves, at a nonzero time (the direct cases have no host and pass the layer through). +#[test] +fn color_stabilizer() { + effect_case("ec.color.colorstabilizer", &[]); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for mode in [0, 1, 2] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut a = s.footage(50, 34); + let pos = a.props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))]; + let op = a.props.prop_mut("transform/opacity").unwrap(); + op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + let vals = [ + ("stabilize", e(mode)), + ("blackPoint", Value::Vec2([18.0, 20.0])), + ("midPoint", Value::Vec2([30.0, 18.0])), + ("whitePoint", Value::Vec2([42.0, 22.0])), + ("sampleSize", n(4.0)), + ]; + s.effect(&mut l, "ec.color.colorstabilizer", &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let t = Tick::from_seconds_f64(0.5); + let label = format!("color stabilizer {mode} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } + } +} diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 9b77ccae4..d80825735 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -247,15 +247,13 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { let c = s.active_comp().unwrap(); assert_eq!(c.layers[0].id.0, sid); let shapes = s.render(cid, t, RenderOpts::default()); - // Clipping groups are not shape-layer features: the clipped bar (rows 33–47) draws in - // full; everything else matches. - let rows = |img: &Image| Image { - width: w, - height: h - 15, - data: img.data.iter().enumerate().filter(|(i, _)| !(33..48).contains(&(*i as u32 / w))).map(|(_, p)| *p).collect(), - }; - let d = mean_diff(&rows(&shapes), &rows(&direct)); + // The clipped bar (rows 33–47) keeps its clip through Merge Paths ▸ Intersect. + let d = mean_diff(&shapes, &direct); assert!(d < 0.01, "{name}: shapes vs page mean diff {d}"); + for x in [10, 50, 100] { + let (a, b) = (shapes.get(x, 40), direct.get(x, 40)); + assert!((0..4).all(|c| (a[c] - b[c]).abs() < 0.02), "{name}: bar clipped at x = {x}: {a:?} vs {b:?}"); + } let fill = shapes.get(60, 30); assert!(fill[0] > 0.9 && fill[1] < 0.1, "red shape {fill:?}"); } @@ -334,3 +332,132 @@ fn eps_footage_imports() { let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); assert!(img.get(10, 10)[0] > 0.99 && img.get(30, 10)[2] > 0.99, "{:?} {:?}", img.get(10, 10), img.get(30, 10)); } + +/// A two-page PDF: page 2 has text in a non-embedded standard font inside a page-wide clip. +fn two_page_pdf() -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 120 80 re f".to_vec())), + ( + 5, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >> /Contents 6 0 R >>".into(), + None, + ), + (6, "<< >>".into(), Some(b"10 5 80 50 re W n 0 0 1 rg 0 0 100 60 re f 1 1 0 rg BT /F1 40 Tf 15 15 Td (Hi) Tj ET".to_vec())), + ], + 1, + ) +} + +#[test] +fn pdf_pages_text_and_clips_to_masks() { + let bytes = two_page_pdf(); + let path = tmp("pages.pdf"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + // Page 2 as footage (File ▸ Import ▸ Page), named after its page. + let r = s.execute_checked("file.import", json!({"paths": [path], "page": 2})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let it = s.project.item(item).unwrap(); + assert_eq!(it.name, "pages.pdf (Page 2)"); + let effectcraft_project::ItemKind::Footage(f) = &it.kind else { panic!() }; + assert_eq!((f.width, f.height, f.page), (100, 60, 1)); + assert!(s.execute_checked("file.import", json!({"paths": [path], "page": 3})).unwrap()["errors"][0].as_str().unwrap().contains("no page 3")); + assert!(s.execute("file.import", json!({"paths": [path], "page": 0})).is_err()); + let doc = effectcraft_pdf::parse_page(&bytes, 1).unwrap(); + let direct = effectcraft_svg::rasterize(&doc, 100, 60, 1.0); + s.execute("comp.new", json!({"name": "P", "width": 100, "height": 60, "frameRate": 30, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).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()); + assert!(mean_diff(&foot, &direct) < 1.0 / 255.0, "page 2 footage"); + // Text drew (yellow glyphs over the blue page). + let ink = (0..60).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| foot.get(x, y)[0] > 0.9).count(); + assert!(ink > 100, "text pixels {ink}"); + // Create Shapes from Vector Layer: the page-wide clip becomes the layer's mask, the text a + // shape group named after it. + s.execute("layer.select", json!({"layers": [lid]})).unwrap(); + let sid = s.execute_checked("layer.create", json!({"op": "shapesFromVector"})).unwrap()["layers"][0].as_u64().unwrap(); + let c = s.active_comp().unwrap(); + let l = c.layer(effectcraft_project::LayerId(sid)).unwrap(); + assert_eq!(l.masks().map(|m| m.groups().count()), Some(1)); + fn names(g: &effectcraft_project::PropGroup, out: &mut Vec) { + for c in g.groups() { + out.push(c.name.clone()); + names(c, out); + } + } + let mut names_found = vec![]; + names(l.props.sub("contents").unwrap(), &mut names_found); + let names = names_found; + assert!(names.iter().any(|n| n == "Text: Hi"), "{names:?}"); + let shapes = s.render(cid, t, RenderOpts::default()); + let d = mean_diff(&shapes, &direct); + assert!(d < 0.01, "shapes vs page {d}"); + assert_eq!(shapes.get(5, 30)[3], 0.0, "masked outside the clip"); + // Page 1 as a composition. + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "page": 1})).unwrap(); + let comp = s.project.comp(ItemId(r["comps"][0].as_u64().unwrap())).unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); +} + +#[test] +fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() { + let mut inner = WDoc::new(20, 10); + inner.layers = + vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])]; + let inner_bytes = write(&inner); + let mut d = WDoc::new(64, 48); + // A perspective trapezoid, and an affine placement with an Arc warp. + let trap = [[20.0, 4.0], [44.0, 10.0], [44.0, 38.0], [20.0, 44.0]]; + let warp = warp_descriptor("warpArc", 50.0, 0.0, 0.0, [0.0, 0.0, 20.0, 10.0], None); + d.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + WLayer::solid("Pinned", Rect::new(20, 4, 24, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped("so-1", trap, [20.0, 10.0], None)), + ]; + d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())]; + let mut d2 = WDoc::new(64, 48); + d2.layers = vec![ + WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]), + WLayer::solid("Arc", Rect::new(20, 10, 40, 20), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block_warped( + "so-2", + [[20.0, 10.0], [60.0, 10.0], [60.0, 30.0], [20.0, 30.0]], + [20.0, 10.0], + Some(warp), + )), + ]; + d2.linked = vec![("so-2".into(), "inner.psd".into(), *b"8BPS", inner_bytes)]; + let parsed = effectcraft_psd::Psd::parse(write(&d2)).unwrap(); + let so = parsed.layers.iter().find_map(|l| l.smart_object.clone()).unwrap(); + assert_eq!(so.warp.as_ref().map(|w| w.style.as_str()), Some("warpArc")); + let mut s = effectcraft_host::session(); + let render = |s: &mut effectcraft_engine::Session, doc: &WDoc, file: &str| { + let path = tmp(file); + std::fs::write(&path, write(doc)).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let placed = s.project.comp(cid).unwrap().layers.iter().any(|l| { + let effectcraft_project::LayerSource::Footage { item } = l.source else { return false }; + matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.layer.as_ref().is_some_and(|x| x.placed)) + }); + assert!(placed, "{file}: baked as placed"); + s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()) + }; + let img = render(&mut s, &d, "pinned.psd"); + let (red, green) = (img.get(24, 24), img.get(40, 24)); + assert!(red[0] > 0.9 && red[1] < 0.1, "{red:?}"); + assert!(green[1] > 0.9 && green[0] < 0.1, "{green:?}"); + // Above the slanted top edge (y = 4 + 6·(x − 20)/24): background. + assert!(img.get(42, 6)[0] < 0.05 && img.get(42, 6)[1] < 0.05, "{:?}", img.get(42, 6)); + let img = render(&mut s, &d2, "arc.psd"); + // The arc keeps the top centre and pulls the corners down and out. + let top = img.get(40, 11); + assert!(top[0] > 0.5 || top[1] > 0.5, "top centre covered {top:?}"); + assert!(img.get(21, 11)[0] < 0.05 && img.get(21, 11)[1] < 0.05, "corner region empty {:?}", img.get(21, 11)); +} diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index ae0b2e568..f1845993a 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -299,6 +299,10 @@ pub struct CharStyle { /// OpenType features (stylistic sets, figures, fractions…). #[serde(skip_serializing_if = "OpenType::is_default")] pub opentype: OpenType, + /// Variable font axis values (tag, user units; axes not listed stay at their defaults): + /// the Character panel's Variable Font Axes. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub variations: Vec<(String, f32)>, } impl Default for CharStyle { @@ -383,6 +387,9 @@ pub struct TextDoc { /// Base OpenType features (see [`CharStyle::opentype`]). #[serde(skip_serializing_if = "OpenType::is_default")] pub opentype: OpenType, + /// Base variable font axis values (see [`CharStyle::variations`]). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub variations: Vec<(String, f32)>, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, @@ -435,6 +442,7 @@ impl Default for TextDoc { tate_chu_yoko: false, vertical_roman_upright: false, opentype: OpenType::default(), + variations: vec![], indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, @@ -522,6 +530,7 @@ impl TextDoc { tate_chu_yoko: self.tate_chu_yoko, vertical_roman_upright: self.vertical_roman_upright, opentype: self.opentype, + variations: self.variations.clone(), } } @@ -550,6 +559,7 @@ impl TextDoc { self.tate_chu_yoko = s.tate_chu_yoko; self.vertical_roman_upright = s.vertical_roman_upright; self.opentype = s.opentype; + self.variations = s.variations.clone(); } /// The base (first paragraph's) settings. @@ -951,6 +961,7 @@ pub const CHAR_ATTRS: &[&str] = &[ "figureStyle", "figureWidth", "figures", + "variations", ]; /// Paragraph attribute keys of `layer.setText`. @@ -1068,6 +1079,28 @@ pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result match v { + J::Null => s.variations.clear(), + J::Object(m) => { + if m.is_empty() { + s.variations.clear(); + } + for (tag, val) in m { + if tag.is_empty() || tag.len() > 4 { + return Err(format!("variations: `{tag}` is not an axis tag")); + } + s.variations.retain(|(t, _)| t != tag); + match val { + J::Null => {} + _ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)), + } + } + s.variations.sort_by(|a, b| a.0.cmp(&b.0)); + } + _ => return Err("variations: expected {tag: number, …}".into()), + }, k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::().is_ok_and(|n| (1..=20).contains(&n)) => { let n = k[2..].parse::().unwrap_or(0); s.opentype.set_stylistic_set(n, flag(key, v)?); @@ -1123,6 +1156,7 @@ pub fn char_style_json(s: &CharStyle) -> J { "stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::>(), "swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions, "allSmallCaps": s.opentype.all_small_caps, + "variations": s.variations.iter().map(|(t, v)| (t.clone(), J::from(*v as f64))).collect::>(), "figureStyle": match s.opentype.figure_style { FigureStyle::Default => "default", FigureStyle::Lining => "lining", FigureStyle::OldStyle => "oldStyle" }, "figureWidth": match s.opentype.figure_width { FigureWidth::Default => "default", FigureWidth::Proportional => "proportional", FigureWidth::Tabular => "tabular" }, }) diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index b2331d812..02546611f 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -13,15 +13,20 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool { path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) } -/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats), restricted -/// to the footage's layer when it names one. -pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>) -> Option> { +/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page +/// `page` of a PDF, restricted to the footage's layer when it names one. +pub fn vector_doc( + path: &str, + bytes: &[u8], + layer: Option<&effectcraft_project::SourceLayer>, + page: u32, +) -> Option> { if is_svg(path, bytes) { return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}"))); } effectcraft_pdf::sniff(bytes)?; Some( - effectcraft_pdf::parse(bytes) + effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), None => d, @@ -70,13 +75,26 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - // A smart object's embedded file: an embedded document's merged image, or an image. if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) { let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?; - if effectcraft_psd::is_psd(data) { + let px = if effectcraft_psd::is_psd(data) { let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; - let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; - return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); + inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))? + } else { + let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + let rgba = img.to_rgba32f(); + effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() } + }; + // A perspective quad or a warp: baked as placed. + let l = footage.layer.as_ref().expect("embedded implies a layer"); + if l.placed + && let Some(so) = psd.layers.get(l.index as usize).and_then(|pl| pl.smart_object.as_ref()) + { + let bb = so.placed_bounds(px.width as f64, px.height as f64); + let k = footage.width as f64 / (bb[2] - bb[0]).max(1e-9); + if let Some((baked, _)) = so.render_placed(&px, k, Some((footage.width, footage.height))) { + return Ok(Some(straight_to_image(baked.width, baked.height, &baked.data, op))); + } } - let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; - return Ok(Some(crate::convert::dynamic_to_image(&img, op))); + return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } let px = match &footage.layer { Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size), @@ -86,7 +104,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - return Ok(Some(straight_to_image(px.width, px.height, &px.data, op))); } if is_svg(path, bytes) || is_pdf_like(path, bytes) { - let doc = vector_doc(path, bytes, footage.layer.as_ref()).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; + let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The rasteriser produces premultiplied pixels already. @@ -97,8 +115,8 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - } /// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). -pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, scale: f64) -> Option { - let doc = vector_doc(path, bytes, layer)?.ok()?; +pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option { + let doc = vector_doc(path, bytes, layer, page)?.ok()?; let (w, h) = doc.pixel_size(); let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 130100579..3aa7fca70 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -489,9 +489,10 @@ impl Inner { Loc { key: key(&footage.sequence[i as usize], 0), media_t: None } } // A layer of a layered still is keyed by its layer index and size mode (smart objects - // by their embedded file). - 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 + if l.embedded.is_some() { 1 << 40 } else { 0 }); + // by their embedded file; PDF pages by their number). + FootageKind::Still if footage.layer.is_some() || footage.page != 0 => { + let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 }) + + ((footage.page as i64) << 42); 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 }, @@ -622,7 +623,7 @@ impl FootageSource for MediaPool { return None; } let bytes = self.inner.read(&footage.path).ok()?; - crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), scale).map(Arc::new) + crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new) } fn aux(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option> { diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 93d082b5c..9b761f57b 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-pdf" -description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, axial / radial shadings, clipping, optional-content layers)" +description = "Vector footage import: PDF and PDF-compatible Illustrator (.ai) pages and EPS (a PostScript subset) parsed into the SVG render tree (paths, fills, strokes, shadings, tiling patterns, clipping, text with embedded fonts, images, blend modes, soft masks, optional-content layers)" version.workspace = true edition.workspace = true license.workspace = true @@ -12,4 +12,14 @@ workspace = true [dependencies] effectcraft-svg = { workspace = true } kurbo = { workspace = true } +effectcraft-text = { workspace = true } miniz_oxide = "0.8" +# Embedded TrueType / OpenType font programs (MIT OR Apache-2.0). +skrifa = { workspace = true } +# DCT (JPEG) images (MIT OR Apache-2.0 OR Zlib; pure Rust). +zune-jpeg = "0.5" + +[dev-dependencies] +# Encodes JPEG fixtures for the DCT image test. +image = { workspace = true } +effectcraft-raster = { workspace = true } diff --git a/crates/pdf/README.md b/crates/pdf/README.md index a1a9d0b3e..43452da90 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -7,10 +7,15 @@ layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as comp layer per file layer (File ▸ Import ▸ Composition). Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file -structure, filters, content streams, graphics state, colour spaces, functions, shadings and -optional content; the **PostScript Language Reference, third edition** and the **Encapsulated -PostScript File Format Specification 3.0** for EPS. No third-party PDF/PostScript code was read; -test files are generated in code ([`write`](src/write.rs) and the tests). +structure, filters, content streams, graphics state, colour spaces, functions, shadings, text, +fonts, images, patterns, transparency (blend modes, soft masks) and optional content; Adobe +**Technical Note #5176** (The Compact Font Format Specification) and **#5177** (The Type 2 +Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version 1.1) for Type 1 +font programs; the **PostScript Language Reference, third edition** and the **Encapsulated +PostScript File Format Specification 3.0** for EPS. TrueType font programs are read through +skrifa and JPEG (DCT) images through zune-jpeg (both permissively licensed, pure Rust). No +third-party PDF/PostScript/font code was read; test files (including the CFF and Type 1 fonts) +are generated in code ([`write`](src/write.rs) and the tests). ## What is read @@ -36,13 +41,52 @@ test files are generated in code ([`write`](src/write.rs) and the tests). binary headers and `%%HiResBoundingBox` / `%%BoundingBox` are honoured; `%%BeginData` / `%%BeginBinary` / preview sections are skipped. +- **Text** (§9): text objects and state (`Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tr`, `Tf`, `Td`, + `TD`, `Tm`, `T*`), `Tj`, `TJ` (with kerning), `'` and `"`; render modes fill / stroke / + fill-and-stroke / invisible and the clipping modes (glyph outlines clip until `Q`). Glyphs + become outlines (one compound shape per show operator, named "Text: …" after the string + through `ToUnicode` or the glyph names). Fonts: simple fonts with `/Widths`, base encodings + (Standard, WinAnsi, MacRoman), `/Differences` and built-in encodings; composite (Type 0) fonts + with Identity-H / Identity-V and embedded CMaps (`cidrange` / `cidchar`), `/W` and `/DW` + widths and `CIDToGIDMap`. Embedded programs: TrueType (`FontFile2`, cmap (3,1) / (3,0) / + (1,0) and `post` names), CFF (`FontFile3` `/Type1C`, `/CIDFontType0C`, `/OpenType`; + name-keyed and CID-keyed with FDArray / FDSelect, flex, hint masks, `seac`), Type 1 + (`FontFile`, eexec, `/Subrs`, flex and hint replacement through othersubrs, `seac`) and Type 3 + glyph procedures. Fonts that are not embedded (the standard 14 and others) are drawn with + the bundled OFL fonts by name: Inter (sans serif, Regular / Italic / SemiBold / Bold), Noto + Serif (Times and other serif names) and JetBrains Mono (Courier), with their own advances + when the PDF gives no widths. +- **Images** (§8.9): image XObjects and inline images (`BI … ID … EI`, abbreviated keys); + 1, 2, 4, 8 and 16 bits per component with `/Decode`; any colour space above (Indexed through + its palette, ICCBased through its `/Alternate` space); DCT (JPEG, through zune-jpeg; Adobe + inverted CMYK) and the general filters; stencil masks (`/ImageMask`) in the fill colour; + soft masks (`/SMask` images) and colour-key and explicit `/Mask`s. Images become image nodes + of the render tree (bilinear, box-filtered when minified). +- **Transparency:** blend modes (`/BM`, all 16 PDF modes) and soft masks in the graphics state + (`/SMask` with `/S /Luminosity` and `/BC` backdrops, or `/S /Alpha`) as groups of the render + tree, open until the state changes or `Q`. +- **Patterns:** tiling patterns (coloured and uncoloured, `XStep` / `YStep`, the cell clipped + to `/BBox`, at most 4096 tiles per fill) for fills; strokes with a tiling pattern use its + colour (uncoloured) or grey. +- **Functions:** sampled, exponential, stitching and PostScript calculator (type 4) functions. +- **Pages:** any page ([`parse_page`], [`page_count`]); File ▸ Import takes `page` (from 1) and + the Import dialog asks for it; footage remembers its page (`Footage::page`). + ## Limitations -- Text is not drawn (embedded fonts are not read); images, tiling patterns, mesh shadings - (types 4–7), PostScript calculator functions, soft masks and blend modes are skipped. What a - document skipped is listed in `Doc::skipped`. +- Mesh shadings (types 4–7) and function-based shadings (type 1), JPX / CCITT / JBIG2 images, + predefined CJK CMaps other than Identity, vertical metrics (`/W2`; vertical text advances by + the font size) and transfer functions (`/TR`) are not read; knockout and isolated transparency + groups composite as normal groups, and a soft mask applies to each painting operation while + it is set rather than to their composite. What a document skipped is listed in `Doc::skipped`. - Illustrator EPS files whose drawing depends on Adobe's procedure-set resources (defined in the file through constructs beyond this subset) and Level 3 `shfill` gradients in EPS draw only what the subset understands; save such artwork as PDF-compatible `.ai` or PDF instead. -- Shape-layer conversion has no clipping: clipped artwork converts unclipped. -- Only the first page of a multi-page PDF is imported. + EPS text is not drawn. +- **Create Shapes from Vector Layer:** clips that enclose the whole page become layer masks + (the first clip Add, later ones Intersect). Nested clipping groups become Merge Paths: each + closed shape inside them is a group with its geometry merged into one compound path, the clip + paths, and Merge Paths ▸ Intersect before its fill and stroke, so fills match exactly and + strokes follow the clipped outline; open stroked paths inside clips stay unclipped. Blend + modes become the groups' Blend Mode; images and soft masks are not converted (shape layers + have no images; use the footage, or Import As: Composition). diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index ca063dee8..4eeeb53bd 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -19,7 +19,7 @@ pub(crate) struct Builder { } pub(crate) fn group(name: &str) -> Group { - Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![] } + Group::new(name) } impl Builder { @@ -70,7 +70,21 @@ impl Builder { self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); } + /// Open a group (blend mode, soft mask) closed with the graphics state. + pub fn open_group(&mut self, g: Group) { + self.stack.push((g, Kind::Plain)); + } + + /// Add a finished node (an image, pattern tiles). + pub fn push(&mut self, n: Node) { + self.add(n, false); + } + pub fn fill_stroke(&mut self, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option) { + self.fill_stroke_named("Path", path, transform, fill, stroke); + } + + pub fn fill_stroke_named(&mut self, name: &str, path: BezPath, transform: Affine, fill: Option<(Paint, f64, FillRule)>, stroke: Option) { if path.elements().is_empty() || (fill.is_none() && stroke.is_none()) { return; } @@ -79,7 +93,7 @@ impl Builder { Some((p, o, r)) => (Some(p), o, r), None => (None, 1.0, FillRule::NonZero), }; - let s = Shape { name: "Path".into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 }; + let s = Shape { name: name.into(), geom: Geom::Path(path), transform, fill: f, fill_opacity: fo, fill_rule: rule, stroke, opacity: 1.0 }; self.add(Node::Shape(s), false); } diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs new file mode 100644 index 000000000..7d647e7cc --- /dev/null +++ b/crates/pdf/src/cff.rs @@ -0,0 +1,1034 @@ +//! Compact Font Format programs (`FontFile3` `/Type1C` and `/CIDFontType0C`, and the `CFF ` +//! table of OpenType fonts), from Adobe Technical Note #5176 (The Compact Font Format +//! Specification) and #5177 (The Type 2 Charstring Format): INDEX and DICT structures, +//! charsets, encodings, CID-keyed FDArray / FDSelect, and a Type 2 charstring interpreter that +//! produces glyph outlines in glyph space. + +use kurbo::BezPath; + +/// The standard strings (SIDs 0–228: the ISOAdobe character set); SIDs 229–390 (the expert +/// set) are not named here and match no glyph name. +const STD: &[&str] = &[ + ".notdef", + "space", + "exclam", + "quotedbl", + "numbersign", + "dollar", + "percent", + "ampersand", + "quoteright", + "parenleft", + "parenright", + "asterisk", + "plus", + "comma", + "hyphen", + "period", + "slash", + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "colon", + "semicolon", + "less", + "equal", + "greater", + "question", + "at", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "bracketleft", + "backslash", + "bracketright", + "asciicircum", + "underscore", + "quoteleft", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "braceleft", + "bar", + "braceright", + "asciitilde", + "exclamdown", + "cent", + "sterling", + "fraction", + "yen", + "florin", + "section", + "currency", + "quotesingle", + "quotedblleft", + "guillemotleft", + "guilsinglleft", + "guilsinglright", + "fi", + "fl", + "endash", + "dagger", + "daggerdbl", + "periodcentered", + "paragraph", + "bullet", + "quotesinglbase", + "quotedblbase", + "quotedblright", + "guillemotright", + "ellipsis", + "perthousand", + "questiondown", + "grave", + "acute", + "circumflex", + "tilde", + "macron", + "breve", + "dotaccent", + "dieresis", + "ring", + "cedilla", + "hungarumlaut", + "ogonek", + "caron", + "emdash", + "AE", + "ordfeminine", + "Lslash", + "Oslash", + "OE", + "ordmasculine", + "ae", + "dotlessi", + "lslash", + "oslash", + "oe", + "germandbls", + "onesuperior", + "logicalnot", + "mu", + "trademark", + "Eth", + "onehalf", + "plusminus", + "Thorn", + "onequarter", + "divide", + "brokenbar", + "degree", + "thorn", + "threequarters", + "twosuperior", + "registered", + "minus", + "eth", + "multiply", + "threesuperior", + "copyright", + "Aacute", + "Acircumflex", + "Adieresis", + "Agrave", + "Aring", + "Atilde", + "Ccedilla", + "Eacute", + "Ecircumflex", + "Edieresis", + "Egrave", + "Iacute", + "Icircumflex", + "Idieresis", + "Igrave", + "Ntilde", + "Oacute", + "Ocircumflex", + "Odieresis", + "Ograve", + "Otilde", + "Scaron", + "Uacute", + "Ucircumflex", + "Udieresis", + "Ugrave", + "Yacute", + "Ydieresis", + "Zcaron", + "aacute", + "acircumflex", + "adieresis", + "agrave", + "aring", + "atilde", + "ccedilla", + "eacute", + "ecircumflex", + "edieresis", + "egrave", + "iacute", + "icircumflex", + "idieresis", + "igrave", + "ntilde", + "oacute", + "ocircumflex", + "odieresis", + "ograve", + "otilde", + "scaron", + "uacute", + "ucircumflex", + "udieresis", + "ugrave", + "yacute", + "ydieresis", + "zcaron", +]; + +const N_STD: usize = 391; + +/// An INDEX: the byte ranges of its objects. +fn index(d: &[u8], pos: usize) -> Option<(Vec>, usize)> { + let count = u16::from_be_bytes([*d.get(pos)?, *d.get(pos + 1)?]) as usize; + if count == 0 { + return Some((vec![], pos + 2)); + } + let os = *d.get(pos + 2)? as usize; + if !(1..=4).contains(&os) { + return None; + } + let off = |i: usize| -> Option { + let p = pos + 3 + i * os; + let mut v = 0usize; + for k in 0..os { + v = (v << 8) | *d.get(p + k)? as usize; + } + Some(v) + }; + let base = pos + 3 + (count + 1) * os - 1; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let (a, b) = (off(i)?, off(i + 1)?); + if b < a || base + b > d.len() { + return None; + } + out.push(base + a..base + b); + } + let end = base + off(count)?; + Some((out, end)) +} + +/// A DICT: (operator, operands) pairs; two-byte operators are `1200 + b1`. +fn dict(d: &[u8]) -> Vec<(u16, Vec)> { + let mut out = vec![]; + let mut ops: Vec = vec![]; + let mut i = 0; + while i < d.len() { + let b0 = d[i]; + match b0 { + 0..=21 => { + let op = if b0 == 12 { + i += 1; + 1200 + *d.get(i).unwrap_or(&0) as u16 + } else { + b0 as u16 + }; + out.push((op, std::mem::take(&mut ops))); + i += 1; + } + 28 => { + ops.push(i16::from_be_bytes([*d.get(i + 1).unwrap_or(&0), *d.get(i + 2).unwrap_or(&0)]) as f64); + i += 3; + } + 29 => { + let b = |k: usize| *d.get(i + k).unwrap_or(&0); + ops.push(i32::from_be_bytes([b(1), b(2), b(3), b(4)]) as f64); + i += 5; + } + 30 => { + // Real number: BCD nibbles. + let mut s = String::new(); + i += 1; + 'n: while i < d.len() { + for nib in [d[i] >> 4, d[i] & 15] { + match nib { + 0..=9 => s.push((b'0' + nib) as char), + 0xA => s.push('.'), + 0xB => s.push('E'), + 0xC => s.push_str("E-"), + 0xE => s.push('-'), + 0xF => { + i += 1; + break 'n; + } + _ => {} + } + } + i += 1; + } + ops.push(s.parse().unwrap_or(0.0)); + } + 32..=246 => { + ops.push(b0 as f64 - 139.0); + i += 1; + } + 247..=250 => { + ops.push((b0 as f64 - 247.0) * 256.0 + *d.get(i + 1).unwrap_or(&0) as f64 + 108.0); + i += 2; + } + 251..=254 => { + ops.push(-(b0 as f64 - 251.0) * 256.0 - *d.get(i + 1).unwrap_or(&0) as f64 - 108.0); + i += 2; + } + _ => i += 1, + } + } + out +} + +fn get(d: &[(u16, Vec)], op: u16) -> Option<&[f64]> { + d.iter().find(|(o, _)| *o == op).map(|(_, v)| v.as_slice()) +} + +/// One font of a CFF program. +#[derive(Clone, Debug)] +pub struct Cff { + data: Vec, + charstrings: Vec>, + gsubrs: Vec>, + /// Local subroutines of each Font DICT (one for name-keyed fonts). + lsubrs: Vec>>, + /// Nominal / default widths per Font DICT. + widths: Vec<(f64, f64)>, + /// Glyph → Font DICT (CID-keyed fonts). + fdselect: Vec, + /// Glyph → SID (name-keyed) or CID. + charset: Vec, + strings: Vec>, + pub cid: bool, + pub font_matrix: [f64; 6], + /// The built-in encoding: code → glyph. + pub encoding: [u16; 256], +} + +impl Cff { + pub fn parse(data: &[u8]) -> Option { + let d = data; + let hdr = *d.get(2)? as usize; + let (_names, p) = index(d, hdr)?; + let (tops, p) = index(d, p)?; + let (strings, p) = index(d, p)?; + let (gsubrs, _) = index(d, p)?; + let top = dict(&d[tops.first()?.clone()]); + let cs_off = get(&top, 17)?.first().copied()? as usize; + let (charstrings, _) = index(d, cs_off)?; + let n = charstrings.len(); + let cid = get(&top, 1230).is_some(); + let font_matrix = match get(&top, 1207) { + Some(m) if m.len() == 6 => [m[0], m[1], m[2], m[3], m[4], m[5]], + _ => [0.001, 0.0, 0.0, 0.001, 0.0, 0.0], + }; + let private = |pd: &[(u16, Vec)]| -> (Vec>, (f64, f64)) { + let Some(pv) = get(pd, 18).filter(|v| v.len() == 2) else { return (vec![], (0.0, 0.0)) }; + let (size, off) = (pv[0] as usize, pv[1] as usize); + let Some(bytes) = d.get(off..off + size) else { return (vec![], (0.0, 0.0)) }; + let pr = dict(bytes); + let nominal = get(&pr, 21).and_then(|v| v.first().copied()).unwrap_or(0.0); + let default = get(&pr, 20).and_then(|v| v.first().copied()).unwrap_or(0.0); + let subrs = get(&pr, 19).and_then(|v| v.first().copied()).and_then(|s| index(d, off + s as usize)).map(|x| x.0).unwrap_or_default(); + (subrs, (default, nominal)) + }; + let mut lsubrs = vec![]; + let mut widths = vec![]; + let mut fdselect = vec![]; + if cid { + if let Some(fa) = get(&top, 1236).and_then(|v| v.first().copied()) + && let Some((fds, _)) = index(d, fa as usize) + { + for r in fds { + let (s, w) = private(&dict(&d[r])); + lsubrs.push(s); + widths.push(w); + } + } + if let Some(fs) = get(&top, 1237).and_then(|v| v.first().copied()) { + fdselect = read_fdselect(d, fs as usize, n).unwrap_or_default(); + } + } else { + let (s, w) = private(&top); + lsubrs.push(s); + widths.push(w); + } + if lsubrs.is_empty() { + lsubrs.push(vec![]); + widths.push((0.0, 0.0)); + } + let charset = match get(&top, 15).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize { + 0..=2 => (0..n as u16).collect(), + off => read_charset(d, off, n).unwrap_or_else(|| (0..n as u16).collect()), + }; + let mut cff = Cff { data: data.to_vec(), charstrings, gsubrs, lsubrs, widths, fdselect, charset, strings, cid, font_matrix, encoding: [0; 256] }; + let enc_off = get(&top, 16).and_then(|v| v.first().copied()).unwrap_or(0.0) as usize; + cff.encoding = cff.read_encoding(enc_off); + Some(cff) + } + + fn read_encoding(&self, off: usize) -> [u16; 256] { + let mut enc = [0u16; 256]; + if self.cid { + return enc; + } + if off <= 1 { + // Standard encoding by glyph name. + for code in 0..=255u8 { + if let Some(name) = crate::encoding::Base::Standard.name(code) + && let Some(g) = self.gid_by_name(&name) + { + enc[code as usize] = g; + } + } + return enc; + } + let d = &self.data; + let Some(&fmt) = d.get(off) else { return enc }; + let mut p = off + 1; + match fmt & 0x7F { + 0 => { + let n = *d.get(p).unwrap_or(&0) as usize; + p += 1; + for i in 0..n { + if let Some(&c) = d.get(p + i) { + enc[c as usize] = (i + 1) as u16; + } + } + p += n; + } + 1 => { + let n = *d.get(p).unwrap_or(&0) as usize; + p += 1; + let mut g = 1u16; + for _ in 0..n { + let (Some(&first), Some(&left)) = (d.get(p), d.get(p + 1)) else { break }; + for k in 0..=left as usize { + if let Some(e) = enc.get_mut(first as usize + k) { + *e = g; + } + g += 1; + } + p += 2; + } + } + _ => {} + } + if fmt & 0x80 != 0 { + let n = *d.get(p).unwrap_or(&0) as usize; + p += 1; + for _ in 0..n { + let (Some(&c), Some(&a), Some(&b)) = (d.get(p), d.get(p + 1), d.get(p + 2)) else { break }; + let sid = u16::from_be_bytes([a, b]); + if let Some(g) = self.charset.iter().position(|s| *s == sid) { + enc[c as usize] = g as u16; + } + p += 3; + } + } + enc + } + + #[cfg(test)] + pub fn glyph_count(&self) -> usize { + self.charstrings.len() + } + + fn sid_name(&self, sid: u16) -> Option<&str> { + let sid = sid as usize; + if sid < STD.len() { + return Some(STD[sid]); + } + if sid < N_STD { + return None; + } + let r = self.strings.get(sid - N_STD)?.clone(); + std::str::from_utf8(&self.data[r]).ok() + } + + /// A glyph by name (name-keyed fonts). + pub fn gid_by_name(&self, name: &str) -> Option { + if self.cid { + return None; + } + self.charset.iter().position(|&sid| self.sid_name(sid) == Some(name)).map(|g| g as u16) + } + + /// A glyph by CID (CID-keyed fonts; name-keyed fonts take the CID as the glyph index). + pub fn gid_by_cid(&self, cid: u16) -> Option { + if !self.cid { + return ((cid as usize) < self.charstrings.len()).then_some(cid); + } + self.charset.iter().position(|&c| c == cid).map(|g| g as u16) + } + + /// A glyph's outline in glyph space (`font_matrix` maps it to text space) and its advance. + pub fn outline(&self, gid: u16) -> Option<(BezPath, f64)> { + let r = self.charstrings.get(gid as usize)?.clone(); + let fd = self.fdselect.get(gid as usize).copied().unwrap_or(0) as usize; + let fd = fd.min(self.lsubrs.len() - 1); + let mut st = + T2 { cff: self, fd, path: BezPath::new(), x: 0.0, y: 0.0, stack: vec![], stems: 0, width: None, open: false, done: false, depth: 0, seac: None }; + st.run(&self.data[r]); + if st.open { + st.path.close_path(); + } + let width = match st.width { + Some(w) if !w.is_nan() => w + self.widths[fd].1, + _ => self.widths[fd].0, + }; + let mut path = st.path; + // endchar seac: base and accent glyphs by standard code. + if let Some((adx, ady, b, a)) = st.seac { + let find = |code: u8| crate::encoding::Base::Standard.name(code).and_then(|n| self.gid_by_name(&n)); + if let Some((bp, _)) = find(b).and_then(|g| self.outline(g)) { + path.extend(bp); + } + if let Some((ap, _)) = find(a).and_then(|g| self.outline(g)) { + path.extend(kurbo::Affine::translate((adx, ady)) * ap); + } + } + Some((path, width)) + } +} + +fn read_charset(d: &[u8], off: usize, n: usize) -> Option> { + let fmt = *d.get(off)?; + let mut out = vec![0u16]; + let mut p = off + 1; + let u16at = |p: usize| -> Option { Some(u16::from_be_bytes([*d.get(p)?, *d.get(p + 1)?])) }; + while out.len() < n { + match fmt { + 0 => { + out.push(u16at(p)?); + p += 2; + } + 1 | 2 => { + let first = u16at(p)?; + let left = if fmt == 1 { *d.get(p + 2)? as u16 } else { u16at(p + 2)? }; + p += if fmt == 1 { 3 } else { 4 }; + for k in 0..=left { + if out.len() >= n { + break; + } + out.push(first.wrapping_add(k)); + } + } + _ => return None, + } + } + Some(out) +} + +fn read_fdselect(d: &[u8], off: usize, n: usize) -> Option> { + match *d.get(off)? { + 0 => d.get(off + 1..off + 1 + n).map(<[u8]>::to_vec), + 3 => { + let nr = u16::from_be_bytes([*d.get(off + 1)?, *d.get(off + 2)?]) as usize; + let mut out = vec![0u8; n]; + for i in 0..nr { + let p = off + 3 + i * 3; + let first = u16::from_be_bytes([*d.get(p)?, *d.get(p + 1)?]) as usize; + let fd = *d.get(p + 2)?; + let next = u16::from_be_bytes([*d.get(p + 3)?, *d.get(p + 4)?]) as usize; + for g in first..next.min(n) { + out[g] = fd; + } + } + Some(out) + } + _ => None, + } +} + +fn bias(n: usize) -> i64 { + if n < 1240 { + 107 + } else if n < 33900 { + 1131 + } else { + 32768 + } +} + +/// Type 2 charstring interpreter state. +struct T2<'a> { + cff: &'a Cff, + fd: usize, + path: BezPath, + x: f64, + y: f64, + stack: Vec, + stems: usize, + width: Option, + open: bool, + done: bool, + depth: usize, + seac: Option<(f64, f64, u8, u8)>, +} + +impl T2<'_> { + fn move_to(&mut self, dx: f64, dy: f64) { + if self.open { + self.path.close_path(); + } + self.x += dx; + self.y += dy; + self.path.move_to((self.x, self.y)); + self.open = true; + } + fn line_to(&mut self, dx: f64, dy: f64) { + self.x += dx; + self.y += dy; + self.path.line_to((self.x, self.y)); + } + fn curve(&mut self, d: [f64; 6]) { + let (x1, y1) = (self.x + d[0], self.y + d[1]); + let (x2, y2) = (x1 + d[2], y1 + d[3]); + self.x = x2 + d[4]; + self.y = y2 + d[5]; + self.path.curve_to((x1, y1), (x2, y2), (self.x, self.y)); + } + /// The width argument before the first stack-clearing operator. + fn take_width(&mut self, expected_even: bool) { + if self.width.is_none() { + let odd = self.stack.len() % 2 == 1; + if expected_even == odd && !self.stack.is_empty() { + self.width = Some(self.stack.remove(0)); + } else { + self.width = Some(f64::NAN); + } + } + } + + fn run(&mut self, cs: &[u8]) { + if self.depth > 10 { + return; + } + let mut i = 0; + while i < cs.len() && !self.done { + let b0 = cs[i]; + i += 1; + match b0 { + 32..=246 => self.stack.push(b0 as f64 - 139.0), + 247..=250 => { + self.stack.push((b0 as f64 - 247.0) * 256.0 + *cs.get(i).unwrap_or(&0) as f64 + 108.0); + i += 1; + } + 251..=254 => { + self.stack.push(-(b0 as f64 - 251.0) * 256.0 - *cs.get(i).unwrap_or(&0) as f64 - 108.0); + i += 1; + } + 28 => { + self.stack.push(i16::from_be_bytes([*cs.get(i).unwrap_or(&0), *cs.get(i + 1).unwrap_or(&0)]) as f64); + i += 2; + } + 255 => { + let b = |k: usize| *cs.get(i + k).unwrap_or(&0); + self.stack.push(i32::from_be_bytes([b(0), b(1), b(2), b(3)]) as f64 / 65536.0); + i += 4; + } + 1 | 3 | 18 | 23 => { + self.take_width(true); + self.stems += self.stack.len() / 2; + self.stack.clear(); + } + 19 | 20 => { + self.take_width(true); + self.stems += self.stack.len() / 2; + self.stack.clear(); + i += self.stems.div_ceil(8); + } + 21 => { + self.take_width(true); + let s = self.args(); + let n = s.len(); + if n >= 2 { + self.move_to(s[n - 2], s[n - 1]); + } + } + 22 | 4 => { + self.take_width(false); + let s = self.args(); + let v = s.last().copied().unwrap_or(0.0); + if b0 == 22 { self.move_to(v, 0.0) } else { self.move_to(0.0, v) } + } + 5 => { + let s = self.args(); + for c in s.chunks_exact(2) { + self.line_to(c[0], c[1]); + } + } + 6 | 7 => { + let s = self.args(); + let mut horiz = b0 == 6; + for v in s { + if horiz { + self.line_to(v, 0.0) + } else { + self.line_to(0.0, v) + } + horiz = !horiz; + } + } + 8 => { + let s = self.args(); + for c in s.chunks_exact(6) { + self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); + } + } + 24 => { + // rcurveline + let s = self.args(); + let nc = s.len().saturating_sub(2) / 6; + for c in s[..nc * 6].chunks_exact(6) { + self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); + } + if s.len() >= nc * 6 + 2 { + self.line_to(s[nc * 6], s[nc * 6 + 1]); + } + } + 25 => { + // rlinecurve + let s = self.args(); + if s.len() >= 6 { + let nl = (s.len() - 6) / 2; + for c in s[..nl * 2].chunks_exact(2) { + self.line_to(c[0], c[1]); + } + let c = &s[nl * 2..]; + self.curve([c[0], c[1], c[2], c[3], c[4], c[5]]); + } + } + 26 => { + // vvcurveto + let mut s = self.args(); + let mut dx1 = 0.0; + if s.len() % 4 == 1 { + dx1 = s.remove(0); + } + for c in s.chunks_exact(4) { + self.curve([dx1, c[0], c[1], c[2], 0.0, c[3]]); + dx1 = 0.0; + } + } + 27 => { + // hhcurveto + let mut s = self.args(); + let mut dy1 = 0.0; + if s.len() % 4 == 1 { + dy1 = s.remove(0); + } + for c in s.chunks_exact(4) { + self.curve([c[0], dy1, c[1], c[2], c[3], 0.0]); + dy1 = 0.0; + } + } + 30 | 31 => { + // vhcurveto / hvcurveto: alternating tangents, optional final delta. + let s = self.args(); + let mut horiz = b0 == 31; + let mut k = 0; + while k + 4 <= s.len() { + let last = k + 4 == s.len() - 1; + let extra = if last { s[k + 4] } else { 0.0 }; + if horiz { + self.curve([s[k], 0.0, s[k + 1], s[k + 2], extra, s[k + 3]]); + } else { + self.curve([0.0, s[k], s[k + 1], s[k + 2], s[k + 3], extra]); + } + horiz = !horiz; + k += 4; + if last { + break; + } + } + } + 10 | 29 => { + let Some(idx) = self.stack.pop() else { continue }; + let subrs = if b0 == 10 { &self.cff.lsubrs[self.fd] } else { &self.cff.gsubrs }; + let k = idx as i64 + bias(subrs.len()); + if let Some(r) = usize::try_from(k).ok().and_then(|k| subrs.get(k)).cloned() { + self.depth += 1; + let data = &self.cff.data[r]; + self.run(data); + self.depth -= 1; + } + } + 11 => return, + 14 => { + self.take_width(false); + if self.stack.len() >= 4 { + let n = self.stack.len(); + let s = &self.stack[n - 4..]; + self.seac = Some((s[0], s[1], s[2] as u8, s[3] as u8)); + } + self.stack.clear(); + self.done = true; + } + 12 => { + let b1 = *cs.get(i).unwrap_or(&0); + i += 1; + self.escape(b1); + } + _ => self.stack.clear(), + } + } + } + + fn args(&mut self) -> Vec { + std::mem::take(&mut self.stack) + } + + fn escape(&mut self, b1: u8) { + let pop = |s: &mut Vec| s.pop().unwrap_or(0.0); + match b1 { + 35 => { + // flex: two curves. + let s = self.args(); + if s.len() >= 12 { + self.curve([s[0], s[1], s[2], s[3], s[4], s[5]]); + self.curve([s[6], s[7], s[8], s[9], s[10], s[11]]); + } + } + 34 => { + // hflex: dx1 dx2 dy2 dx3 dx4 dx5 dx6. + let s = self.args(); + if s.len() >= 7 { + self.curve([s[0], 0.0, s[1], s[2], s[3], 0.0]); + self.curve([s[4], 0.0, s[5], -s[2], s[6], 0.0]); + } + } + 36 => { + // hflex1: dx1 dy1 dx2 dy2 dx3 dx4 dx5 dy5 dx6. + let s = self.args(); + if s.len() >= 9 { + self.curve([s[0], s[1], s[2], s[3], s[4], 0.0]); + self.curve([s[5], 0.0, s[6], s[7], s[8], -(s[1] + s[3] + s[7])]); + } + } + 37 => { + // flex1 + let s = self.args(); + if s.len() >= 11 { + let (x0, y0) = (self.x, self.y); + let dx: f64 = s[0] + s[2] + s[4] + s[6] + s[8]; + let dy: f64 = s[1] + s[3] + s[5] + s[7] + s[9]; + self.curve([s[0], s[1], s[2], s[3], s[4], s[5]]); + let (lx, ly) = if dx.abs() > dy.abs() { (s[10], y0 - (self.y + s[7] + s[9])) } else { (x0 - (self.x + s[6] + s[8]), s[10]) }; + self.curve([s[6], s[7], s[8], s[9], lx, ly]); + } + } + 9 => { + let a = pop(&mut self.stack); + self.stack.push(a.abs()); + } + 10 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(a + b); + } + 11 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(a - b); + } + 12 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(if b != 0.0 { a / b } else { 0.0 }); + } + 14 => { + let a = pop(&mut self.stack); + self.stack.push(-a); + } + 18 => { + self.stack.pop(); + } + 24 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(a * b); + } + 27 => { + let a = pop(&mut self.stack); + self.stack.push(a); + self.stack.push(a); + } + 28 => { + let (b, a) = (pop(&mut self.stack), pop(&mut self.stack)); + self.stack.push(b); + self.stack.push(a); + } + _ => self.stack.clear(), + } + } +} + +/// A minimal CFF writer for tests: one name-keyed font whose glyphs are Type 2 charstrings, +/// named by custom strings. +#[cfg(test)] +pub(crate) fn write_test_cff(glyphs: &[(&str, Vec)]) -> Vec { + fn idx(items: &[Vec]) -> Vec { + let mut out = (items.len() as u16).to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(4); + let mut off = 1u32; + out.extend_from_slice(&off.to_be_bytes()); + for it in items { + off += it.len() as u32; + out.extend_from_slice(&off.to_be_bytes()); + } + for it in items { + out.extend_from_slice(it); + } + out + } + let int = |v: i32| { + let mut o = vec![29]; + o.extend_from_slice(&v.to_be_bytes()); + o + }; + let names = idx(&[b"Test".to_vec()]); + let strings: Vec> = glyphs.iter().skip(1).map(|(n, _)| n.as_bytes().to_vec()).collect(); + let strings_idx = idx(&strings); + let gsubrs = idx(&[]); + let cs = idx(&glyphs.iter().map(|(_, c)| c.clone()).collect::>()); + // charset format 0: SIDs of glyphs 1.. (custom strings from 391). + let mut charset = vec![0u8]; + for i in 0..glyphs.len() - 1 { + charset.extend_from_slice(&((N_STD + i) as u16).to_be_bytes()); + } + // Top DICT with fixed-size operands so offsets can be computed first. + let top_len = 4 * 5 + 3; + let header = [1u8, 0, 4, 4]; + let top_index_len = 2 + 1 + 8 + top_len; + let base = header.len() + names.len() + top_index_len + strings_idx.len() + gsubrs.len(); + let charset_off = base; + let cs_off = charset_off + charset.len(); + let priv_off = cs_off + cs.len(); + let mut top = vec![]; + top.extend(int(charset_off as i32)); + top.push(15); + top.extend(int(cs_off as i32)); + top.push(17); + top.extend(int(0)); + top.extend(int(priv_off as i32)); + top.push(18); + assert_eq!(top.len(), top_len); + let mut out = header.to_vec(); + out.extend(names); + out.extend(idx(&[top])); + out.extend(strings_idx); + out.extend(gsubrs); + out.extend(charset); + out.extend(cs); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn standard_strings_cover_isoadobe() { + assert_eq!(STD.len(), 229); + assert_eq!(STD[34], "A"); + assert_eq!(STD[66], "a"); + assert_eq!(STD[149], "germandbls"); + assert_eq!(STD[228], "zcaron"); + } + + #[test] + fn type2_charstrings_draw_outlines() { + // A 500-unit square with a width: `w dx dy rmoveto dx hlineto dy vlineto dx hlineto endchar`. + let enc = |v: i32| -> Vec { + if (-107..=107).contains(&v) { + vec![(v + 139) as u8] + } else { + let mut o = vec![28]; + o.extend_from_slice(&(v as i16).to_be_bytes()); + o + } + }; + let mut square = vec![]; + for v in [600, 50, 0] { + square.extend(enc(v)); + } + square.push(21); + for v in [500, 500, -500] { + square.extend(enc(v)); + } + square.push(6); + square.push(14); + let font = write_test_cff(&[(".notdef", vec![14]), ("square", square)]); + let cff = Cff::parse(&font).unwrap(); + assert_eq!(cff.glyph_count(), 2); + let g = cff.gid_by_name("square").unwrap(); + assert_eq!(g, 1); + let (p, w) = cff.outline(g).unwrap(); + assert_eq!(w, 600.0); + assert!((p.area().abs() - 250_000.0).abs() < 1e-6, "{}", p.area()); + assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); + } +} diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index b35e9f39f..256f5d2fd 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -1,5 +1,5 @@ //! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled, -//! exponential and stitching; PostScript calculator functions are not evaluated). +//! exponential, stitching and PostScript calculator functions). use crate::object::{Dict, File, Obj, decode_stream}; @@ -98,7 +98,13 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs { let head = a.first().map(|h| file.resolve(h)).and_then(Obj::name).unwrap_or(""); match head { "ICCBased" => { - let n = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict).and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize; + let d = a.get(1).map(|s| file.resolve(s)).and_then(Obj::dict); + // The profile's alternate space when it names one (profiles are not + // evaluated), else the device space with the same number of components. + if let Some(alt) = d.and_then(|d| file.get(d, "Alternate")) { + return color_space(file, alt, res); + } + let n = d.and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize; match n { 1 => Cs::Gray, 4 => Cs::Cmyk, @@ -163,9 +169,197 @@ pub enum Func { }, /// An array of one-output functions. Array(Vec), + /// Type 4: a PostScript calculator program (§7.10.5). + Calc { + domain: Vec, + range: Vec, + code: Vec, + }, Unsupported, } +/// A PostScript calculator token; procedures nest for `if` / `ifelse`. +#[derive(Clone, Debug, PartialEq)] +pub enum PsOp { + Num(f64), + Op(String), + Proc(Vec), +} + +fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { + let mut out = vec![]; + while let Some(o) = lx.next() { + match o { + Obj::Num(n) => out.push(PsOp::Num(n)), + Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })), + Obj::Op(op) if op == "{" => { + if depth < 32 { + out.push(PsOp::Proc(ps_parse(lx, depth + 1))); + } + } + Obj::Op(op) if op == "}" => return out, + Obj::Op(op) => out.push(PsOp::Op(op)), + _ => {} + } + } + out +} + +fn ps_run(code: &[PsOp], st: &mut Vec, depth: usize) { + if depth > 32 { + return; + } + let mut procs: Vec<&[PsOp]> = vec![]; + for op in code { + match op { + PsOp::Num(n) => st.push(*n), + PsOp::Proc(p) => procs.push(p), + PsOp::Op(o) => { + let pop = |st: &mut Vec| st.pop().unwrap_or(0.0); + let b = |v: bool| if v { 1.0 } else { 0.0 }; + match o.as_str() { + "if" => { + let p = procs.pop(); + if pop(st) != 0.0 + && let Some(p) = p + { + ps_run(p, st, depth + 1); + } + } + "ifelse" => { + let (f, t) = (procs.pop(), procs.pop()); + let c = pop(st) != 0.0; + if let Some(p) = if c { t } else { f } { + ps_run(p, st, depth + 1); + } + } + "true" => st.push(1.0), + "false" => st.push(0.0), + "pop" => { + st.pop(); + } + "dup" => { + let v = st.last().copied().unwrap_or(0.0); + st.push(v); + } + "exch" => { + let n = st.len(); + if n >= 2 { + st.swap(n - 1, n - 2); + } + } + "copy" => { + let k = pop(st).max(0.0) as usize; + let n = st.len(); + if k <= n { + let tail = st[n - k..].to_vec(); + st.extend(tail); + } + } + "index" => { + let k = pop(st).max(0.0) as usize; + let v = st.len().checked_sub(k + 1).map(|i| st[i]).unwrap_or(0.0); + st.push(v); + } + "roll" => { + let j = pop(st) as i64; + let n = pop(st).max(0.0) as usize; + let len = st.len(); + if n > 0 && n <= len { + let s = &mut st[len - n..]; + let r = j.rem_euclid(n as i64) as usize; + s.rotate_right(r); + } + } + _ => { + // Unary and binary operators. + let unary: Option f64> = match o.as_str() { + "abs" => Some(f64::abs), + "neg" => Some(|x| -x), + "ceiling" => Some(f64::ceil), + "floor" => Some(f64::floor), + "round" => Some(|x: f64| (x + 0.5).floor()), + "truncate" | "cvi" => Some(f64::trunc), + "cvr" => Some(|x| x), + "sqrt" => Some(|x: f64| x.max(0.0).sqrt()), + "sin" => Some(|x: f64| x.to_radians().sin()), + "cos" => Some(|x: f64| x.to_radians().cos()), + "ln" => Some(|x: f64| x.max(1e-300).ln()), + "log" => Some(|x: f64| x.max(1e-300).log10()), + "not" => Some(|x: f64| { + if x == 0.0 { + 1.0 + } else if x == 1.0 { + 0.0 + } else { + !(x as i64) as f64 + } + }), + _ => None, + }; + if let Some(f) = unary { + let x = pop(st); + st.push(f(x)); + continue; + } + let y = pop(st); + let x = pop(st); + let v = match o.as_str() { + "add" => x + y, + "sub" => x - y, + "mul" => x * y, + "div" => { + if y != 0.0 { + x / y + } else { + 0.0 + } + } + "idiv" => { + if y as i64 != 0 { + ((x as i64) / (y as i64)) as f64 + } else { + 0.0 + } + } + "mod" => { + if y as i64 != 0 { + ((x as i64) % (y as i64)) as f64 + } else { + 0.0 + } + } + "exp" => x.powf(y), + "atan" => { + let a = x.atan2(y).to_degrees(); + if a < 0.0 { a + 360.0 } else { a } + } + "eq" => b(x == y), + "ne" => b(x != y), + "gt" => b(x > y), + "ge" => b(x >= y), + "lt" => b(x < y), + "le" => b(x <= y), + "and" => ((x as i64) & (y as i64)) as f64, + "or" => ((x as i64) | (y as i64)) as f64, + "xor" => ((x as i64) ^ (y as i64)) as f64, + "bitshift" => { + let (v, s) = (x as i64, y as i64); + (if s >= 0 { v << s.min(62) } else { v >> (-s).min(62) }) as f64 + } + _ => { + st.push(x); + y + } + }; + st.push(v); + } + } + } + } + } +} + impl Func { pub fn read(file: &File, o: &Obj) -> Func { let o = file.resolve(o); @@ -215,6 +409,17 @@ impl Func { } Func::Sampled { domain, size, outputs, encode, decode, samples } } + 4 => { + let Obj::Stream(sd, raw) = o else { return Func::Unsupported }; + let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported }; + let mut lx = crate::object::Lexer::new(&data, 0); + // The program is one procedure: `{ … }`. + let code = match ps_parse(&mut lx, 0).into_iter().next() { + Some(PsOp::Proc(p)) => p, + _ => return Func::Unsupported, + }; + Func::Calc { domain: dom, range: file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default(), code } + } _ => Func::Unsupported, } } @@ -222,6 +427,20 @@ impl Func { /// Evaluate at the first input (the functions used by shadings and tints take one input /// in practice; extra inputs of DeviceN tints are averaged in). pub fn eval(&self, input: &[f64]) -> Vec { + if let Func::Calc { domain, range, code } = self { + let mut st: Vec = input + .iter() + .enumerate() + .map(|(i, v)| match (domain.get(2 * i), domain.get(2 * i + 1)) { + (Some(a), Some(b)) => v.clamp(a.min(*b), a.max(*b)), + _ => *v, + }) + .collect(); + ps_run(code, &mut st, 0); + let n = range.len() / 2; + let k = st.len().saturating_sub(n); + return st[k..].iter().enumerate().map(|(i, v)| v.clamp(range[2 * i], range[2 * i + 1])).collect(); + } let t = input.first().copied().unwrap_or(0.0); self.eval1(t) } @@ -262,6 +481,7 @@ impl Func { .collect() } Func::Array(fs) => fs.iter().map(|f| f.eval1(t).first().copied().unwrap_or(0.0)).collect(), + Func::Calc { .. } => self.eval(&[t]), Func::Unsupported => vec![0.5], } } @@ -286,3 +506,19 @@ pub fn text_string(b: &[u8]) -> String { } b.iter().map(|&c| c as char).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calculator_functions() { + // Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 } + let code = b"{ 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } { 0.25 mul } ifelse 0 }"; + let mut lx = crate::object::Lexer::new(code, 0); + let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() }; + let f = Func::Calc { domain: vec![0.0, 1.0, 0.0, 1.0], range: vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0], code }; + assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]); + assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]); + } +} diff --git a/crates/pdf/src/encoding.rs b/crates/pdf/src/encoding.rs new file mode 100644 index 000000000..ebb63c321 --- /dev/null +++ b/crates/pdf/src/encoding.rs @@ -0,0 +1,389 @@ +//! Simple-font encodings (ISO 32000-1 §9.6.6 and Annex D): StandardEncoding, WinAnsiEncoding +//! and MacRomanEncoding as code → Unicode tables, and the Latin glyph names those encodings +//! use (with `uniXXXX` / `uXXXX[XX]` names for everything else). + +/// Glyph names of the Latin text character set (Annex D.2), with their Unicode values. +const NAMES: &[(&str, u32)] = &[ + ("space", 0x20), + ("exclam", 0x21), + ("quotedbl", 0x22), + ("numbersign", 0x23), + ("dollar", 0x24), + ("percent", 0x25), + ("ampersand", 0x26), + ("quotesingle", 0x27), + ("parenleft", 0x28), + ("parenright", 0x29), + ("asterisk", 0x2A), + ("plus", 0x2B), + ("comma", 0x2C), + ("hyphen", 0x2D), + ("period", 0x2E), + ("slash", 0x2F), + ("zero", 0x30), + ("one", 0x31), + ("two", 0x32), + ("three", 0x33), + ("four", 0x34), + ("five", 0x35), + ("six", 0x36), + ("seven", 0x37), + ("eight", 0x38), + ("nine", 0x39), + ("colon", 0x3A), + ("semicolon", 0x3B), + ("less", 0x3C), + ("equal", 0x3D), + ("greater", 0x3E), + ("question", 0x3F), + ("at", 0x40), + ("bracketleft", 0x5B), + ("backslash", 0x5C), + ("bracketright", 0x5D), + ("asciicircum", 0x5E), + ("underscore", 0x5F), + ("grave", 0x60), + ("braceleft", 0x7B), + ("bar", 0x7C), + ("braceright", 0x7D), + ("asciitilde", 0x7E), + ("exclamdown", 0xA1), + ("cent", 0xA2), + ("sterling", 0xA3), + ("currency", 0xA4), + ("yen", 0xA5), + ("brokenbar", 0xA6), + ("section", 0xA7), + ("dieresis", 0xA8), + ("copyright", 0xA9), + ("ordfeminine", 0xAA), + ("guillemotleft", 0xAB), + ("logicalnot", 0xAC), + ("registered", 0xAE), + ("macron", 0xAF), + ("degree", 0xB0), + ("plusminus", 0xB1), + ("twosuperior", 0xB2), + ("threesuperior", 0xB3), + ("acute", 0xB4), + ("mu", 0xB5), + ("paragraph", 0xB6), + ("periodcentered", 0xB7), + ("cedilla", 0xB8), + ("onesuperior", 0xB9), + ("ordmasculine", 0xBA), + ("guillemotright", 0xBB), + ("onequarter", 0xBC), + ("onehalf", 0xBD), + ("threequarters", 0xBE), + ("questiondown", 0xBF), + ("Agrave", 0xC0), + ("Aacute", 0xC1), + ("Acircumflex", 0xC2), + ("Atilde", 0xC3), + ("Adieresis", 0xC4), + ("Aring", 0xC5), + ("AE", 0xC6), + ("Ccedilla", 0xC7), + ("Egrave", 0xC8), + ("Eacute", 0xC9), + ("Ecircumflex", 0xCA), + ("Edieresis", 0xCB), + ("Igrave", 0xCC), + ("Iacute", 0xCD), + ("Icircumflex", 0xCE), + ("Idieresis", 0xCF), + ("Eth", 0xD0), + ("Ntilde", 0xD1), + ("Ograve", 0xD2), + ("Oacute", 0xD3), + ("Ocircumflex", 0xD4), + ("Otilde", 0xD5), + ("Odieresis", 0xD6), + ("multiply", 0xD7), + ("Oslash", 0xD8), + ("Ugrave", 0xD9), + ("Uacute", 0xDA), + ("Ucircumflex", 0xDB), + ("Udieresis", 0xDC), + ("Yacute", 0xDD), + ("Thorn", 0xDE), + ("germandbls", 0xDF), + ("agrave", 0xE0), + ("aacute", 0xE1), + ("acircumflex", 0xE2), + ("atilde", 0xE3), + ("adieresis", 0xE4), + ("aring", 0xE5), + ("ae", 0xE6), + ("ccedilla", 0xE7), + ("egrave", 0xE8), + ("eacute", 0xE9), + ("ecircumflex", 0xEA), + ("edieresis", 0xEB), + ("igrave", 0xEC), + ("iacute", 0xED), + ("icircumflex", 0xEE), + ("idieresis", 0xEF), + ("eth", 0xF0), + ("ntilde", 0xF1), + ("ograve", 0xF2), + ("oacute", 0xF3), + ("ocircumflex", 0xF4), + ("otilde", 0xF5), + ("odieresis", 0xF6), + ("divide", 0xF7), + ("oslash", 0xF8), + ("ugrave", 0xF9), + ("uacute", 0xFA), + ("ucircumflex", 0xFB), + ("udieresis", 0xFC), + ("yacute", 0xFD), + ("thorn", 0xFE), + ("ydieresis", 0xFF), + ("dotlessi", 0x131), + ("Lslash", 0x141), + ("lslash", 0x142), + ("OE", 0x152), + ("oe", 0x153), + ("Scaron", 0x160), + ("scaron", 0x161), + ("Ydieresis", 0x178), + ("Zcaron", 0x17D), + ("zcaron", 0x17E), + ("florin", 0x192), + ("circumflex", 0x2C6), + ("caron", 0x2C7), + ("breve", 0x2D8), + ("dotaccent", 0x2D9), + ("ring", 0x2DA), + ("ogonek", 0x2DB), + ("tilde", 0x2DC), + ("hungarumlaut", 0x2DD), + ("Omega", 0x3A9), + ("pi", 0x3C0), + ("endash", 0x2013), + ("emdash", 0x2014), + ("quoteleft", 0x2018), + ("quoteright", 0x2019), + ("quotesinglbase", 0x201A), + ("quotedblleft", 0x201C), + ("quotedblright", 0x201D), + ("quotedblbase", 0x201E), + ("dagger", 0x2020), + ("daggerdbl", 0x2021), + ("bullet", 0x2022), + ("ellipsis", 0x2026), + ("perthousand", 0x2030), + ("guilsinglleft", 0x2039), + ("guilsinglright", 0x203A), + ("fraction", 0x2044), + ("Euro", 0x20AC), + ("trademark", 0x2122), + ("partialdiff", 0x2202), + ("Delta", 0x2206), + ("product", 0x220F), + ("summation", 0x2211), + ("minus", 0x2212), + ("radical", 0x221A), + ("infinity", 0x221E), + ("integral", 0x222B), + ("approxequal", 0x2248), + ("notequal", 0x2260), + ("lessequal", 0x2264), + ("greaterequal", 0x2265), + ("lozenge", 0x25CA), + ("fi", 0xFB01), + ("fl", 0xFB02), + ("nbspace", 0xA0), + ("sfthyphen", 0xAD), +]; + +/// Unicode value of a glyph name (`A`, `eacute`, `uni00E9`, `u1F600`, `f_i`→first part). +pub fn name_to_unicode(name: &str) -> Option { + let base = name.split('.').next().unwrap_or(name); + if base.len() == 1 && base.as_bytes()[0].is_ascii_alphabetic() { + return base.chars().next(); + } + if let Some((_, u)) = NAMES.iter().find(|(n, _)| *n == base) { + return char::from_u32(*u); + } + if let Some(h) = base.strip_prefix("uni") + && h.len() >= 4 + { + return u32::from_str_radix(&h[..4], 16).ok().and_then(char::from_u32); + } + if let Some(h) = base.strip_prefix('u') + && (4..=6).contains(&h.len()) + { + return u32::from_str_radix(h, 16).ok().and_then(char::from_u32); + } + // Ligature names (`f_f_i`): the first component. + if let Some((a, _)) = base.split_once('_') { + return name_to_unicode(a); + } + None +} + +/// The glyph name of a Unicode value (Latin names, else `uniXXXX`). +pub fn unicode_to_name(c: char) -> String { + let u = c as u32; + if c.is_ascii_alphabetic() { + return c.to_string(); + } + match NAMES.iter().find(|(_, v)| *v == u) { + Some((n, _)) => (*n).to_string(), + None => format!("uni{u:04X}"), + } +} + +/// A simple font's base encoding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Base { + Standard, + WinAnsi, + MacRoman, +} + +impl Base { + pub fn from_name(n: &str) -> Option { + match n { + "StandardEncoding" => Some(Base::Standard), + "WinAnsiEncoding" => Some(Base::WinAnsi), + "MacRomanEncoding" => Some(Base::MacRoman), + _ => None, + } + } + + /// The character of a code (`None` for unassigned codes). + pub fn unicode(self, code: u8) -> Option { + let u = match self { + Base::Standard => standard(code)?, + Base::WinAnsi => win_ansi(code)?, + Base::MacRoman => mac_roman(code)?, + }; + char::from_u32(u) + } + + /// The glyph name of a code. + pub fn name(self, code: u8) -> Option { + self.unicode(code).map(unicode_to_name) + } +} + +fn ascii(code: u8) -> Option { + (0x20..0x7F).contains(&code).then_some(code as u32) +} + +fn standard(code: u8) -> Option { + Some(match code { + 0x27 => 0x2019, + 0x60 => 0x2018, + 0x20..=0x7E => code as u32, + 0xA1 => 0xA1, + 0xA2 => 0xA2, + 0xA3 => 0xA3, + 0xA4 => 0x2044, + 0xA5 => 0xA5, + 0xA6 => 0x192, + 0xA7 => 0xA7, + 0xA8 => 0xA4, + 0xA9 => 0x27, + 0xAA => 0x201C, + 0xAB => 0xAB, + 0xAC => 0x2039, + 0xAD => 0x203A, + 0xAE => 0xFB01, + 0xAF => 0xFB02, + 0xB1 => 0x2013, + 0xB2 => 0x2020, + 0xB3 => 0x2021, + 0xB4 => 0xB7, + 0xB6 => 0xB6, + 0xB7 => 0x2022, + 0xB8 => 0x201A, + 0xB9 => 0x201E, + 0xBA => 0x201D, + 0xBB => 0xBB, + 0xBC => 0x2026, + 0xBD => 0x2030, + 0xBF => 0xBF, + 0xC1 => 0x60, + 0xC2 => 0xB4, + 0xC3 => 0x2C6, + 0xC4 => 0x2DC, + 0xC5 => 0xAF, + 0xC6 => 0x2D8, + 0xC7 => 0x2D9, + 0xC8 => 0xA8, + 0xCA => 0x2DA, + 0xCB => 0xB8, + 0xCD => 0x2DD, + 0xCE => 0x2DB, + 0xCF => 0x2C7, + 0xD0 => 0x2014, + 0xE1 => 0xC6, + 0xE3 => 0xAA, + 0xE8 => 0x141, + 0xE9 => 0xD8, + 0xEA => 0x152, + 0xEB => 0xBA, + 0xF1 => 0xE6, + 0xF5 => 0x131, + 0xF8 => 0x142, + 0xF9 => 0xF8, + 0xFA => 0x153, + 0xFB => 0xDF, + _ => return None, + }) +} + +/// Windows code page 1252 (WinAnsiEncoding; bullets for the unused codes, as Annex D notes). +fn win_ansi(code: u8) -> Option { + const HIGH: [u32; 32] = [ + 0x20AC, 0x2022, 0x201A, 0x192, 0x201E, 0x2026, 0x2020, 0x2021, 0x2C6, 0x2030, 0x160, 0x2039, 0x152, 0x2022, 0x17D, 0x2022, 0x2022, 0x2018, 0x2019, + 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x2DC, 0x2122, 0x161, 0x203A, 0x153, 0x2022, 0x17E, 0x178, + ]; + match code { + 0x80..=0x9F => Some(HIGH[(code - 0x80) as usize]), + 0xA0 => Some(0x20), + 0xA1..=0xFF => Some(code as u32), + _ => ascii(code), + } +} + +fn mac_roman(code: u8) -> Option { + const HIGH: [u32; 128] = [ + 0xC4, 0xC5, 0xC7, 0xC9, 0xD1, 0xD6, 0xDC, 0xE1, 0xE0, 0xE2, 0xE4, 0xE3, 0xE5, 0xE7, 0xE9, 0xE8, 0xEA, 0xEB, 0xED, 0xEC, 0xEE, 0xEF, 0xF1, 0xF3, 0xF2, + 0xF4, 0xF6, 0xF5, 0xFA, 0xF9, 0xFB, 0xFC, 0x2020, 0xB0, 0xA2, 0xA3, 0xA7, 0x2022, 0xB6, 0xDF, 0xAE, 0xA9, 0x2122, 0xB4, 0xA8, 0x2260, 0xC6, 0xD8, + 0x221E, 0xB1, 0x2264, 0x2265, 0xA5, 0xB5, 0x2202, 0x2211, 0x220F, 0x3C0, 0x222B, 0xAA, 0xBA, 0x3A9, 0xE6, 0xF8, 0xBF, 0xA1, 0xAC, 0x221A, 0x192, + 0x2248, 0x2206, 0xAB, 0xBB, 0x2026, 0x20, 0xC0, 0xC3, 0xD5, 0x152, 0x153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0xF7, 0x25CA, 0xFF, 0x178, + 0x2044, 0xA4, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0xB7, 0x201A, 0x201E, 0x2030, 0xC2, 0xCA, 0xC1, 0xCB, 0xC8, 0xCD, 0xCE, 0xCF, 0xCC, 0xD3, 0xD4, + 0xF8FF, 0xD2, 0xDA, 0xDB, 0xD9, 0x131, 0x2C6, 0x2DC, 0xAF, 0x2D8, 0x2D9, 0x2DA, 0xB8, 0x2DD, 0x2DB, 0x2C7, + ]; + match code { + 0x80..=0xFF => Some(HIGH[(code - 0x80) as usize]), + 0x27 => Some(0x27), + _ => ascii(code), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodings_and_names() { + assert_eq!(Base::WinAnsi.unicode(0x80), Some('€')); + assert_eq!(Base::WinAnsi.name(0xE9).as_deref(), Some("eacute")); + assert_eq!(Base::Standard.name(0x27).as_deref(), Some("quoteright")); + assert_eq!(Base::Standard.name(0xAE).as_deref(), Some("fi")); + assert_eq!(Base::MacRoman.unicode(0x8E), Some('é')); + assert_eq!(name_to_unicode("uni20AC"), Some('€')); + assert_eq!(name_to_unicode("u1F600"), Some('😀')); + assert_eq!(name_to_unicode("a.sc"), Some('a')); + assert_eq!(name_to_unicode("f_i"), Some('f')); + assert_eq!(unicode_to_name('Ω'), "Omega"); + assert_eq!(unicode_to_name('中'), "uni4E2D"); + } +} diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs new file mode 100644 index 000000000..480873b0e --- /dev/null +++ b/crates/pdf/src/font.rs @@ -0,0 +1,636 @@ +//! PDF fonts (ISO 32000-1 §9.5–9.10): simple fonts (Type 1, TrueType, Type 3) and composite +//! (Type 0) fonts with their encodings, widths and embedded programs, turned into glyph +//! outlines in text space. +//! +//! Embedded programs: TrueType (`FontFile2`, through skrifa), CFF (`FontFile3` `/Type1C`, +//! `/CIDFontType0C` and OpenType, [`crate::cff`]), Type 1 (`FontFile`, [`crate::type1`]) and +//! Type 3 glyph procedures (drawn by the content interpreter). Fonts that are not embedded +//! (the standard 14 and others) are drawn with the bundled OFL fonts: Inter for sans serif, +//! Noto Serif for serif and JetBrains Mono for monospaced families. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use kurbo::{Affine, BezPath}; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::cmap::PlatformId; +use skrifa::{FontRef, GlyphId, MetadataProvider}; + +use crate::cff::Cff; +use crate::encoding::{Base, name_to_unicode}; +use crate::object::{Dict, File, Lexer, Obj, decode_stream}; +use crate::type1::Type1; + +pub(crate) enum Program { + TrueType(Arc>), + Cff(Box), + Type1(Box), + /// A bundled stand-in (not embedded). + Fallback(&'static [u8]), + /// Glyph procedures: (name → content stream, resources). + Type3 { + procs: HashMap>, + resources: Option, + }, + None, +} + +pub(crate) struct Font { + /// Two-byte (or CMap-coded) codes and CIDs. + pub composite: bool, + pub program: Program, + /// Glyph space → text space. + pub matrix: Affine, + first_char: u32, + widths: Vec, + cid_widths: HashMap, + default_width: f64, + /// Simple fonts: code → glyph name. + names: Vec>, + symbolic: bool, + to_unicode: HashMap, + cid_to_gid: Option>, + /// Composite fonts: code space ranges (bytes, lo, hi) and code → CID ranges. + codespace: Vec<(usize, u32, u32)>, + cid_ranges: Vec<(u32, u32, u32)>, + pub vertical: bool, + cache: RefCell>>>, + /// Program glyph advances (font units → text space) for fonts without `/Widths`. + has_widths: bool, + pub base_name: String, +} + +struct Pen(BezPath); + +impl OutlinePen for Pen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((x as f64, y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((x as f64, y as f64)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64)); + } + fn close(&mut self) { + self.0.close_path(); + } +} + +/// A bundled stand-in for a font that is not embedded, by its PostScript name. +fn fallback(base: &str) -> &'static [u8] { + use effectcraft_text::fonts::*; + let b = base.to_ascii_lowercase(); + if b.contains("courier") || b.contains("mono") || b.contains("consol") { + return JETBRAINS_MONO_REGULAR; + } + if b.contains("times") + || b.contains("serif") && !b.contains("sans") + || b.contains("georgia") + || b.contains("garamond") + || b.contains("minion") + || b.contains("roman") + { + return NOTO_SERIF_REGULAR; + } + if b.contains("bold") || b.contains("black") || b.contains("heavy") { + return INTER_BOLD; + } + if b.contains("semibold") || b.contains("demi") { + return INTER_SEMIBOLD; + } + if b.contains("italic") || b.contains("oblique") { + return INTER_ITALIC; + } + INTER_REGULAR +} + +fn sfnt_upem(data: &[u8]) -> f64 { + FontRef::new(data).ok().and_then(|f| f.head().ok().map(|h| h.units_per_em() as f64)).filter(|u| *u > 0.0).unwrap_or(1000.0) +} + +/// A TrueType / OpenType glyph's outline in font units. +fn sfnt_outline(data: &[u8], gid: u16) -> Option { + let font = FontRef::new(data).ok()?; + let g = font.outline_glyphs().get(GlyphId::new(gid as u32))?; + let mut pen = Pen(BezPath::new()); + g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?; + Some(pen.0) +} + +fn sfnt_advance(data: &[u8], gid: u16) -> Option { + let font = FontRef::new(data).ok()?; + font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid as u32)).map(|a| a as f64) +} + +/// A glyph of a TrueType font for a simple-font code (§9.6.6.4): the (3,1) subtable through +/// the glyph's Unicode value, the (3,0) symbol subtable at `0xF000 + code`, the (1,0) Macintosh +/// subtable by code, then `post` glyph names. +fn sfnt_glyph(data: &[u8], code: u8, name: Option<&str>, symbolic: bool) -> Option { + let font = FontRef::new(data).ok()?; + let cmap = font.cmap().ok(); + let sub = |pid: PlatformId, eid: u16| { + let cmap = cmap.as_ref()?; + cmap.encoding_records().iter().find(|r| r.platform_id() == pid && r.encoding_id() == eid).and_then(|r| r.subtable(cmap.offset_data()).ok()) + }; + let uni = name.and_then(name_to_unicode); + let ok = |g: Option| g.map(|g| g.to_u32() as u16).filter(|g| *g != 0); + if !symbolic + && let Some(u) = uni + && let Some(g) = ok(sub(PlatformId::Windows, 1).and_then(|s| s.map_codepoint(u as u32))).or_else(|| ok(font.charmap().map(u))) + { + return Some(g); + } + if let Some(s) = sub(PlatformId::Windows, 0) { + for c in [0xF000 + code as u32, 0xF100 + code as u32, 0xF200 + code as u32, code as u32] { + if let Some(g) = ok(s.map_codepoint(c)) { + return Some(g); + } + } + } + if let Some(g) = ok(sub(PlatformId::Macintosh, 0).and_then(|s| s.map_codepoint(code as u32))) { + return Some(g); + } + if let Some(n) = name + && let Some((g, _)) = font.glyph_names().iter().find(|(_, gn)| gn.as_str() == n) + { + return Some(g.to_u32() as u16); + } + if let Some(u) = uni { + return ok(font.charmap().map(u)); + } + // Symbolic fonts without a usable cmap: the code as the glyph index. + (symbolic && code != 0).then_some(code as u16) +} + +/// `/Differences [code /name /name … code /name …]`. +fn differences(file: &File, enc: &Dict, names: &mut [Option]) { + let Some(diffs) = file.get(enc, "Differences").and_then(Obj::array) else { return }; + let mut code = 0usize; + for d in diffs { + match file.resolve(d) { + Obj::Num(n) => code = *n as usize, + Obj::Name(n) => { + if let Some(slot) = names.get_mut(code) { + *slot = Some(n.clone()); + } + code += 1; + } + _ => {} + } + } +} + +/// A ToUnicode or encoding CMap: `bfchar` / `bfrange` → strings, `cidchar` / `cidrange` → +/// CIDs, `codespacerange` → code lengths. +#[derive(Default)] +struct CMap { + codespace: Vec<(usize, u32, u32)>, + unicode: HashMap, + cids: Vec<(u32, u32, u32)>, +} + +fn be(b: &[u8]) -> u32 { + b.iter().fold(0u32, |a, &c| (a << 8) | c as u32) +} + +fn utf16(b: &[u8]) -> String { + let u: Vec = b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect(); + String::from_utf16_lossy(&u) +} + +fn parse_cmap(data: &[u8]) -> CMap { + let mut m = CMap::default(); + let mut lx = Lexer::new(data, 0); + let mut args: Vec = vec![]; + let mut mode = ""; + while let Some(o) = lx.next() { + let Obj::Op(op) = o else { + args.push(o); + continue; + }; + match op.as_str() { + "begincodespacerange" => mode = "cs", + "beginbfchar" => mode = "bfchar", + "beginbfrange" => mode = "bfrange", + "begincidchar" => mode = "cidchar", + "begincidrange" => mode = "cidrange", + "endcodespacerange" | "endbfchar" | "endbfrange" | "endcidchar" | "endcidrange" => { + let a = std::mem::take(&mut args); + match mode { + "cs" => { + for p in a.chunks_exact(2) { + if let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) { + m.codespace.push((lo.len().max(1), be(lo), be(hi))); + } + } + } + "bfchar" => { + for p in a.chunks_exact(2) { + if let (Obj::Str(src), Obj::Str(dst)) = (&p[0], &p[1]) { + m.unicode.insert(be(src), utf16(dst)); + } + } + } + "bfrange" => { + for p in a.chunks_exact(3) { + let (Obj::Str(lo), Obj::Str(hi)) = (&p[0], &p[1]) else { continue }; + let (lo, hi) = (be(lo), be(hi)); + if hi < lo || hi - lo > 65535 { + continue; + } + match &p[2] { + Obj::Str(dst) => { + let mut d = dst.clone(); + for c in lo..=hi { + m.unicode.insert(c, utf16(&d)); + if let Some(last) = d.last_mut() { + *last = last.wrapping_add(1); + } + } + } + Obj::Array(list) => { + for (k, s) in list.iter().enumerate() { + if let Obj::Str(s) = s { + m.unicode.insert(lo + k as u32, utf16(s)); + } + } + } + _ => {} + } + } + } + "cidchar" => { + for p in a.chunks_exact(2) { + if let (Obj::Str(src), Some(cid)) = (&p[0], p[1].num()) { + let c = be(src); + m.cids.push((c, c, cid as u32)); + } + } + } + "cidrange" => { + for p in a.chunks_exact(3) { + if let (Obj::Str(lo), Obj::Str(hi), Some(cid)) = (&p[0], &p[1], p[2].num()) { + m.cids.push((be(lo), be(hi), cid as u32)); + } + } + } + _ => {} + } + mode = ""; + } + _ => { + if mode.is_empty() { + args.clear(); + } + } + } + } + m +} + +impl Font { + /// Read a font dictionary. `skipped` collects what could not be read. + pub fn load(file: &File, d: &Dict, skipped: &mut Vec) -> Font { + let subtype = file.get(d, "Subtype").and_then(Obj::name).unwrap_or("Type1").to_string(); + let base_name = file.get(d, "BaseFont").and_then(Obj::name).unwrap_or("").to_string(); + let mut f = Font { + composite: subtype == "Type0", + program: Program::None, + matrix: Affine::scale(0.001), + first_char: 0, + widths: vec![], + cid_widths: HashMap::new(), + default_width: 0.0, + names: vec![None; 256], + symbolic: false, + to_unicode: HashMap::new(), + cid_to_gid: None, + codespace: vec![], + cid_ranges: vec![], + vertical: false, + cache: RefCell::new(HashMap::new()), + has_widths: false, + base_name: base_name.clone(), + }; + if let Some(Obj::Stream(sd, raw)) = file.get(d, "ToUnicode") + && let Some(data) = decode_stream(file, sd, raw) + { + f.to_unicode = parse_cmap(&data).unicode; + } + let (desc_dict, fd) = if f.composite { + let desc = file + .get(d, "DescendantFonts") + .and_then(Obj::array) + .and_then(|a| a.first()) + .map(|x| file.resolve(x)) + .and_then(Obj::dict) + .cloned() + .unwrap_or_default(); + let fd = file.get_dict(&desc, "FontDescriptor").cloned(); + (desc, fd) + } else { + (d.clone(), file.get_dict(d, "FontDescriptor").cloned()) + }; + if let Some(fd) = &fd { + f.symbolic = file.get_num(fd, "Flags").is_some_and(|fl| (fl as u32) & 4 != 0); + } + // Widths. + if f.composite { + f.default_width = file.get_num(&desc_dict, "DW").unwrap_or(1000.0) / 1000.0; + if let Some(w) = file.get(&desc_dict, "W").and_then(Obj::array) { + let mut i = 0; + while i < w.len() { + let first = file.resolve(&w[i]).num().unwrap_or(0.0) as u32; + match w.get(i + 1).map(|x| file.resolve(x)) { + Some(Obj::Array(list)) => { + for (k, v) in list.iter().enumerate() { + f.cid_widths.insert(first + k as u32, file.resolve(v).num().unwrap_or(0.0) / 1000.0); + } + i += 2; + } + Some(Obj::Num(last)) => { + let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0; + for c in first..=(*last as u32).min(first + 65535) { + f.cid_widths.insert(c, wv); + } + i += 3; + } + _ => break, + } + } + } + f.has_widths = true; + // Encoding CMap. + match file.get(d, "Encoding") { + Some(Obj::Name(n)) => { + f.vertical = n.ends_with("-V"); + if !n.starts_with("Identity") { + skipped.push(format!("CMap {n}")); + } + } + Some(Obj::Stream(sd, raw)) => { + if let Some(data) = decode_stream(file, sd, raw) { + let cm = parse_cmap(&data); + f.codespace = cm.codespace; + f.cid_ranges = cm.cids; + } + } + _ => {} + } + match file.get(&desc_dict, "CIDToGIDMap") { + Some(Obj::Stream(sd, raw)) => { + f.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); + } + _ => f.cid_to_gid = None, + } + } else { + f.first_char = file.get_num(d, "FirstChar").unwrap_or(0.0) as u32; + if let Some(w) = file.get(d, "Widths") { + f.widths = file.nums(w); + f.has_widths = !f.widths.is_empty(); + } + if let Some(fd) = &fd { + f.default_width = file.get_num(fd, "MissingWidth").unwrap_or(0.0); + } + } + // The program. + let font_file = |key: &str| -> Option<(Dict, Vec)> { + let fd = fd.as_ref()?; + match file.get(fd, key) { + Some(Obj::Stream(sd, raw)) => Some((sd.clone(), decode_stream(file, sd, raw)?)), + _ => None, + } + }; + if subtype == "Type3" { + f.matrix = file + .get(d, "FontMatrix") + .map(|m| file.nums(m)) + .filter(|m| m.len() == 6) + .map(|m| Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])) + .unwrap_or(Affine::scale(0.001)); + let mut procs = HashMap::new(); + if let Some(cp) = file.get_dict(d, "CharProcs") { + for (k, v) in cp { + if let Obj::Stream(sd, raw) = file.resolve(v) + && let Some(data) = decode_stream(file, sd, raw) + { + procs.insert(k.clone(), data); + } + } + } + f.program = Program::Type3 { procs, resources: file.get_dict(d, "Resources").cloned() }; + // Type 3 widths are in glyph space. + let m = f.matrix.as_coeffs()[0]; + for w in &mut f.widths { + *w *= m * 1000.0; + } + } else if let Some((_, data)) = font_file("FontFile2") { + f.program = Program::TrueType(Arc::new(data)); + } else if let Some((sd, data)) = font_file("FontFile3") { + match file.get(&sd, "Subtype").and_then(Obj::name) { + Some("OpenType") => { + // An OpenType wrapper: TrueType outlines through skrifa, CFF through ours. + let cff = FontRef::new(&data).ok().and_then(|fr| fr.table_data(skrifa::Tag::new(b"CFF ")).map(|t| t.as_bytes().to_vec())); + match cff.and_then(|c| Cff::parse(&c)) { + Some(c) => f.program = Program::Cff(Box::new(c)), + None => f.program = Program::TrueType(Arc::new(data)), + } + } + _ => match Cff::parse(&data) { + Some(c) => f.program = Program::Cff(Box::new(c)), + None => skipped.push("font program (CFF)".into()), + }, + } + } else if let Some((_, data)) = font_file("FontFile") { + match Type1::parse(&data) { + Some(t) => f.program = Program::Type1(Box::new(t)), + None => skipped.push("font program (Type 1)".into()), + } + } else { + f.program = Program::Fallback(fallback(&base_name)); + } + if let Program::Cff(c) = &f.program { + let m = c.font_matrix; + f.matrix = Affine::new(m); + } + if let Program::Type1(t) = &f.program + && let Some(m) = t.font_matrix + { + f.matrix = Affine::new(m); + } + // Simple-font encoding: base encoding, built-in encoding, Differences. + if !f.composite { + let enc = file.get(d, "Encoding").map(|e| file.resolve(e)); + let base = match enc { + Some(Obj::Name(n)) => Base::from_name(n), + Some(Obj::Dict(e)) => file.get(e, "BaseEncoding").and_then(Obj::name).and_then(Base::from_name), + _ => None, + }; + let builtin: Option>> = match &f.program { + Program::Type1(t) if !t.encoding.is_empty() => Some((0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect()), + _ => None, + }; + let fallback_base = if f.symbolic { None } else { Some(Base::Standard) }; + for c in 0..256usize { + f.names[c] = match (base, &builtin) { + (Some(b), _) => b.name(c as u8), + (None, Some(bi)) => bi[c].clone(), + (None, None) => match &f.program { + // TrueType symbolic fonts map codes directly; others use Standard. + Program::TrueType(_) if f.symbolic => None, + Program::Fallback(_) if base_name.contains("Symbol") || base_name.contains("Dingbats") => None, + _ => fallback_base.or(Some(Base::Standard)).and_then(|b| b.name(c as u8)), + }, + }; + } + if let Some(Obj::Dict(e)) = enc { + differences(file, e, &mut f.names); + } + } + f + } + + /// Split a string into (code, byte length) pairs. + pub fn codes(&self, s: &[u8]) -> Vec<(u32, usize)> { + if !self.composite { + return s.iter().map(|&b| (b as u32, 1)).collect(); + } + let mut out = vec![]; + let mut i = 0; + while i < s.len() { + let mut len = 2; + if !self.codespace.is_empty() { + len = (1..=4) + .find(|&n| { + i + n <= s.len() && { + let v = be(&s[i..i + n]); + self.codespace.iter().any(|(l, lo, hi)| *l == n && v >= *lo && v <= *hi) + } + }) + .unwrap_or(1); + } + let end = (i + len).min(s.len()); + out.push((be(&s[i..end]), end - i)); + i = end; + } + out + } + + fn cid(&self, code: u32) -> u32 { + if self.cid_ranges.is_empty() { + return code; + } + self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0) + } + + /// The advance of a code in text space (before the font size). + pub fn width(&self, code: u32) -> f64 { + if self.composite { + return self.cid_widths.get(&self.cid(code)).copied().unwrap_or(self.default_width); + } + if self.has_widths { + return match code.checked_sub(self.first_char).and_then(|i| self.widths.get(i as usize)) { + Some(w) => w / 1000.0, + None => self.default_width / 1000.0, + }; + } + // No /Widths (standard 14 fonts): the program's own advance. + match &self.program { + Program::Fallback(data) => { + self.sfnt_gid(data, code).and_then(|g| sfnt_advance(data, g)).map(|a| a / sfnt_upem(data)).unwrap_or(if code == 32 { 0.25 } else { 0.5 }) + } + Program::TrueType(data) => self.sfnt_gid(data, code).and_then(|g| sfnt_advance(data, g)).map(|a| a / sfnt_upem(data)).unwrap_or(0.5), + Program::Type1(t) => self.name(code).and_then(|n| t.outline(n)).map(|(_, w)| self.matrix.as_coeffs()[0] * w).unwrap_or(0.5), + Program::Cff(c) => { + self.name(code).and_then(|n| c.gid_by_name(n)).and_then(|g| c.outline(g)).map(|(_, w)| self.matrix.as_coeffs()[0] * w).unwrap_or(0.5) + } + _ => 0.5, + } + } + + fn name(&self, code: u32) -> Option<&str> { + self.names.get(code as usize).and_then(|n| n.as_deref()) + } + + fn sfnt_gid(&self, data: &[u8], code: u32) -> Option { + if self.composite { + let cid = self.cid(code); + return Some(match &self.cid_to_gid { + Some(map) => *map.get(cid as usize)?, + None => cid as u16, + }); + } + // Stand-ins are looked up by Unicode (ToUnicode first). + if let Program::Fallback(_) = self.program + && let Some(u) = self.to_unicode.get(&code).and_then(|s| s.chars().next()) + { + let font = FontRef::new(data).ok()?; + return font.charmap().map(u).map(|g| g.to_u32() as u16); + } + sfnt_glyph(data, code as u8, self.name(code), self.symbolic && !matches!(self.program, Program::Fallback(_))) + } + + /// The Type 3 glyph procedure of a code. + pub fn type3_proc(&self, code: u32) -> Option<(&[u8], Option<&Dict>)> { + let Program::Type3 { procs, resources } = &self.program else { return None }; + let n = self.name(code)?; + procs.get(n).map(|p| (p.as_slice(), resources.as_ref())) + } + + /// A code's outline in text space (font size 1), cached. + pub fn glyph(&self, code: u32) -> Option> { + if let Some(v) = self.cache.borrow().get(&code) { + return v.clone(); + } + let path = self.glyph_uncached(code).map(Arc::new); + self.cache.borrow_mut().insert(code, path.clone()); + path + } + + fn glyph_uncached(&self, code: u32) -> Option { + match &self.program { + Program::TrueType(data) => self.sfnt_path(data, code), + Program::Fallback(data) => self.sfnt_path(data, code), + Program::Cff(c) => { + let g = if self.composite { + c.gid_by_cid(self.cid(code) as u16)? + } else { + match self.name(code).and_then(|n| c.gid_by_name(n)) { + Some(g) => g, + None => Some(c.encoding[(code & 255) as usize]).filter(|g| *g != 0)?, + } + }; + c.outline(g).map(|(p, _)| self.matrix * p) + } + Program::Type1(t) => { + let name = self.name(code).filter(|n| t.has_glyph(n)).or_else(|| t.encoding.get(&(code as u8)).map(String::as_str))?; + t.outline(name).map(|(p, _)| self.matrix * p) + } + Program::Type3 { .. } | Program::None => None, + } + } + + fn sfnt_path(&self, data: &[u8], code: u32) -> Option { + let g = self.sfnt_gid(data, code)?; + let p = sfnt_outline(data, g)?; + Some(Affine::scale(1.0 / sfnt_upem(data)) * p) + } + + /// The text of a code (ToUnicode, else the glyph name), for naming text shapes. + pub fn unicode(&self, code: u32) -> String { + if let Some(s) = self.to_unicode.get(&code) { + return s.clone(); + } + if self.composite { + return String::new(); + } + self.name(code).and_then(name_to_unicode).map(String::from).unwrap_or_default() + } +} diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs new file mode 100644 index 000000000..0f0a481dc --- /dev/null +++ b/crates/pdf/src/image.rs @@ -0,0 +1,310 @@ +//! Image XObjects and inline images (ISO 32000-1 §8.9): sample decoding (1–16 bits per +//! component, `/Decode` arrays), colour spaces (through [`crate::color`]: Indexed lookups and +//! ICCBased profiles by their `/Alternate` space), DCT (JPEG, through the pure-Rust `zune-jpeg`) +//! and the general filters, stencil masks (`/ImageMask`), soft masks (`/SMask`) and colour-key +//! and explicit masks (`/Mask`). The result is straight RGBA8. + +use crate::color::{Cs, color_space}; +use crate::object::{Dict, File, Obj, decode_stream}; + +pub(crate) struct Decoded { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The longest side of a decoded image (larger images are refused rather than exhausting +/// memory). +const MAX_SIDE: usize = 16384; + +fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> { + file.get(d, long).or_else(|| file.get(d, short)) +} + +fn filters(file: &File, d: &Dict) -> Vec { + match get(file, d, "Filter", "F").map(|f| file.resolve(f)) { + Some(Obj::Name(n)) => vec![n.clone()], + Some(Obj::Array(a)) => a.iter().filter_map(|f| file.resolve(f).name().map(str::to_string)).collect(), + _ => vec![], + } +} + +/// Inline-image abbreviations (§8.9.7) expanded to the full keys and names. +pub(crate) fn expand_inline(d: &Dict) -> Dict { + let mut out = Dict::new(); + for (k, v) in d { + let key = match k.as_str() { + "BPC" => "BitsPerComponent", + "CS" => "ColorSpace", + "D" => "Decode", + "DP" => "DecodeParms", + "F" => "Filter", + "H" => "Height", + "IM" => "ImageMask", + "I" => "Interpolate", + "W" => "Width", + other => other, + }; + let name = |n: &str| -> String { + match n { + "G" => "DeviceGray", + "RGB" => "DeviceRGB", + "CMYK" => "DeviceCMYK", + "I" => "Indexed", + "AHx" => "ASCIIHexDecode", + "A85" => "ASCII85Decode", + "LZW" => "LZWDecode", + "Fl" => "FlateDecode", + "RL" => "RunLengthDecode", + "CCF" => "CCITTFaxDecode", + "DCT" => "DCTDecode", + other => other, + } + .to_string() + }; + let v = match v { + Obj::Name(n) => Obj::Name(name(n)), + Obj::Array(a) => Obj::Array(a.iter().map(|x| if let Obj::Name(n) = x { Obj::Name(name(n)) } else { x.clone() }).collect()), + other => other.clone(), + }; + out.insert(key.to_string(), v); + } + out +} + +/// The number of bytes an unfiltered inline image occupies. +pub(crate) fn inline_len(file: &File, d: &Dict, res: Option<&Dict>) -> Option { + if !filters(file, d).is_empty() { + return None; + } + let w = file.get_num(d, "Width")? as usize; + let h = file.get_num(d, "Height")? as usize; + let mask = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true))); + let bpc = if mask { 1 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize }; + let n = if mask { 1 } else { file.get(d, "ColorSpace").map(|c| color_space(file, c, res).components()).unwrap_or(1) }; + Some((w * n * bpc).div_ceil(8) * h) +} + +/// Decode an image's samples (all filters, including DCT). Returns (bytes, components from +/// a JPEG when it decided them itself). +fn samples(file: &File, d: &Dict, raw: &[u8]) -> Result<(Vec, Option), String> { + let fs = filters(file, d); + match fs.last().map(String::as_str) { + Some("DCTDecode" | "DCT") => { + let mut d2 = d.clone(); + d2.remove("F"); + let rest: Vec = fs[..fs.len() - 1].iter().map(|n| Obj::Name(n.clone())).collect(); + d2.insert("Filter".into(), Obj::Array(rest)); + d2.remove("DecodeParms"); + d2.remove("DP"); + let jpeg = decode_stream(file, &d2, raw).ok_or("image (filters)")?; + jpeg_decode(&jpeg).map(|(b, n)| (b, Some(n))) + } + Some(f @ ("JPXDecode" | "CCITTFaxDecode" | "JBIG2Decode" | "CCF")) => Err(format!("image ({f})")), + _ => decode_stream(file, d, raw).map(|b| (b, None)).ok_or_else(|| "image (filters)".to_string()), + } +} + +fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> { + use zune_jpeg::JpegDecoder; + use zune_jpeg::zune_core::colorspace::ColorSpace; + use zune_jpeg::zune_core::options::DecoderOptions; + let mut probe = JpegDecoder::new(std::io::Cursor::new(data)); + probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?; + let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3); + let out = match n { + 1 => ColorSpace::Luma, + 4 => ColorSpace::CMYK, + _ => ColorSpace::RGB, + }; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(MAX_SIDE).set_max_height(MAX_SIDE); + let mut dec = JpegDecoder::new_with_options(std::io::Cursor::new(data), opts); + let mut px = dec.decode().map_err(|_| "image (JPEG)".to_string())?; + // Adobe CMYK JPEGs (an APP14 "Adobe" marker) store inverted values. + if n == 4 && data.windows(5).take(4096).any(|w| w == b"Adobe") { + for v in &mut px { + *v = 255 - *v; + } + } + Ok((px, n.max(1))) +} + +/// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers. +fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec { + let row_bytes = (w * n * bpc).div_ceil(8); + let mut out = Vec::with_capacity(w * h * n); + for y in 0..h { + let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]); + for i in 0..w * n { + let v = match bpc { + 8 => row.get(i).copied().unwrap_or(0) as u16, + 16 => u16::from_be_bytes([row.get(2 * i).copied().unwrap_or(0), row.get(2 * i + 1).copied().unwrap_or(0)]), + 1 | 2 | 4 => { + let bit = i * bpc; + let byte = row.get(bit / 8).copied().unwrap_or(0); + ((byte >> (8 - bpc - bit % 8)) & ((1u8 << bpc) - 1)) as u16 + } + _ => 0, + }; + out.push(v); + } + } + out +} + +/// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`. +fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec { + if mw == w && mh == h { + return m.to_vec(); + } + let mut out = Vec::with_capacity(w * h); + for y in 0..h { + let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1)); + for x in 0..w { + let sx = (x * mw / w.max(1)).min(mw.saturating_sub(1)); + out.push(m.get(sy * mw + sx).copied().unwrap_or(255)); + } + } + out +} + +/// A stencil or soft mask stream as 8-bit coverage. +fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize)> { + let Obj::Stream(d, raw) = file.resolve(o) else { return None }; + let w = file.get_num(d, "Width")? as usize; + let h = file.get_num(d, "Height")? as usize; + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + return None; + } + let (data, jpeg_n) = samples(file, d, raw).ok()?; + if jpeg_n.is_some() { + return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h)); + } + let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 }; + let max = ((1u32 << bpc) - 1).max(1) as f64; + let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2); + let v = unpack(&data, w, h, 1, bpc); + let out = v + .into_iter() + .map(|s| { + let t = s as f64 / max; + let t = match &dec { + Some(dd) => dd[0] + t * (dd[1] - dd[0]), + None => t, + }; + // Explicit (stencil) masks: sample 1 masks out with the default Decode. + let a = if soft { t } else { 1.0 - t }; + (a.clamp(0.0, 1.0) * 255.0).round() as u8 + }) + .collect(); + Some((out, w, h)) +} + +/// Decode an image. `fill` is the current fill colour (stencil masks paint with it). +pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result { + let w = file.get_num(d, "Width").unwrap_or(0.0) as usize; + let h = file.get_num(d, "Height").unwrap_or(0.0) as usize; + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 { + return Err("image (size)".into()); + } + let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true))); + let (data, jpeg_n) = samples(file, d, raw)?; + let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default(); + let mut rgba = vec![0u8; w * h * 4]; + if stencil { + let v = unpack(&data, w, h, 1, 1); + let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 }; + let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8); + for (i, s) in v.into_iter().enumerate() { + rgba[i * 4..i * 4 + 3].copy_from_slice(&c); + rgba[i * 4 + 3] = if s == paint_on { 255 } else { 0 }; + } + return Ok(Decoded { width: w as u32, height: h as u32, rgba }); + } + let cs = match file.get(d, "ColorSpace") { + Some(c) => color_space(file, c, res), + None if jpeg_n == Some(4) => Cs::Cmyk, + None if jpeg_n == Some(1) => Cs::Gray, + None => Cs::Rgb, + }; + let n = match jpeg_n { + Some(n) => n, + None => cs.components().max(1), + }; + let bpc = if jpeg_n.is_some() { 8 } else { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize }; + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + return Err(format!("image ({bpc} bits per component)")); + } + let max = ((1u32 << bpc) - 1) as f64; + let raw_samples = unpack(&data, w, h, n, bpc); + // Decode ranges per component. + let indexed = matches!(cs, Cs::Indexed { .. }); + let range = |k: usize| -> (f64, f64) { + match (decode_arr.get(2 * k), decode_arr.get(2 * k + 1)) { + (Some(a), Some(b)) => (*a, *b), + _ if indexed => (0.0, max), + _ => (0.0, 1.0), + } + }; + let ranges: Vec<(f64, f64)> = (0..n).map(range).collect(); + // Fast paths: a lookup per 8-bit value for Gray / RGB / CMYK / Indexed. + let mut comp = vec![0.0f64; n]; + // Indexed images: one colour per palette entry. + let palette: Option> = match &cs { + Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()), + _ => None, + }; + for i in 0..w * h { + if let Some(pal) = &palette { + let s = raw_samples[i] as f64 / max; + let (a, b) = ranges[0]; + let idx = (a + s * (b - a)).round().clamp(0.0, (pal.len() - 1) as f64) as usize; + let c = pal[idx]; + rgba[i * 4..i * 4 + 4].copy_from_slice(&[(c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8, 255]); + continue; + } + for k in 0..n { + let s = raw_samples[i * n + k] as f64 / max; + let (a, b) = ranges[k]; + comp[k] = a + s * (b - a); + } + let c = cs.to_rgb(&comp); + let o = i * 4; + rgba[o] = (c[0] * 255.0).round() as u8; + rgba[o + 1] = (c[1] * 255.0).round() as u8; + rgba[o + 2] = (c[2] * 255.0).round() as u8; + rgba[o + 3] = 255; + } + // Colour-key masking: samples inside every [min max] pair are transparent. + match file.get(d, "Mask").map(|m| file.resolve(m)) { + Some(Obj::Array(a)) => { + let keys: Vec = a.iter().filter_map(|x| file.resolve(x).num()).collect(); + if keys.len() >= 2 * n { + for i in 0..w * h { + if (0..n).all(|k| { + let v = raw_samples[i * n + k] as f64; + v >= keys[2 * k] && v <= keys[2 * k + 1] + }) { + rgba[i * 4 + 3] = 0; + } + } + } + } + Some(m @ Obj::Stream(..)) => { + if let Some((m, mw, mh)) = mask_image(file, m, false) { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + rgba[i * 4 + 3] = a; + } + } + } + _ => {} + } + if let Some(sm) = file.get(d, "SMask") + && let Some((m, mw, mh)) = mask_image(file, sm, true) + { + for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() { + rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8; + } + } + Ok(Decoded { width: w as u32, height: h as u32, rgba }) +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index cd835113e..db443a418 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -8,10 +8,15 @@ //! (Illustrator layers), with content outside them gathered into groups of its own. mod build; +mod cff; mod color; +mod encoding; +mod font; +mod image; mod object; mod page; mod ps; +mod type1; pub mod write; pub use effectcraft_svg::Doc; @@ -124,7 +129,10 @@ fn finish(nodes: Vec, flags: Vec, skipped: Vec, width: f64, g.name = format!("Layer {}", i + 1); } } - Doc { width, height, root: Group { name: "page".into(), transform: to_doc, opacity: 1.0, children: layers, clip: vec![] }, skipped } + let mut root = Group::new("page"); + root.transform = to_doc; + root.children = layers; + Doc { width, height, root, skipped } } fn parse_pdf(bytes: &[u8], index: usize) -> Result { @@ -170,6 +178,7 @@ pub fn layer_names(doc: &Doc) -> Vec { .map(|n| match n { Node::Group(g) => g.name.clone(), Node::Shape(s) => s.name.clone(), + Node::Image(i) => i.name.clone(), }) .collect() } diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 9a67f85c4..cbfaad01b 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -1,13 +1,20 @@ //! Pages and content streams (ISO 32000-1 §7.7.3, §8): the page tree, the graphics state, //! path construction and painting, clipping, colour, shading patterns and `sh`, form XObjects -//! and optional-content marked sequences. Text, images and tiling patterns are skipped (listed -//! in [`effectcraft_svg::Doc::skipped`]). +//! and optional-content marked sequences, text (§9: text state, text objects, glyph outlines +//! from [`crate::font`]), images ([`crate::image`]), tiling patterns, blend modes and soft +//! masks. What is not drawn is listed in [`effectcraft_svg::Doc::skipped`]. -use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Gradient, GradientKind, Join, Paint, Spread, Stroke}; -use kurbo::Point; +use std::collections::HashMap; +use std::rc::Rc; + +use effectcraft_svg::{ + Affine, BezPath, BlendMode, Cap, FillRule, Gradient, GradientKind, Group, Image as SvgImage, Join, Node, Paint, SoftMask, Spread, Stroke, +}; +use kurbo::{Point, Shape as _}; use crate::build::Builder; use crate::color::{Cs, Func, color_space, text_string}; +use crate::font::Font; use crate::object::{Dict, File, Lexer, Obj, decode_stream}; /// A page: its dictionary with inherited attributes resolved. @@ -70,10 +77,24 @@ pub fn page_content(file: &File, page: &Page) -> Vec { out } +/// A tiling pattern (§8.7.3.1): its cell content, drawn in pattern space at every step. +pub(crate) struct Tiling { + /// Pattern space → page (default user space of the stream that set it). + space: Affine, + bbox: [f64; 4], + xstep: f64, + ystep: f64, + content: Vec, + res: Dict, + /// PaintType 2: the cell is painted with this colour. + uncolored: Option<[f64; 3]>, +} + #[derive(Clone)] enum Fill { Color([f64; 3]), Paint(Paint), + Tiling(Rc), None, } @@ -91,6 +112,20 @@ struct GState { dash: Option<(Vec, f64)>, fill_alpha: f64, stroke_alpha: f64, + // Text state (§9.3). + font: Option>, + font_size: f64, + char_spacing: f64, + word_spacing: f64, + h_scale: f64, + leading: f64, + rise: f64, + render_mode: u8, + /// Builder depths of the open blend-mode and soft-mask groups. + blend_depth: Option, + mask_depth: Option, + /// Colour operators are ignored (uncoloured tiling patterns, `d1` glyphs). + lock_color: bool, } impl Default for GState { @@ -108,6 +143,17 @@ impl Default for GState { dash: None, fill_alpha: 1.0, stroke_alpha: 1.0, + font: None, + font_size: 0.0, + char_spacing: 0.0, + word_spacing: 0.0, + h_scale: 1.0, + leading: 0.0, + rise: 0.0, + render_mode: 0, + blend_depth: None, + mask_depth: None, + lock_color: false, } } } @@ -125,6 +171,12 @@ pub(crate) struct Interp<'a> { /// The page box in default user space (for `sh`). page_box: [f64; 4], budget: usize, + // Text object state. + tm: Affine, + tlm: Affine, + /// Glyph outlines collected for a clipping text render mode (page space). + text_clip: Option, + fonts: HashMap>, } fn affine(v: &[f64]) -> Affine { @@ -134,6 +186,15 @@ fn affine(v: &[f64]) -> Affine { Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]) } +/// The most tiles one tiling-pattern fill draws. +const MAX_TILES: usize = 4096; + +/// A `TJ` element. +enum TextItem { + Str(Vec), + Adjust(f64), +} + impl<'a> Interp<'a> { pub fn new(file: &'a File, page_box: [f64; 4]) -> Self { Interp { @@ -148,6 +209,21 @@ impl<'a> Interp<'a> { marked: vec![], page_box, budget: 20_000_000, + tm: Affine::IDENTITY, + tlm: Affine::IDENTITY, + text_clip: None, + fonts: HashMap::new(), + } + } + + /// Close builder groups down to `depth` and forget graphics-state groups above it. + fn close_to(&mut self, depth: usize) { + self.b.close_to(depth); + if self.gs.blend_depth.is_some_and(|d| d >= depth) { + self.gs.blend_depth = None; + } + if self.gs.mask_depth.is_some_and(|d| d >= depth) { + self.gs.mask_depth = None; } } @@ -156,7 +232,6 @@ impl<'a> Interp<'a> { pub fn run(&mut self, content: &[u8], res: &Dict, pattern_base: Affine, depth: usize) { let mut lx = Lexer::new(content, 0); let mut args: Vec = Vec::with_capacity(8); - let mut in_text = false; while let Some(o) = lx.next() { let Obj::Op(op) = o else { if args.len() < 64 { @@ -169,21 +244,15 @@ impl<'a> Interp<'a> { return; } self.budget -= 1; - if in_text { - if op == "ET" { - in_text = false; - } - args.clear(); - continue; - } let n = |i: usize| args.get(i).and_then(Obj::num).unwrap_or(0.0); let nums = || args.iter().filter_map(Obj::num).collect::>(); + let locked = self.gs.lock_color; match op.as_str() { "q" => self.saved.push((self.gs.clone(), self.b.depth())), "Q" => { if let Some((g, d)) = self.saved.pop() { self.gs = g; - self.b.close_to(d); + self.close_to(d); } } "cm" => self.gs.ctm *= affine(&nums()), @@ -199,7 +268,8 @@ impl<'a> Interp<'a> { if let Some(name) = args.first().and_then(Obj::name) && let Some(g) = self.file.get_dict(res, "ExtGState").and_then(|e| self.file.get_dict(e, name)) { - self.ext_gstate(g); + let g = g.clone(); + self.ext_gstate(&g, res, depth); } } // Path construction. @@ -251,27 +321,28 @@ impl<'a> Interp<'a> { self.start = self.cur; } // Painting. - "S" => self.paint(false, None, true), + "S" => self.paint(false, None, true, depth), "s" => { self.path.close_path(); - self.paint(false, None, true) + self.paint(false, None, true, depth) } - "f" | "F" => self.paint(true, Some(FillRule::NonZero), false), - "f*" => self.paint(true, Some(FillRule::EvenOdd), false), - "B" => self.paint(true, Some(FillRule::NonZero), true), - "B*" => self.paint(true, Some(FillRule::EvenOdd), true), + "f" | "F" => self.paint(true, Some(FillRule::NonZero), false, depth), + "f*" => self.paint(true, Some(FillRule::EvenOdd), false, depth), + "B" => self.paint(true, Some(FillRule::NonZero), true, depth), + "B*" => self.paint(true, Some(FillRule::EvenOdd), true, depth), "b" => { self.path.close_path(); - self.paint(true, Some(FillRule::NonZero), true) + self.paint(true, Some(FillRule::NonZero), true, depth) } "b*" => { self.path.close_path(); - self.paint(true, Some(FillRule::EvenOdd), true) + self.paint(true, Some(FillRule::EvenOdd), true, depth) } - "n" => self.paint(false, None, false), + "n" => self.paint(false, None, false, depth), "W" => self.pending_clip = Some(FillRule::NonZero), "W*" => self.pending_clip = Some(FillRule::EvenOdd), - // Colour. + // Colour (ignored inside uncoloured patterns and `d1` glyphs). + "g" | "G" | "rg" | "RG" | "k" | "K" | "cs" | "CS" | "sc" | "scn" | "SC" | "SCN" if locked => {} "g" => self.gs.fill_cs = Cs::Gray, "G" => self.gs.stroke_cs = Cs::Gray, "rg" => self.gs.fill_cs = Cs::Rgb, @@ -293,7 +364,7 @@ impl<'a> Interp<'a> { let fill = op.starts_with('s'); let cs = if fill { self.gs.fill_cs.clone() } else { self.gs.stroke_cs.clone() }; let v = if let (Cs::Pattern, Some(name)) = (&cs, args.last().and_then(Obj::name)) { - self.pattern(res, name, pattern_base) + self.pattern(res, name, pattern_base, &nums()) } else { Fill::Color(cs.to_rgb(&nums())) }; @@ -313,25 +384,65 @@ impl<'a> Interp<'a> { self.xobject(res, name, depth); } } + // Text objects (§9.4). "BT" => { - in_text = true; - self.b.skip("text"); + self.tm = Affine::IDENTITY; + self.tlm = Affine::IDENTITY; + self.text_clip = None; } - "BI" => { - // Inline image: skip to EI. - let d = lx.data; - let mut p = lx.pos; - while p + 2 < d.len() && !(d[p] == b'I' && d[p - 1] == b'D' && crate::object::is_white(d[p + 1])) { - p += 1; + "ET" => { + if let Some(clip) = self.text_clip.take() { + self.b.clip(clip, FillRule::NonZero); } - while p + 2 < d.len() - && !(crate::object::is_white(d[p - 1]) && d[p] == b'E' && d[p + 1] == b'I' && (p + 2 == d.len() || crate::object::is_white(d[p + 2]))) - { - p += 1; - } - lx.pos = (p + 2).min(d.len()); - self.b.skip("image"); } + "Tc" => self.gs.char_spacing = n(0), + "Tw" => self.gs.word_spacing = n(0), + "Tz" => self.gs.h_scale = n(0) / 100.0, + "TL" => self.gs.leading = n(0), + "Ts" => self.gs.rise = n(0), + "Tr" => self.gs.render_mode = (n(0) as i64).clamp(0, 7) as u8, + "Tf" => { + self.gs.font = args.first().and_then(Obj::name).map(str::to_string).and_then(|name| self.font(res, &name)); + self.gs.font_size = n(1); + } + "Td" => self.text_move(n(0), n(1)), + "TD" => { + self.gs.leading = -n(1); + self.text_move(n(0), n(1)); + } + "Tm" => { + self.tm = affine(&nums()); + self.tlm = self.tm; + } + "T*" => self.text_move(0.0, -self.gs.leading), + "Tj" | "'" | "\"" => { + if op == "\"" { + self.gs.word_spacing = n(0); + self.gs.char_spacing = n(1); + } + if op != "Tj" { + self.text_move(0.0, -self.gs.leading); + } + if let Some(Obj::Str(s)) = args.iter().rev().find(|a| matches!(a, Obj::Str(_))) { + let s = s.clone(); + self.show(&[TextItem::Str(s)], res, pattern_base, depth); + } + } + "TJ" => { + if let Some(Obj::Array(a)) = args.first() { + let items: Vec = a + .iter() + .filter_map(|x| match x { + Obj::Str(s) => Some(TextItem::Str(s.clone())), + Obj::Num(v) => Some(TextItem::Adjust(*v)), + _ => None, + }) + .collect(); + self.show(&items, res, pattern_base, depth); + } + } + "d1" => self.gs.lock_color = true, + "BI" => self.inline_image(&mut lx, res), "BMC" => self.marked.push(None), "BDC" => { let layer = match (args.first().and_then(Obj::name), args.get(1)) { @@ -353,21 +464,69 @@ impl<'a> Interp<'a> { } "EMC" => { if let Some(Some(d)) = self.marked.pop() { - self.b.close_to(d); + self.close_to(d); } } _ => {} } // Colour operands set by the device-space shorthands. - match op.as_str() { - "g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())), - "G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())), - _ => {} + if !locked { + match op.as_str() { + "g" | "rg" | "k" => self.gs.fill = Fill::Color(self.gs.fill_cs.to_rgb(&nums())), + "G" | "RG" | "K" => self.gs.stroke = Fill::Color(self.gs.stroke_cs.to_rgb(&nums())), + _ => {} + } } args.clear(); } } + /// An inline image: `BI key value … ID data EI` (the lexer is just past `BI`). + fn inline_image(&mut self, lx: &mut Lexer, res: &Dict) { + let mut d = Dict::new(); + let mut key: Option = None; + while let Some(o) = lx.next() { + match o { + Obj::Op(ref x) if x == "ID" => break, + Obj::Name(k) if key.is_none() => key = Some(k), + v => { + if let Some(k) = key.take() { + d.insert(k, v); + } + } + } + } + let d = crate::image::expand_inline(&d); + let data = lx.data; + let start = (lx.pos + 1).min(data.len()); + let is_ei = |p: usize| { + p >= 1 + && p + 1 < data.len() + && crate::object::is_white(data[p - 1]) + && data[p] == b'E' + && data[p + 1] == b'I' + && (p + 2 == data.len() || crate::object::is_white(data[p + 2])) + }; + let end = match crate::image::inline_len(self.file, &d, Some(res)) { + Some(len) => (start + len).min(data.len()), + None => { + let mut p = start; + while p + 1 < data.len() && !is_ei(p) { + p += 1; + } + // Data ends before the whitespace preceding `EI`. + p.saturating_sub(1).max(start).min(data.len()) + } + }; + let raw = data[start..end].to_vec(); + let mut p = end; + while p + 1 < data.len() && !(data[p] == b'E' && data[p + 1] == b'I') { + p += 1; + } + lx.pos = (p + 2).min(data.len()); + self.image(&d, &raw, res); + } + fn ocg_name(&self, ocg: &Dict) -> Option { // An optional-content membership dictionary names its groups in /OCGs. let d = match self.file.get(ocg, "OCGs") { @@ -389,7 +548,7 @@ impl<'a> Interp<'a> { } } - fn ext_gstate(&mut self, g: &Dict) { + fn ext_gstate(&mut self, g: &Dict, res: &Dict, depth: usize) { let f = self.file; if let Some(v) = f.get_num(g, "LW") { self.gs.lw = v; @@ -414,27 +573,120 @@ impl<'a> Interp<'a> { let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0); self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph)); } - if f.get(g, "SMask").is_some_and(|s| !matches!(s, Obj::Name(n) if n == "None")) { - self.b.skip("soft mask"); + if let Some(Obj::Array(fa)) = f.get(g, "Font") + && let (Some(Obj::Dict(fd)), Some(size)) = (fa.first().map(|x| f.resolve(x)), fa.get(1).and_then(|x| f.resolve(x).num())) + { + let mut sk = vec![]; + self.gs.font = Some(Rc::new(Font::load(f, fd, &mut sk))); + self.gs.font_size = size; + for s in sk { + self.b.skip(&s); + } + } + // Blend mode: a group composited with it, open until the mode changes or `Q`. + let bm = match f.get(g, "BM") { + Some(Obj::Name(n)) => Some(n.clone()), + Some(Obj::Array(a)) => a.first().and_then(|x| f.resolve(x).name()).map(str::to_string), + _ => None, + }; + if let Some(bm) = bm { + let mode = BlendMode::from_pdf_name(&bm); + if let Some(d) = self.gs.blend_depth { + self.close_to(d); + } + if mode != BlendMode::Normal { + self.gs.blend_depth = Some(self.b.depth()); + let mut grp = Group::new(&bm); + grp.blend = mode; + self.b.open_group(grp); + } + } + // Soft mask: the mask group drawn now, applied to what is painted while it is set. + match f.get(g, "SMask") { + None => {} + Some(Obj::Name(n)) if n == "None" => { + if let Some(d) = self.gs.mask_depth { + self.close_to(d); + } + } + Some(sm) => { + let Some(sm) = sm.dict().cloned() else { return }; + let luminosity = f.get(&sm, "S").and_then(Obj::name) != Some("Alpha"); + let Some(Obj::Stream(gd, graw)) = f.get(&sm, "G").cloned() else { return }; + // Backdrop: in the group's colour space (black by default). + let bc = f.get(&sm, "BC").map(|b| f.nums(b)).unwrap_or_default(); + let gcs = f.get_dict(&gd, "Group").and_then(|gr| f.get(gr, "CS")).map(|c| color_space(f, c, Some(res))).unwrap_or(Cs::Rgb); + let backdrop = if bc.is_empty() { [0.0; 3] } else { gcs.to_rgb(&bc) }; + let saved_b = std::mem::replace(&mut self.b, Builder::new()); + let ctm = self.gs.ctm; + let saved_gs = std::mem::replace(&mut self.gs, GState { ctm, ..GState::default() }); + let saved_stack = std::mem::take(&mut self.saved); + self.form(&gd, &graw, res, depth); + let mask_b = std::mem::replace(&mut self.b, saved_b); + self.gs = saved_gs; + self.saved = saved_stack; + let (nodes, _, sk) = mask_b.finish(); + for s in sk { + self.b.skip(&s); + } + if let Some(d) = self.gs.mask_depth { + self.close_to(d); + } + let mut content = Group::new("Soft Mask"); + content.children = nodes; + let mut grp = Group::new("Soft Mask Group"); + grp.mask = Some(Box::new(SoftMask { content, luminosity, backdrop })); + self.gs.mask_depth = Some(self.b.depth()); + self.b.open_group(grp); + } } } - fn paint_of(f: &Fill) -> Option { + fn paint_of(&mut self, f: &Fill) -> Option { match f { Fill::Color(c) => Some(Paint::Color(*c)), Fill::Paint(p) => Some(p.clone()), + Fill::Tiling(t) => { + self.b.skip("tiling pattern stroke"); + Some(Paint::Color(t.uncolored.unwrap_or([0.5; 3]))) + } Fill::None => None, } } - fn paint(&mut self, fill: bool, rule: Option, stroke: bool) { + fn paint(&mut self, fill: bool, rule: Option, stroke: bool, depth: usize) { let path = std::mem::take(&mut self.path); self.cur = None; self.start = None; + self.paint_path(path.clone(), fill, rule.unwrap_or(FillRule::NonZero), stroke, "Path", depth); + if let Some(r) = self.pending_clip.take() { + self.b.clip(self.gs.ctm * path, r); + } + } + + /// Fill and / or stroke `path` (user space) with the current graphics state. + fn paint_path(&mut self, path: BezPath, fill: bool, rule: FillRule, stroke: bool, name: &str, depth: usize) { + if path.elements().is_empty() { + return; + } let ctm = self.gs.ctm; - let fill = if fill { Self::paint_of(&self.gs.fill).map(|p| (p, self.gs.fill_alpha, rule.unwrap_or(FillRule::NonZero))) } else { None }; + let mut fill_paint = None; + if fill { + match self.gs.fill.clone() { + Fill::Tiling(t) => { + let page = ctm * path.clone(); + let d = self.b.depth(); + let area = page.bounding_box(); + self.b.clip(page, rule); + self.tile(&t, area, depth); + self.close_to(d); + } + f => fill_paint = self.paint_of(&f).map(|p| (p, self.gs.fill_alpha, rule)), + } + } let stroke = if stroke { - Self::paint_of(&self.gs.stroke).map(|p| { + let sf = self.gs.stroke.clone(); + self.paint_of(&sf).map(|p| { // Width 0: the thinnest line the device can draw (one pixel). let s = { (ctm.determinant().abs()).sqrt() }; let width = if self.gs.lw <= 0.0 { if s > 0.0 { 1.0 / s } else { 1.0 } } else { self.gs.lw }; @@ -451,12 +703,67 @@ impl<'a> Interp<'a> { } else { None }; - if fill.is_some() || stroke.is_some() { - self.b.fill_stroke(path.clone(), ctm, fill, stroke); + if fill_paint.is_some() || stroke.is_some() { + self.b.fill_stroke_named(name, path, ctm, fill_paint, stroke); } - if let Some(r) = self.pending_clip.take() { - self.b.clip(ctm * path, r); + } + + /// Draw a tiling pattern's cells over `area` (page space; the caller clips). + fn tile(&mut self, t: &Tiling, area: kurbo::Rect, depth: usize) { + if depth > 8 { + return; } + // The cell, drawn once in pattern space. + let saved_b = std::mem::replace(&mut self.b, Builder::new()); + let saved_gs = std::mem::take(&mut self.gs); + let saved_stack = std::mem::take(&mut self.saved); + let saved_path = std::mem::take(&mut self.path); + if let Some(c) = t.uncolored { + self.gs.fill = Fill::Color(c); + self.gs.stroke = Fill::Color(c); + self.gs.lock_color = true; + } + self.run(&t.content, &t.res, Affine::IDENTITY, depth + 1); + let cell_b = std::mem::replace(&mut self.b, saved_b); + self.gs = saved_gs; + self.saved = saved_stack; + self.path = saved_path; + let (cell, _, sk) = cell_b.finish(); + for s in sk { + self.b.skip(&s); + } + if cell.is_empty() { + return; + } + let (xs, ys) = (t.xstep.abs().max(1e-6), t.ystep.abs().max(1e-6)); + let inv = t.space.inverse(); + let corners = [(area.x0, area.y0), (area.x1, area.y0), (area.x1, area.y1), (area.x0, area.y1)].map(|(x, y)| inv * Point::new(x, y)); + let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)); + for c in corners { + lo = Point::new(lo.x.min(c.x), lo.y.min(c.y)); + hi = Point::new(hi.x.max(c.x), hi.y.max(c.y)); + } + let [bx0, by0, bx1, by1] = t.bbox; + let i0 = ((lo.x - bx1) / xs).floor(); + let i1 = ((hi.x - bx0) / xs).ceil(); + let j0 = ((lo.y - by1) / ys).floor(); + let j1 = ((hi.y - by0) / ys).ceil(); + if !(i0.is_finite() && i1.is_finite() && j0.is_finite() && j1.is_finite()) || (i1 - i0 + 1.0) * (j1 - j0 + 1.0) > MAX_TILES as f64 { + self.b.skip("tiling pattern (too many tiles)"); + return; + } + let cell_clip = kurbo::Rect::new(bx0, by0, bx1, by1).to_path(0.1); + let mut tiles = Group::new("Pattern"); + for j in j0 as i64..=j1 as i64 { + for i in i0 as i64..=i1 as i64 { + let mut g = Group::new("Tile"); + g.transform = t.space * Affine::translate((i as f64 * xs, j as f64 * ys)); + g.clip.push((cell_clip.clone(), FillRule::NonZero)); + g.children = cell.clone(); + tiles.children.push(Node::Group(g)); + } + } + self.b.push(Node::Group(tiles)); } /// A shading dictionary → gradient paint in shading space. @@ -490,19 +797,20 @@ impl<'a> Interp<'a> { let stops = ts .into_iter() .map(|t| { - let c = cs.to_rgb(&func.eval1(dom[0] + t * (dom[1] - dom[0]))); + let c = cs.to_rgb(&func.eval(&[dom[0] + t * (dom[1] - dom[0])])); (t, [c[0], c[1], c[2], 1.0]) }) .collect(); Some(Gradient { kind, stops, bbox_units: false, transform: Affine::IDENTITY, spread: Spread::Pad }) } - fn pattern(&mut self, res: &Dict, name: &str, base: Affine) -> Fill { + fn pattern(&mut self, res: &Dict, name: &str, base: Affine, comps: &[f64]) -> Fill { let f = self.file; - let Some(p) = f.get_dict(res, "Pattern").and_then(|pp| f.get_dict(pp, name)) else { return Fill::None }; + let Some(po) = f.get_dict(res, "Pattern").and_then(|pp| pp.get(name)).map(|x| f.resolve(x)) else { return Fill::None }; + let Some(p) = po.dict() else { return Fill::None }; + let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); match f.get_num(p, "PatternType").unwrap_or(0.0) as i32 { 2 => { - let m = f.get(p, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); let Some(sh) = f.get(p, "Shading").and_then(Obj::dict) else { return Fill::None }; let sh = sh.clone(); match self.shading_gradient(&sh) { @@ -514,10 +822,27 @@ impl<'a> Interp<'a> { None => Fill::None, } } - _ => { - self.b.skip("tiling pattern"); - Fill::Color([0.5; 3]) + 1 => { + let Obj::Stream(d, raw) = po else { return Fill::None }; + let Some(content) = decode_stream(f, d, raw) else { return Fill::None }; + let bb = f.get(d, "BBox").map(|b| f.nums(b)).filter(|b| b.len() == 4).unwrap_or_else(|| vec![0.0, 0.0, 1.0, 1.0]); + let uncolored = (f.get_num(d, "PaintType").unwrap_or(1.0) as i32 == 2).then(|| match comps.len() { + 1 => Cs::Gray.to_rgb(comps), + 4 => Cs::Cmyk.to_rgb(comps), + 3 => Cs::Rgb.to_rgb(comps), + _ => [0.0; 3], + }); + Fill::Tiling(Rc::new(Tiling { + space: base * m, + bbox: [bb[0].min(bb[2]), bb[1].min(bb[3]), bb[0].max(bb[2]), bb[1].max(bb[3])], + xstep: f.get_num(d, "XStep").unwrap_or(bb[2] - bb[0]), + ystep: f.get_num(d, "YStep").unwrap_or(bb[3] - bb[1]), + content, + res: f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()), + uncolored, + })) } + _ => Fill::None, } } @@ -541,33 +866,177 @@ impl<'a> Interp<'a> { self.b.fill_stroke(path, self.gs.ctm, Some((Paint::Gradient(g), self.gs.fill_alpha, FillRule::NonZero)), None); } + /// Run a form XObject (matrix, bounding-box clip, its own resources) in a saved state. + fn form(&mut self, d: &Dict, raw: &[u8], res: &Dict, depth: usize) { + if depth >= 12 { + return; + } + let f = self.file; + let Some(content) = decode_stream(f, d, raw) else { return }; + let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); + let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()); + self.saved.push((self.gs.clone(), self.b.depth())); + self.gs.ctm *= m; + let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default(); + if bb.len() == 4 { + let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]); + self.b.clip(self.gs.ctm * r.to_path(0.1), FillRule::NonZero); + } + let base = self.gs.ctm; + let path = std::mem::take(&mut self.path); + let (tm, tlm) = (self.tm, self.tlm); + self.run(&content, &form_res, base, depth + 1); + self.path = path; + self.tm = tm; + self.tlm = tlm; + if let Some((g, dd)) = self.saved.pop() { + self.gs = g; + self.close_to(dd); + } + } + fn xobject(&mut self, res: &Dict, name: &str, depth: usize) { let f = self.file; let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return }; let Obj::Stream(d, raw) = x else { return }; match f.get(d, "Subtype").and_then(Obj::name) { - Some("Form") if depth < 12 => { - let Some(content) = decode_stream(f, d, raw) else { return }; - let m = f.get(d, "Matrix").map(|m| affine(&f.nums(m))).unwrap_or(Affine::IDENTITY); - let form_res = f.get_dict(d, "Resources").cloned().unwrap_or_else(|| res.clone()); - self.saved.push((self.gs.clone(), self.b.depth())); - self.gs.ctm *= m; - let bb = f.get(d, "BBox").map(|b| f.nums(b)).unwrap_or_default(); - if bb.len() == 4 { - let r = kurbo::Rect::new(bb[0], bb[1], bb[2], bb[3]); - self.b.clip(self.gs.ctm * kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero); - } - let base = self.gs.ctm; - let path = std::mem::take(&mut self.path); - self.run(&content, &form_res, base, depth + 1); - self.path = path; - if let Some((g, dd)) = self.saved.pop() { - self.gs = g; - self.b.close_to(dd); - } - } - Some("Image") => self.b.skip("image"), + Some("Form") => self.form(d, raw, res, depth), + Some("Image") => self.image(d, raw, res), _ => {} } } + + /// Paint an image: the unit square of user space, pixel (0, 0) at its top-left corner. + fn image(&mut self, d: &Dict, raw: &[u8], res: &Dict) { + let fill = match &self.gs.fill { + Fill::Color(c) => *c, + _ => [0.0; 3], + }; + match crate::image::decode(self.file, d, raw, Some(res), fill) { + Ok(img) => { + let (w, h) = (img.width as f64, img.height as f64); + let transform = self.gs.ctm * Affine::new([1.0 / w, 0.0, 0.0, -1.0 / h, 0.0, 1.0]); + self.b.push(Node::Image(SvgImage { + name: "Image".into(), + transform, + width: img.width, + height: img.height, + rgba: std::sync::Arc::new(img.rgba), + opacity: self.gs.fill_alpha, + })); + } + Err(what) => self.b.skip(&what), + } + } + + /// The font named `name` in the resources (cached by object). + fn font(&mut self, res: &Dict, name: &str) -> Option> { + let f = self.file; + let entry = f.get_dict(res, "Font")?.get(name)?; + let key = match entry { + Obj::Ref(n, g) => Some(format!("{n} {g}")), + _ => None, + }; + if let Some(k) = &key + && let Some(font) = self.fonts.get(k) + { + return Some(font.clone()); + } + let d = f.resolve(entry).dict()?; + let mut sk = vec![]; + let font = Rc::new(Font::load(f, d, &mut sk)); + for s in sk { + self.b.skip(&s); + } + if let Some(k) = key { + self.fonts.insert(k, font.clone()); + } + Some(font) + } + + fn text_move(&mut self, tx: f64, ty: f64) { + self.tlm *= Affine::translate((tx, ty)); + self.tm = self.tlm; + } + + /// Show text (`Tj`, `TJ`, `'`, `"`): glyph outlines placed by the text matrix, painted by + /// the text render mode as one compound shape. + fn show(&mut self, items: &[TextItem], res: &Dict, pattern_base: Affine, depth: usize) { + let Some(font) = self.gs.font.clone() else { + self.b.skip("text (no font)"); + return; + }; + let (fs, th, rise) = (self.gs.font_size, self.gs.h_scale, self.gs.rise); + let mut path = BezPath::new(); + let mut text = String::new(); + let mut missing = false; + for it in items { + match it { + TextItem::Adjust(v) => { + let d = -v / 1000.0 * fs; + self.tm *= if font.vertical { Affine::translate((0.0, d)) } else { Affine::translate((d * th, 0.0)) }; + } + TextItem::Str(s) => { + for (code, nbytes) in font.codes(s) { + let w0 = font.width(code); + let trm = self.tm * Affine::new([fs * th, 0.0, 0.0, fs, 0.0, rise]); + let trm = if font.vertical { trm * Affine::translate((-w0 / 2.0, -0.88)) } else { trm }; + if let Some((proc_, t3res)) = font.type3_proc(code) { + let proc_ = proc_.to_vec(); + let r = t3res.cloned().unwrap_or_else(|| res.clone()); + self.type3_glyph(&proc_, &r, trm * font.matrix, pattern_base, depth); + } else if let Some(g) = font.glyph(code) { + path.extend(trm * (*g).clone()); + } else if code != 32 { + missing = true; + } + text.push_str(&font.unicode(code)); + let tw = if nbytes == 1 && code == 32 { self.gs.word_spacing } else { 0.0 }; + if font.vertical { + self.tm *= Affine::translate((0.0, -(fs + self.gs.char_spacing + tw))); + } else { + self.tm *= Affine::translate(((w0 * fs + self.gs.char_spacing + tw) * th, 0.0)); + } + } + } + } + } + if missing && !matches!(font.program, crate::font::Program::Type3 { .. }) { + self.b.skip(&format!("glyphs missing from font {}", font.base_name)); + } + if path.elements().is_empty() { + return; + } + let mode = self.gs.render_mode; + let fill = matches!(mode, 0 | 2 | 4 | 6); + let stroke = matches!(mode, 1 | 2 | 5 | 6); + let label: String = text.chars().filter(|c| !c.is_control()).take(40).collect(); + let name = if label.trim().is_empty() { "Text".to_string() } else { format!("Text: {}", label.trim()) }; + if mode >= 4 { + let clip = self.text_clip.get_or_insert_with(BezPath::new); + clip.extend(self.gs.ctm * path.clone()); + } + if fill || stroke { + self.paint_path(path, fill, FillRule::NonZero, stroke, &name, depth); + } + } + + /// Run a Type 3 glyph procedure with `m` (glyph space → user space). + fn type3_glyph(&mut self, content: &[u8], res: &Dict, m: Affine, pattern_base: Affine, depth: usize) { + if depth >= 8 { + return; + } + self.saved.push((self.gs.clone(), self.b.depth())); + self.gs.ctm *= m; + let path = std::mem::take(&mut self.path); + let (tm, tlm) = (self.tm, self.tlm); + self.run(content, res, pattern_base, depth + 1); + self.path = path; + self.tm = tm; + self.tlm = tlm; + if let Some((g, d)) = self.saved.pop() { + self.gs = g; + self.close_to(d); + } + } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b4e91f2bd..39c82b6e1 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -49,7 +49,7 @@ fn pdf_paths_clip_gradient_and_layers() { let doc = parse(&bytes).unwrap(); assert_eq!((doc.width, doc.height), (200.0, 100.0)); assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}"); - assert!(doc.skipped.contains(&"text".to_string())); + assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped); let (w, h) = doc.pixel_size(); let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); let at = |x: i64, y: i64| img.get(x, y); @@ -148,3 +148,362 @@ fn filters_decode() { let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); assert_eq!(object::inflate(&z).unwrap(), b"abc"); } + +// ------------------------------------------------------------------ M13.6: text, images, +// patterns, soft masks, blend modes, pages + +type Objs = Vec<(u32, String, Option>)>; + +/// A one-page 200×100 PDF: `resources` is the page's resource dictionary body, `extra` more +/// objects (numbered from 10). +fn page_pdf(content: &str, resources: &str, extra: Objs) -> Vec { + let mut objs: Objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << {resources} >> /Contents 4 0 R >>"), None), + (4, "<< >>".into(), Some(content.as_bytes().to_vec())), + ]; + objs.extend(extra); + pdf(&objs, 1) +} + +fn render(doc: &Doc) -> effectcraft_raster::Image { + let (w, h) = doc.pixel_size(); + effectcraft_svg::rasterize(doc, w, h, 1.0) +} + +fn coverage(img: &effectcraft_raster::Image, x0: i64, y0: i64, x1: i64, y1: i64) -> f32 { + let mut s = 0.0; + for y in y0..y1 { + for x in x0..x1 { + s += img.get(x, y)[3]; + } + } + s +} + +fn shape_names(doc: &Doc) -> Vec { + fn walk(g: &effectcraft_svg::Group, out: &mut Vec) { + for c in &g.children { + match c { + Node::Group(s) => walk(s, out), + Node::Shape(s) => out.push(s.name.clone()), + Node::Image(i) => out.push(i.name.clone()), + } + } + } + let mut out = vec![]; + walk(&doc.root, &mut out); + out +} + +/// Type 2 charstring operands. +fn t2(v: &[i32]) -> Vec { + let mut o = vec![]; + for &x in v { + if (-107..=107).contains(&x) { + o.push((x + 139) as u8); + } else { + o.push(28); + o.extend_from_slice(&(x as i16).to_be_bytes()); + } + } + o +} + +/// A CFF font with a 500-unit square glyph `square` (x 50..550, y 0..500, width 600). +fn square_cff() -> Vec { + let mut sq = t2(&[600, 50, 0]); + sq.push(21); + sq.extend(t2(&[500, 500, -500])); + sq.push(6); + sq.push(14); + crate::cff::write_test_cff(&[(".notdef", vec![14]), ("square", sq)]) +} + +const SQUARE_FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Square /FirstChar 65 /LastChar 65 /Widths [600] /Encoding << /Differences [65 /square] >> /FontDescriptor 11 0 R >>"; + +fn square_font_objs() -> Objs { + vec![ + (10, SQUARE_FONT.into(), None), + (11, "<< /Type /FontDescriptor /FontName /Square /Flags 32 /FontFile3 12 0 R >>".into(), None), + (12, "<< /Subtype /Type1C >>".into(), Some(square_cff())), + ] +} + +#[test] +fn text_with_an_embedded_cff_font_spacing_and_tj() { + // Squares 10 pt per 100 units: A at 0..60 (square 5..55), Tc 10 and a TJ kern of −400 + // (+40) put the second at 110 (square 115..165). + let bytes = page_pdf("BT /F1 100 Tf 0 0 Td 10 Tc [(A) -400 (A)] TJ ET", "/Font << /F1 10 0 R >>", square_font_objs()); + let doc = parse(&bytes).unwrap(); + assert!(!doc.skipped.iter().any(|s| s.contains("text")), "{:?}", doc.skipped); + assert!(shape_names(&doc).iter().any(|n| n.starts_with("Text")), "{:?}", shape_names(&doc)); + let img = render(&doc); + for (x, on) in [(30, true), (60, false), (105, false), (140, true), (170, false)] { + assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}: {:?}", img.get(x, 75)); + } + // Squares are 50 pt tall: rows 50..100. + assert_eq!(img.get(30, 45)[3], 0.0); +} + +#[test] +fn text_render_mode_clip() { + let bytes = page_pdf("BT /F1 100 Tf 7 Tr 0 0 Td (A) Tj ET 1 0 0 rg 0 0 200 100 re f", "/Font << /F1 10 0 R >>", square_font_objs()); + let img = render(&parse(&bytes).unwrap()); + assert!(img.get(30, 75)[0] > 0.99 && img.get(30, 75)[3] > 0.99); + assert_eq!(img.get(100, 75)[3], 0.0, "clipped to the glyph"); + assert_eq!(img.get(30, 25)[3], 0.0); +} + +#[test] +fn text_with_an_embedded_type1_font() { + // hsbw 50 600; 0 0 rmoveto; 500 hlineto 500 vlineto -500 hlineto closepath endchar + let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14]; + let font = crate::type1::write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(66, "box")]); + let extra: Objs = vec![ + (10, "<< /Type /Font /Subtype /Type1 /BaseFont /Box /FirstChar 66 /LastChar 66 /Widths [600] /FontDescriptor 11 0 R >>".into(), None), + (11, "<< /Type /FontDescriptor /FontName /Box /Flags 4 /FontFile 12 0 R >>".into(), None), + (12, "<< /Length1 0 /Length2 0 /Length3 0 >>".into(), Some(font)), + ]; + // The font's built-in encoding maps B (66) to `box`. + let img = render(&parse(&page_pdf("BT /F1 100 Tf 0 0 Td (BB) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap()); + for (x, on) in [(30, true), (60, false), (90, true), (120, false)] { + assert_eq!(img.get(x, 75)[3] > 0.99, on, "x = {x}"); + } +} + +#[test] +fn text_with_a_type3_font() { + let extra: Objs = vec![ + ( + 10, + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /sq 11 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq] >> /FirstChar 65 /LastChar 65 /Widths [100] >>".into(), + None, + ), + (11, "<< >>".into(), Some(b"100 0 d0 0 0 100 100 re f".to_vec())), + ]; + let img = render(&parse(&page_pdf("BT 0 0 1 rg /F1 20 Tf 10 10 Td (AA) Tj ET", "/Font << /F1 10 0 R >>", extra)).unwrap()); + // 20 pt squares from x = 10, advancing 20: user 10..30 and 30..50, y 10..30 (rows 70..90). + assert!(img.get(20, 80)[2] > 0.99 && img.get(20, 80)[3] > 0.99, "{:?}", img.get(20, 80)); + assert!(img.get(40, 80)[3] > 0.99); + assert_eq!(img.get(55, 80)[3], 0.0); + assert_eq!(img.get(20, 60)[3], 0.0); +} + +#[test] +fn text_with_standard_fonts_embedded_truetype_and_cid_fonts() { + use skrifa::MetadataProvider; + let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec(); + // Not embedded: Helvetica drawn with the bundled sans serif. + let std14 = page_pdf("BT /F1 60 Tf 10 30 Td (Hi) Tj ET", "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![]); + let doc = parse(&std14).unwrap(); + assert!(shape_names(&doc).contains(&"Text: Hi".to_string()), "{:?}", shape_names(&doc)); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let a = render(&doc); + let ink = coverage(&a, 0, 0, 200, 100); + assert!(ink > 300.0, "{ink}"); + // Ink only on the text line: baseline at row 70, cap height ≈ 44 px above it. + assert!(coverage(&a, 0, 72, 200, 100) < 1.0 && coverage(&a, 0, 0, 200, 20) < 1.0); + // The same face embedded (a TrueType font program, WinAnsiEncoding): the same outlines. + let tt = page_pdf( + "BT /F1 60 Tf 10 30 Td (Hi) Tj ET", + "/Font << /F1 10 0 R >>", + vec![ + (10, "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Inter /Encoding /WinAnsiEncoding /FontDescriptor 11 0 R >>".into(), None), + (11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None), + (12, "<< >>".into(), Some(inter.clone())), + ], + ); + let b = render(&parse(&tt).unwrap()); + assert!((coverage(&b, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&b, 0, 0, 200, 100)); + // A composite font: Identity-H codes are glyph ids of a CIDFontType2. + let font = skrifa::FontRef::new(&inter).unwrap(); + let gid = |c: char| font.charmap().map(c).unwrap().to_u32() as u16; + let (h, i) = (gid('H'), gid('i')); + let loc = skrifa::instance::LocationRef::default(); + let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em as f32; + let w = |g: u16| font.glyph_metrics(skrifa::instance::Size::unscaled(), loc).advance_width(skrifa::GlyphId::new(g as u32)).unwrap() / upem * 1000.0; + let cid = page_pdf( + &format!("BT /F1 60 Tf 10 30 Td <{h:04X}{i:04X}> Tj ET"), + "/Font << /F1 10 0 R >>", + vec![ + (10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-H /DescendantFonts [13 0 R] >>".into(), None), + ( + 13, + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [{}] {i} [{}]] /FontDescriptor 11 0 R >>", + w(h), + w(i) + ), + None, + ), + (11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None), + (12, "<< >>".into(), Some(inter)), + ], + ); + let c = render(&parse(&cid).unwrap()); + assert!((coverage(&c, 0, 0, 200, 100) - ink).abs() < 1.0, "{} vs {ink}", coverage(&c, 0, 0, 200, 100)); +} + +#[test] +fn images_flate_smask_indexed_stencil_inline_and_dct() { + let objs = |im: &str, data: Vec, extra: Objs| -> Vec { + let mut e: Objs = vec![(10, im.to_string(), Some(data))]; + e.extend(extra); + page_pdf("q 100 0 0 50 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", e) + }; + // RGB with a soft mask: red opaque, green transparent. + let doc = parse(&objs( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R >>", + vec![255, 0, 0, 0, 255, 0], + vec![(11, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 >>".into(), Some(vec![255, 0]))], + )) + .unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + assert!(img.get(25, 75)[0] > 0.95 && img.get(25, 75)[3] > 0.95, "{:?}", img.get(25, 75)); + assert!(img.get(75, 75)[3] < 0.05, "{:?}", img.get(75, 75)); + assert_eq!(img.get(25, 25)[3], 0.0, "the image is the unit square of user space"); + // Indexed (palette in a string), 8 bits. + let img = render( + &parse(&objs( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1 <0000FFFFFF00>] /BitsPerComponent 8 >>", + vec![1, 0], + vec![], + )) + .unwrap(), + ); + let (l, r) = (img.get(25, 75), img.get(75, 75)); + assert!(l[0] > 0.95 && l[1] > 0.95 && l[2] < 0.05 && r[2] > 0.95 && r[0] < 0.05, "{l:?} {r:?}"); + // ICCBased with an /Alternate space, 1-bit samples. + let img = render( + &parse(&objs( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 11 0 R] /BitsPerComponent 1 >>", + vec![0b0100_0000], + vec![(11, "<< /N 1 /Alternate /DeviceGray >>".into(), Some(vec![0; 16]))], + )) + .unwrap(), + ); + assert!(img.get(25, 75)[0] < 0.05 && img.get(75, 75)[0] > 0.95); + // Stencil mask painted in the fill colour (sample 0 paints). + let stencil = page_pdf( + "0 1 0 rg q 100 0 0 50 0 0 cm /Im1 Do Q", + "/XObject << /Im1 10 0 R >>", + vec![(10, "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true >>".into(), Some(vec![0b0100_0000]))], + ); + let img = render(&parse(&stencil).unwrap()); + assert!(img.get(25, 75)[1] > 0.95 && img.get(25, 75)[3] > 0.95); + assert!(img.get(75, 75)[3] < 0.05); + // Inline image (abbreviated keys, ASCIIHex) in the top half. + let inline = page_pdf("q 100 0 0 50 0 50 cm BI /W 2 /H 1 /CS /RGB /BPC 8 /F /AHx ID 00FF00FF0000> EI Q 0 0 1 rg 150 0 50 50 re f", "", vec![]); + let doc = parse(&inline).unwrap(); + let img = render(&doc); + assert!(img.get(25, 25)[1] > 0.95 && img.get(75, 25)[0] > 0.95, "{:?} {:?}", img.get(25, 25), img.get(75, 25)); + assert!(img.get(175, 75)[2] > 0.95, "content after EI still runs"); + // DCT (JPEG): left half red, right half blue. + let mut jpg = vec![]; + let mut rgb = ::image::RgbImage::new(16, 16); + for (x, _, p) in rgb.enumerate_pixels_mut() { + *p = if x < 8 { ::image::Rgb([255, 0, 0]) } else { ::image::Rgb([0, 0, 255]) }; + } + ::image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), ::image::ImageFormat::Jpeg).unwrap(); + let doc = + parse(&objs("<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode >>", jpg, vec![])) + .unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + let (l, r) = (img.get(20, 75), img.get(80, 75)); + assert!(l[0] > 0.85 && l[2] < 0.15 && r[2] > 0.85 && r[0] < 0.15, "{l:?} {r:?}"); +} + +#[test] +fn soft_masks_and_blend_modes() { + // Luminosity: a form that is white on the left half, transparent (→ black backdrop) elsewhere. + let lum = page_pdf( + "q /GS1 gs 1 0 0 rg 0 0 200 100 re f Q 0 0 1 rg 0 0 20 20 re f", + "/ExtGState << /GS1 << /SMask << /S /Luminosity /G 10 0 R >> >> >>", + vec![( + 10, + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Group << /S /Transparency /CS /DeviceGray >> >>".into(), + Some(b"1 g 0 0 100 100 re f".to_vec()), + )], + ); + let doc = parse(&lum).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + assert!(img.get(50, 50)[0] > 0.99 && img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); + assert!(img.get(150, 50)[3] < 0.01, "{:?}", img.get(150, 50)); + assert!(img.get(10, 90)[2] > 0.99, "the mask ends with Q"); + // Alpha: the mask form's coverage. + let alpha = page_pdf( + "/GS1 gs 1 0 0 rg 0 0 200 100 re f", + "/ExtGState << /GS1 << /SMask << /S /Alpha /G 10 0 R >> >> >>", + vec![(10, "<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] >>".into(), Some(b"0 0 0 rg 100 0 100 100 re f".to_vec()))], + ); + let img = render(&parse(&alpha).unwrap()); + assert!(img.get(50, 50)[3] < 0.01 && img.get(150, 50)[0] > 0.99); + // Multiply over a light blue backdrop; Screen in the right half. + let blend = page_pdf( + "0.5 0.5 1 rg 0 0 200 100 re f q /M gs 1 1 0 rg 0 0 100 100 re f Q q /S gs 0.5 0 0 rg 100 0 100 100 re f Q", + "/ExtGState << /M << /BM /Multiply >> /S << /BM [/Screen] >> >>", + vec![], + ); + let img = render(&parse(&blend).unwrap()); + let m = img.get(50, 50); + assert!((m[0] - 0.5).abs() < 0.01 && (m[1] - 0.5).abs() < 0.01 && m[2] < 0.01, "multiply {m:?}"); + let s = img.get(150, 50); + assert!((s[0] - 0.75).abs() < 0.01 && (s[1] - 0.5).abs() < 0.01 && (s[2] - 1.0).abs() < 0.01, "screen {s:?}"); +} + +#[test] +fn tiling_patterns_coloured_and_uncoloured() { + let cell = b"1 0 0 rg 0 0 10 10 re f 0 0 1 rg 10 10 10 10 re f".to_vec(); + let bytes = page_pdf( + "/Pattern cs /P1 scn 0 0 100 100 re f /Cs1 cs 0 1 0 /P2 scn 100 0 100 100 re f", + "/Pattern << /P1 10 0 R /P2 11 0 R >> /ColorSpace << /Cs1 [/Pattern /DeviceRGB] >>", + vec![ + (10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >> >>".into(), Some(cell)), + ( + 11, + "<< /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 >>".into(), + Some(b"1 0 0 rg 0 0 10 10 re f".to_vec()), + ), + ], + ); + let doc = parse(&bytes).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let img = render(&doc); + // PDF (x, y) → pixel (x, 100 − y). + let at = |x: i64, y: i64| img.get(x, 100 - y); + assert!(at(5, 5)[0] > 0.99 && at(25, 5)[0] > 0.99 && at(85, 45)[0] > 0.99, "{:?}", at(5, 5)); + assert!(at(15, 15)[2] > 0.99 && at(55, 35)[2] > 0.99); + assert_eq!(at(15, 5)[3], 0.0); + // Uncoloured: the cell in the colour given with the pattern (green), its own colour ignored. + assert!(at(105, 5)[1] > 0.99 && at(105, 5)[0] < 0.01, "{:?}", at(105, 5)); + assert_eq!(at(115, 15)[3], 0.0); +} + +#[test] +fn multiple_pages_and_calculator_shadings() { + let objs: Objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(b"1 0 0 rg 0 0 200 100 re f".to_vec())), + (5, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Resources << /Shading << /Sh 7 0 R >> >> /Contents 6 0 R >>".into(), None), + (6, "<< >>".into(), Some(b"/Sh sh".to_vec())), + (7, "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 8 0 R >>".into(), None), + (8, "<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] >>".into(), Some(b"{ 0 0 }".to_vec())), + ]; + let bytes = pdf(&objs, 1); + assert_eq!(page_count(&bytes), 2); + let p1 = parse_page(&bytes, 0).unwrap(); + assert_eq!((p1.width, p1.height), (200.0, 100.0)); + let p2 = parse_page(&bytes, 1).unwrap(); + assert_eq!((p2.width, p2.height), (100.0, 50.0)); + assert!(p2.skipped.is_empty(), "{:?}", p2.skipped); + let img = render(&p2); + assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25)); + assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2))); +} diff --git a/crates/pdf/src/type1.rs b/crates/pdf/src/type1.rs new file mode 100644 index 000000000..b9e680cfd --- /dev/null +++ b/crates/pdf/src/type1.rs @@ -0,0 +1,535 @@ +//! Type 1 font programs (`FontFile`), from Adobe's published *Adobe Type 1 Font Format*: +//! the cleartext part (`/FontMatrix`, the built-in `/Encoding`), `eexec` decryption, `/Subrs` +//! and `/CharStrings`, and a Type 1 charstring interpreter (including flex and hint +//! replacement through `callothersubr`, and `seac` accented characters). + +use std::collections::HashMap; + +use kurbo::BezPath; + +const EEXEC_R: u16 = 55665; +const CHARSTRING_R: u16 = 4330; + +fn decrypt(data: &[u8], mut r: u16, skip: usize) -> Vec { + const C1: u16 = 52845; + const C2: u16 = 22719; + let mut out = Vec::with_capacity(data.len()); + for &c in data { + out.push(c ^ (r >> 8) as u8); + r = (c as u16).wrapping_add(r).wrapping_mul(C1).wrapping_add(C2); + } + out.drain(..skip.min(out.len())); + out +} + +/// Encrypt (for test fonts): the inverse of [`decrypt`] with `skip` leading zero bytes. +#[cfg(test)] +pub(crate) fn encrypt(plain: &[u8], mut r: u16, skip: usize) -> Vec { + const C1: u16 = 52845; + const C2: u16 = 22719; + let mut out = vec![]; + for &p in std::iter::repeat_n(&0u8, skip).chain(plain.iter()) { + let c = p ^ (r >> 8) as u8; + r = (c as u16).wrapping_add(r).wrapping_mul(C1).wrapping_add(C2); + out.push(c); + } + out +} + +/// A parsed Type 1 font. +#[derive(Clone, Debug, Default)] +pub struct Type1 { + pub font_matrix: Option<[f64; 6]>, + /// The built-in encoding (code → glyph name), when the font has one. + pub encoding: HashMap, + subrs: Vec>, + charstrings: HashMap>, +} + +fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { + if from >= hay.len() { + return None; + } + hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +/// Strip PFB segment headers (`0x80 type len32le`). +fn unpfb(data: &[u8]) -> Vec { + if data.first() != Some(&0x80) { + return data.to_vec(); + } + let mut out = vec![]; + let mut p = 0; + while p + 6 <= data.len() && data[p] == 0x80 && data[p + 1] != 3 { + let n = u32::from_le_bytes([data[p + 2], data[p + 3], data[p + 4], data[p + 5]]) as usize; + let end = (p + 6 + n).min(data.len()); + out.extend_from_slice(&data[p + 6..end]); + p = end; + } + out +} + +fn tokens(s: &[u8]) -> impl Iterator { + s.split(|c| c.is_ascii_whitespace() || matches!(c, b'[' | b']' | b'{' | b'}')).filter(|t| !t.is_empty()) +} + +impl Type1 { + pub fn parse(data: &[u8]) -> Option { + let data = unpfb(data); + let mut f = Type1::default(); + let ee = find(&data, b"eexec", 0)?; + let clear = &data[..ee]; + // /FontMatrix [a b c d e f] + if let Some(p) = find(clear, b"/FontMatrix", 0) { + let end = find(clear, b"]", p).unwrap_or(clear.len()); + let v: Vec = tokens(&clear[p + 11..end]).filter_map(|t| std::str::from_utf8(t).ok()?.parse().ok()).collect(); + if v.len() == 6 { + f.font_matrix = Some([v[0], v[1], v[2], v[3], v[4], v[5]]); + } + } + // /Encoding: `dup code /name put` entries (StandardEncoding otherwise). + if let Some(p) = find(clear, b"/Encoding", 0) { + let mut toks = tokens(&clear[p + 9..]).peekable(); + let mut last: Vec<&[u8]> = vec![]; + for t in toks.by_ref() { + if t == b"readonly" || t == b"def" { + break; + } + if t == b"put" && last.len() >= 3 && last[last.len() - 3] == b"dup" { + let code = std::str::from_utf8(last[last.len() - 2]).ok().and_then(|c| c.parse::().ok()); + let name = last[last.len() - 1].strip_prefix(b"/"); + if let (Some(c), Some(n)) = (code, name) { + f.encoding.insert(c, String::from_utf8_lossy(n).into_owned()); + } + } + last.push(t); + if last.len() > 4 { + last.remove(0); + } + } + } + // The encrypted part: binary or hexadecimal. + let mut body = &data[ee + 5..]; + while body.first().is_some_and(|c| c.is_ascii_whitespace()) { + body = &body[1..]; + } + let hex = body.iter().take(4).all(u8::is_ascii_hexdigit); + let raw: Vec = if hex { + let digits: Vec = body.iter().copied().filter(u8::is_ascii_hexdigit).collect(); + digits.chunks_exact(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect() + } else { + body.to_vec() + }; + let priv_ = decrypt(&raw, EEXEC_R, 4); + let len_iv = + find(&priv_, b"/lenIV", 0).and_then(|p| tokens(&priv_[p + 6..]).next()).and_then(|t| std::str::from_utf8(t).ok()?.parse::().ok()).unwrap_or(4); + let cs = |bytes: &[u8]| if len_iv < 0 { bytes.to_vec() } else { decrypt(bytes, CHARSTRING_R, len_iv as usize) }; + // Binary items: ` RD ` (RD may be spelt `-|`). + let read_binary = |p: usize| -> Option<(Vec, usize)> { + // p points at the length token. + let mut q = p; + while q < priv_.len() && priv_[q].is_ascii_whitespace() { + q += 1; + } + let s = q; + while q < priv_.len() && priv_[q].is_ascii_digit() { + q += 1; + } + let n: usize = std::str::from_utf8(&priv_[s..q]).ok()?.parse().ok()?; + while q < priv_.len() && priv_[q].is_ascii_whitespace() { + q += 1; + } + while q < priv_.len() && !priv_[q].is_ascii_whitespace() { + q += 1; + } + q += 1; + let bytes = priv_.get(q..q + n)?; + Some((cs(bytes), q + n)) + }; + if let Some(p) = find(&priv_, b"/Subrs", 0) { + let mut q = p + 6; + while let Some(d) = find(&priv_, b"dup", q) { + let lim = find(&priv_, b"/CharStrings", q).unwrap_or(priv_.len()); + if d > lim { + break; + } + let mut r = d + 3; + while r < priv_.len() && priv_[r].is_ascii_whitespace() { + r += 1; + } + let s = r; + while r < priv_.len() && priv_[r].is_ascii_digit() { + r += 1; + } + let Some(i) = std::str::from_utf8(&priv_[s..r]).ok().and_then(|x| x.parse::().ok()) else { break }; + let Some((bytes, end)) = read_binary(r) else { break }; + if i < 65536 { + if f.subrs.len() <= i { + f.subrs.resize(i + 1, vec![]); + } + f.subrs[i] = bytes; + } + q = end; + } + } + let cs_at = find(&priv_, b"/CharStrings", 0)?; + let mut q = cs_at + 12; + // Skip `n dict dup begin`. + q = find(&priv_, b"begin", q).map(|b| b + 5).unwrap_or(q); + while let Some(slash) = priv_[q.min(priv_.len())..].iter().position(|&c| c == b'/').map(|x| x + q) { + let mut e = slash + 1; + while e < priv_.len() && !priv_[e].is_ascii_whitespace() && !matches!(priv_[e], b'/' | b'[' | b'{') { + e += 1; + } + let name = String::from_utf8_lossy(&priv_[slash + 1..e]).into_owned(); + let Some((bytes, end)) = read_binary(e) else { break }; + f.charstrings.insert(name, bytes); + q = end; + // Stop at the end of the CharStrings dictionary. + let mut r = q; + while r < priv_.len() && !priv_[r].is_ascii_whitespace() { + r += 1; + } + let rest = &priv_[r.min(priv_.len())..]; + let next_tok = tokens(rest).next().unwrap_or(b""); + if next_tok == b"end" { + break; + } + } + (!f.charstrings.is_empty()).then_some(f) + } + + pub fn has_glyph(&self, name: &str) -> bool { + self.charstrings.contains_key(name) + } + + /// A glyph's outline in glyph space and its advance width. + pub fn outline(&self, name: &str) -> Option<(BezPath, f64)> { + self.outline_depth(name, 0) + } + + fn outline_depth(&self, name: &str, depth: usize) -> Option<(BezPath, f64)> { + let cs = self.charstrings.get(name)?; + let mut st = T1 { + font: self, + path: BezPath::new(), + x: 0.0, + y: 0.0, + stack: vec![], + ps: vec![], + width: 0.0, + sbx: 0.0, + open: false, + flex: None, + done: false, + depth: 0, + seac: None, + }; + st.run(cs); + if st.open { + st.path.close_path(); + } + let mut path = st.path; + if let Some((asb, adx, ady, b, a)) = st.seac + && depth < 2 + { + let name_of = |c: u8| crate::encoding::Base::Standard.name(c); + if let Some((bp, _)) = name_of(b).and_then(|n| self.outline_depth(&n, depth + 1)) { + path.extend(bp); + } + if let Some((ap, _)) = name_of(a).and_then(|n| self.outline_depth(&n, depth + 1)) { + // The accent's origin: its own sidebearing is replaced by `asb`. + path.extend(kurbo::Affine::translate((adx - asb + st.sbx, ady)) * ap); + } + } + Some((path, st.width)) + } +} + +fn hexv(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + b'A'..=b'F' => c - b'A' + 10, + _ => 0, + } +} + +struct T1<'a> { + font: &'a Type1, + path: BezPath, + x: f64, + y: f64, + stack: Vec, + /// The PostScript operand stack shared with `callothersubr` / `pop`. + ps: Vec, + width: f64, + sbx: f64, + open: bool, + /// Flex points being collected (othersubr 1 … 0). + flex: Option>, + done: bool, + depth: usize, + seac: Option<(f64, f64, f64, u8, u8)>, +} + +impl T1<'_> { + fn rmove(&mut self, dx: f64, dy: f64) { + self.x += dx; + self.y += dy; + if let Some(f) = &mut self.flex { + f.push((self.x, self.y)); + return; + } + if self.open { + self.path.close_path(); + } + self.path.move_to((self.x, self.y)); + self.open = true; + } + fn rline(&mut self, dx: f64, dy: f64) { + if !self.open { + self.path.move_to((self.x, self.y)); + self.open = true; + } + self.x += dx; + self.y += dy; + self.path.line_to((self.x, self.y)); + } + fn rcurve(&mut self, d: [f64; 6]) { + if !self.open { + self.path.move_to((self.x, self.y)); + self.open = true; + } + let (x1, y1) = (self.x + d[0], self.y + d[1]); + let (x2, y2) = (x1 + d[2], y1 + d[3]); + self.x = x2 + d[4]; + self.y = y2 + d[5]; + self.path.curve_to((x1, y1), (x2, y2), (self.x, self.y)); + } + + fn run(&mut self, cs: &[u8]) { + if self.depth > 10 { + return; + } + let mut i = 0; + while i < cs.len() && !self.done { + let b0 = cs[i]; + i += 1; + let s = |st: &Self, k: usize| st.stack.get(k).copied().unwrap_or(0.0); + match b0 { + 32..=246 => self.stack.push(b0 as f64 - 139.0), + 247..=250 => { + self.stack.push((b0 as f64 - 247.0) * 256.0 + *cs.get(i).unwrap_or(&0) as f64 + 108.0); + i += 1; + } + 251..=254 => { + self.stack.push(-(b0 as f64 - 251.0) * 256.0 - *cs.get(i).unwrap_or(&0) as f64 - 108.0); + i += 1; + } + 255 => { + let b = |k: usize| *cs.get(i + k).unwrap_or(&0); + self.stack.push(i32::from_be_bytes([b(0), b(1), b(2), b(3)]) as f64); + i += 4; + } + 13 => { + // hsbw: sbx wx + self.sbx = s(self, 0); + self.x = self.sbx; + self.y = 0.0; + self.width = s(self, 1); + self.stack.clear(); + } + 21 => { + let (a, b) = (s(self, 0), s(self, 1)); + self.rmove(a, b); + self.stack.clear(); + } + 22 => { + let a = s(self, 0); + self.rmove(a, 0.0); + self.stack.clear(); + } + 4 => { + let a = s(self, 0); + self.rmove(0.0, a); + self.stack.clear(); + } + 5 => { + let (a, b) = (s(self, 0), s(self, 1)); + self.rline(a, b); + self.stack.clear(); + } + 6 => { + let a = s(self, 0); + self.rline(a, 0.0); + self.stack.clear(); + } + 7 => { + let a = s(self, 0); + self.rline(0.0, a); + self.stack.clear(); + } + 8 => { + let d = [s(self, 0), s(self, 1), s(self, 2), s(self, 3), s(self, 4), s(self, 5)]; + self.rcurve(d); + self.stack.clear(); + } + 30 => { + // vhcurveto: dy1 dx2 dy2 dx3 + let d = [0.0, s(self, 0), s(self, 1), s(self, 2), s(self, 3), 0.0]; + self.rcurve(d); + self.stack.clear(); + } + 31 => { + // hvcurveto: dx1 dx2 dy2 dy3 + let d = [s(self, 0), 0.0, s(self, 1), s(self, 2), 0.0, s(self, 3)]; + self.rcurve(d); + self.stack.clear(); + } + 9 => { + if self.open { + self.path.close_path(); + self.open = false; + } + self.stack.clear(); + } + 10 => { + let Some(n) = self.stack.pop() else { continue }; + if let Some(sub) = self.font.subrs.get(n.max(0.0) as usize) { + self.depth += 1; + let sub = sub.clone(); + self.run(&sub); + self.depth -= 1; + } + } + 11 => return, + 14 => { + self.done = true; + self.stack.clear(); + } + 1 | 3 => self.stack.clear(), + 12 => { + let b1 = *cs.get(i).unwrap_or(&0); + i += 1; + self.escape(b1); + } + _ => self.stack.clear(), + } + } + } + + fn escape(&mut self, b1: u8) { + match b1 { + 6 => { + // seac: asb adx ady bchar achar + let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0); + self.seac = Some((v(0), v(1), v(2), v(3) as u8, v(4) as u8)); + self.done = true; + self.stack.clear(); + } + 7 => { + // sbw: sbx sby wx wy + let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0); + self.sbx = v(0); + self.x = v(0); + self.y = v(1); + self.width = v(2); + self.stack.clear(); + } + 12 => { + let b = self.stack.pop().unwrap_or(1.0); + let a = self.stack.pop().unwrap_or(0.0); + self.stack.push(if b != 0.0 { a / b } else { 0.0 }); + } + 16 => { + // callothersubr: args… n othersubr# + let other = self.stack.pop().unwrap_or(0.0) as i32; + let n = self.stack.pop().unwrap_or(0.0).max(0.0) as usize; + let k = self.stack.len().saturating_sub(n); + let args: Vec = self.stack.drain(k..).collect(); + match other { + 1 => self.flex = Some(vec![]), + 2 => {} + 0 => { + // End of flex: seven points (a reference point, then two curves). + let pts = self.flex.take().unwrap_or_default(); + if pts.len() >= 7 { + if !self.open { + self.path.move_to((self.x, self.y)); + self.open = true; + } + self.path.curve_to(pts[1], pts[2], pts[3]); + self.path.curve_to(pts[4], pts[5], pts[6]); + self.x = pts[6].0; + self.y = pts[6].1; + } + // `pop pop setcurrentpoint` follows. + self.ps = vec![self.y, self.x]; + } + _ => { + // Hint replacement and others: hand the arguments back to `pop`. + self.ps = args.into_iter().rev().collect(); + } + } + } + 17 => { + let v = self.ps.pop().unwrap_or(0.0); + self.stack.push(v); + } + 33 => { + // setcurrentpoint + let v = |k: usize| self.stack.get(k).copied().unwrap_or(0.0); + self.x = v(0); + self.y = v(1); + self.stack.clear(); + } + _ => self.stack.clear(), + } + } +} + +/// A minimal Type 1 font for tests: `glyphs` are (name, plain charstring bytes). +#[cfg(test)] +pub(crate) fn write_test_type1(glyphs: &[(&str, Vec)], encoding: &[(u8, &str)]) -> Vec { + let mut clear = b"%!PS-AdobeFont-1.0: Test 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\n/Encoding 256 array\n".to_vec(); + for (c, n) in encoding { + clear.extend_from_slice(format!("dup {c} /{n} put\n").as_bytes()); + } + clear.extend_from_slice(b"readonly def\ncurrentfile eexec\n"); + let mut priv_ = + b"dup /Private 8 dict dup begin /RD{string currentfile exch readstring pop}executeonly def\n/lenIV 4 def\n/Subrs 0 array\nND\n2 index /CharStrings " + .to_vec(); + priv_.extend_from_slice(format!("{} dict dup begin\n", glyphs.len()).as_bytes()); + for (n, cs) in glyphs { + let enc = encrypt(cs, CHARSTRING_R, 4); + priv_.extend_from_slice(format!("/{n} {} RD ", enc.len()).as_bytes()); + priv_.extend_from_slice(&enc); + priv_.extend_from_slice(b" ND\n"); + } + priv_.extend_from_slice(b"end\nend\nmark currentfile closefile\n"); + let mut out = clear; + out.extend(encrypt(&priv_, EEXEC_R, 4)); + out.extend_from_slice(b"\n0000000000000000\ncleartomark\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn type1_decrypts_and_draws() { + // hsbw 50 600; rmoveto 0 0; 500 hlineto 500 vlineto -500 hlineto closepath endchar + let cs = vec![50 + 139, 248, 236, 13, 139, 139, 21, 248, 136, 6, 248, 136, 7, 252, 136, 6, 9, 14]; + let font = write_test_type1(&[(".notdef", vec![139, 139, 13, 14]), ("box", cs)], &[(65, "box")]); + let t = Type1::parse(&font).unwrap(); + assert_eq!(t.font_matrix, Some([0.001, 0.0, 0.0, 0.001, 0.0, 0.0])); + assert_eq!(t.encoding.get(&65).map(String::as_str), Some("box")); + let (p, w) = t.outline("box").unwrap(); + assert_eq!(w, 600.0); + assert!((p.area().abs() - 250_000.0).abs() < 1e-6); + assert_eq!(p.bounding_box(), kurbo::Rect::new(50.0, 0.0, 550.0, 500.0)); + } +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 9e54bdc37..edf1d7811 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -1,6 +1,7 @@ //! A minimal PDF writer for generating test files (EffectCraft does not export PDF). -/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed), a +/// Numbered objects `(number, dictionary, stream data)` (streams Flate-compressed unless the +/// dictionary names its own `/Filter`, in which case the data is written as given), a /// cross-reference table and a trailer naming object `root` as the catalog. pub fn pdf(objects: &[(u32, String, Option>)], root: u32) -> Vec { let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); @@ -8,6 +9,12 @@ pub fn pdf(objects: &[(u32, String, Option>)], root: u32) -> Vec { for (n, dict, stream) in objects { offsets.push((*n, out.len())); match stream { + Some(data) if dict.contains("/Filter") => { + let d = format!("{} /Length {} >>", dict.trim_end().trim_end_matches(">>"), data.len()); + out.extend_from_slice(format!("{n} 0 obj\n{d}\nstream\n").as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream\nendobj\n"); + } Some(data) => { let z = miniz_oxide::deflate::compress_to_vec_zlib(data, 6); let d = format!("{} /Filter /FlateDecode /Length {} >>", dict.trim_end().trim_end_matches(">>"), z.len()); diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index bbf3b62c6..fcf2cfb3b 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -3,7 +3,8 @@ //! Effects' Master Properties). //! //! - A comp's [`EssentialGraphics`] lists controls: properties of its layers (shown as text, -//! colour, slider, checkbox, point, angle or dropdown controls), Media Replacement slots for +//! colour, slider, checkbox, point, angle or dropdown controls; Source Text also as a font +//! control and Scale as one uniform slider), Media Replacement slots for //! footage layers, comments and groups. //! - Every layer that nests such a comp gets an **Essential Properties** group (match id //! `essential`, [`GroupKind::Essential`]) mirroring the controls, one child per control with @@ -36,6 +37,11 @@ pub struct EgControl { /// Display name (renamable; defaults to the property's name). pub name: String, pub kind: EgKind, + /// The control a property is shown as when not its natural one ([`control_type`]): a + /// Source Text property as a [`ControlType::Font`] control, a Scale as a uniform + /// [`ControlType::Scale`] slider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_type: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -83,11 +89,34 @@ pub enum ControlType { Angle, Dropdown, Media, + /// The font family, style and size of a Source Text property (the text itself stays). + Font, + /// One uniform percentage for a Scale property (every axis the same). + Scale, } impl ControlType { + /// By label or serde name, case-insensitively (`"font"`, `"Scale"`…). + pub fn parse(s: &str) -> Option { + [ + ControlType::Text, + ControlType::Color, + ControlType::Slider, + ControlType::Checkbox, + ControlType::Point, + ControlType::Angle, + ControlType::Dropdown, + ControlType::Media, + ControlType::Font, + ControlType::Scale, + ] + .into_iter() + .find(|t| t.label().eq_ignore_ascii_case(s.trim())) + } pub fn label(self) -> &'static str { match self { + ControlType::Font => "Font", + ControlType::Scale => "Scale", ControlType::Text => "Text", ControlType::Color => "Color", ControlType::Slider => "Slider", @@ -118,6 +147,35 @@ pub fn control_type(p: &Property) -> Option { }) } +/// Whether property `p` can be shown as a `ty` control: its natural control, a Source Text +/// property as Font, a two- or three-dimensional property as uniform Scale. +pub fn can_show_as(p: &Property, ty: ControlType) -> bool { + if matches!(p.ui, ParamUi::Hidden) { + return false; + } + match ty { + ControlType::Font => matches!(p.value, Value::Text(_)), + ControlType::Scale => matches!(p.value, Value::Vec2(_) | Value::Vec3(_)) && !matches!(p.ui, ParamUi::Angle), + ControlType::Media => false, + t => control_type(p) == Some(t), + } +} + +/// The control `c` shows for its property `p` (its `as_type`, else the natural one). +pub fn effective_type(c: &EgControl, p: &Property) -> Option { + c.as_type.filter(|t| can_show_as(p, *t)).or_else(|| control_type(p)) +} + +/// Apply a Font control's value: the font family, style and size of `from` onto `to` (the text, +/// colours and every other attribute stay). +pub fn merge_font(to: &mut Value, from: &Value) { + if let (Value::Text(t), Value::Text(f)) = (to, from) { + t.font = f.font.clone(); + t.style = f.style.clone(); + t.size = f.size; + } +} + impl EssentialGraphics { /// Every control, depth first (groups before their children). pub fn flat(&self) -> Vec<&EgControl> { @@ -172,10 +230,15 @@ impl EssentialGraphics { }, } } - /// The control exposing property `prop` of `layer` (its main property), if any. + /// The control exposing property `prop` of `layer`, if any. pub fn control_for(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p, .. } if l == layer && p == prop)) } + /// The control exposing property `prop` of `layer` as `as_type` (`None` = its natural + /// control): a property can have one control of each kind (Source Text as Text and Font). + pub fn control_for_as(&self, layer: LayerId, prop: Uid, as_type: Option) -> Option<&EgControl> { + self.flat().into_iter().find(|c| c.as_type == as_type && matches!(c.kind, EgKind::Property { layer: l, prop: p, .. } if l == layer && p == prop)) + } /// The property control a property drives: its main property or one of its links. pub fn driver_of(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { self.flat().into_iter().find(|c| match &c.kind { @@ -279,7 +342,8 @@ fn sync_links(before: &Project, p: &mut Project) { let mut writes: Vec<(LayerId, Uid, Value, Vec)> = vec![]; for c in eg.flat() { let targets = c.kind.targets(); - if targets.len() < 2 { + // Font controls take only the font: they never drive whole values. + if targets.len() < 2 || c.as_type == Some(ControlType::Font) { continue; } let get = |cm: &crate::Comp, (l, u): (LayerId, Uid)| cm.layer(l).and_then(|l| l.props.find(u).cloned()); @@ -590,10 +654,24 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - let mut p = project.clone(); let c = p.comp_mut(comp)?; let mut any = false; - for o in overrides { - // A mirror's value is its master's. + // Whole values first, then Font controls (which change only the font of whatever text the + // instance shows). A mirror acts as its master control. + let is_font = |o: &&Override| eg.resolve(o.control).is_some_and(|c| c.as_type == Some(ControlType::Font)); + let ordered = overrides.iter().filter(|o| !is_font(o)).chain(overrides.iter().filter(is_font)); + for o in ordered { let Some(ctl) = eg.resolve(o.control) else { continue }; match &ctl.kind { + EgKind::Property { .. } if ctl.as_type == Some(ControlType::Font) => { + for (layer, prop) in ctl.kind.targets() { + if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { + merge_font(&mut pr.value, &o.value); + for k in &mut pr.keys { + merge_font(&mut k.value, &o.value); + } + any = true; + } + } + } EgKind::Property { .. } => { for (layer, prop) in ctl.kind.targets() { if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 887bcc70e..c651165e1 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -770,6 +770,9 @@ pub struct Footage { /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] pub data: Option, + /// PDF / Illustrator footage: the page shown (0-based; File ▸ Import ▸ Page). + #[serde(default, skip_serializing_if = "is_default")] + pub page: u32, } fn is_default(v: &T) -> bool { @@ -801,6 +804,7 @@ impl Default for Footage { linear_light: false, data: None, layer: None, + page: 0, } } } @@ -845,6 +849,10 @@ pub struct SourceLayer { /// is the footage instead of the layer's pixels. #[serde(default, skip_serializing_if = "Option::is_none")] pub embedded: Option, + /// A smart object with a perspective quad or a warp: the footage is its embedded file baked + /// as placed (warped and pinned to its corners) over the placed bounds in document space. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub placed: bool, } fn one() -> u32 { diff --git a/crates/psd/README.md b/crates/psd/README.md index b0b19ba3b..acbfe7a95 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -16,6 +16,12 @@ Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes. Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and justification of the first style run. Smart objects import as footage of their embedded file (a Photoshop document's merged image, or an image) placed by the smart object's corner quad -(position, scale, rotation; perspective and warps are not applied). Externally linked files +(position, scale, rotation). A non-affine quad (`nonAffineTransform`: perspective) or a placed +layer warp (`warp`: the named styles with bend and horizontal / vertical distortion, and custom +envelope warps whose quilt mesh of bicubic Bezier patches is read from `meshPoints` (`ObAr`) +and `quiltSliceX` / `quiltSliceY`) is baked as placed over the placed bounds, at up to 4x the +document resolution ([`placed`](src/placed.rs)). Custom meshes are evaluated exactly; the +named styles' curves are not published, so they are clean-room geometric definitions matching +each style by behaviour. Externally linked files fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)). diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index e121f823d..d426f7143 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -30,6 +30,9 @@ pub enum DValue { Class(String, String), Raw(Vec), Reference(Vec), + /// An object array (`ObAr`): `count` objects of one class stored column-wise, each item a + /// list of `count` values (unit floats). + ObjectArray(u32, Descriptor), } /// An item of a reference (`obj `). @@ -161,6 +164,10 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result { } DValue::List(v) } + b"ObAr" => { + let n = r.u32()?; + DValue::ObjectArray(n, read_descriptor(r)?) + } b"doub" => DValue::Double(r.f64()?), b"UntF" => { let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); @@ -280,6 +287,11 @@ fn write_value(out: &mut Vec, v: &DValue) { write_value(out, x); } } + DValue::ObjectArray(n, d) => { + out.extend_from_slice(b"ObAr"); + put_u32(out, *n); + write_descriptor(out, d); + } DValue::Double(x) => { out.extend_from_slice(b"doub"); out.extend_from_slice(&x.to_be_bytes()); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 61cadc7d9..de3ec1826 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -9,6 +9,7 @@ pub mod descriptor; pub mod engine_data; +pub mod placed; mod read; pub mod write; @@ -214,6 +215,8 @@ pub struct SmartObject { pub quad: [[f64; 2]; 4], /// The content's size in pixels (`Sz `), when recorded. pub size: Option<[f64; 2]>, + /// The placed layer's warp (`warp`), when it has one ([`placed`]). + pub warp: Option, } impl SmartObject { @@ -665,8 +668,11 @@ impl Psd { } }; let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]); + // The non-affine transform when present (perspective), else the transform. + let t = nums("nonAffineTransform").filter(|t| t.len() == 8).unwrap_or(t); let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?])); - l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size }); + let warp = d.obj("warp").and_then(placed::Warp::read); + l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size, warp }); } b"PlLd" if l.smart_object.is_none() => { let _id = r.tag()?; @@ -681,7 +687,7 @@ impl Psd { for v in &mut t { *v = r.f64()?; } - l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None }); + l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None, warp: None }); } b"vmsk" | b"vsms" => { let _ver = r.u32()?; diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs new file mode 100644 index 000000000..156a8e271 --- /dev/null +++ b/crates/psd/src/placed.rs @@ -0,0 +1,410 @@ +//! Placed-layer (smart object) geometry: the non-affine corner quad (a projective map of the +//! content rectangle) and the placed-layer warp (`warp` in `SoLd`: the named warp styles with +//! bend and horizontal / vertical distortion, and custom envelope warps whose quilt mesh is a +//! grid of bicubic Bézier patches), and a renderer that bakes both into document-space pixels. +//! +//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by +//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles; +//! the styles here are clean-room geometric definitions of what each name describes (Arc, +//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by +//! behaviour, not numerically. + +use crate::descriptor::{DValue, Descriptor}; +use crate::{Pixels, SmartObject}; + +/// A placed layer's warp. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Warp { + /// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`, + /// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`, + /// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`. + pub style: String, + /// Bend, −100..100 (%). + pub bend: f64, + /// Horizontal and vertical distortion, −100..100 (%). + pub horizontal: f64, + pub vertical: f64, + /// The style is applied rotated a quarter turn (`warpRotate` = vertical). + pub rotate_vertical: bool, + /// The warped area in content pixels: left, top, right, bottom. + pub bounds: [f64; 4], + /// Custom envelope: patch columns / rows and the (3·cols+1)×(3·rows+1) control points + /// (content pixels, row-major). + pub cols: usize, + pub rows: usize, + pub mesh: Vec<[f64; 2]>, +} + +fn floats(v: Option<&DValue>) -> Vec { + match v { + Some(DValue::UnitFloats(_, v)) => v.clone(), + Some(DValue::List(l)) => l + .iter() + .filter_map(|x| match x { + DValue::Double(v) | DValue::UnitFloat(_, v) => Some(*v), + DValue::Integer(i) => Some(*i as f64), + _ => None, + }) + .collect(), + _ => vec![], + } +} + +impl Warp { + /// Read a `warp` descriptor; `None` for no warp. + pub fn read(d: &Descriptor) -> Option { + let style = d.enum_value("warpStyle").unwrap_or("warpNone").to_string(); + let b = d.obj("bounds"); + let bounds = b + .map(|b| [b.num("Left").unwrap_or(0.0), b.num("Top ").unwrap_or(0.0), b.num("Rght").unwrap_or(0.0), b.num("Btom").unwrap_or(0.0)]) + .unwrap_or([0.0; 4]); + let mut w = Warp { + bend: d.num("warpValue").unwrap_or(0.0), + horizontal: d.num("warpPerspective").unwrap_or(0.0), + vertical: d.num("warpPerspectiveOther").unwrap_or(0.0), + rotate_vertical: d.enum_value("warpRotate") == Some("Vrtc"), + bounds, + cols: 1, + rows: 1, + mesh: vec![], + style, + }; + if let Some(env) = d.obj("customEnvelopeWarp") { + let cols = floats(env.get("quiltSliceX")).len().saturating_sub(1).max(1); + let rows = floats(env.get("quiltSliceY")).len().saturating_sub(1).max(1); + if let Some(DValue::ObjectArray(_, pts)) = env.get("meshPoints") { + let xs = floats(pts.get("Hrzn")); + let ys = floats(pts.get("Vrtc")); + let n = (3 * cols + 1) * (3 * rows + 1); + if xs.len() == n && ys.len() == n { + w.cols = cols; + w.rows = rows; + w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect(); + } + } + } + let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0); + let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty(); + (!identity && custom_ok).then_some(w) + } + + /// The warp of a content point (content pixels → content pixels). + pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] }; + let (bw, bh) = (r - l, b - t); + let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh); + if self.style == "warpCustom" { + return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + } + // Styles work in a centred frame (pixels), optionally rotated a quarter turn. + let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) }; + let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh); + // Distortions: a perspective-like taper across the width (horizontal) or height. + let (hd, vd) = if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) }; + let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5); + let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5); + y2 *= 1.0 + hd * s; + x2 *= 1.0 + vd * tt; + let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) }; + [l + bw / 2.0 + ox, t + bh / 2.0 + oy] + } + + /// A point of the custom quilt mesh at (u, v) ∈ [0, 1]². + fn mesh_point(&self, u: f64, v: f64) -> [f64; 2] { + let (cols, rows) = (self.cols, self.rows); + let pu = (u * cols as f64).min(cols as f64 - 1e-9); + let pv = (v * rows as f64).min(rows as f64 - 1e-9); + let (ci, cj) = (pu.floor() as usize, pv.floor() as usize); + let (fu, fv) = (pu - ci as f64, pv - cj as f64); + let stride = 3 * cols + 1; + let bern = |t: f64| { + let s = 1.0 - t; + [s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t] + }; + let (bu, bv) = (bern(fu), bern(fv)); + let mut out = [0.0; 2]; + for j in 0..4 { + for i in 0..4 { + let q = self.mesh[(3 * cj + j) * stride + 3 * ci + i]; + let k = bu[i] * bv[j]; + out[0] += q[0] * k; + out[1] += q[1] * k; + } + } + out + } +} + +/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1]. +fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] { + use std::f64::consts::PI; + let (hw, hh) = (fw / 2.0, fh / 2.0); + let s = x / hw; // −1..1 across + let t = y / hh; // −1..1 down + // Concentric arcs: the midline bends through an angle of b·π, rows keep their distance. + let arc = |x: f64, y: f64| -> [f64; 2] { + let theta = b * PI; + if theta.abs() < 1e-9 { + return [x, y]; + } + let r = fw / theta; + let phi = x / hw * theta / 2.0; + let rr = r - y; + // Arc centre below the shape for upward bends; keep the midline's centre fixed. + [rr * phi.sin(), r - rr * phi.cos()] + }; + let bump = 1.0 - s * s; + match style { + "warpArc" => arc(x, y), + "warpArcLower" => { + let a = arc(x, y); + let k = (t + 1.0) / 2.0; + [x + (a[0] - x) * k, y + (a[1] - y) * k] + } + "warpArcUpper" => { + let a = arc(x, y); + let k = (1.0 - t) / 2.0; + [x + (a[0] - x) * k, y + (a[1] - y) * k] + } + "warpArch" => [x, y - b * hh * bump], + "warpBulge" => [x, y + t * b * hh * bump], + "warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump], + "warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump], + "warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()], + "warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)], + "warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)], + "warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()], + "warpFisheye" | "warpInflate" => { + let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 }; + [x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)] + } + "warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)], + "warpTwist" => { + let r = ((s * s + t * t) / 2.0).sqrt().min(1.0); + let a = b * PI / 2.0 * (1.0 - r); + let (sn, cs) = a.sin_cos(); + [x * cs - y * sn, x * sn + y * cs] + } + _ => [x, y], + } +} + +/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q` +/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1). +fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { + let [p0, p1, p2, p3] = q; + let sx = p0[0] - p1[0] + p2[0] - p3[0]; + let sy = p0[1] - p1[1] + p2[1] - p3[1]; + if sx.abs() < 1e-12 && sy.abs() < 1e-12 { + return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]]; + } + let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]); + let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]); + let den = dx1 * dy2 - dx2 * dy1; + let g = (sx * dy2 - dx2 * sy) / den; + let h = (dx1 * sy - sx * dy1) / den; + [[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]] +} + +impl SmartObject { + /// Whether the corner quad is a parallelogram (an affine placement). + pub fn is_affine(&self) -> bool { + let q = self.quad; + let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0]; + let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1]; + ex.abs() < 1e-3 && ey.abs() < 1e-3 + } + + /// Whether placing needs baking (a perspective quad or a warp). + pub fn needs_bake(&self) -> bool { + !self.is_affine() || self.warp.is_some() + } + + /// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad. + pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let p = match &self.warp { + Some(wp) => wp.apply(p, w, h), + None => p, + }; + let m = square_to_quad(self.quad); + let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); + let z = m[2][0] * u + m[2][1] * v + m[2][2]; + let z = if z.abs() < 1e-12 { 1e-12 } else { z }; + [(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z] + } + + /// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content. + pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] { + let n = 32; + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for j in 0..=n { + for i in 0..=n { + let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + } + } + [b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()] + } + + /// The baked size for a `w`×`h` content at `k` pixels per document pixel. + pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) { + let bb = self.placed_bounds(w, h); + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) + } + + /// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at + /// `k` pixels per document pixel (into `size` pixels when given; see [`Self::placed_size`]). + /// Returns the pixels and their document-space origin. + pub fn render_placed(&self, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> { + let (cw, ch) = (content.width as f64, content.height as f64); + if content.width == 0 || content.height == 0 { + return None; + } + let bb = self.placed_bounds(cw, ch); + let (ow, oh) = match size { + Some((w, h)) => (w.max(1), h.max(1)), + None => self.placed_size(cw, ch, k), + }; + let (ow, oh) = (ow as usize, oh as usize); + // A forward grid, filled triangle by triangle with inverse barycentric sampling. + let n = 48usize; + let mut dst = Vec::with_capacity((n + 1) * (n + 1)); + let mut src = Vec::with_capacity((n + 1) * (n + 1)); + for j in 0..=n { + for i in 0..=n { + let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch]; + let d = self.map(s, cw, ch); + dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]); + src.push(s); + } + } + let mut out = vec![[0.0f32; 4]; ow * oh]; + let sample = |x: f64, y: f64| -> [f32; 4] { + let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0)); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1)); + let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let at = |x: usize, y: usize| { + let p = content.data[y * content.width as usize + x]; + [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] + }; + let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); + let mut o = [0.0; 4]; + for c in 0..4 { + o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b; + } + o + }; + let idx = |i: usize, j: usize| j * (n + 1) + i; + for j in 0..n { + for i in 0..n { + for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] { + let [a, b, c] = tri.map(|t| dst[t]); + let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + if den.abs() < 1e-12 { + continue; + } + let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize; + let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow); + let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize; + let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh); + for y in y0..y1 { + for x in x0..x1 { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let l1 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; + let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + let l3 = 1.0 - l1 - l2; + if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 { + continue; + } + let [sa, sb, sc] = tri.map(|t| src[t]); + let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; + let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; + out[y * ow + x] = sample(sx, sy); + } + } + } + } + } + // Back to straight alpha. + for p in &mut out { + if p[3] > 0.0 { + for c in 0..3 { + p[c] /= p[3]; + } + } + } + Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn so(quad: [[f64; 2]; 4], warp: Option) -> SmartObject { + SmartObject { uuid: "x".into(), quad, size: Some([20.0, 10.0]), warp } + } + + #[test] + fn perspective_quad_maps_corners_and_is_projective() { + let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]]; + let s = so(q, None); + assert!(!s.is_affine() && s.needs_bake()); + for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] { + let p = s.map(c, 20.0, 10.0); + assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}"); + } + // Projective, not bilinear: the content centre is where the diagonals cross. + let c = s.map([10.0, 5.0], 20.0, 10.0); + assert!((c[0] - 30.0).abs() > 0.5, "{c:?}"); + let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None); + assert!(affine.is_affine() && !affine.needs_bake()); + } + + #[test] + fn custom_mesh_and_styles_warp() { + // An identity 4×4 mesh shifted 3 px right in its interior row: interior points move. + let mut mesh = vec![]; + for j in 0..4 { + for i in 0..4 { + mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]); + } + } + let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() }; + let p = w.apply([10.0, 5.0], 20.0, 10.0); + assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}"); + assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]); + // Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up, + // bottom down; Flag keeps the centre. + let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() }; + let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0); + let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0); + assert!(top[1] < corner[1], "arc {top:?} {corner:?}"); + let b = style("warpBulge"); + assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0); + assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9); + // Vertical rotation swaps the roles of the axes. + let mut v = style("warpBulge"); + v.rotate_vertical = true; + let l = v.apply([0.0, 50.0], 200.0, 100.0); + assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}"); + // Horizontal distortion: the right side grows taller. + let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() }; + assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]); + } + + #[test] + fn render_placed_bakes_a_perspective_quad() { + // 2×1 content: red | green, pinned to a trapezoid. + let content = Pixels { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] }; + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], size: None, warp: None }; + let (px, origin) = s.render_placed(&content, 1.0, None).unwrap(); + assert_eq!(origin, [0.0, 0.0]); + assert_eq!((px.width, px.height), (40, 20)); + let at = |x: usize, y: usize| px.data[y * 40 + x]; + assert!(at(5, 10)[0] > 0.9 && at(5, 10)[3] > 0.99, "{:?}", at(5, 10)); + assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10)); + assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid"); + } +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs index 160e81d30..cb3b2bf06 100644 --- a/crates/psd/src/write.rs +++ b/crates/psd/src/write.rs @@ -414,14 +414,53 @@ fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { /// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at /// `quad` (top-left, top-right, bottom-right, bottom-left). pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec) { + smart_object_block_warped(uuid, quad, size, None) +} + +/// A `warp` descriptor: a named style (bend and distortions in %) over `bounds` (left, top, +/// right, bottom), or with `mesh` (16 points, content pixels) a custom envelope. +pub fn warp_descriptor(style: &str, bend: f64, h: f64, v: f64, bounds: [f64; 4], mesh: Option<&[[f64; 2]]>) -> Descriptor { + let mut d = Descriptor::new("warp") + .with("warpStyle", DValue::Enum("warpStyle".into(), style.into())) + .with("warpValue", DValue::Double(bend)) + .with("warpPerspective", DValue::Double(h)) + .with("warpPerspectiveOther", DValue::Double(v)) + .with("warpRotate", DValue::Enum("Ornt".into(), "Hrzn".into())) + .with( + "bounds", + DValue::Descriptor( + Descriptor::new("Rctn") + .with("Top ", DValue::UnitFloat("#Pxl".into(), bounds[1])) + .with("Left", DValue::UnitFloat("#Pxl".into(), bounds[0])) + .with("Btom", DValue::UnitFloat("#Pxl".into(), bounds[3])) + .with("Rght", DValue::UnitFloat("#Pxl".into(), bounds[2])), + ), + ) + .with("uOrder", DValue::Integer(4)) + .with("vOrder", DValue::Integer(4)); + if let Some(m) = mesh { + let pts = Descriptor::new("rationalPoint") + .with("Hrzn", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[0]).collect())) + .with("Vrtc", DValue::UnitFloats("#Pxl".into(), m.iter().map(|p| p[1]).collect())); + d = d + .with("customEnvelopeWarp", DValue::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", DValue::ObjectArray(m.len() as u32, pts)))); + } + d +} + +/// `SoLd` with an optional warp; `quad` may be non-affine (perspective). +pub fn smart_object_block_warped(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2], warp: Option) -> ([u8; 4], Vec) { let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect()); let t: Vec = quad.iter().flat_map(|p| [p[0], p[1]]).collect(); - let d = Descriptor::new("null") + let mut d = Descriptor::new("null") .with("Idnt", DValue::Text(uuid.to_string())) .with("placed", DValue::Text(uuid.to_string())) .with("Trnf", list(&t)) .with("nonAffineTransform", list(&t)) .with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1])))); + if let Some(w) = warp { + d = d.with("warp", DValue::Descriptor(w)); + } let mut out = b"soLD".to_vec(); u32b(&mut out, 4); u32b(&mut out, 16); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bfbd2405d..e76afb715 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -326,7 +326,8 @@ impl FxTarget for CpuFx<'_> { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). pub scale: f64, @@ -401,6 +402,8 @@ pub struct Renderer<'a> { /// Switches inherited from the precomp layers this comp is rendered through (see /// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed. pub(crate) inherited: Option<(Quality, bool)>, + /// Classic 3D depth of field is left to an accelerator ([`three_d::Plane3d::dof`]). + pub(crate) defer_dof: bool, /// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed /// precomp drawn straight into it): the region of interest's offset applies. top: bool, @@ -448,6 +451,7 @@ impl<'a> Renderer<'a> { opacity_mul: 1.0, collapse3d: None, inherited: None, + defer_dof: false, top: true, } } @@ -483,7 +487,7 @@ impl<'a> Renderer<'a> { /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { - Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, top: false, ..*self } + Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, top: false, ..*self } } /// The nested comp of a precomp layer whose transformations collapse into this comp: the diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 66eea4117..12bcbcf37 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -190,32 +190,15 @@ pub fn boost_highlights(img: &mut Image, h: &Highlight) { }); } -/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels -/// beyond the image are transparent. -pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { - if r < 0.3 { - return img.clone(); - } - if iris.sides == 0 { - // Fast Rectangle: a separable box of the iris' width and height. - let a = iris.aspect.clamp(0.01, 100.0).sqrt(); - let rx = (r * a).round().max(0.0) as usize; - let ry = (r / a).round().max(0.0) as usize; - return effectcraft_raster::box_blur(img, rx, ry, 1, false); - } - match kernel(iris, r) { - Some((kernels, norm)) => span_gather(img, &kernels, norm), - None => img.clone(), - } -} +/// Weighted row-span kernels and their normalisation. +pub type Kernels = (Vec<(f32, Vec<(i32, f64, f64)>)>, f32); -/// Weighted row-span kernels (see [`Iris::spans`]). -pub type SpanKernels = Vec<(f32, Vec<(i32, f64, f64)>)>; - -/// [`bokeh_blur`]'s gather kernel at radius `r` as weighted row spans and their normaliser -/// (accelerators run the same gather). Fast Rectangle is the box of the iris' width and height -/// (what [`bokeh_blur`]'s separable box computes). `None` = the identity (no blur). -pub fn kernel(iris: &Iris, r: f64) -> Option<(SpanKernels, f32)> { +/// The bokeh kernel of radius `r` as weighted row spans (see [`Iris::spans`]) and the sum of +/// weights; `None` when the blur does nothing (radius below 0.3 px). Fast Rectangle is the box +/// of the iris' width and height (one span per row covering whole pixels); the diffraction +/// fringe adds the rim as the full iris minus a shrunken one. The GPU gathers with exactly these +/// spans. +pub fn kernel_spans(iris: &Iris, r: f64) -> Option { if r < 0.3 { return None; } @@ -229,8 +212,6 @@ pub fn kernel(iris: &Iris, r: f64) -> Option<(SpanKernels, f32)> { let spans = (-ry..=ry).map(|dy| (dy, -rx as f64 - 0.5, rx as f64 + 0.5)).collect(); return Some((vec![(1.0, spans)], ((2 * rx + 1) * (2 * ry + 1)) as f32)); } - // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The - // diffraction fringe adds the rim: the full iris minus a shrunken one. let outer = iris.spans(r); let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::(); let mut kernels = vec![(1.0f32, outer)]; @@ -245,10 +226,30 @@ pub fn kernel(iris: &Iris, r: f64) -> Option<(SpanKernels, f32)> { (norm > 0.0).then_some((kernels, norm)) } -/// [`progressive_blur`]'s blur levels for a radius range: `None` = no blur at all; one level -/// when the range is narrow (every pixel takes it); else up to eight spanning `lo..=hi` -/// (each pixel blends the two around its own radius). -pub fn progressive_levels(lo: f32, hi: f32) -> Option> { +/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels +/// beyond the image are transparent. +pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { + if r < 0.3 { + return img.clone(); + } + if iris.sides == 0 { + // Fast Rectangle: a separable box of the iris' width and height (the same as its spans). + let a = iris.aspect.clamp(0.01, 100.0).sqrt(); + let rx = (r * a).round().max(0.0) as usize; + let ry = (r / a).round().max(0.0) as usize; + return effectcraft_raster::box_blur(img, rx, ry, 1, false); + } + // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). + match kernel_spans(iris, r) { + Some((kernels, norm)) => span_gather(img, &kernels, norm), + None => img.clone(), + } +} + +/// The blur levels of a depth-varying blur ([`progressive_blur`]): `None` when nothing blurs, +/// one level for a near-constant radius, else up to eight radii spanning the range. +pub fn blur_levels(radius: &[f32]) -> Option> { + let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); if !lo.is_finite() || hi < 0.3 { return None; } @@ -259,16 +260,35 @@ pub fn progressive_levels(lo: f32, hi: f32) -> Option> { Some((0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect()) } +/// How much level `k` of `levels` contributes at radius `r` (the two levels bracketing `r` +/// are blended linearly). +pub fn level_weight(levels: &[f32], k: usize, r: f32) -> f32 { + let n = levels.len(); + if n <= 1 { + return 1.0; + } + let (lo, hi) = (levels[0], levels[n - 1]); + let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let kk = (f.floor() as usize).min(n - 2); + let t = f - kk as f32; + if k == kk { + 1.0 - t + } else if k == kk + 1 { + t + } else { + 0.0 + } +} + /// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to /// eight radii spanning the range and each pixel interpolates between the two levels around /// its own radius. pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { - let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); - let Some(levels) = progressive_levels(lo, hi) else { return img.clone() }; - let n = levels.len(); - if n == 1 { + let Some(levels) = blur_levels(radius) else { return img.clone() }; + if levels.len() == 1 { return bokeh_blur(img, iris, levels[0] as f64); } + let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); let mut out = Image::new(img.width, img.height); out.data.par_iter_mut().enumerate().for_each(|(i, o)| { diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 5836f9340..5ef271bea 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -62,6 +62,21 @@ pub(crate) struct Item<'a> { winv: Mat4, bounds: [f64; 4], bbox: [i64; 4], + /// Depth of field left to an accelerator (`buf` is then unblurred and unpadded). + dof: Option, +} + +/// Depth of field of a plane for an accelerator to apply ([`Plane3d::dof`]): pad the buffer by +/// `pad` transparent pixels, boost its highlights, then blur it at each of `levels` with +/// [`super::bokeh::kernel_spans`] and blend the levels by each pixel's `radius` (padded buffer +/// pixels, row-major) with [`super::bokeh::level_weight`] — what the CPU's depth of field does. +#[derive(Clone, Debug)] +pub struct PlaneDof { + pub pad: u32, + pub iris: super::bokeh::Iris, + pub highlight: super::bokeh::Highlight, + pub radius: Arc>, + pub levels: Vec, } impl Item<'_> { @@ -218,10 +233,20 @@ fn prepare_with<'a>( let world = outer * ctx.world_matrix(layer) * local; // Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a // layer facing the camera, progressive for a tilted one). + let mut plane_dof = None; + let mut bounds = buf_bounds(&buf); if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { - buf = dof_blur(&cam, &dof, world, buf); + if r.defer_dof { + // An accelerator blurs: hand it the radius map of the padded buffer. + if let Some((pd, padded_bounds)) = dof_plan(&cam, &dof, world, &buf) { + plane_dof = Some(pd); + bounds = padded_bounds; + } + } else { + buf = dof_blur(&cam, &dof, world, buf); + bounds = buf_bounds(&buf); + } } - let bounds = buf_bounds(&buf); let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { geo(&cam, world, bounds, comp, s, out, r.out_offset()).into_iter().collect() @@ -260,16 +285,20 @@ fn prepare_with<'a>( winv: world.inverse().unwrap_or(Mat4::IDENTITY), bounds, bbox, + dof: plane_dof, }) } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from /// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - let s = buf.scale.max(1e-9); + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +} + +fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { + let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); - let (w, h) = (buf.img.width as usize, buf.img.height as usize); - let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0))); + let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); // Camera depth is affine across a plane. let z0 = at(0.5, 0.5); let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0); @@ -287,6 +316,30 @@ fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf out } +/// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. +fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); + (rmax * a).ceil() as u32 + 2 +} + +/// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded +/// buffer's layer-space bounds. `None` when nothing blurs. +fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { + let radii = dof_radii(cam, dof, world, buf); + let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; + if rmax <= 0.3 { + return None; + } + let pad = dof_pad(dof, rmax); + let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let levels = super::bokeh::blur_levels(&radius)?; + let s = buf.scale.max(1e-9); + let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; + Some((PlaneDof { pad, iris: dof.iris, highlight: dof.highlight, radius: Arc::new(radius), levels }, bounds)) +} + /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { let radii = dof_radii(cam, dof, world, &buf); @@ -294,8 +347,7 @@ fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc, + /// Depth of field for the accelerator to apply to `buf` (`bounds` are then the padded + /// buffer's). + pub dof: Option, pub bicubic: bool, /// One per motion-blur sub-sample (equal weights). pub geos: Vec, @@ -697,7 +752,9 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 if super::adv::active(r, ctx) { return None; } - let g = gather(r, ctx, run, out); + // Depth of field is left to the accelerator (its blur runs where the planes are drawn). + let rd = Renderer { defer_dof: true, ..*r }; + let g = gather(&rd, ctx, run, out); let planes: Vec = g .items .into_iter() @@ -706,6 +763,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 layer_id: it.layer.id.0 as u32, mode: it.layer.blend_mode, buf: it.buf, + dof: it.dof, bicubic: it.bicubic, geos: it.geos, mat: it.mat, diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index b880cdcdc..80d5dd19d 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -17,7 +17,7 @@ mod tests; mod tests_orient; pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; -pub use compose::{Geo as PlaneGeo, Plane3d, Run3d}; +pub use compose::{Geo as PlaneGeo, Plane3d, PlaneDof, Run3d}; pub use light::{LightState, Material, lights_at}; use effectcraft_geom::Mat3; diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index a7a85213f..ad7a9fb72 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -37,7 +37,13 @@ //! `onChange` / `onChanging` / `onClose`, `show()` / `close()` and `layout.layout()`. Windows //! are published to [`Session::script_ui`](effectcraft_engine::Session::script_ui) for //! frontends and agents (`scriptui.*` commands); modal dialogs block `show()` until they close. -//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. +//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. Resource +//! strings (`new Window("dialog { ok: Button { text: 'OK' } }")`, `add("group { … }")`) build +//! whole trees; `onDraw` handlers paint with `ScriptUIGraphics` (`newPen`/`newBrush`/`rectPath`/ +//! `ellipsePath`/`moveTo`/`lineTo`/`fillPath`/`strokePath`/`drawString`/`measureString`) into a +//! serde draw list frontends paint; edit texts and sliders fire `onChanging` live. +//! * **Sockets** (`socket.rs`): `Socket` (`open`/`listen`/`poll`/`read`/`readln`/`write`/`close`) +//! over TCP, behind the same network preference; on the web `open` returns false. //! //! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which //! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console @@ -48,6 +54,7 @@ pub mod matchnames; mod model; mod runtime; +mod socket; mod ui; use effectcraft_engine::{ScriptRequest, Session}; @@ -75,3 +82,5 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { mod tests; #[cfg(test)] mod tests_ui; +#[cfg(test)] +mod tests_ui_more; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9fde823dd..c06bc9bfa 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -166,7 +166,7 @@ File.prototype = { read: function (n) { if (this.__buf === null) this.open("r"); if (this.__buf === null) return ""; - var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substr(this.__pos, n); + var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substring(this.__pos, this.__pos + n); this.__pos += s.length; return s; }, @@ -255,7 +255,77 @@ Object.defineProperty(Folder, "temp", { get: function () { return null; } }); Object.defineProperty(Folder, "desktop", { get: function () { return null; } }); Object.defineProperty(Folder, "myDocuments", { get: function () { return null; } }); -function Socket() { __file("network", ""); } +// TCP sockets (behind Allow Scripts to Write Files and Access Network; see socket.rs). +function Socket() { + if (!(this instanceof Socket)) return new Socket(); + __sock("check", 0, "{}"); + this.__id = 0; + this.host = ""; + this.timeout = 10; + this.encoding = "ASCII"; + this.error = ""; +} +function __sockCall(s, op, extra) { + var p = extra || {}; + p.timeout = s.timeout; + p.encoding = s.encoding; + var r = JSON.parse(__sock(op, s.__id, JSON.stringify(p))); + s.error = r.error || ""; + return r; +} +Socket.prototype = { + constructor: Socket, + get connected() { return this.__id > 0 && __sockCall(this, "connected").value === true; }, + get eof() { return this.__id === 0 || __sockCall(this, "eof").value === true; }, + open: function (host, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "open", { host: String(host) }); + if (r.error) return false; + this.__id = r.id; + this.host = String(host); + return true; + }, + listen: function (port, encoding) { + if (this.__id) this.close(); + if (encoding) this.encoding = String(encoding); + var r = __sockCall(this, "listen", { port: __num(port, "port") }); + if (r.error) return false; + this.__id = r.id; + this.host = "127.0.0.1:" + r.port; + this.port = r.port; + return true; + }, + poll: function () { + if (!this.__id) return null; + var r = __sockCall(this, "poll"); + if (r.id === null || r.id === undefined) return null; + var s = Object.create(Socket.prototype); + s.__id = r.id; + s.host = r.host || ""; + s.timeout = this.timeout; + s.encoding = this.encoding; + s.error = ""; + return s; + }, + read: function (count) { + if (!this.__id) return ""; + return __sockCall(this, "read", { count: count === undefined ? -1 : __num(count, "count") }).data || ""; + }, + readln: function () { return this.__id ? __sockCall(this, "readln").data || "" : ""; }, + write: function () { + if (!this.__id) return false; + var s = Array.prototype.slice.call(arguments).map(__str).join(""); + return !__sockCall(this, "write", { data: s }).error; + }, + writeln: function () { return this.write(Array.prototype.slice.call(arguments).map(__str).join("") + "\n"); }, + close: function () { + if (this.__id) __sockCall(this, "close"); + this.__id = 0; + return true; + }, + toString: function () { return "[object Socket]"; } +}; var system = { callSystem: function () { throw __err("scripts can't run system commands"); } }; // ------------------------------------------------------------------ helpers @@ -645,6 +715,19 @@ Property.prototype.property = function () { return null; }; }); def("isDropdownEffect", function () { return false; }); + // Essential Graphics: expose this property in `comp` (its own composition). + P.canAddToMotionGraphicsTemplate = function (comp) { + if (!comp || comp.__id !== this.__layer.__comp) return false; + return __call("essential.canAdd", this.__ref()).ok === true; + }; + P.addToMotionGraphicsTemplateAs = function (comp, name) { + if (!this.canAddToMotionGraphicsTemplate(comp)) return false; + var p = this.__ref(); + if (name !== undefined && name !== null) p.name = String(name); + __call("essential.addProperty", p); + return true; + }; + P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); }; P.getSeparationFollower = function (dim) { return this.parentProperty.property(["ADBE Position_0", "ADBE Position_1", "ADBE Position_2"][dim]); }; @@ -805,22 +888,6 @@ Property.prototype.property = function () { return null; }; }; P.keyLabel = function () { return 0; }; P.setLabelAtKey = function () {}; - // Essential Graphics: `comp` must be the comp holding the property. A property already in - // the panel is added again as a mirror (both controls edit one value). - P.canAddToMotionGraphicsTemplate = function (comp) { - var c = comp && comp.__id !== undefined ? comp.__id : this.__layer.__comp; - if (c !== this.__layer.__comp) return false; - var sup = __call("essential.list", { comp: c, supported: true }).supported || [], uid = this.__uid; - for (var i = 0; i < sup.length; i++) if (sup[i].uid === uid) return true; - return false; - }; - P.addToMotionGraphicsTemplateAs = function (comp, name) { - if (!this.canAddToMotionGraphicsTemplate(comp)) return false; - var p = this.__ref(); - if (name !== undefined) p.name = String(name); - return __call("essential.addProperty", p).controls.length > 0; - }; - P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); }; })(); // Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. @@ -1366,38 +1433,46 @@ CompItem.prototype.constructor = CompItem; }; P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; }; P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); }; - // Essential Graphics template: name, controllers (property, mirror and media controls in - // panel order, 1-based) and export. - P.__egControllers = function () { + // Essential Graphics (templates are EffectCraft's open .ectemplate files). + P.__eg = function () { return __call("essential.list", { comp: this.__id }); }; + P.__controllers = function () { var out = []; - (function go(v) { - for (var i = 0; i < v.length; i++) { - if (v[i].kind === "group") go(v[i].children || []); - else if (v[i].kind !== "comment") out.push(v[i]); + (function walk(list) { + for (var i = 0; i < list.length; i++) { + if (list[i].kind === "property" || list[i].kind === "mirror" || list[i].kind === "media") out.push(list[i]); + if (list[i].children) walk(list[i].children); } - })(__call("essential.list", { comp: this.__id }).controls || []); + })(this.__eg().controls); return out; }; - def("motionGraphicsTemplateName", function () { return __call("essential.list", { comp: this.__id }).name; }, function (v) { + def("motionGraphicsTemplateName", function () { return this.__eg().name; }, function (v) { __call("essential.setName", { comp: this.__id, name: String(v) }); }); - def("motionGraphicsTemplateControllerCount", function () { return this.__egControllers().length; }); + def("motionGraphicsTemplateControllerCount", function () { return this.__controllers().length; }); P.getMotionGraphicsTemplateControllerName = function (i) { - var c = this.__egControllers()[__num(i, "index") - 1]; - if (!c) throw __err("controller index out of range"); - return c.name; + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + return c[i - 1].name; }; P.setMotionGraphicsControllerName = function (i, name) { - var c = this.__egControllers()[__num(i, "index") - 1]; - if (!c) throw __err("controller index out of range"); - __call("essential.rename", { comp: this.__id, control: c.id, name: String(name) }); + var c = this.__controllers(); + i = __num(i, "index"); + if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length); + __call("essential.rename", { comp: this.__id, control: c[i - 1].id, name: String(name) }); return String(name); }; + P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; P.exportAsMotionGraphicsTemplate = function (overwrite, path) { - if (path === undefined) return false; - var p = path && path.fsName !== undefined ? path.fsName : String(path); - if (!overwrite && new File(p).exists) return false; - try { __call("essential.exportTemplate", { comp: this.__id, path: p }); return true; } catch (e) { return false; } + var f = __str(path); + if (!f) return false; + if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; + if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; + if (this.__controllers().length === 0) return false; + // The file gate applies to templates too. + __file("write", f, ""); + __call("essential.exportTemplate", { comp: this.__id, path: f }); + return true; }; })(); diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 82e456af0..291608c10 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -282,7 +282,7 @@ fn normalize(p: &str) -> std::path::PathBuf { .unwrap_or(out) } -const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; +pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network"; /// The file-access gate: reads under the project's folder are always allowed; other reads, all /// writes and listing other folders need the Allow Scripts to Write Files preference. @@ -375,13 +375,14 @@ pub(crate) fn new_context() -> Result { let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 9] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 10] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), (js_string!("__task"), 3, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), (js_string!("__uiModal"), 1, crate::ui::native_modal), (js_string!("__uiLayoutNative"), 1, crate::ui::native_layout), diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index e214be305..57e0f445a 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -58,13 +58,205 @@ var __uiContainers = { window: 1, panel: 1, group: 1, tabbedpanel: 1, tab: 1 }; function Window(type, title, bounds, props) { if (!(this instanceof Window)) return new Window(type, title, bounds, props); - type = String(type === undefined ? "window" : type).toLowerCase(); - if (type.indexOf("{") >= 0) throw __err("ScriptUI resource strings are not supported: build the window with add()"); + type = String(type === undefined ? "window" : type); + var res = null; + if (type.indexOf("{") >= 0) { + // A resource string: `dialog { text: 'Title', ok: Button { text: 'OK' } }`. + res = __uiParseRes(type); + type = res.type; + var p0 = __uiResProps(res).props; + if (title === undefined && p0.text !== undefined) title = p0.text; + if (bounds === undefined && p0.bounds !== undefined) bounds = p0.bounds; + if (props === undefined && p0.properties !== undefined) props = p0.properties; + } + type = type.toLowerCase(); if (type !== "dialog" && type !== "palette" && type !== "window") throw __err("Bad window type " + type); __uiInit(this, "window", null, null, bounds, title, props); this.__kind = type; this.__root = true; __uiWins.push(this); + if (res) __uiApplyRes(this, res); +} + +// ---------------------------------------------------------------- resource strings +// +// The resource specification the JavaScript Tools Guide documents: `type { name: value, … }` +// where a value is a string ('…' or "…"), a number, true / false, an array `[…]`, an object +// literal `{…}` (e.g. `properties: {multiline: true}`) or a nested element `Type { … }` (a +// child control, also reachable as `parent.name`). Comments are allowed. + +function __uiParseRes(src) { + var s = String(src), i = 0; + function fail(m) { throw __err("ScriptUI resource string: " + m + " at offset " + i); } + function ws() { + for (;;) { + while (i < s.length && /\s/.test(s.charAt(i))) i++; + if (s.substring(i, i + 2) === "//") { + while (i < s.length && s.charAt(i) !== "\n") i++; + continue; + } + if (s.substring(i, i + 2) === "/*") { + var e = s.indexOf("*/", i + 2); + i = e < 0 ? s.length : e + 2; + continue; + } + return; + } + } + function ident() { + ws(); + var m = /^[A-Za-z_$][\w$]*/.exec(s.slice(i)); + if (!m) fail("expected a name"); + i += m[0].length; + return m[0]; + } + function str(q) { + i++; + var out = ""; + while (i < s.length && s.charAt(i) !== q) { + var c = s.charAt(i); + if (c === "\\") { + i++; + var e = s.charAt(i); + out += e === "n" ? "\n" : e === "t" ? "\t" : e === "r" ? "\r" : e; + } else out += c; + i++; + } + if (i >= s.length) fail("unterminated string"); + i++; + return out; + } + function value() { + ws(); + var c = s.charAt(i); + if (c === "'" || c === '"') return str(c); + if (c === "[") { + i++; + var a = []; + ws(); + if (s.charAt(i) === "]") { + i++; + return a; + } + for (;;) { + a.push(value()); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "]") { + i++; + return a; + } + fail("expected , or ]"); + } + } + if (c === "{") return { __object: body() }; + var m = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?/.exec(s.slice(i)); + if (m) { + i += m[0].length; + return Number(m[0]); + } + var id = ident(); + if (id === "true") return true; + if (id === "false") return false; + if (id === "null" || id === "undefined") return null; + ws(); + if (s.charAt(i) === "{") return { __element: id, list: body() }; + // A bare word (an enum-like value). + return id; + } + function body() { + i++; + var list = []; + for (;;) { + ws(); + if (s.charAt(i) === "}") { + i++; + return list; + } + var k = s.charAt(i) === "'" || s.charAt(i) === '"' ? str(s.charAt(i)) : ident(); + ws(); + if (s.charAt(i) !== ":") fail("expected : after " + k); + i++; + list.push([k, value()]); + ws(); + if (s.charAt(i) === ",") { + i++; + continue; + } + if (s.charAt(i) === "}") { + i++; + return list; + } + fail("expected , or }"); + } + } + var t = ident(); + ws(); + if (s.charAt(i) !== "{") fail("expected { after " + t); + var list = body(); + ws(); + if (i < s.length) fail("unexpected text after the resource"); + return { type: t, list: list }; +} + +// The plain JavaScript value of a parsed resource value (object literals become objects). +function __uiResPlain(v) { + if (v instanceof Array) return v.map(__uiResPlain); + if (v && typeof v === "object" && v.__object) { + var o = {}; + for (var k = 0; k < v.__object.length; k++) o[v.__object[k][0]] = __uiResPlain(v.__object[k][1]); + return o; + } + return v; +} + +// A resource's own properties and its child elements (in source order). +function __uiResProps(res) { + var props = {}, kids = []; + for (var k = 0; k < res.list.length; k++) { + var e = res.list[k]; + if (e[1] && typeof e[1] === "object" && e[1].__element) kids.push([e[0], { type: e[1].__element, list: e[1].list }]); + else props[e[0]] = __uiResPlain(e[1]); + } + return { props: props, kids: kids }; +} + +var __uiResSkip = { text: 1, bounds: 1, properties: 1, minvalue: 1, maxvalue: 1, items: 1 }; +var __uiSizes = { preferredSize: 1, minimumSize: 1, maximumSize: 1, size: 1, location: 1 }; + +// Set a control's properties and create its children from a parsed resource. +function __uiApplyRes(c, res) { + var pk = __uiResProps(res), props = pk.props; + // Ranges before values, items before selections. + if (props.minvalue !== undefined) c.minvalue = __num(props.minvalue); + if (props.maxvalue !== undefined) c.maxvalue = __num(props.maxvalue); + if (props.items instanceof Array && c.items) for (var n = 0; n < props.items.length; n++) c.add("item", props.items[n]); + for (var k in props) { + if (__uiResSkip[k]) continue; + var v = props[k]; + if (__uiSizes[k] && v instanceof Array) v = __dim(v[0], v[1]); + c[k] = v; + } + for (var j = 0; j < pk.kids.length; j++) { + var child = __uiAddRes(c, pk.kids[j][1]); + child.__key = pk.kids[j][0]; + c[pk.kids[j][0]] = child; + } + return c; +} + +// add() with a resource string (or a parsed element): the new control. +function __uiAddRes(parent, spec) { + var res = typeof spec === "string" ? __uiParseRes(spec) : spec; + var p = __uiResProps(res).props; + var t = String(res.type).toLowerCase(); + var valued = t === "dropdownlist" || t === "listbox" || t === "slider" || t === "scrollbar" || t === "progressbar"; + var c = parent.add(t, p.bounds, valued ? undefined : p.text, p.properties); + if (valued && p.text !== undefined) c.text = String(p.text); + return __uiApplyRes(c, res); } Window.alert = function (s) { alert(s); }; Window.confirm = function (s) { return confirm(s); }; @@ -106,18 +298,95 @@ var ScriptUI = { events: { createEvent: function (t) { return { type: t }; } } }; +// A colour for the draw list: [r, g, b, a] in 0..1, or null for a theme colour (by name). +function __uiColor(c) { + if (c instanceof Array && c.length >= 3) return [__num(c[0]), __num(c[1]), __num(c[2]), c.length > 3 ? __num(c[3]) : 1]; + return null; +} +function __uiFontSize(f, g) { + var x = f && f.size !== undefined ? Number(f.size) : g && g.font && g.font.size !== undefined ? Number(g.font.size) : 12; + return x > 0 ? x : 12; +} + +// ScriptUIGraphics: what onDraw handlers paint with. Paths and paint calls are recorded into the +// control's draw list (`draw` in the window snapshot), which frontends paint. function __uiGraphics() { return { - newBrush: function (t, c) { return { type: t, color: c }; }, - newPen: function (t, c, w) { return { type: t, color: c, lineWidth: w }; }, - newPath: function () {}, moveTo: function () {}, lineTo: function () {}, rectPath: function () {}, - ellipsePath: function () {}, fillPath: function () {}, strokePath: function () {}, closePath: function () {}, - drawString: function () {}, drawOSControl: function () {}, drawImage: function () {}, drawFocusRing: function () {}, - measureString: function (s) { return __dim(String(s).length * 7, 16); }, - font: null, foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null + BrushType: ScriptUI.BrushType, + PenType: ScriptUI.PenType, + font: ScriptUI.newFont("dialog", "REGULAR", 12), + foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null, + currentPath: [], + currentPoint: [0, 0], + __rec: null, + __push: function (op) { if (this.__rec) this.__rec.push(op); }, + newBrush: function (t, c) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "" }; }, + newPen: function (t, c, w) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "", lineWidth: w === undefined ? 1 : __num(w) }; }, + newPath: function () { this.currentPath = []; return this.currentPath; }, + moveTo: function (x, y) { this.currentPath.push({ k: "M", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + lineTo: function (x, y) { this.currentPath.push({ k: "L", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; }, + closePath: function () { this.currentPath.push({ k: "Z" }); }, + rectPath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "R", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + ellipsePath: function (x, y, w, h) { + if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; } + this.currentPath.push({ k: "E", x: __num(x), y: __num(y), w: __num(w), h: __num(h) }); + return this.currentPath; + }, + fillPath: function (brush, path) { + this.__push({ op: "fill", color: brush ? brush.color : null, path: (path || this.currentPath).slice() }); + }, + strokePath: function (pen, path) { + this.__push({ op: "stroke", color: pen ? pen.color : null, width: pen && pen.lineWidth !== undefined ? pen.lineWidth : 1, path: (path || this.currentPath).slice() }); + }, + drawString: function (text, pen, x, y, font) { + var f = font || this.font; + this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") }); + }, + drawOSControl: function () { this.__push({ op: "os" }); }, + drawImage: function (img, x, y, w, h) { this.__push({ op: "image", x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); }, + drawFocusRing: function () {}, + measureString: function (s, font, boundaryWidth) { + var size = __uiFontSize(font, this); + var lines = String(s).split("\n"); + var cw = size * 0.58, w = 0; + for (var i = 0; i < lines.length; i++) w = Math.max(w, Math.ceil(lines[i].length * cw)); + var n = lines.length; + if (boundaryWidth > 0 && w > boundaryWidth) { + n = Math.ceil(w / boundaryWidth) + lines.length - 1; + w = boundaryWidth; + } + return __dim(w, Math.ceil(size * 1.25) * n); + } }; } +// Run the onDraw handlers of a window's controls (after laying it out, so `size` is known), +// recording what they paint. +function __uiRunDraws(win) { + var drawn = []; + (function walk(c) { + if (typeof c.onDraw === "function") drawn.push(c); + for (var i = 0; i < (c.children || []).length; i++) walk(c.children[i]); + })(win); + if (!drawn.length) return; + __uiLayout(win); + for (var k = 0; k < drawn.length; k++) { + var c = drawn[k]; + c.__draw = []; + c.graphics.__rec = c.__draw; + try { + c.onDraw.call(c, { type: "draw", target: c }); + } catch (e) { + try { writeLn("onDraw: " + (e && e.message ? e.message : e)); } catch (e2) {} + } + c.graphics.__rec = null; + } +} + function __uiInit(o, type, win, parent, bounds, text, props) { o.type = type; o.window = win || o; @@ -180,6 +449,10 @@ function __uiProto() { return { add: function (type, bounds, text, props) { var t = String(type).toLowerCase(); + if (t.indexOf("{") >= 0) { + if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); + return __uiAddRes(this, String(type)); + } if (t === "item" || t === "separator") return this.__addItem(t === "separator" ? "-" : bounds); if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); var ctor = __uiTypes[t]; @@ -302,7 +575,7 @@ function __uiProto() { }, __handlers: function () { var out = []; - var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize"]; + var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize", "onDraw"]; for (var i = 0; i < names.length; i++) { var n = names[i]; if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n); @@ -411,7 +684,7 @@ function __uiJson(c) { var o = { id: c.__id, type: c.type, - name: String(c.name || c.properties.name || ""), + name: String(c.name || c.properties.name || c.__key || ""), text: String(c.text === undefined || c.text === null ? "" : c.text), enabled: c.enabled !== false, visible: c.visible !== false, @@ -444,6 +717,7 @@ function __uiJson(c) { o.selection = c.__sel.slice(); } if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0; + if (c.__draw && typeof c.onDraw === "function") o.draw = c.__draw; if (c.type === "edittext") { o.multiline = !!c.__multiline; o.readOnly = !!c.__readonly; @@ -456,6 +730,7 @@ function __uiSnapshot() { var out = []; for (var i = 0; i < __uiWins.length; i++) { var w = __uiWins[i]; + if (w.__shown && !w.__closed) __uiRunDraws(w); out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) }); } // Closed windows are forgotten. @@ -529,14 +804,17 @@ function __uiDispatch(winId, id, kind, valueJson) { } return; } - // change / changing + // change / changing (live: each keystroke of an edit text, each step of a slider drag) + var before = c.type === "edittext" ? c.text : c.value; if (c.type === "edittext") c.text = v === null ? "" : String(v); else if (c.type === "statictext") c.text = v === null ? "" : String(v); else if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") c.value = Number(v); else if (c.type === "checkbox") c.value = !!v; else if (c.type === "radiobutton") { if (v) radio(); else c.value = false; } else if (c.type === "dropdownlist" || c.type === "listbox" || c.type === "tabbedpanel") c.selection = v; - if (c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") c.__fire("onChanging"); + var now = c.type === "edittext" ? c.text : c.value; + // onChanging already ran for live updates of the same value. + if ((c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") && (kind === "changing" || now !== before)) c.__fire("onChanging"); if (kind === "change") { if (c.type === "checkbox" || c.type === "radiobutton") c.__fire("onClick"); else c.__fire("onChange"); diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs new file mode 100644 index 000000000..8b1074d40 --- /dev/null +++ b/crates/script/src/socket.rs @@ -0,0 +1,241 @@ +//! `Socket`: TCP connections for scripts (the JavaScript Tools Guide's Socket object: `open`, +//! `listen`, `poll`, `read`, `readln`, `write`, `writeln`, `close`, `connected`, `eof`). +//! +//! Network access follows Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files +//! and Access Network: with it off, `new Socket()` throws. On the web there are no raw sockets: +//! `open` and `listen` return false (with `error` set). +//! +//! The JavaScript side holds only a handle; the streams live here, keyed by handle, so a script +//! window's handlers can keep using a socket opened earlier in the same script. + +use boa_engine::{Context, JsResult, JsValue}; +use serde_json::{Value as J, json}; + +use crate::runtime::{arg_string, js_str, throw, with_active}; + +/// `__sock(op, handle, paramsJson)` → result JSON (`{error}` on failure). +pub(crate) fn native_sock(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = arg_string(args, 0, ctx)?; + let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; + let params = arg_string(args, 2, ctx)?; + let p: J = serde_json::from_str(¶ms).unwrap_or(J::Null); + let allowed = with_active(|a| Ok(a.session.prefs.scripting.allow_scripts_write_files)).unwrap_or(false); + if !allowed { + return Err(throw(format!("scripts can't access the network unless {} is on", crate::runtime::GATE))); + } + if op == "check" { + return Ok(js_str("{}")); + } + let r = imp::run(&op, id, &p); + Ok(js_str(&r.to_string())) +} + +fn err(e: impl std::fmt::Display) -> J { + json!({"error": e.to_string()}) +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::*; + pub(super) fn run(op: &str, _: u32, _: &J) -> J { + match op { + "connected" | "eof" => json!({"value": op == "eof"}), + "close" => json!({}), + _ => err("sockets are not available in the web version of EffectCraft"), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::collections::BTreeMap; + use std::io::{ErrorKind, Read, Write}; + use std::net::{TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + use super::*; + + enum Sock { + Stream { s: TcpStream, buf: Vec, eof: bool }, + Listener(TcpListener), + } + + struct Table { + next: u32, + socks: BTreeMap, + } + + fn table() -> &'static Mutex { + static T: Mutex
= Mutex::new(Table { next: 1, socks: BTreeMap::new() }); + &T + } + + fn add(s: Sock) -> u32 { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + let id = t.next; + t.next += 1; + t.socks.insert(id, s); + id + } + + fn timeout(p: &J) -> Duration { + Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) + } + + /// `host:port` (After Effects' form) → socket addresses. + fn addr(host: &str) -> Result, String> { + let a = host.to_socket_addrs().map_err(|e| format!("bad address `{host}` (use host:port): {e}"))?; + Ok(a.collect()) + } + + /// Text ↔ bytes in the socket's encoding: UTF-8, else one byte per character (ASCII / + /// BINARY, as in After Effects). + fn encode(s: &str, utf8: bool) -> Vec { + if utf8 { s.as_bytes().to_vec() } else { s.chars().map(|c| (c as u32).min(255) as u8).collect() } + } + fn decode(b: &[u8], utf8: bool) -> String { + if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } + } + + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + let end = Instant::now() + limit; + let mut chunk = [0u8; 4096]; + while !done(buf) && !*eof { + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + match s.read(&mut chunk) { + Ok(0) => *eof = true, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => { + if e.kind() != ErrorKind::Interrupted { + break; + } + } + Err(_) => *eof = true, + } + } + } + + pub(super) fn run(op: &str, id: u32, p: &J) -> J { + let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); + match op { + "open" => { + let host = p.get("host").and_then(J::as_str).unwrap_or(""); + let addrs = match addr(host) { + Ok(a) => a, + Err(e) => return err(e), + }; + let limit = timeout(p).max(Duration::from_millis(10)); + let mut last = "no address".to_string(); + for a in addrs { + match TcpStream::connect_timeout(&a, limit) { + Ok(s) => { + let _ = s.set_nodelay(true); + return json!({"id": add(Sock::Stream { s, buf: vec![], eof: false })}); + } + Err(e) => last = e.to_string(), + } + } + err(format!("cannot connect to {host}: {last}")) + } + "listen" => { + let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16; + match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) { + Ok(l) => { + let port = l.local_addr().map(|a| a.port()).unwrap_or(port); + json!({"id": add(Sock::Listener(l)), "port": port}) + } + Err(e) => err(format!("cannot listen on port {port}: {e}")), + } + } + _ => { + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + if op == "close" { + t.socks.remove(&id); + return json!({}); + } + let Some(sock) = t.socks.get_mut(&id) else { + return match op { + "connected" => json!({"value": false}), + "eof" => json!({"value": true}), + _ => err("the socket is not open"), + }; + }; + match (op, sock) { + ("poll", Sock::Listener(l)) => match l.accept() { + Ok((s, peer)) => { + let _ = s.set_nonblocking(false); + let host = peer.to_string(); + let new = Sock::Stream { s, buf: vec![], eof: false }; + let nid = t.next; + t.next += 1; + t.socks.insert(nid, new); + json!({"id": nid, "host": host}) + } + Err(_) => json!({"id": null}), + }, + ("connected", Sock::Stream { s, buf, eof }) => { + if !*eof && buf.is_empty() { + // Peek without blocking to notice a closed peer. + let _ = s.set_nonblocking(true); + let mut b = [0u8; 1]; + match s.peek(&mut b) { + Ok(0) => *eof = true, + Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true, + _ => {} + } + let _ = s.set_nonblocking(false); + } + json!({"value": !(*eof && buf.is_empty())}) + } + ("connected", Sock::Listener(_)) => json!({"value": true}), + ("eof", Sock::Stream { buf, eof, .. }) => json!({"value": *eof && buf.is_empty()}), + ("read", Sock::Stream { s, buf, eof }) => { + let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); + let limit = timeout(p); + if count < 0 { + fill(s, buf, eof, limit, |_| false); + let all = std::mem::take(buf); + json!({"data": decode(&all, utf8)}) + } else { + let n = count as usize; + fill(s, buf, eof, limit, |b| b.len() >= n); + let take = n.min(buf.len()); + let out: Vec = buf.drain(..take).collect(); + json!({"data": decode(&out, utf8)}) + } + } + ("readln", Sock::Stream { s, buf, eof }) => { + fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + let line: Vec = match buf.iter().position(|&c| c == b'\n') { + Some(i) => { + let mut l: Vec = buf.drain(..=i).collect(); + l.pop(); + if l.last() == Some(&b'\r') { + l.pop(); + } + l + } + None => std::mem::take(buf), + }; + json!({"data": decode(&line, utf8)}) + } + ("write", Sock::Stream { s, .. }) => { + let data = p.get("data").and_then(J::as_str).unwrap_or(""); + match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { + Ok(()) => json!({"ok": true}), + Err(e) => err(e), + } + } + ("host", Sock::Stream { s, .. }) => json!({"value": s.peer_addr().map(|a| a.to_string()).unwrap_or_default()}), + (op, _) => err(format!("`{op}` is not supported on this socket")), + } + } + } + } +} diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index c9a42692b..151ab57f5 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -450,37 +450,24 @@ fn layer_switches_timing_and_comp_settings() { #[test] fn motion_graphics_template_hooks_add_mirrors_and_controllers() { let mut s = session(); - let dir = std::env::temp_dir().join(format!("effectcraft-eg-script-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let path = dir.join("card.ectemplate"); let o = ok( &mut s, - &format!( - r#" + r#" var comp = app.project.items.addComp("Card", 64, 64, 1, 2, 30); var bg = comp.layers.addSolid([1, 1, 1], "BG", 64, 64, 1); var op = bg.transform.opacity; var r = [op.canAddToMotionGraphicsTemplate(comp), op.addToMotionGraphicsTemplateAs(comp, "Fade")]; - // Again: a mirror of the same property. + // Again: a mirror of the same property (a controller of its own). r.push(op.addToMotionGraphicsTemplate(comp)); r.push(comp.motionGraphicsTemplateControllerCount); r.push(comp.getMotionGraphicsTemplateControllerName(1), comp.getMotionGraphicsTemplateControllerName(2)); comp.setMotionGraphicsControllerName(2, "Fade Again"); - comp.motionGraphicsTemplateName = "Lower Card"; - r.push(comp.getMotionGraphicsTemplateControllerName(2), comp.motionGraphicsTemplateName); - // A property of another comp can't be added. - var other = app.project.items.addComp("Other", 64, 64, 1, 2, 30); - r.push(op.canAddToMotionGraphicsTemplate(other)); - r.push(comp.exportAsMotionGraphicsTemplate(true, new File("{}"))); + r.push(comp.getMotionGraphicsTemplateControllerName(2)); r "#, - path.to_string_lossy() - ), ); - assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again", "Lower Card", false, true])); + assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again"])); let (_, c) = comp_named(&s, "Card"); let eg = c.essential.as_ref().unwrap(); assert!(matches!(eg.controls[1].kind, effectcraft_engine::project::essential::EgKind::Mirror { of } if of == eg.controls[0].id)); - assert!(path.exists()); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/script/src/tests_ui.rs b/crates/script/src/tests_ui.rs index 667a32423..460376ac5 100644 --- a/crates/script/src/tests_ui.rs +++ b/crates/script/src/tests_ui.rs @@ -183,9 +183,9 @@ fn layout_and_handler_errors() { assert_eq!(r["ok"], false); assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}"); assert_eq!(s.script_ui.windows.len(), 1); - // Resource strings aren't supported (a clear error). - let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\")", "res.jsx"); - assert!(o.error.unwrap().message.contains("resource")); + // Resource strings build windows too (see tests_ui_more). + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\").text", "res.jsx"); + assert_eq!(o.result, json!("x")); s.execute("scriptui.close", json!({})).unwrap(); } diff --git a/crates/script/src/tests_ui_more.rs b/crates/script/src/tests_ui_more.rs new file mode 100644 index 000000000..2c24329ac --- /dev/null +++ b/crates/script/src/tests_ui_more.rs @@ -0,0 +1,242 @@ +//! ScriptUI resource strings, onDraw graphics, live onChanging updates and sockets. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, WidgetKind, WindowKind}; +use serde_json::json; + +use crate::run_code; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default())); + s +} + +const RESOURCE: &str = r#" +var w = new Window("palette { text: 'Res', orientation: 'column', alignChildren: ['fill', 'top'], \ + g: Group { orientation: 'row', b: Button { text: 'OK', properties: { name: 'okb' } }, cancel: Button { text: \"Can't\" } }, \ + nm: EditText { text: 'abc', characters: 10 }, \ + s: Slider { minvalue: 0, maxvalue: 10, value: 4 }, \ + dd: DropDownList { properties: { items: ['A', 'B'] }, selection: 1 }, \ + /* a panel */ p: Panel { text: 'Opts', preferredSize: [200, -1], c: Checkbox { text: 'On', value: true } } }"); +var extra = w.p.add("group { orientation: 'column', t: StaticText { text: 'hi' } }"); +w.g.b.onClick = function () { writeLn("ok " + w.nm.text + " " + w.s.value); }; +w.show(); +[w.text, w.g.orientation, w.g.b.text, w.g.cancel.text, w.s.maxvalue, w.dd.selection.text, w.p.c.value, extra.t.text, w.g.children.length, w.p.preferredSize.width].join("|") +"#; + +#[test] +fn resource_strings_build_windows_and_controls() { + let mut s = session(); + let o = run_code(&mut s, RESOURCE, "res.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("Res|row|OK|Can't|10|B|true|hi|2|200")); + let w = s.script_ui.windows[0].clone(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Palette, "Res")); + let kinds: Vec = w.root.children.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [WidgetKind::Group, WidgetKind::EditText, WidgetKind::Slider, WidgetKind::DropDownList, WidgetKind::Panel]); + assert_eq!(w.root.children[2].value, 4.0); + assert_eq!(w.root.children[3].items, ["A", "B"]); + assert_eq!(w.root.children[3].selection, [1]); + assert!(w.root.children[4].children[0].checked); + assert_eq!(w.root.children[4].children[1].children[0].text, "hi"); + // Elements are named after their resource keys (agents address them by it). + assert_eq!(w.root.children[1].name, "nm"); + s.execute("scriptui.set", json!({"widget": "nm", "value": "xyz"})).unwrap(); + let r = s.execute("scriptui.click", json!({"widget": "okb"})).unwrap(); + assert_eq!(r["output"], "ok xyz 4", "{r}"); + s.execute("scriptui.close", json!({})).unwrap(); + // Syntax errors say where. + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' oops }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("resource string")); + let o = run_code(&mut s, "new Window(\"panel { }\")", "bad.jsx"); + assert!(o.error.unwrap().message.contains("Bad window type")); +} + +const DRAW: &str = r#" +var w = new Window("palette", "Draw"); +var c = w.add("group"); +c.preferredSize = [100, 50]; +c.onDraw = function () { + var g = this.graphics; + var b = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1]); + g.newPath(); + g.rectPath(0, 0, this.size.width, this.size.height); + g.fillPath(b); + var p = g.newPen(g.PenType.SOLID_COLOR, [0, 0, 1], 2); + g.newPath(); + g.moveTo(0, 0); + g.lineTo(10, 10); + g.strokePath(p); + g.ellipsePath(20, 20, 10, 10); + g.drawString("Hi", p, 5, 6, ScriptUI.newFont("dialog", "BOLD", 20)); +}; +var m = c.graphics.measureString("Hello", ScriptUI.newFont("dialog", "REGULAR", 12)); +w.show(); +[m.width, m[1]].join(",") +"#; + +#[test] +fn on_draw_handlers_record_a_draw_list() { + let mut s = session(); + let o = run_code(&mut s, DRAW, "draw.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("35,15")); + let w = s.script_ui.windows[0].clone(); + let g = &w.root.children[0]; + assert!(g.handlers.contains(&"onDraw".to_string())); + assert_eq!(g.draw.len(), 3, "{:?}", g.draw); + // The handler saw the laid-out size. + assert_eq!(g.draw[0], DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path: vec![PathSeg::R { x: 0.0, y: 0.0, w: 100.0, h: 50.0 }] }); + match &g.draw[1] { + DrawOp::Stroke { color, width, path } => { + assert_eq!((*color, *width), (Some([0.0, 0.0, 1.0, 1.0]), 2.0)); + assert_eq!(path.len(), 2); + } + o => panic!("{o:?}"), + } + assert!(matches!(&g.draw[2], DrawOp::Text { text, size, style, .. } if text == "Hi" && *size == 20.0 && style == "BOLD")); + // scriptui.get carries it for agents. + let tree = s.execute("scriptui.get", json!({})).unwrap(); + assert_eq!(tree["root"]["children"][0]["draw"][0]["op"], "fill"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +const LIVE: &str = r#" +var w = new Window("palette", "Live"); +var e = w.add("edittext", undefined, ""); +var sl = w.add("slider", undefined, 0, 0, 100); +var log = w.add("statictext", undefined, "-"); +e.onChanging = function () { writeLn("changing " + e.text); log.text = "typing " + e.text; }; +e.onChange = function () { writeLn("change " + e.text); }; +sl.onChanging = function () { writeLn("slide " + Math.round(sl.value)); }; +sl.onChange = function () { writeLn("slid " + Math.round(sl.value)); }; +w.show(); +"#; + +#[test] +fn on_changing_fires_per_keystroke_and_slider_step() { + let mut s = session(); + let o = run_code(&mut s, LIVE, "live.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "a", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing a"); + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab", "changing": true})).unwrap(); + assert_eq!(r["output"], "changing ab"); + assert_eq!(s.script_ui.windows[0].root.children[2].text, "typing ab"); + // Committing the same text runs onChange only. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab"})).unwrap(); + assert_eq!(r["output"], "change ab"); + // A committed edit without live updates runs both. + let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "abc"})).unwrap(); + assert_eq!(r["output"], "changing abc\nchange abc"); + // Slider drags update the value live. + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 40, "changing": true})).unwrap(); + assert_eq!(r["output"], "slide 40"); + assert_eq!(s.script_ui.windows[0].root.children[1].value, 40.0); + let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 41})).unwrap(); + assert_eq!(r["output"], "slide 41\nslid 41"); + s.execute("scriptui.close", json!({})).unwrap(); +} + +const MOGRT: &str = r#" +var c = app.project.activeItem; +var t = c.layer(1); +var st = t.property("ADBE Text Properties").property("ADBE Text Document"); +var op = t.property("ADBE Transform Group").property("ADBE Opacity"); +var r = [st.canAddToMotionGraphicsTemplate(c), st.addToMotionGraphicsTemplate(c), op.addToMotionGraphicsTemplateAs(c, "Fade")]; +c.motionGraphicsTemplateName = "Lower Third"; +c.setMotionGraphicsControllerName(1, "Title"); +r.concat([c.motionGraphicsTemplateName, c.motionGraphicsTemplateControllerCount, c.getMotionGraphicsTemplateControllerName(1), c.getMotionGraphicsTemplateControllerName(2)]).join("|") +"#; + +#[test] +fn essential_graphics_scripting_hooks() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + let o = run_code(&mut s, MOGRT, "mogrt.jsx"); + assert!(o.error.is_none(), "{:?} {:?}", o.error, o.output); + assert_eq!(o.result, json!("true|true|true|Lower Third|2|Title|Fade")); + let cid = s.active_comp_id().unwrap(); + assert_eq!(s.project.comp(cid).unwrap().essential.as_ref().unwrap().name, "Lower Third"); + // Another comp can't take this comp's properties. + s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap(); + let code = "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); var o = app.project.activeItem; var p = card.layer(1).property('ADBE Transform Group').property('ADBE Position'); [p.canAddToMotionGraphicsTemplate(o), p.canAddToMotionGraphicsTemplate(card)].join('|')"; + let o = run_code(&mut s, code, "x.jsx"); + assert_eq!(o.result, json!("false|true"), "{:?}", o.error); + // Export (behind the file-write preference). + let dir = std::env::temp_dir().join(format!("ec-mogrt-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("lower.ectemplate"); + let export = format!( + "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); card.exportAsMotionGraphicsTemplate(false, {:?})", + path.to_string_lossy() + ); + assert!(run_code(&mut s, &export, "x.jsx").error.is_some()); + s.prefs.scripting.allow_scripts_write_files = true; + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(true)); + assert!(std::fs::metadata(&path).unwrap().len() > 100); + // It exists now: no overwrite → false. + assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(false)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn create_nulls_from_paths_expressions_evaluate() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap(); + s.execute("paths.nullsFollowPoints", json!({"layer": l})).unwrap(); + s.execute("paths.tracePath", json!({"layer": l})).unwrap(); + // Layer 1: the trace null; layers 2..5: the vertex nulls (top = last vertex). + let pos = |i: u32, t: f64| format!("app.project.activeItem.layer({i}).property('ADBE Transform Group').property('ADBE Position').valueAtTime({t}, false)"); + let o = run_code(&mut s, &format!("[{}, {}, {}].join('|')", pos(2, 0.0), pos(1, 0.0), pos(1, 0.5)), "x.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + let parts: Vec> = o.result.as_str().unwrap().split('|').map(|p| p.split(',').map(|x| x.parse().unwrap()).collect()).collect(); + assert_eq!(&parts[0][..2], &[30.0, 60.0], "vertex 4 (0, 40) in comp space"); + assert_eq!(&parts[1][..2], &[30.0, 20.0], "progress 0: the first vertex"); + // Halfway in time along the closed square: the opposite corner. + assert!((parts[2][0] - 70.0).abs() < 0.5 && (parts[2][1] - 60.0).abs() < 0.5, "{:?}", parts[2]); + // Points Follow Nulls: moving the last vertex's null moves the vertex. + let r = s.execute("paths.pointsFollowNulls", json!({"layer": l})).unwrap(); + let last = r["nulls"][3].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": last, "path": "transform/position", "value": [10, 10]})).unwrap(); + let code = "var c = app.project.activeItem; var m = c.layer(c.numLayers).property('ADBE Mask Parade').property(1).property('ADBE Mask Shape').valueAtTime(0, false); [m.vertices[3][0], m.vertices[3][1], m.vertices[0][0], m.vertices.length].join(',')"; + let o = run_code(&mut s, code, "x.jsx"); + assert_eq!(o.result, json!("-20,-10,0,4"), "{:?}", o.error); +} + +#[test] +fn sockets_talk_tcp_behind_the_network_preference() { + use std::io::{BufRead, BufReader, Write}; + let mut s = session(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut c, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(c.try_clone().unwrap()).read_line(&mut line).unwrap(); + c.write_all(format!("pong:{}\nrest", line.trim()).as_bytes()).unwrap(); + }); + let code = format!( + "var s = new Socket(); var ok = s.open('127.0.0.1:{port}'); s.writeln('ping'); var r = s.readln(); var rest = s.read(); var e = s.eof; s.close(); [ok, r, rest, e, s.connected].join('|')" + ); + // Off: no network. + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.unwrap().message.contains("Access Network")); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_code(&mut s, &code, "net.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("true|pong:ping|rest|true|false")); + server.join().unwrap(); + // Refused connections return false with an error; listen + poll serve clients. + let o = run_code(&mut s, "var s = new Socket(); s.timeout = 1; var ok = s.open('127.0.0.1:1'); [ok, s.error.length > 0].join('|')", "net.jsx"); + assert_eq!(o.result, json!("false|true")); + let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx"); + assert_eq!(o.result, json!("true|true")); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 0536480c9..8bc170b1f 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -13,7 +13,7 @@ use std::collections::HashMap; pub use color::parse_color; pub use kurbo::{Affine, BezPath}; pub use pathdata::parse_path_data; -pub use raster::{rasterize, rasterize_with}; +pub use raster::{blend_pixel, rasterize, rasterize_with}; #[derive(Debug, Clone, PartialEq)] pub enum Error { @@ -173,6 +173,88 @@ pub struct Group { /// Clipping paths in the group's space (after `transform`); the children show only where /// every one of them covers (PDF / EPS clipping; SVG `clipPath` is not read yet). pub clip: Vec<(BezPath, FillRule)>, + /// How the group composites onto what is below it (PDF blend modes; SVG `mix-blend-mode` + /// is not read yet). + pub blend: BlendMode, + /// A soft mask (PDF `SMask` in the graphics state): the group shows where the mask's + /// luminosity (or alpha) is high. + pub mask: Option>, +} + +impl Group { + /// An empty, opaque, unclipped group. + pub fn new(name: &str) -> Group { + Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![], blend: BlendMode::Normal, mask: None } + } +} + +/// Separable and non-separable blend modes (ISO 32000-1 §11.3.5; the same formulas as the +/// W3C Compositing and Blending Level 1 specification). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BlendMode { + #[default] + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +impl BlendMode { + /// A PDF blend mode name (`/Multiply` …); unknown names are Normal. + pub fn from_pdf_name(n: &str) -> BlendMode { + use BlendMode::*; + match n { + "Multiply" => Multiply, + "Screen" => Screen, + "Overlay" => Overlay, + "Darken" => Darken, + "Lighten" => Lighten, + "ColorDodge" => ColorDodge, + "ColorBurn" => ColorBurn, + "HardLight" => HardLight, + "SoftLight" => SoftLight, + "Difference" => Difference, + "Exclusion" => Exclusion, + "Hue" => Hue, + "Saturation" => Saturation, + "Color" => Color, + "Luminosity" => Luminosity, + _ => Normal, + } + } +} + +/// A soft mask: `content` is drawn in the masked group's space; its luminosity (composited +/// over `backdrop`) or its alpha is the mask. +#[derive(Clone, Debug, PartialEq)] +pub struct SoftMask { + pub content: Group, + pub luminosity: bool, + pub backdrop: [f64; 3], +} + +/// A raster image: `width`×`height` straight RGBA8 pixels; `transform` maps pixel space (y +/// down, pixel `(0, 0)` at the top-left corner) to the parent's space. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + pub name: String, + pub transform: Affine, + pub width: u32, + pub height: u32, + pub rgba: std::sync::Arc>, + pub opacity: f64, } #[derive(Clone, Debug, PartialEq)] @@ -180,6 +262,7 @@ pub struct Group { pub enum Node { Group(Group), Shape(Shape), + Image(Image), } /// A parsed SVG document. `root.transform` maps the viewBox to `width`×`height` pixels. @@ -207,6 +290,7 @@ impl Doc { match c { Node::Group(sub) => walk(sub, m, o, out), Node::Shape(s) => out.push((m * s.transform, o * s.opacity, s)), + Node::Image(_) => {} } } } @@ -669,7 +753,7 @@ impl<'a, 'i> Parser<'a, 'i> { for c in n.children() { self.node(c, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None })); } "svg" => { // Nested viewport. @@ -688,7 +772,15 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } (self.vw, self.vh) = saved; - out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children, clip: vec![] })); + out.push(Node::Group(Group { + name: Self::element_name(n), + transform: Affine::translate((x, y)) * vb, + opacity, + children, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + })); } "use" => { let Some(target) = self.href(n) else { return }; @@ -709,11 +801,19 @@ impl<'a, 'i> Parser<'a, 'i> { for c in target.children() { self.node(c, &sst, &mut inner, depth + 1); } - children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner, clip: vec![] })); + children.push(Node::Group(Group { + name: Self::element_name(target), + transform: vb, + opacity: o, + children: inner, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + })); } else { self.node(target, &st, &mut children, depth + 1); } - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); + out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None })); } "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => { if !st.visible { @@ -753,7 +853,15 @@ impl<'a, 'i> Parser<'a, 'i> { self.node(c, &st, &mut children, depth + 1); } if !children.is_empty() { - out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children, clip: vec![] })); + out.push(Node::Group(Group { + name: Self::element_name(n), + transform, + opacity, + children, + clip: vec![], + blend: BlendMode::Normal, + mask: None, + })); } } } @@ -945,7 +1053,12 @@ pub fn parse(bytes: &[u8]) -> Result { p.node(c, &rst, &mut children, 1); } let transform = viewbox_transform(root, dw, dh); - Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children, clip: vec![] }, skipped: std::mem::take(&mut p.skipped) }) + Ok(Doc { + width: dw, + height: dh, + root: Group { name: "svg".into(), transform, opacity, children, clip: vec![], blend: BlendMode::Normal, mask: None }, + skipped: std::mem::take(&mut p.skipped), + }) } #[cfg(test)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index de82695b1..975adc5aa 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -6,7 +6,7 @@ use effectcraft_raster::{Image, Mask}; use kurbo::{Affine, Point, Shape as _}; use rayon::prelude::*; -use crate::{Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread}; +use crate::{BlendMode, Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread}; fn mat3(a: Affine) -> Mat3 { let c = a.as_coeffs(); @@ -31,38 +31,166 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) { if o <= 0.0 { return; } - if !g.clip.is_empty() { - // Draw the children on their own, keep what every clip path covers, composite. - let mut tmp = Image::new(dst.width, dst.height); - for c in &g.children { - draw_node(c, m, 1.0, &mut tmp); - } - for (path, rule) in &g.clip { - let rule = match rule { - FillRule::NonZero => effectcraft_path::FillRule::NonZero, - FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, - }; - let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule); - tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| { - for v in p.iter_mut() { - *v *= c; - } - }); - } - over(dst, &tmp, o as f32); - return; - } - if g.opacity < 1.0 && g.children.len() > 1 { - let mut tmp = Image::new(dst.width, dst.height); - for c in &g.children { - draw_node(c, m, 1.0, &mut tmp); - } - over(dst, &tmp, o as f32); - } else { + let isolated = !g.clip.is_empty() || g.mask.is_some() || g.blend != BlendMode::Normal || (g.opacity < 1.0 && g.children.len() > 1); + if !isolated { for c in &g.children { draw_node(c, m, o, dst); } + return; } + // Draw the children on their own, keep what every clip path covers and the soft mask + // lets through, then composite with the group's opacity and blend mode. + let mut tmp = Image::new(dst.width, dst.height); + for c in &g.children { + draw_node(c, m, 1.0, &mut tmp); + } + for (path, rule) in &g.clip { + let rule = match rule { + FillRule::NonZero => effectcraft_path::FillRule::NonZero, + FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, + }; + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(path), &mat3(m), dst.width, dst.height, rule); + tmp.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(p, c)| { + for v in p.iter_mut() { + *v *= c; + } + }); + } + if let Some(sm) = &g.mask { + let mut mk = Image::new(dst.width, dst.height); + draw_group(&sm.content, m, 1.0, &mut mk); + let bc = sm.backdrop.map(|v| v as f32); + let lum = sm.luminosity; + tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| { + let k = if lum { + // The mask group composited over its backdrop colour, then its luminosity. + let c = [0, 1, 2].map(|i| q[i] + bc[i] * (1.0 - q[3])); + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] + } else { + q[3] + }; + let k = k.clamp(0.0, 1.0); + for v in p.iter_mut() { + *v *= k; + } + }); + } + if g.blend == BlendMode::Normal { + over(dst, &tmp, o as f32); + } else { + let mode = g.blend; + let k = o as f32; + dst.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(d, s)| { + let s = [s[0] * k, s[1] * k, s[2] * k, s[3] * k]; + if s[3] > 0.0 { + *d = blend_pixel(mode, *d, s); + } + }); + } +} + +/// `src` composited onto `dst` with a blend mode (both premultiplied RGBA): +/// `co = cs·(1 − αb) + cb·(1 − αs) + αs·αb·B(cb, cs)` (ISO 32000-1 §11.3.6). +pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4]) -> [f32; 4] { + let (ab, as_) = (dst[3], src[3]); + let un = |c: [f32; 4]| if c[3] > 0.0 { [c[0] / c[3], c[1] / c[3], c[2] / c[3]] } else { [0.0; 3] }; + let (cb, cs) = (un(dst), un(src)); + let b = blend_color(mode, cb, cs); + let mut out = [0.0; 4]; + for i in 0..3 { + out[i] = src[i] * (1.0 - ab) + dst[i] * (1.0 - as_) + as_ * ab * b[i]; + } + out[3] = as_ + ab * (1.0 - as_); + out +} + +fn blend_color(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { + use BlendMode::*; + let sep = |f: fn(f32, f32) -> f32| [f(cb[0], cs[0]), f(cb[1], cs[1]), f(cb[2], cs[2])]; + fn hard(b: f32, s: f32) -> f32 { + if s <= 0.5 { + b * 2.0 * s + } else { + let t = 2.0 * s - 1.0; + b + t - b * t + } + } + match mode { + Normal => cs, + Multiply => sep(|b, s| b * s), + Screen => sep(|b, s| b + s - b * s), + Overlay => sep(|b, s| hard(s, b)), + Darken => sep(f32::min), + Lighten => sep(f32::max), + ColorDodge => sep(|b, s| { + if b <= 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } + }), + ColorBurn => sep(|b, s| { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } + }), + HardLight => sep(hard), + SoftLight => sep(|b, s| { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } + }), + Difference => sep(|b, s| (b - s).abs()), + Exclusion => sep(|b, s| b + s - 2.0 * b * s), + Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)), + Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)), + Color => set_lum(cs, lum(cb)), + Luminosity => set_lum(cb, lum(cs)), + } +} + +fn lum(c: [f32; 3]) -> f32 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + let mut c = c; + if n < 0.0 && l - n > 0.0 { + c = c.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 && x - l > 0.0 { + c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + c +} + +fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let (mx, mn) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); + if mx - mn <= 0.0 { + return [0.0; 3]; + } + c.map(|v| (v - mn) * s / (mx - mn)) } fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) { @@ -77,9 +205,73 @@ fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) { draw_shape(s, m, opacity * s.opacity, dst); } } + Node::Image(im) => draw_image(im, m, opacity * im.opacity, dst), } } +/// Draw a raster image: bilinear samples inside the image's (anti-aliased) quadrilateral. +fn draw_image(im: &crate::Image, m: Affine, opacity: f64, dst: &mut Image) { + let (iw, ih) = (im.width as usize, im.height as usize); + if iw == 0 || ih == 0 || im.rgba.len() < iw * ih * 4 || opacity <= 0.0 { + return; + } + let full = m * im.transform; + let inv = full.inverse(); + if !inv.as_coeffs().iter().all(|v| v.is_finite()) { + return; + } + let quad = kurbo::Rect::new(0.0, 0.0, iw as f64, ih as f64).to_path(0.01); + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&quad), &mat3(full), dst.width, dst.height, effectcraft_path::FillRule::NonZero); + let w = dst.width as usize; + if w == 0 { + return; + } + let px = |x: usize, y: usize| -> [f32; 4] { + let i = (y * iw + x) * 4; + let a = im.rgba[i + 3] as f32 / 255.0; + [im.rgba[i] as f32 / 255.0 * a, im.rgba[i + 1] as f32 / 255.0 * a, im.rgba[i + 2] as f32 / 255.0 * a, a] + }; + // Pixel footprint in image pixels: box-filter heavy minification instead of aliasing. + let c = inv.as_coeffs(); + let fx = (c[0] * c[0] + c[1] * c[1]).sqrt(); + let fy = (c[2] * c[2] + c[3] * c[3]).sqrt(); + let taps = (fx.max(fy).ceil() as usize).clamp(1, 8); + let k = opacity as f32; + dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, d) in row.iter_mut().enumerate() { + let cv = cov.data[y * w + x]; + if cv <= 0.0 { + continue; + } + let mut acc = [0.0f32; 4]; + for ty in 0..taps { + for tx in 0..taps { + let sx = x as f64 + (tx as f64 + 0.5) / taps as f64; + let sy = y as f64 + (ty as f64 + 0.5) / taps as f64; + let p = inv * Point::new(sx, sy); + let (u, v) = ((p.x - 0.5).clamp(0.0, iw as f64 - 1.0), (p.y - 0.5).clamp(0.0, ih as f64 - 1.0)); + let (x0, y0) = (u.floor() as usize, v.floor() as usize); + let (x1, y1) = ((x0 + 1).min(iw - 1), (y0 + 1).min(ih - 1)); + let (a, b) = ((u - x0 as f64) as f32, (v - y0 as f64) as f32); + let (p00, p10, p01, p11) = (px(x0, y0), px(x1, y0), px(x0, y1), px(x1, y1)); + for i in 0..4 { + acc[i] += (p00[i] * (1.0 - a) + p10[i] * a) * (1.0 - b) + (p01[i] * (1.0 - a) + p11[i] * a) * b; + } + } + } + let n = (taps * taps) as f32; + let s = acc.map(|v| v / n * cv * k); + if s[3] <= 0.0 { + continue; + } + for i in 0..3 { + d[i] = s[i] + d[i] * (1.0 - s[3]); + } + d[3] = s[3] + d[3] * (1.0 - s[3]); + } + }); +} + /// `dst = src·k over dst` (premultiplied). fn over(dst: &mut Image, src: &Image, k: f32) { dst.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(d, s)| { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3333cdd11..f90aa7f3d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -85,6 +85,9 @@ pub struct TextStyle { pub leading: Option, /// OpenType features (stylistic sets, figures, fractions, all small caps…). pub opentype: OpenType, + /// Variable font axis values (tag, user units): shaping (advances through HVAR / gvar + /// phantom points) and outlines use this design-space position. + pub variations: Vec<(String, f32)>, } impl Default for TextStyle { @@ -109,6 +112,7 @@ impl Default for TextStyle { script: Script::Normal, leading: None, opentype: OpenType::default(), + variations: Vec::new(), } } } @@ -194,6 +198,10 @@ impl Hash for TextStyle { self.leading.map(f32::to_bits).hash(h); (self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h); self.opentype.hash(h); + for (t, v) in &self.variations { + t.hash(h); + hf(h, *v); + } } } @@ -225,6 +233,9 @@ pub struct Glyph { pub v_scale: f32, /// Index of the style run the glyph belongs to. pub run: usize, + /// The run's variable font axis values, interned ([`crate::variable::coords`]; 0 = the + /// default instance): outlines are drawn there. + pub variations: u32, } /// One laid-out line. @@ -425,7 +436,13 @@ fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> { fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec { let f = fonts::face(face); let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() }; - let shaper = data.shaper(&font).build(); + let instance = (!style.variations.is_empty()).then(|| { + harfrust::ShaperInstance::from_variations( + &font, + style.variations.iter().map(|(t, v)| harfrust::Variation { tag: crate::variable::tag_of(t), value: *v }), + ) + }); + let shaper = data.shaper(&font).instance(instance.as_ref()).build(); let mut buf = UnicodeBuffer::new(); for &(b, _, sc) in chars { buf.add(if sc == '\t' { ' ' } else { sc }, b as u32); @@ -819,6 +836,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar h_scale: st.h_scale, v_scale: st.v_scale, run: g.run, + variations: crate::variable::intern(&st.variations), }); pen += g.adv; if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index beef03822..65232f02f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -70,7 +70,8 @@ pub(crate) fn outline_units(face: FaceId, gid: u32) -> Option> { /// The outline of a laid-out glyph in pixels, origin on its baseline (y down), with its /// horizontal / vertical scale and faux italic. pub fn glyph_outline(g: &Glyph) -> BezPath { - let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() }; + let units = if g.variations == 0 { outline_units(g.face, g.id) } else { variable::outline_units_at(g.face, g.id, &variable::coords(g.variations)) }; + let Some(units) = units else { return BezPath::new() }; glyph_affine(g) * (*units).clone() } @@ -107,6 +108,8 @@ pub struct CharGlyph { pub face: FaceId, pub gid: u32, pub outline_xf: Affine, + /// The character's variable font axis values (its style's Variable Font Axes). + pub variations: Vec<(String, f32)>, } #[derive(Clone, Debug, Default)] @@ -162,6 +165,7 @@ pub fn text_style(s: &CharStyle) -> TextStyle { }, leading: s.leading.map(|l| l as f32), opentype: s.opentype, + variations: s.variations.clone(), } } @@ -336,6 +340,7 @@ pub fn layout_doc(doc: &TextDoc) -> TextLayout { face: g.face, gid: g.id, outline_xf, + variations: variable::coords(g.variations).to_vec(), }); } } diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index 95fdec182..2072c294f 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -12,6 +12,32 @@ use skrifa::{GlyphId, MetadataProvider, Tag}; use crate::fonts::{self, FaceId}; use crate::{CharGlyph, Pen}; +/// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them. +pub fn intern(coords: &[(String, f32)]) -> u32 { + if coords.is_empty() { + return 0; + } + let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); + if let Some(i) = t.iter().position(|c| c.as_slice() == coords) { + return i as u32 + 1; + } + t.push(Arc::new(coords.to_vec())); + t.len() as u32 +} + +/// The axis values of an interned id (empty for 0). +pub fn coords(id: u32) -> Arc> { + if id == 0 { + return Arc::new(Vec::new()); + } + table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default() +} + +fn table() -> &'static Mutex>>> { + static T: OnceLock>>>> = OnceLock::new(); + T.get_or_init(Default::default) +} + /// One variation axis of a face, in user units. #[derive(Clone, Debug, PartialEq)] pub struct FontAxis { @@ -38,7 +64,7 @@ pub fn font_axes(face: FaceId) -> Vec { } /// A tag string as a [`Tag`] (padded with spaces, at most four characters). -fn tag_of(s: &str) -> Tag { +pub(crate) fn tag_of(s: &str) -> Tag { let mut b = [b' '; 4]; for (i, c) in s.bytes().take(4).enumerate() { b[i] = c; @@ -78,14 +104,21 @@ pub fn outline_units_at(face: FaceId, gid: u32, coords: &[(String, f32)]) -> Opt } /// A laid-out character's outline (same placement as [`CharGlyph::path`]) with its face's axes -/// moved by `deltas` (tag, user-unit offset from the axis default). +/// moved by `deltas` (tag, user-unit offset from the character's own axis value — its style's +/// Variable Font Axes setting, else the axis default). pub fn char_outline_varied(g: &CharGlyph, deltas: &[(String, f32)]) -> Option { let axes = font_axes(g.face); - let coords: Vec<(String, f32)> = - deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (a.default + d).clamp(a.min, a.max)))).collect(); + let base = |t: &str, d: f32| g.variations.iter().find(|(x, _)| x.trim_end() == t.trim_end()).map(|(_, v)| *v).unwrap_or(d); + let mut coords: Vec<(String, f32)> = + deltas.iter().filter_map(|(t, d)| axes.iter().find(|a| a.tag == *t).map(|a| (t.clone(), (base(&a.tag, a.default) + d).clamp(a.min, a.max)))).collect(); if coords.is_empty() { return None; } + for (t, v) in &g.variations { + if !coords.iter().any(|(c, _)| c == t) { + coords.push((t.clone(), *v)); + } + } let units = outline_units_at(g.face, g.gid, &coords)?; Some(g.outline_xf * (*units).clone()) } @@ -122,4 +155,37 @@ mod tests { let s = crate::outline_units(face, gid).unwrap(); assert!((d.area() - s.area()).abs() < 1e-3); } + + #[test] + fn style_variations_change_advances_and_outlines() { + assert_eq!(intern(&[]), 0); + let id = intern(&[("wght".into(), 700.0)]); + assert_eq!(intern(&[("wght".into(), 700.0)]), id); + assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]); + let Some((face, axes)) = find_variable_face() else { + eprintln!("no variable font installed: skipped"); + return; + }; + // An axis that changes widths (wdth or wght, else the first that moves). + let a = axes.iter().find(|a| a.tag == "wdth" && a.max > a.min).or_else(|| axes.iter().find(|a| a.tag == "wght" && a.max > a.min)).unwrap_or(&axes[0]); + let info = crate::fonts::face(face).info.clone(); + let st = |v: f32| crate::TextStyle { + family: info.family.clone(), + style: info.style.clone(), + size: 100.0, + variations: vec![(a.tag.clone(), v)], + ..Default::default() + }; + let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a.min)), crate::layout::measure("Hamburgefonts", &st(a.max))); + if crate::resolve(&info.family, &info.style).face != face { + eprintln!("variable face not reachable by family/style: skipped"); + return; + } + assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max); + let ink = |v: f32| { + let l = crate::layout::layout("H", &st(v), &crate::ParagraphStyle::default()); + l.glyphs.iter().map(|g| crate::glyph_outline(g).area().abs()).sum::() + }; + assert!((ink(a.min) - ink(a.max)).abs() > 1.0); + } } diff --git a/crates/track/src/roto/mod.rs b/crates/track/src/roto/mod.rs index 393f743e0..7103624be 100644 --- a/crates/track/src/roto/mod.rs +++ b/crates/track/src/roto/mod.rs @@ -230,6 +230,61 @@ impl FrameSeg { } (n > 0.0).then(|| [sx / n, sy / n]) } + + /// A compact binary form (to ship a segmentation between engine instances, e.g. from a Web + /// Worker to the page): `ECSG`, width, height (u32 LE), the colour models as JSON (length + /// prefixed), then the matte and the Refine Edge band run-length encoded (u16 LE run, byte). + pub fn to_bytes(&self) -> Vec { + let models = serde_json::to_vec(&(&self.fg, &self.bg)).unwrap_or_default(); + let mut out = Vec::with_capacity(16 + models.len() + 64); + out.extend_from_slice(b"ECSG"); + out.extend_from_slice(&(self.w as u32).to_le_bytes()); + out.extend_from_slice(&(self.h as u32).to_le_bytes()); + out.extend_from_slice(&(models.len() as u32).to_le_bytes()); + out.extend_from_slice(&models); + for plane in [&self.matte, &self.refine] { + let mut i = 0; + while i < plane.len() { + let v = plane[i]; + let mut n = 1; + while i + n < plane.len() && plane[i + n] == v && n < u16::MAX as usize { + n += 1; + } + out.extend_from_slice(&(n as u16).to_le_bytes()); + out.push(v); + i += n; + } + } + out + } + + /// Inverse of [`FrameSeg::to_bytes`] (`None` for malformed input). + pub fn from_bytes(b: &[u8]) -> Option { + if b.len() < 16 || &b[..4] != b"ECSG" { + return None; + } + let u = |i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize; + let (w, h, ml) = (u(4), u(8), u(12)); + let n = w.checked_mul(h)?; + let models = b.get(16..16 + ml)?; + let (fg, bg): (Gmm, Gmm) = serde_json::from_slice(models).ok()?; + let mut rest = &b[16 + ml..]; + let mut plane = || -> Option> { + let mut v = Vec::with_capacity(n); + while v.len() < n { + if rest.len() < 3 { + return None; + } + let run = u16::from_le_bytes([rest[0], rest[1]]) as usize; + v.resize(v.len() + run, rest[2]); + rest = &rest[3..]; + } + (v.len() == n).then_some(v) + }; + let matte = plane()?; + let refine = plane()?; + Some(FrameSeg { w, h, matte, refine, fg, bg }) + } } /// Straight RGB of an image (transparent pixels are black). diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index bec3f3783..15bd1f68b 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -89,6 +89,12 @@ fn strokes_segment_the_base_frame() { // Deterministic. let again = segment(&img, &refs, None, &SegOpts::default(), 0.0); assert_eq!(seg, again); + // The binary form round-trips exactly and is compact (run-length encoded planes). + let bytes = seg.to_bytes(); + assert_eq!(FrameSeg::from_bytes(&bytes).as_ref(), Some(&seg)); + assert!(bytes.len() < seg.w * seg.h / 4, "{} bytes", bytes.len()); + assert!(FrameSeg::from_bytes(&bytes[..bytes.len() - 1]).is_none()); + assert!(FrameSeg::from_bytes(b"nope").is_none()); } #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ff3db561c..3e6bdd10f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -55,6 +55,87 @@ pub enum Outcome { path: Option, crop: Option<[f32; 4]>, }, + /// Reply once background jobs end (a `wait: true` command whose job runs in a worker). + AwaitJobs(JobWaiter), +} + +/// A `wait: true` engine command whose job was sent to the session's offload (the browser's +/// Web Workers) instead of blocking the UI thread: the reply goes out when the job ends. +pub struct JobWaiter { + pub command: String, + pub params: Value, + /// The command's own result (with `wait: false`). + pub result: Value, + /// Ids of the jobs it started (`jobs.list` ids: `render`, `track`, `roto`…). + pub jobs: Vec, + /// Length of [`effectcraft_engine::Session::offload_log`] when it started. + pub log_len: usize, +} + +/// Commands that block with `wait` (and its default). +fn waits(command: &str, params: &Value) -> bool { + let default = command == "renderQueue.render"; + params.get("wait").and_then(Value::as_bool).unwrap_or(default) +} + +/// Run an engine command. A waiting command (`wait: true`) whose job the session offloads +/// runs with `wait: false` instead, and the reply waits for the job ([`Outcome::AwaitJobs`]), +/// so the browser's UI keeps drawing while it runs. +fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result { + if !app.session.offloads() || !waits(id, ¶ms) { + return app.session.execute_checked(id, params).map(ok).map_err(|e| e.to_string()); + } + let before: Vec = app.session.jobs().into_iter().map(|j| j.id).collect(); + let log_len = app.session.offload_log.len(); + let mut p = params.clone(); + p["wait"] = json!(false); + let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?; + let jobs: Vec = app.session.jobs().into_iter().map(|j| j.id).filter(|j| !before.contains(j)).collect(); + if jobs.is_empty() { + return Ok(ok(result)); + } + Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_len })) +} + +impl JobWaiter { + /// Whether its jobs are still running. + pub fn pending(&self, app: &EffectcraftApp) -> bool { + app.session.jobs().iter().any(|j| self.jobs.contains(&j.id)) + } + + /// The reply: what the blocking command would have returned (`running: false`, the final + /// progress, render items and analysis status), or the job's error. + pub fn finish(self, app: &mut EffectcraftApp) -> Value { + let s = &mut app.session; + s.poll_render(); + let errors: Vec = s.offload_log.iter().skip(self.log_len).filter_map(|(_, e)| e.clone()).collect(); + if let Some(e) = errors.first() { + return json!({"ok": false, "error": e}); + } + let mut r = self.result; + if r.get("running").is_some() { + r["running"] = json!(false); + } + if self.command == "renderQueue.render" { + let ids: Vec = r["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default(); + if let Ok(list) = s.execute("renderQueue.list", json!({})) { + r["items"] = + json!(list["items"].as_array().map(|a| a.iter().filter(|i| ids.contains(&i["id"])).cloned().collect::>()).unwrap_or_default()); + } + r["rendering"] = json!(s.is_rendering()); + } + let prefix = self.command.split('.').next().unwrap_or(""); + if matches!(prefix, "warp" | "camera" | "roto") + && r.get("layer").is_some() + && let Ok(st) = s.execute(&format!("{prefix}.status"), json!({"layer": r["layer"], "effect": r["effect"]})) + { + r["status"] = st; + } + if r.get("progress").is_some() { + r["progress"] = Value::Null; + } + json!({"ok": true, "result": r}) + } } fn ok(v: Value) -> Outcome { @@ -94,12 +175,12 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques // `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`, // `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click. let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() { - app.session.execute_checked(id, params).map_err(|e| e.to_string()) + execute_engine(app, id, params) } else { - crate::menus::invoke(app, ctx, id, params) + crate::menus::invoke(app, ctx, id, params).map(ok) }; match r { - Ok(v) => ok(v), + Ok(o) => o, Err(e) => { app.ui.status = e.clone(); err(e) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 28c048374..590843d7c 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -44,10 +44,14 @@ pub enum PanelKind { Metadata, Progress, ContentAwareFill, + /// Window ▸ Create Nulls From Paths (our own panel). + CreateNullsFromPaths, + /// Window ▸ VR Comp Editor (our own panel). + VrCompEditor, } impl PanelKind { - pub const ALL: [PanelKind; 32] = [ + pub const ALL: [PanelKind; 34] = [ PanelKind::Project, PanelKind::EffectControls, PanelKind::Composition, @@ -80,6 +84,8 @@ impl PanelKind { PanelKind::Metadata, PanelKind::Progress, PanelKind::ContentAwareFill, + PanelKind::CreateNullsFromPaths, + PanelKind::VrCompEditor, ]; pub fn title(self) -> &'static str { match self { @@ -116,6 +122,8 @@ impl PanelKind { PanelKind::Metadata => "Metadata", PanelKind::Progress => "Progress", PanelKind::ContentAwareFill => "Content-Aware Fill", + PanelKind::CreateNullsFromPaths => "Create Nulls From Paths", + PanelKind::VrCompEditor => "VR Comp Editor", } } pub fn id(self) -> String { @@ -342,7 +350,20 @@ pub fn workspace(name: &str) -> DockNode { tabs(&[Composition, Layer, Footage], 0), right( &[Info, Preview, Audio, Align, Character, Paragraph, LumetriScopes], - &[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers, ContentAwareFill], + &[ + EffectsPresets, + Properties, + Tracker, + Wiggler, + Smoother, + MotionSketch, + Paint, + Brushes, + Markers, + ContentAwareFill, + CreateNullsFromPaths, + VrCompEditor, + ], ), ), ), @@ -701,6 +722,18 @@ pub enum DockAction { PanelMenu(PanelKind, egui::Pos2), /// A tab started being dragged (re-dock or undock). BeginDrag(PanelKind), + /// The tab's lock was toggled (Composition / Timeline viewer lock). + ToggleLock(PanelKind), +} + +/// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close +/// button (×), the item's label colour as a small square and the viewer lock. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TabDeco { + pub swatch: egui::Color32, + pub locked: bool, + /// Close button and lock (Composition, Timeline); Effect Controls shows just the swatch. + pub viewer: bool, } /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. @@ -786,6 +819,7 @@ pub fn draw_group_chrome( t: &Tokens, reg: &mut crate::automation::Registry, title: &dyn Fn(PanelKind) -> String, + decos: &[(PanelKind, TabDeco)], ) -> Vec { let mut actions = Vec::new(); let painter = ui.painter().clone(); @@ -812,7 +846,14 @@ pub fn draw_group_chrome( let label = title(*p); let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text }); let menu_w = if is_active { 20.0 } else { 0.0 }; - let w = galley.size().x + 16.0 + menu_w; + let deco = decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d); + // × (active tab only), swatch and lock before the label. + let deco_w = match deco { + Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + 16.0, + Some(_) => 14.0, + None => 0.0, + }; + let w = galley.size().x + 16.0 + menu_w + deco_w; if x + w > strip.max.x - 20.0 && i > g.active { let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0)); let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); @@ -830,7 +871,44 @@ pub fn draw_group_chrome( actions.push(DockAction::BeginDrag(*p)); } reg.add(&format!("panel.tab.{}", p.id()), tab, &label); - let label_x = tab.min.x + 8.0; + let mut label_x = tab.min.x + 8.0; + if let Some(d) = deco { + let mut dx = tab.min.x + 6.0; + if is_active && d.viewer { + let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0)); + let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), i)), Sense::click()); + let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; + let k = 3.5; + painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc)); + painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc)); + reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close"); + if cresp.clicked() { + actions.push(DockAction::Close(*p)); + } + dx += 16.0; + } + let sw = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(9.0, 9.0)); + painter.rect_filled(sw, 1.0, d.swatch); + dx += 14.0; + label_x = dx; + if d.viewer { + let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0)); + let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), i)), Sense::click()); + let lc = if d.locked { + t.tab_text_active + } else if lresp.hovered() { + t.tab_text + } else { + t.text_faint + }; + icons::paint(&painter, lr, Icon::Lock, lc); + reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" }); + if lresp.clicked() { + actions.push(DockAction::ToggleLock(*p)); + } + label_x = dx + 16.0; + } + } let label_w = galley.size().x; let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text }; painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 61fa5ad63..1f42df2e2 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -35,6 +35,26 @@ impl EffectcraftApp { out } + /// The Composition and Timeline tabs' close button, label-colour swatch and viewer lock. + fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> { + let Some(cid) = self.session.active_comp_id() else { return vec![] }; + let swatch = match self.session.project.item(cid).map(|i| i.label) { + Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l), + _ => self.tokens.text_faint, + }; + let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline] + .into_iter() + .map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true })) + .collect(); + // Effect Controls carries the selected layer's label colour. + if let Some(l) = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|id| c.layer(*id))) + && l.label != effectcraft_engine::color::Label::None + { + v.push((PanelKind::EffectControls, dock::TabDeco { swatch: self.tokens.label(l.label), locked: false, viewer: false })); + } + v + } + /// Settings ▸ Appearance ▸ Use Label Color for Related Tabs: the Composition and Timeline /// tabs carry their comp's label colour, Effect Controls and Properties the layer's. fn label_tab_marks(&self, ui: &egui::Ui) { @@ -44,12 +64,9 @@ impl EffectcraftApp { let Some(cid) = self.session.active_comp_id() else { return }; let comp_label = self.session.project.item(cid).map(|i| i.label); let layer_label = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.label); - for (p, label) in [ - (PanelKind::Composition, comp_label), - (PanelKind::Timeline, comp_label), - (PanelKind::EffectControls, layer_label), - (PanelKind::Properties, layer_label), - ] { + // (the Composition, Timeline and Effect Controls tabs carry their swatch already) + let _ = comp_label; + for (p, label) in [(PanelKind::Properties, layer_label)] { let (Some(label), Some(e)) = (label, self.auto.find(&format!("panel.tab.{}", p.id()))) else { continue }; if label == effectcraft_engine::color::Label::None { continue; @@ -131,9 +148,10 @@ impl EffectcraftApp { dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); let mut actions = Vec::new(); let titles = self.tab_titles(); + let decos = self.tab_decos(); let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); + actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title, &decos)); } self.label_tab_marks(ui); if maximized.is_none() { @@ -174,6 +192,11 @@ impl EffectcraftApp { DockAction::BeginDrag(p) => ctx.data_mut(|d| { d.insert_temp(drag_id(), p); }), + DockAction::ToggleLock(p) => { + if !self.ui.locked_tabs.remove(&p.id()) { + self.ui.locked_tabs.insert(p.id().to_string()); + } + } } } self.tab_drag(&ctx, &groups, &float_groups); @@ -213,7 +236,8 @@ impl EffectcraftApp { active: f.active.min(f.panels.len().saturating_sub(1)), stacked: None, }; - actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title)); + let decos = self.tab_decos(); + actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title, &decos)); ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside); if let Some(p) = g.panels.get(g.active).copied() { self.panel_body(ui, p, g.content); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index fd6283514..880d19285 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -4,8 +4,10 @@ //! never waits for the compositor. Results land in a memory-budgeted cache keyed by (project //! revision, comp, frame, scale); the timeline draws the cached range as the green cache bar. //! -//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] renders them -//! on the UI thread between egui frames, within a time budget. +//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] hands them +//! to a [`RemoteFrames`] (the browser's frame worker, a second engine instance fed with project +//! diffs, `effectcraft_engine::remote`) or, without one, renders them on the UI thread between +//! egui frames, within a time budget. //! //! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also //! written to disk under a content key (the comp's content hash, frame, scale, view and render @@ -163,6 +165,38 @@ fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_ha disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash) } +/// A frame for a [`RemoteFrames`] renderer. +pub struct RemoteJob { + pub project: Arc, + pub revision: u64, + pub comp: ItemId, + pub t: Tick, + pub opts: RenderOpts, +} + +/// Premultiplied RGBA8 pixels from a [`RemoteFrames`] renderer. +pub struct RemoteFrame { + pub width: u32, + pub height: u32, + pub rgba: Vec, + /// Render time (ms). + pub ms: f64, +} + +/// Called once with a remote frame (or why there is none). +pub type RemoteDone = Box) + Send>; + +/// Renders viewer frames elsewhere (the browser's frame worker), so the UI thread never does. +pub trait RemoteFrames: Send + Sync { + /// Frames it can render at the same time (0 when it is not usable: frames then render on + /// the UI thread again). + fn slots(&self) -> usize; + /// Start rendering `job`; `done` runs when the frame arrives. + fn start(&self, job: RemoteJob, done: RemoteDone); + /// Drop cached intermediate results (Edit ▸ Purge). + fn purge(&self) {} +} + pub struct Frames { cache: Arc>, /// Keys queued or rendering. @@ -175,6 +209,9 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, + /// Where frames render instead of the UI thread (wasm32), and how many it is rendering. + remote: Option>, + remote_busy: Arc>, } impl Default for Frames { @@ -190,6 +227,8 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), + remote: None, + remote_busy: Arc::default(), } } } @@ -270,6 +309,9 @@ impl Frames { } pub fn clear(&self) { + if let Some(r) = &self.remote { + r.purge(); + } if let Ok(mut c) = self.cache.lock() { c.map.clear(); c.order.clear(); @@ -324,7 +366,7 @@ impl Frames { q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq }); drop(q); #[cfg(not(target_arch = "wasm32"))] - { + if self.remote.is_none() { let w = self.worker(); // One spawn per job; each spawn renders whichever queued job matters most right now. self.pool.spawn(move || { @@ -349,13 +391,102 @@ impl Frames { } } - /// Render queued frames on this thread, most important first, until the queue is empty or - /// `budget` has passed (at least one frame). Returns whether frames are still queued. Only - /// wasm32 needs this (native frames render on the pool); elsewhere it does nothing. + /// Render frames with `remote` from now on (`None`: on this thread again). + pub fn set_remote(&mut self, remote: Option>) { + self.remote = remote; + } + + /// Whether frames go to a usable [`RemoteFrames`]. + pub fn remote_active(&self) -> bool { + self.remote.as_ref().is_some_and(|r| r.slots() > 0) + } + + /// Frames the remote renderer renders at once (0 without one). + pub fn remote_slots(&self) -> usize { + self.remote.as_ref().map(|r| r.slots()).unwrap_or(0) + } + + /// Frames being rendered remotely. + pub fn remote_busy(&self) -> usize { + self.remote_busy.lock().map(|b| *b).unwrap_or(0) + } + + /// Hand queued frames to the remote renderer while it has free slots, most important first + /// (the viewer's frame, then prefetch in request order; a viewer frame requested while every + /// slot is busy goes to the first one that frees up). Frames that the GPU compositor can keep + /// on the GPU render here instead (no readback). Returns how many were started. + pub fn dispatch_remote(&self) -> usize { + let Some(remote) = self.remote.clone() else { return 0 }; + let slots = remote.slots(); + let w = self.worker(); + let mut started = 0; + loop { + let busy = self.remote_busy(); + if busy >= slots { + break; + } + let job = { + let Ok(mut q) = self.queue.lock() else { break }; + let Some(i) = Queue::best(&q.jobs) else { break }; + let job = q.jobs.swap_remove(i); + q.running.insert(job.key); + if job.urgent { + q.urgent_running += 1; + } + job + }; + if job.src.gpu.is_some() && job.src.gpu_display { + let t0 = web_time::Instant::now(); + if let Some(img) = w.render_gpu(&job) { + w.finish(&job, img, t0); + continue; + } + } + if let Ok(mut b) = self.remote_busy.lock() { + *b += 1; + } + started += 1; + let (key, urgent) = (job.key, job.urgent); + let fw = self.worker(); + let busy = self.remote_busy.clone(); + let t0 = web_time::Instant::now(); + let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts }; + remote.start( + rj, + Box::new(move |r: Result| { + if let Ok(mut b) = busy.lock() { + *b = b.saturating_sub(1); + } + match r { + Ok(f) => { + let img = egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba); + fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), t0); + } + Err(e) => { + // Not rendered (a stale revision, a lost worker): the viewer asks again. + log::debug!("remote frame: {e}"); + fw.release(key, urgent); + } + } + }), + ); + } + started + } + + /// Render queued frames, most important first. With a usable [`RemoteFrames`] they are only + /// handed over ([`Frames::dispatch_remote`]); otherwise they render on this thread until the + /// queue is empty or `budget` has passed (at least one frame). Returns whether frames are + /// still queued or rendering remotely. Only wasm32 needs this (native frames render on the + /// pool); elsewhere it does nothing. pub fn pump(&self, budget: std::time::Duration) -> bool { if cfg!(not(target_arch = "wasm32")) { return false; } + if self.remote_active() { + self.dispatch_remote(); + return self.queue.lock().map(|q| !q.jobs.is_empty()).unwrap_or(false) || self.remote_busy() > 0; + } let t0 = web_time::Instant::now(); let w = self.worker(); while w.run_next() { @@ -380,12 +511,10 @@ struct Worker { impl Worker { /// Render the queued job that matters most right now. `false` when the queue was empty. fn run_next(&self) -> bool { - let Worker { queue, cache, inflight, ctx, last, content_keys } = self; + let Worker { queue, content_keys, .. } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; - // Urgent first (newest), then prefetch in request order. - let best = q.jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i); - let Some(i) = best else { return false }; + let Some(i) = Queue::best(&q.jobs) else { return false }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -407,27 +536,50 @@ impl Worker { ci } }; - if job.urgent - && let Ok(mut l) = last.lock() - { + self.finish(&job, ci, t0); + true + } + + /// Store a finished frame and release its job. + fn finish(&self, job: &Job, img: FrameImage, t0: web_time::Instant) { + self.finish_key(job.key, job.urgent, img, t0); + } + + fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, t0: web_time::Instant) { + if urgent && let Ok(mut l) = self.last.lock() { *l = t0.elapsed().as_secs_f64() * 1000.0; } - if let Ok(mut c) = cache.lock() { - c.insert(job.key, ci); + if let Ok(mut c) = self.cache.lock() { + c.insert(key, img); } - if let Ok(mut inf) = inflight.lock() { - inf.remove(&job.key); + self.release(key, urgent); + } + + /// The job of `key` is no longer queued or running (rendered or dropped). + fn release(&self, key: FrameKey, urgent: bool) { + if let Ok(mut inf) = self.inflight.lock() { + inf.remove(&key); } - if let Ok(mut q) = queue.lock() { - q.running.remove(&job.key); - if job.urgent { - q.urgent_running -= 1; + if let Ok(mut q) = self.queue.lock() { + q.running.remove(&key); + if urgent { + q.urgent_running = q.urgent_running.saturating_sub(1); } } - if let Some(ctx) = ctx { + if let Some(ctx) = &self.ctx { ctx.request_repaint(); } - true + } + + /// The frame on the GPU, kept there for the viewer, if the compositor can do it alone. + fn render_gpu(&self, job: &Job) -> Option { + let g = job.src.gpu.as_ref()?; + let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + r.expr = job.src.expr.as_deref(); + r.cache = Some(&job.src.layer_cache); + r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); + r.active_accel()?; + g.render_display(&r, job.comp, job.t).map(|f| FrameImage::Gpu(Arc::new(f))) } fn render_job(&self, job: &Job) -> FrameImage { @@ -465,3 +617,10 @@ struct Queue { urgent_running: usize, seq: u64, } + +impl Queue { + /// The job that matters most: urgent first (newest), then prefetch in request order. + fn best(jobs: &[Job]) -> Option { + jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i) + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 92eed7aec..1380553a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -143,6 +143,8 @@ pub struct EffectcraftApp { pub(crate) deferred: Vec<(ControlRequest, f64)>, pub(crate) synthetic: Vec, pub(crate) input_waiters: Vec>, + /// Control requests waiting for an offloaded job (`wait: true` in the browser). + pub(crate) job_waiters: Vec<(control::JobWaiter, Sender)>, queued_screenshots: Vec<(u64, f64, u32)>, pending_screenshots: Vec<(u64, Option, Option<[f32; 4]>, Sender, f64)>, next_token: u64, @@ -210,6 +212,7 @@ impl EffectcraftApp { deferred: vec![], synthetic: vec![], input_waiters: vec![], + job_waiters: vec![], queued_screenshots: vec![], pending_screenshots: vec![], next_token: 1, @@ -847,8 +850,9 @@ impl EffectcraftApp { fn frames_parallelism(&self) -> usize { if cfg!(target_arch = "wasm32") { - // frames render one at a time on the UI thread (`Frames::pump`) - return 1; + // frame workers render one frame each; without them frames render one at a time + // on the UI thread (`Frames::pump`) + return self.frames.remote_slots().max(1); } std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) } @@ -881,6 +885,10 @@ impl EffectcraftApp { } } control::Outcome::AfterInput => self.input_waiters.push(reply), + control::Outcome::AwaitJobs(w) => { + self.job_waiters.push((w, reply)); + ctx.request_repaint(); + } control::Outcome::Screenshot { path, crop } => { // A covered or minimized window presents no frames (macOS skips its redraws), so // a screenshot would never arrive and nothing would tick its timeout. @@ -897,6 +905,28 @@ impl EffectcraftApp { } } self.control_rx = Some(rx); + self.finish_job_waiters(ctx); + } + + /// Reply to the control requests whose offloaded jobs ended. + fn finish_job_waiters(&mut self, ctx: &egui::Context) { + if self.job_waiters.is_empty() { + return; + } + self.session.poll_render(); + self.session.poll_offload(); + let waiters = std::mem::take(&mut self.job_waiters); + for (w, reply) in waiters { + if w.pending(self) { + self.job_waiters.push((w, reply)); + } else { + let v = w.finish(self); + let _ = reply.send(v); + } + } + if !self.job_waiters.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } } fn issue_screenshots(&mut self, ctx: &egui::Context) { @@ -961,7 +991,9 @@ impl EffectcraftApp { for ev in self.session.drain_events() { match ev { effectcraft_engine::Event::OpenComp(_) => { - self.ui.dock.activate(PanelKind::Composition); + if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) { + self.ui.dock.activate(PanelKind::Composition); + } self.ui.timeline.pps = None; } effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), @@ -1094,6 +1126,23 @@ impl EffectcraftApp { if self.session.prefs.auto_save.enabled { ctx.request_repaint_after(std::time::Duration::from_secs(30)); } + // File ▸ Watch Folder: look for new projects every 10 seconds. + if self.session.state.watch_folder.is_some() { + let key = egui::Id::new("watchFolder.lastPoll"); + let last: f64 = ctx.data(|d| d.get_temp(key)).unwrap_or(0.0); + if now - last >= 10.0 { + ctx.data_mut(|d| d.insert_temp(key, now)); + match self.session.execute("file.watchFolder.poll", serde_json::json!({})) { + Ok(r) => { + if let Some(n) = r["rendered"].as_array().map(Vec::len).filter(|n| *n > 0) { + self.ui.status = format!("Watch Folder: rendered {n} project(s)"); + } + } + Err(e) => self.ui.status = e.to_string(), + } + } + ctx.request_repaint_after(std::time::Duration::from_secs(10)); + } } /// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f3c8df3c0..1cb295677 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -818,6 +818,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({})); Value::Null } + // Help ▸ In-App Tutorials: the Home screen's Learn tab. + "help.inAppTutorials" => { + app.ui.start_screen = true; + app.ui.home_learn = true; + Value::Null + } "effect.manage" | "anim.browsePresets" => { app.show_panel(PanelKind::EffectsPresets); Value::Null @@ -848,6 +854,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Save("Untitled Project.ecproj")), "file.saveAs" | "file.saveCopy" => ("path", Ask::Save("Untitled Project.ecproj")), "comp.saveFrameAs" => ("path", Ask::Save("Frame.png")), + "comp.saveFrameAsPsd" => ("path", Ask::Save("Frame.psd")), + "comp.saveFrameAsExr" => ("path", Ask::Save("Frame.exr")), + "file.watchFolder" if params.get("stop").is_none() => ("folder", Ask::Save("Watch Folder")), "anim.savePreset" => ("path", Ask::Save("Preset.ecpreset")), "anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])), "view.exportGuides" => ("path", Ask::Save("Guides.json")), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 02dcb1a9b..b5160410d 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -12,7 +12,8 @@ //! - layer parameters have a layer popup and a Source / Masks / Effects & Masks popup; mask //! ("Path") parameters list the layer's masks; //! - Curves has its graph editor, Levels its histogram with input/output triangles, Auto -//! Levels a histogram. +//! Levels a histogram; Lumetri's curves, Colorama's output cycle, Glow's colour map and +//! Reshape's correspondence points have visual editors ([`super::fx_editors`]). use effectcraft_engine::geom::Mat3; use effectcraft_engine::keyframe::Value; @@ -40,7 +41,7 @@ fn selected_layer(app: &EffectcraftApp) -> Option { /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. -fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { +pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { let kid = id.with("gesture"); let n: u64 = if started { let n = ui.data(|d| d.get_temp::(kid)).unwrap_or(0) + 1; @@ -536,6 +537,15 @@ fn group_rows( } if open { let sub = fx.sub(&sg.match_id); + // A visual editor for the group's hidden parameters (Lumetri curves, …). + let eh = super::fx_editors::inline_height(fx.effect, &sub.path, rect.width()); + if eh > 0.0 { + let er = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), eh)); + *y += eh; + if er.max.y >= rect.min.y && er.min.y <= rect.max.y { + super::fx_editors::inline_editor(app, ui, p, layer, fx.root, sg, fx.effect, &sub.path, ectx, er, actions); + } + } group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions); } } @@ -603,8 +613,9 @@ fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l // Curves / Levels editors /// Height of the custom editor shown under an effect's header (0 when none). -fn editor_height(effect: &str, width: f32) -> f32 { +fn editor_height(effect: &str, g: &PropGroup, width: f32) -> f32 { match effect { + super::fx_editors::GLOW | super::fx_editors::RESHAPE => super::fx_editors::header_height(effect, g, width), "ec.color.curves" => 34.0 + curves_size(width) + 26.0, effectcraft_engine::effects::warp_stab::ID => 50.0, effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT, @@ -995,7 +1006,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2})); }); if open { - let eh = editor_height(effect, body.width()); + let eh = editor_height(effect, g, body.width()); if eh > 0.0 { let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh)); y += eh; @@ -1006,6 +1017,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions), effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions), "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions), + super::fx_editors::GLOW | super::fx_editors::RESHAPE => { + super::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions) + } _ => histogram_only(app, ui, &bp, g, er), } } @@ -1130,10 +1144,14 @@ pub fn viewer_hook( } else if app.ui.viewer.show_layer_controls { // ⊕ controls for the point parameters of the selected effects. let sel = app.session.state.selected_props.clone(); - for layer in ectx.comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) { + let selected = app.session.state.selected_layers.clone(); + for layer in ectx.comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(ectx.time)) { let Some(fx) = layer.effects() else { continue }; let m = l2c(ectx, layer); for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) { + if matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) { + super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions); + } let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect(); let screen: Vec = pts .iter() diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 3f8f1985e..0ccc46e6b 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -4,7 +4,8 @@ //! be exposed) and the template name. //! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add //! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point / -//! angle / dropdown control editing the source property; rename by double-clicking the name; +//! angle / dropdown control editing the source property (Add Selected As: Source Text as a font +//! control, Scale as one uniform slider); rename by double-clicking the name; //! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media //! Replacement for the selected footage layer. //! - **Mirrors and links**: adding a property that is already in the panel adds a *mirror* @@ -138,6 +139,19 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { } }); app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting"); + // The selected property as a Font control (Source Text) or a uniform Scale slider. + let has_sel = !app.session.state.selected_props.is_empty(); + let r = ui.add_enabled_ui(has_sel, |ui| { + ui.menu_button("Add Selected As", |ui| { + for (label, as_type) in [("Font Control", "font"), ("Uniform Scale", "scale")] { + if ui.button(label).clicked() { + actions.push(("essential.addProperty".into(), json!({"as": as_type}))); + ui.close(); + } + } + }) + }); + app.auto.add("essential.addAs", r.response.rect, "Add Selected As"); let footage_layer = app.session.active_comp().and_then(|c| { app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0) }); @@ -205,7 +219,14 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg match src { Some((layer, lt, p)) => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) { + if let Some(nv) = value_widget( + app, + ui, + &format!("essential.control.{}.value", c.id), + essential::effective_type(eg.resolve(c.id).unwrap_or(c), &p), + &p, + &v, + ) { actions.push(( "prop.set".into(), json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}), @@ -296,8 +317,8 @@ fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCo } /// The editor for a control's value; returns the new value (JSON for `prop.set`). -fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option { - let ty = essential::control_type(p)?; +fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Option, p: &Property, v: &KV) -> Option { + let ty = ty?; let mut out = None; let rect; match ty { @@ -370,6 +391,48 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Pro } rect = r.rect; } + ControlType::Font => { + // Family (from the installed fonts), style and size; the text stays as it is. + let KV::Text(doc) = v else { return None }; + let fams = effectcraft_engine::text::fonts::families(); + let mut d = (**doc).clone(); + let mut changed = false; + let r1 = egui::ComboBox::from_id_salt(("eg-font", auto)).selected_text(&d.font).width(110.0).show_ui(ui, |ui| { + for (f, _) in &fams { + if ui.selectable_label(*f == d.font, f).clicked() { + d.font = f.clone(); + changed = true; + } + } + }); + let styles: Vec = fams.iter().find(|(f, _)| *f == d.font).map(|(_, st)| st.clone()).unwrap_or_default(); + let r2 = egui::ComboBox::from_id_salt(("eg-style", auto)).selected_text(&d.style).width(70.0).show_ui(ui, |ui| { + for st in &styles { + if ui.selectable_label(*st == d.style, st).clicked() { + d.style = st.clone(); + changed = true; + } + } + }); + let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); + app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); + app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); + if changed || r3.changed() { + out = serde_json::to_value(&d).ok(); + } + rect = r1.response.rect.union(r3.rect); + } + ControlType::Scale => { + // One percentage for every axis. + let comps = v.components(); + let mut x = comps.first().copied().unwrap_or(100.0); + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + if r.changed() { + out = Some(json!(vec![x; comps.len().max(1)])); + } + rect = r.rect; + } ControlType::Slider | ControlType::Media => { let mut x = v.as_f64(); let r = match p.ui { @@ -440,7 +503,7 @@ fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) } _ => { let v = p.value_at(lt); - if let Some(nv) = value_widget(app, ui, &auto, &p, &v) { + if let Some(nv) = value_widget(app, ui, &auto, essential::effective_type(eg.resolve(c.id).unwrap_or(c), &p), &p, &v) { actions.push(( "essential.set".into(), json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}), diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 8d2df3474..2bbe2f437 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -118,6 +118,16 @@ fn is_layered(p: &Value) -> bool { } } +/// The import includes a PDF or Illustrator file (which may have several pages). +fn is_pdf(p: &Value) -> bool { + let pdf = |v: &Value| v.as_str().is_some_and(|s| matches!(s.rsplit('.').next().map(str::to_ascii_lowercase).as_deref(), Some("pdf" | "ai"))); + match p.get("paths").or(p.get("path")) { + Some(Value::Array(a)) => a.iter().any(pdf), + Some(v) => pdf(v), + None => false, + } +} + pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let s = &app.session; let comp = s.active_comp(); @@ -136,6 +146,14 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { 1, )], ), + // PDF / Illustrator files: Import Kind and the page. + "file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => ( + "Import PDF / Illustrator File".into(), + vec![ + Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0), + Field::num("page", "Page", 1.0), + ], + ), // Puppet tool ▸ Record Options. "puppet.recordOptions" if !has(p, &["speed", "smoothing", "useDraftDeformation", "showMesh"]) => { let o = &s.state.puppet; diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs new file mode 100644 index 000000000..e47d0a8df --- /dev/null +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -0,0 +1,932 @@ +//! Visual editors for effect parameters stored as hidden or text parameters (Effect Controls): +//! +//! - Lumetri Color ▸ Curves ▸ RGB Curves: master / red / green / blue curve graphs (channel +//! swatches like After Effects'), and ▸ Hue Saturation Curves: Hue vs Sat, Hue vs Hue, Hue vs +//! Luma, Luma vs Sat and Sat vs Sat graphs over their colour gradients (y = ½ is neutral); +//! - Colorama ▸ Output Cycle: the palette wheel, with draggable colour stops (click the ring to +//! add one, Alt-click or drag one off to remove it, select one to pick its colour); editing a +//! preset palette turns it into Custom; +//! - Glow: the colour map (A & B Colors: a preview strip of the gradient over glow brightness; +//! Arbitrary Map: red / green / blue curve graphs and the resulting strip); +//! - Reshape: correspondence point list buttons here, and on the Composition viewer the source +//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]). +//! +//! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every +//! handle registers an automation id. + +use effectcraft_engine::effects::{ColoramaPalette, OffsetCurve}; +use effectcraft_engine::geom::Mat3; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::{Layer, PropGroup}; +use effectcraft_engine::render::EvalCtx; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use super::effect_controls::gesture_key; +use super::fx_widgets as fw; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +type Actions = Vec<(String, serde_json::Value)>; + +pub const LUMETRI: &str = "ec.color.lumetri"; +pub const COLORAMA: &str = "ec.color.colorama"; +pub const GLOW: &str = "ec.stylize.glow"; +pub const RESHAPE: &str = "ec.distort.reshape"; + +fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String { + match g.get(id).map(|p| ectx.value(layer, p)) { + Some(Value::Str(s)) => s, + _ => String::new(), + } +} + +fn set_str(actions: &mut Actions, layer: &Layer, g: &PropGroup, id: &str, v: String, merge: Option) { + let Some(p) = g.get(id) else { return }; + let mut a = json!({"layer": layer.id.0, "prop": p.uid, "value": v}); + if let Some(m) = merge { + a["merge"] = json!(m); + } + actions.push(("prop.set".into(), a)); +} + +/// Register automation ids for a curve's control points (`.point.`). +fn register_points(app: &mut EffectcraftApp, prefix: &str, gr: Rect, pts: &[[f32; 2]]) { + for (i, q) in pts.iter().enumerate() { + let s = pos2(gr.min.x + q[0] * gr.width(), gr.max.y - q[1] * gr.height()); + app.auto.add(&format!("{prefix}.point.{i}"), Rect::from_center_size(s, vec2(9.0, 9.0)), &format!("Point {}", i + 1)); + } +} + +/// Small coloured tabs (`names[i]`, swatch `cols[i]`); returns the clicked one. +fn tabs( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + x0: f32, + cy: f32, + cur: usize, + names: &[&str], + cols: &[Color32], + id: &str, +) -> Option { + let t = app.tokens; + let mut x = x0; + let mut out = None; + for (i, name) in names.iter().enumerate() { + let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 }; + let r = Rect::from_min_size(pos2(x, cy - 9.0), vec2(w, 18.0)); + let resp = ui.interact(r, egui::Id::new((id, i)), Sense::click()); + let sel = i == cur; + if sel { + p.rect_filled(r, 3.0, t.row_selected); + } else if resp.hovered() { + p.rect_filled(r, 3.0, t.hover); + } + match cols.get(i) { + Some(c) => { + p.circle_filled(r.center(), 5.5, *c); + if sel { + p.circle_stroke(r.center(), 7.0, Stroke::new(1.0, t.text)); + } + } + None => { + p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.0), if sel { t.text } else { t.text_dim }); + } + } + app.auto.add(&format!("{id}.{i}"), r, name); + if resp.clicked() { + out = Some(i); + } + resp.on_hover_text(*name); + x += w + 4.0; + } + out +} + +fn reset_link(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str) -> bool { + let t = app.tokens; + let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + p.text(r.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text_dim }); + app.auto.add(id, r, "Reset"); + resp.clicked() +} + +fn graph_size(width: f32) -> f32 { + (width - 72.0).clamp(120.0, 256.0) +} + +// --------------------------------------------------------------------------------------------- +// Inline editors (inside a parameter group) + +/// Height of the editor shown at the top of group `path` (spec-id path with a trailing `/`) +/// of `effect` (0 when none). +pub fn inline_height(effect: &str, path: &str, width: f32) -> f32 { + match (effect, path) { + (LUMETRI, "curves/rgbCurves/") => 30.0 + graph_size(width) + 22.0, + (LUMETRI, "curves/hueSaturationCurves/") => 30.0 + graph_size(width) * 0.6 + 34.0, + (COLORAMA, "outputCycle/") => 30.0 + wheel_size(width) + 34.0, + _ => 0.0, + } +} + +/// Draw the editor of group `g` (at `path`) in `r`. `root`: the effect's group. +#[allow(clippy::too_many_arguments)] +pub fn inline_editor( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + effect: &str, + path: &str, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + match (effect, path) { + (LUMETRI, "curves/rgbCurves/") => rgb_curves(app, ui, p, layer, root, g, ectx, r, actions), + (LUMETRI, "curves/hueSaturationCurves/") => huesat_curves(app, ui, p, layer, root, g, ectx, r, actions), + (COLORAMA, "outputCycle/") => colorama_wheel(app, ui, p, layer, root, g, ectx, r, actions), + _ => {} + } +} + +const RGB_CURVES: [(&str, &str); 4] = [("master", "Master"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]; + +#[allow(clippy::too_many_arguments)] +fn rgb_curves( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = root.uid; + let prefix = format!("effectControls.effect.{euid}.lumetri.rgbCurves"); + let ch = app.ui.fx_curve_channel.get(&g.uid).copied().unwrap_or(0).min(3); + let x0 = r.min.x + 38.0; + let cy = r.min.y + 15.0; + let cols = [Color32::from_gray(0xe8), Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)]; + let names: Vec<&str> = RGB_CURVES.iter().map(|c| c.1).collect(); + if let Some(n) = tabs(app, ui, p, x0, cy, ch, &names, &cols, &format!("{prefix}.channel")) { + app.ui.fx_curve_channel.insert(g.uid, n); + } + let id = RGB_CURVES[ch].0; + let cur = fw::CurvePoints::parse(&str_value(layer, ectx, g, id)); + let size = graph_size(r.width()); + if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, cy - 9.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) { + set_str(actions, layer, g, id, fw::CurvePoints::identity().format(), None); + } + let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size)); + let gid = egui::Id::new(("lumetri-rgb", euid, ch)); + let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, gid, &t); + app.auto.add(&format!("{prefix}.graph"), gr, "RGB Curves"); + register_points(app, &prefix, gr, &cur.0); + let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked()); + if let Some(np) = edit.changed { + set_str(actions, layer, g, id, np.format(), Some(key)); + } + p.text(pos2(gr.min.x, gr.max.y + 11.0), Align2::LEFT_CENTER, "Click adds a point; drag one off to remove", Tokens::ui(10.5), t.text_faint); +} + +/// A hue / saturation curve's points (`x,y` in 0..1, y = ½ neutral), any count (empty = flat). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OffsetPoints(pub Vec<[f32; 2]>); + +impl OffsetPoints { + pub fn parse(s: &str) -> OffsetPoints { + let mut v: Vec<[f32; 2]> = s + .split(|c: char| c.is_whitespace() || c == ';') + .filter_map(|tk| { + let (x, y) = tk.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?); + (x.is_finite() && y.is_finite()).then_some([x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)]) + }) + .collect(); + v.sort_by(|a, b| a[0].total_cmp(&b[0])); + OffsetPoints(v) + } + pub fn format(&self) -> String { + let f = |v: f32| { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() || s == "-0" { "0".into() } else { s } + }; + self.0.iter().map(|q| format!("{},{}", f(q[0]), f(q[1]))).collect::>().join(" ") + } + pub fn insert(&mut self, x: f32, y: f32) -> usize { + let i = self.0.partition_point(|q| q[0] < x); + self.0.insert(i, [x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)]); + i + } + /// Move point `i`, keeping the order. + pub fn move_point(&mut self, i: usize, x: f32, y: f32) { + let n = self.0.len(); + if i >= n { + return; + } + let lo = if i == 0 { 0.0 } else { self.0[i - 1][0] + 0.002 }; + let hi = if i + 1 == n { 1.0 } else { self.0[i + 1][0] - 0.002 }; + self.0[i] = [x.clamp(lo, hi.max(lo)), y.clamp(0.0, 1.0)]; + } + pub fn hit(&self, x: f32, y: f32, r: [f32; 2]) -> Option { + self.0 + .iter() + .enumerate() + .map(|(i, q)| (i, ((q[0] - x) / r[0]).hypot((q[1] - y) / r[1]))) + .filter(|(_, d)| *d <= 1.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } +} + +/// Lumetri's hue / saturation curves: parameter id, tab name, x axis (0 hue, 1 luma, 2 sat), +/// whether x wraps around. +const HUESAT: [(&str, &str, u8, bool); 5] = [ + ("hueVsSat", "Hue vs Sat", 0, true), + ("hueVsHue", "Hue vs Hue", 0, true), + ("hueVsLuma", "Hue vs Luma", 0, true), + ("lumaVsSat", "Luma vs Sat", 1, false), + ("satVsSat", "Sat vs Sat", 2, false), +]; + +fn hue_color(h: f32) -> Color32 { + let c = egui::ecolor::Hsva::new(h.rem_euclid(1.0), 0.85, 0.9, 1.0); + Color32::from(c) +} + +#[allow(clippy::too_many_arguments)] +fn huesat_curves( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = root.uid; + let prefix = format!("effectControls.effect.{euid}.lumetri.hueSatCurves"); + let k = app.ui.fx_curve_channel.get(&g.uid).copied().unwrap_or(0).min(4); + let x0 = r.min.x + 38.0; + let cy = r.min.y + 15.0; + let names: Vec<&str> = HUESAT.iter().map(|c| c.1).collect(); + if let Some(n) = tabs(app, ui, p, x0, cy, k, &names, &[], &format!("{prefix}.curve")) { + app.ui.fx_curve_channel.insert(g.uid, n); + } + let (id, name, axis, periodic) = HUESAT[k]; + let cur = OffsetPoints::parse(&str_value(layer, ectx, g, id)); + let size = graph_size(r.width()); + let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size * 0.6)); + // Background: the x axis' colours (hue spectrum, grey ramp) as a band, the neutral line. + p.rect_filled(gr, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c)); + let n = 48; + for i in 0..n { + let f = (i as f32 + 0.5) / n as f32; + let c = match axis { + 0 => hue_color(f), + 1 => Color32::from_gray((f * 255.0) as u8), + _ => Color32::from(egui::ecolor::Hsva::new(0.0, f, 0.85, 1.0)), + }; + let x = gr.min.x + gr.width() * i as f32 / n as f32; + let band = Rect::from_min_max(pos2(x, gr.max.y - 8.0), pos2(x + gr.width() / n as f32 + 0.5, gr.max.y)); + p.rect_filled(band, 0.0, c); + p.rect_filled(Rect::from_min_max(pos2(band.min.x, gr.min.y), pos2(band.max.x, gr.max.y - 8.0)), 0.0, c.gamma_multiply(0.12)); + } + let to_s = |x: f32, y: f32| pos2(gr.min.x + x * gr.width(), gr.max.y - y * gr.height()); + p.line_segment([to_s(0.0, 0.5), to_s(1.0, 0.5)], Stroke::new(1.0, Color32::from_white_alpha(60))); + p.rect_stroke(gr, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + let curve = OffsetCurve::parse(&cur.format(), periodic); + let line: Vec = (0..=96) + .map(|i| { + let x = i as f32 / 96.0; + to_s(x, 0.5 + curve.as_ref().map(|c| c.at(x)).unwrap_or(0.0)) + }) + .collect(); + p.add(egui::Shape::line(line, Stroke::new(1.5, Color32::from_gray(0xe8)))); + let gid = egui::Id::new(("lumetri-hs", euid, k)); + let resp = ui.interact(gr.expand(6.0), gid, Sense::click_and_drag()); + app.auto.add(&format!("{prefix}.graph"), gr, name); + let drag_id = gid.with("drag"); + let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + for (i, q) in cur.0.iter().enumerate() { + let s = to_s(q[0], q[1]); + let hr = Rect::from_center_size(s, vec2(7.0, 7.0)); + p.circle_filled(s, 3.5, if dragging == Some(i) { Color32::WHITE } else { Color32::from_rgb(0x1c, 0x1c, 0x1c) }); + p.circle_stroke(s, 3.5, Stroke::new(1.2, Color32::WHITE)); + app.auto.add(&format!("{prefix}.point.{i}"), hr, &format!("Point {}", i + 1)); + } + let to_g = |s: Pos2| [((s.x - gr.min.x) / gr.width()).clamp(0.0, 1.0), ((gr.max.y - s.y) / gr.height()).clamp(0.0, 1.0)]; + let hit_r = [7.0 / gr.width(), 7.0 / gr.height()]; + let mut changed: Option = None; + let alt = ui.input(|i| i.modifiers.alt); + // One undo step per click or drag. + let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked()); + if resp.drag_started() + && let Some(pt) = ui.input(|i| i.pointer.press_origin()) + { + let q = to_g(pt); + let i = match cur.hit(q[0], q[1], hit_r) { + Some(i) => i, + None => { + let mut np = cur.clone(); + let i = np.insert(q[0], q[1]); + changed = Some(np); + i + } + }; + ui.data_mut(|d| d.insert_temp(drag_id, i)); + } else if resp.dragged() + && let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos()) + { + let q = to_g(pt); + let mut np = cur.clone(); + np.move_point(i, q[0], q[1]); + if np != cur { + changed = Some(np); + } + } + if resp.drag_stopped() { + if let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos()) + && !gr.expand(14.0).contains(pt) + && i < cur.0.len() + { + let mut np = cur.clone(); + np.0.remove(i); + changed = Some(np); + } + ui.data_mut(|d| d.remove::(drag_id)); + } + if resp.clicked() + && let Some(pt) = resp.interact_pointer_pos() + { + let q = to_g(pt); + let mut np = cur.clone(); + match cur.hit(q[0], q[1], hit_r) { + Some(i) if alt => { + np.0.remove(i); + } + Some(_) => {} + None => { + np.insert(q[0], q[1]); + } + } + if np != cur { + changed = Some(np); + } + } + if let Some(np) = changed { + set_str(actions, layer, g, id, np.format(), Some(key)); + } + if reset_link(app, ui, p, Rect::from_min_size(pos2(gr.max.x - 44.0, gr.max.y + 4.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) { + set_str(actions, layer, g, id, String::new(), None); + } + p.text(pos2(gr.min.x, gr.max.y + 13.0), Align2::LEFT_CENTER, "Click adds a point (½ height = no change)", Tokens::ui(10.5), t.text_faint); +} + +// --------------------------------------------------------------------------------------------- +// Colorama output cycle + +fn wheel_size(width: f32) -> f32 { + (width - 90.0).clamp(110.0, 200.0) +} + +/// Colorama's palette as stops: (position, straight RGBA). +pub fn format_palette(stops: &[(f32, [f32; 4])]) -> String { + let f = |v: f32| { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() || s == "-0" { "0".into() } else { s } + }; + stops + .iter() + .map(|(pos, c)| { + let rgb = format!("{},{},{}", f(c[0]), f(c[1]), f(c[2])); + if (c[3] - 1.0).abs() > 1e-4 { format!("{}:{rgb},{}", f(*pos), f(c[3])) } else { format!("{}:{rgb}", f(*pos)) } + }) + .collect::>() + .join(" ") +} + +fn rgba8(c: [f32; 4]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// Phase (0..1, clockwise from the top) of a point around `c`. +fn phase_at(c: Pos2, q: Pos2) -> f32 { + let a = (q.x - c.x).atan2(-(q.y - c.y)); + (a / std::f32::consts::TAU).rem_euclid(1.0) +} + +fn wheel_point(c: Pos2, radius: f32, phase: f32) -> Pos2 { + let a = phase * std::f32::consts::TAU; + pos2(c.x + radius * a.sin(), c.y - radius * a.cos()) +} + +#[allow(clippy::too_many_arguments)] +fn colorama_wheel( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + root: &PropGroup, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = root.uid; + let prefix = format!("effectControls.effect.{euid}.colorama"); + let preset = g.get("usePresetPalette").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0); + let custom = preset as usize == effectcraft_engine::effects::COLORAMA_PRESETS.len() - 1; + let interp = g.get("interpolatePalette").is_none_or(|pr| ectx.value(layer, pr).as_bool()); + let pal = if custom { ColoramaPalette::parse(&str_value(layer, ectx, g, "palette")) } else { None }.unwrap_or_else(|| ColoramaPalette::preset(preset)); + let x0 = r.min.x + 38.0; + let label = if custom { "Custom palette" } else { effectcraft_engine::effects::COLORAMA_PRESETS.get(preset as usize).copied().unwrap_or("Preset") }; + p.text(pos2(x0, r.min.y + 15.0), Align2::LEFT_CENTER, format!("Output Cycle: {label}"), Tokens::ui(11.5), t.text_dim); + let size = wheel_size(r.width()); + let c = pos2(x0 + size / 2.0, r.min.y + 30.0 + size / 2.0); + let (outer, inner) = (size / 2.0 - 8.0, size / 2.0 - 26.0); + // The ring: palette colours around the wheel. + let n = 96; + for i in 0..n { + let (a0, a1) = (i as f32 / n as f32, (i + 1) as f32 / n as f32); + let col = rgba8(pal.eval((a0 + a1) * 0.5, interp)); + let quad = vec![wheel_point(c, inner, a0), wheel_point(c, outer, a0), wheel_point(c, outer, a1 + 0.002), wheel_point(c, inner, a1 + 0.002)]; + p.add(egui::Shape::convex_polygon(quad, col, Stroke::NONE)); + } + p.circle_stroke(c, outer, Stroke::new(1.0, Color32::from_black_alpha(160))); + p.circle_stroke(c, inner, Stroke::new(1.0, Color32::from_black_alpha(160))); + let wid = egui::Id::new(("colorama-wheel", euid)); + let ring = Rect::from_center_size(c, vec2(outer * 2.0 + 40.0, outer * 2.0 + 40.0)); + let resp = ui.interact(ring, wid, Sense::click_and_drag()); + app.auto.add(&format!("{prefix}.wheel"), ring, "Output Cycle"); + let sel_id = wid.with("sel"); + let drag_id = wid.with("drag"); + let mut sel: usize = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0); + if sel >= pal.stops.len() { + sel = 0; + } + let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + // Stops: triangles on the outside of the ring. + let mut stop_rects = vec![]; + for (i, (ph, col)) in pal.stops.iter().enumerate() { + let tip = wheel_point(c, outer + 2.0, *ph); + let a = wheel_point(c, outer + 12.0, *ph - 0.018); + let b = wheel_point(c, outer + 12.0, *ph + 0.018); + let fill = rgba8(*col); + p.add(egui::Shape::convex_polygon( + vec![tip, a, b], + fill, + Stroke::new(if i == sel || dragging == Some(i) { 1.6 } else { 1.0 }, if i == sel { Color32::WHITE } else { Color32::from_gray(0x80) }), + )); + let hr = Rect::from_center_size(wheel_point(c, outer + 8.0, *ph), vec2(14.0, 14.0)); + app.auto.add(&format!("{prefix}.stop.{i}"), hr, &format!("Stop {}", i + 1)); + stop_rects.push(hr); + } + let hit = |q: Pos2| -> Option { + stop_rects + .iter() + .enumerate() + .filter(|(_, hr)| hr.expand(3.0).contains(q)) + .min_by(|a, b| a.1.center().distance(q).total_cmp(&b.1.center().distance(q))) + .map(|(i, _)| i) + }; + let mut new_stops: Option> = None; + let alt = ui.input(|i| i.modifiers.alt); + // One undo step per click or drag (a colour pick is its own step). + let key = gesture_key(ui, wid, resp.drag_started() || resp.clicked() || !resp.dragged()); + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()) + && let Some(i) = hit(q) + { + ui.data_mut(|d| { + d.insert_temp(drag_id, i); + d.insert_temp(sel_id, i); + }); + } else if resp.dragged() + && let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) + && i < pal.stops.len() + { + let mut st = pal.stops.clone(); + st[i].0 = phase_at(c, q); + new_stops = Some(st); + } + if resp.drag_stopped() { + if let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos()) + && q.distance(c) > outer + 40.0 + && pal.stops.len() > 1 + && i < pal.stops.len() + { + let mut st = pal.stops.clone(); + st.remove(i); + new_stops = Some(st); + } + ui.data_mut(|d| d.remove::(drag_id)); + } + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + match hit(q) { + Some(i) if alt && pal.stops.len() > 1 => { + let mut st = pal.stops.clone(); + st.remove(i); + new_stops = Some(st); + } + Some(i) => { + ui.data_mut(|d| d.insert_temp(sel_id, i)); + } + // A click on the ring adds a stop with the colour there (and selects it). + None if (inner - 4.0..=outer + 4.0).contains(&q.distance(c)) => { + let ph = phase_at(c, q); + let mut st = pal.stops.clone(); + st.push((ph, pal.eval(ph, interp))); + st.sort_by(|a, b| a.0.total_cmp(&b.0)); + let k = st.iter().position(|s| s.0 == ph).unwrap_or(0); + ui.data_mut(|d| d.insert_temp(sel_id, k)); + new_stops = Some(st); + } + None => {} + } + } + // The selected stop's colour. + let sr = Rect::from_min_size(pos2(x0, c.y + size / 2.0 + 8.0), vec2(30.0, 16.0)); + if let Some((ph, col)) = pal.stops.get(sel).copied() { + let pop = wid.with("color"); + if widgets::swatch(ui, sr, col, wid.with("swatch"), &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("{prefix}.color"), sr, "Stop color"); + p.text(pos2(sr.max.x + 8.0, sr.center().y), Align2::LEFT_CENTER, format!("Stop {} at {:.0}°", sel + 1, ph * 360.0), Tokens::ui(11.0), t.text_dim); + let mut rgb = [col[0], col[1], col[2]]; + if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + let mut st = pal.stops.clone(); + st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]]; + new_stops = Some(st); + } + } + p.text( + pos2(sr.min.x, sr.max.y + 9.0), + Align2::LEFT_CENTER, + "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + Tokens::ui(10.5), + t.text_faint, + ); + if let Some(mut st) = new_stops { + st.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Editing a preset makes it the custom palette (both in one undo step). + if !custom && let Some(pr) = g.get("usePresetPalette") { + actions.push(( + "prop.set".into(), + json!({"layer": layer.id.0, "prop": pr.uid, "value": effectcraft_engine::effects::COLORAMA_PRESETS.len() - 1, "merge": key}), + )); + } + set_str(actions, layer, g, "palette", format_palette(&st), Some(key)); + } +} + +// --------------------------------------------------------------------------------------------- +// Header editors (under the effect's title) + +/// Height of the editor under `effect`'s header (`g`: the instance), 0 when none. +pub fn header_height(effect: &str, g: &PropGroup, width: f32) -> f32 { + match effect { + GLOW => match g.get("colors").map(|p| p.value.as_enum()) { + Some(2) => 30.0 + graph_size(width) + 40.0, + Some(1) => 34.0, + _ => 0.0, + }, + RESHAPE => 50.0, + _ => 0.0, + } +} + +#[allow(clippy::too_many_arguments)] +pub fn header_editor( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + g: &PropGroup, + effect: &str, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + match effect { + GLOW => glow_map(app, ui, p, layer, g, ectx, r, actions), + RESHAPE => reshape_buttons(app, ui, p, layer, g, ectx, r, actions), + _ => {} + } +} + +/// Glow's Arbitrary Map: "red | green | blue" Curves point lists. +pub fn split_map(s: &str) -> [fw::CurvePoints; 3] { + let mut parts = s.split('|'); + std::array::from_fn(|_| parts.next().map(fw::CurvePoints::parse).unwrap_or_else(fw::CurvePoints::identity)) +} + +pub fn join_map(m: &[fw::CurvePoints; 3]) -> String { + m.iter().map(|c| c.format()).collect::>().join(" | ") +} + +#[allow(clippy::too_many_arguments)] +fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { + let t = app.tokens; + let euid = g.uid; + let prefix = format!("effectControls.effect.{euid}.glow"); + let mode = g.get("colors").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0); + let x0 = r.min.x + 38.0; + let num = |id: &str| g.get(id).map(|pr| ectx.value(layer, pr).as_f64()).unwrap_or(0.0); + let looping = g.get("colorLooping").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(2); + let (loops, phase, mid) = (num("colorLoops") as f32, (num("colorPhase") / 360.0) as f32, (num("abMidpoint") / 100.0) as f32); + let map = split_map(&str_value(layer, ectx, g, "arbitraryMap")); + let color_at = |l: f32| -> Color32 { + let tt = effectcraft_engine::effects::glow_ab_t(l, looping, loops, phase, mid); + if mode == 2 { + Color32::from_rgb( + (map[0].eval(tt).clamp(0.0, 1.0) * 255.0) as u8, + (map[1].eval(tt).clamp(0.0, 1.0) * 255.0) as u8, + (map[2].eval(tt).clamp(0.0, 1.0) * 255.0) as u8, + ) + } else { + let col = |id: &str| match g.get(id).map(|pr| ectx.value(layer, pr)) { + Some(Value::Color(c)) => c, + _ => [0.0; 4], + }; + let (a, b) = (col("colorA"), col("colorB")); + let m = |i: usize| ((a[i] + (b[i] - a[i]) * tt as f64).clamp(0.0, 1.0) * 255.0) as u8; + Color32::from_rgb(m(0), m(1), m(2)) + } + }; + let size = graph_size(r.width()); + let strip_y = if mode == 2 { r.min.y + 30.0 + size + 12.0 } else { r.min.y + 8.0 }; + let strip = Rect::from_min_size(pos2(x0, strip_y), vec2(size, 12.0)); + let n = 64; + for i in 0..n { + let f = (i as f32 + 0.5) / n as f32; + let x = strip.min.x + strip.width() * i as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(x, strip.min.y), pos2(x + strip.width() / n as f32 + 0.5, strip.max.y)), 0.0, color_at(f)); + } + p.rect_stroke(strip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + app.auto.add(&format!("{prefix}.colorMap"), strip, "Glow colour map"); + p.text(pos2(strip.max.x + 8.0, strip.center().y), Align2::LEFT_CENTER, "Colour map", Tokens::ui(10.5), t.text_faint); + if mode != 2 { + return; + } + let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(2); + let cols = [Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)]; + if let Some(n) = tabs(app, ui, p, x0, r.min.y + 15.0, ch, &["Red", "Green", "Blue"], &cols, &format!("{prefix}.channel")) { + app.ui.fx_curve_channel.insert(euid, n); + } + if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, r.min.y + 6.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) { + let mut m = map.clone(); + m[ch] = fw::CurvePoints::identity(); + set_str(actions, layer, g, "arbitraryMap", join_map(&m), None); + } + let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size)); + let gid = egui::Id::new(("glow-map", euid, ch)); + let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, gid, &t); + app.auto.add(&format!("{prefix}.graph"), gr, "Arbitrary Map"); + register_points(app, &prefix, gr, &map[ch].0); + let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked()); + if let Some(np) = edit.changed { + let mut m = map.clone(); + m[ch] = np; + set_str(actions, layer, g, "arbitraryMap", join_map(&m), Some(key)); + } +} + +// --------------------------------------------------------------------------------------------- +// Reshape + +/// Reshape's correspondence pairs (source, destination outline fractions), in stored order. +pub fn parse_pairs(s: &str) -> Vec<(f64, f64)> { + s.split_whitespace() + .filter_map(|tk| { + let (a, b) = tk.split_once(',')?; + Some((a.trim().parse::().ok()?.rem_euclid(1.0), b.trim().parse::().ok()?.rem_euclid(1.0))) + }) + .collect() +} + +pub fn format_pairs(v: &[(f64, f64)]) -> String { + let f = |x: f64| { + let s = format!("{x:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + if s.is_empty() { "0".into() } else { s } + }; + v.iter().map(|(a, b)| format!("{},{}", f(*a), f(*b))).collect::>().join(" ") +} + +/// A new pair in the widest gap between the destination fractions (the source fraction +/// interpolated between its neighbours'), or (0, 0) for the first. +pub fn add_pair(v: &[(f64, f64)]) -> (f64, f64) { + if v.is_empty() { + return (0.0, 0.0); + } + let mut s: Vec<(f64, f64)> = v.to_vec(); + s.sort_by(|a, b| a.1.total_cmp(&b.1)); + let n = s.len(); + let (mut best, mut gap) = (0, -1.0); + for i in 0..n { + let g = (s[(i + 1) % n].1 - s[i].1).rem_euclid(1.0); + let g = if n == 1 { 1.0 } else { g }; + if g > gap { + gap = g; + best = i; + } + } + let (a, b) = (s[best], s[(best + 1) % n]); + let ds = if n == 1 { 1.0 } else { (b.0 - a.0).rem_euclid(1.0) }; + ((a.0 + ds * 0.5).rem_euclid(1.0), (a.1 + gap * 0.5).rem_euclid(1.0)) +} + +#[allow(clippy::too_many_arguments)] +fn reshape_buttons( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + g: &PropGroup, + ectx: &EvalCtx, + r: Rect, + actions: &mut Actions, +) { + let t = app.tokens; + let euid = g.uid; + let pairs = parse_pairs(&str_value(layer, ectx, g, "correspondencePoints")); + let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); + let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); + let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); + if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::new(("reshape-add", euid))).clicked() { + let mut v = pairs.clone(); + v.push(add_pair(&pairs)); + set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); + } + app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); + if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + let mut v = pairs.clone(); + v.pop(); + set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); + } + app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); + if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + set_str(actions, layer, g, "correspondencePoints", String::new(), None); + } + app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); + let status = if pairs.is_empty() { + "Correspondence: automatic (select the effect to edit points on the viewer)".to_string() + } else { + format!("{} correspondence point(s): drag them along the mask outlines in the viewer", pairs.len()) + }; + p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); +} + +/// Arc-length fraction (0..1) of the point of a polyline nearest to `q`. +pub fn nearest_fraction(pts: &[[f64; 2]], closed: bool, q: [f64; 2]) -> f64 { + let n = pts.len(); + if n < 2 { + return 0.0; + } + let total = effectcraft_engine::effects::util::poly_length(pts, closed).max(1e-9); + let segs = if closed { n } else { n - 1 }; + let (mut best, mut best_s, mut acc) = (f64::INFINITY, 0.0, 0.0); + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let l = l2.sqrt(); + let tt = if l2 > 0.0 { (((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let d = (a[0] + dx * tt - q[0]).hypot(a[1] + dy * tt - q[1]); + if d < best { + best = d; + best_s = acc + l * tt; + } + acc += l; + } + (best_s / total).rem_euclid(1.0) +} + +/// The Composition viewer's Reshape controls while the effect is selected: the source (yellow) +/// and destination (cyan) mask outlines and each correspondence pair as a line between its two +/// handles; dragging a handle slides it along its outline (one undo step per drag). +#[allow(clippy::too_many_arguments)] +pub fn reshape_overlay( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + layer: &Layer, + g: &PropGroup, + m: &Mat3, + actions: &mut Actions, +) { + let shapes = effectcraft_engine::render::masks::shapes(ectx, layer); + let mask = |id: &str| -> Option<(Vec<[f64; 2]>, bool)> { + let i = g.get(id).map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0) as usize; + let s = shapes.get(i.checked_sub(1)?)?; + (s.points.len() >= 2).then(|| (s.points.clone(), s.closed)) + }; + let (Some(src), Some(dst)) = (mask("sourceMask"), mask("destinationMask")) else { return }; + let to_screen = |q: [f64; 2]| map.to_screen(fw::layer_to_comp(m, q)); + let outline = |pts: &[[f64; 2]], closed: bool, col: Color32| { + let mut v: Vec = pts.iter().map(|q| to_screen(*q)).collect(); + if closed && let Some(f) = v.first().copied() { + v.push(f); + } + painter.add(egui::Shape::dashed_line(&v, Stroke::new(1.0, col), 5.0, 3.0)); + }; + let (ys, cs) = (Color32::from_rgb(0xff, 0xd8, 0x40), Color32::from_rgb(0x40, 0xd8, 0xff)); + outline(&src.0, src.1, ys); + outline(&dst.0, dst.1, cs); + let at = |pts: &[[f64; 2]], closed: bool, f: f64| { + let len = effectcraft_engine::effects::util::poly_length(pts, closed); + effectcraft_engine::effects::util::poly_point_at(pts, closed, f * len).0 + }; + let raw = match g.get("correspondencePoints").map(|pr| ectx.value(layer, pr)) { + Some(Value::Str(s)) => s, + _ => String::new(), + }; + let pairs = parse_pairs(&raw); + let ctx = ui.ctx().clone(); + for (i, (fs, fd)) in pairs.iter().enumerate() { + let (ps, pd) = (to_screen(at(&src.0, src.1, *fs)), to_screen(at(&dst.0, dst.1, *fd))); + painter.line_segment([ps, pd], Stroke::new(1.0, Color32::from_white_alpha(150))); + for (end, s, col) in [(0usize, ps, ys), (1, pd, cs)] { + let hr = Rect::from_center_size(s, vec2(12.0, 12.0)); + let id = egui::Id::new(("reshape-pt", g.uid, i, end)); + let resp = ui.interact(hr, id, Sense::drag()); + let hot = resp.hovered() || resp.dragged(); + painter.rect_filled(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, if hot { Color32::WHITE } else { col }); + painter.rect_stroke(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside); + let which = if end == 0 { "source" } else { "dest" }; + app.auto.add(&format!("viewer.reshape.{}.{which}.{i}", g.uid), hr, &format!("Correspondence point {} ({which})", i + 1)); + if hot { + ctx.set_cursor_icon(egui::CursorIcon::Move); + } + if resp.dragged() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(lp) = fw::comp_to_layer(m, map.to_comp(pos)) + { + let mut v = pairs.clone(); + if end == 0 { + v[i].0 = nearest_fraction(&src.0, src.1, lp); + } else { + v[i].1 = nearest_fraction(&dst.0, dst.1, lp); + } + let key = gesture_key(ui, id, resp.drag_started()); + if let Some(pr) = g.get("correspondencePoints") { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": format_pairs(&v), "merge": key}))); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn offset_points_and_palettes_round_trip() { + let p = OffsetPoints::parse("0.5,0.75 0.1,0.5"); + assert_eq!(p.0, vec![[0.1, 0.5], [0.5, 0.75]]); + assert_eq!(OffsetPoints::parse(&p.format()), p); + assert!(OffsetPoints::parse("").0.is_empty()); + let stops = vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 0.25, 1.0, 0.5])]; + let s = format_palette(&stops); + assert_eq!(s, "0:1,0,0 0.5:0,0.25,1,0.5"); + assert_eq!(ColoramaPalette::parse(&s).unwrap().stops, stops); + let m = split_map("0,0 1,0.5 | | 0,1 1,0"); + assert_eq!(m[1], fw::CurvePoints::identity()); + assert_eq!(split_map(&join_map(&m)), m); + } + + #[test] + fn reshape_pairs_and_outline_fractions() { + assert_eq!(parse_pairs("0.25,0 1.5,0.5"), vec![(0.25, 0.0), (0.5, 0.5)]); + assert_eq!(format_pairs(&[(0.25, 0.0), (0.5, 0.125)]), "0.25,0 0.5,0.125"); + assert_eq!(add_pair(&[]), (0.0, 0.0)); + assert_eq!(add_pair(&[(0.1, 0.0)]), (0.6, 0.5)); + let (s, d) = add_pair(&[(0.0, 0.0), (0.2, 0.25)]); + assert!((d - 0.625).abs() < 1e-9 && (s - 0.6).abs() < 1e-9, "{s} {d}"); + // A 10×10 square (closed): the point nearest (10, 5) is 3/8 of the way round. + let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + assert!((nearest_fraction(&sq, true, [12.0, 5.0]) - 0.375).abs() < 1e-9); + assert!(nearest_fraction(&sq, true, [-1.0, -1.0]).abs() < 1e-9); + } +} diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index 4f6fd0ae3..8cc14dccb 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -65,13 +65,13 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ctx = ui.ctx().clone(); let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.panel_bg); - if !mb::available() { - p.text(rect.center(), Align2::CENTER_CENTER, "The Media Browser needs the desktop app (use File ▸ Import here)", Tokens::ui(12.0), t.text_faint); + let Some(b) = app.session.media_browser() else { + p.text(rect.center(), Align2::CENTER_CENTER, "The Media Browser is not available here (use File ▸ Import)", Tokens::ui(12.0), t.text_faint); app.auto.add("mediaBrowser.unavailable", rect, "Media Browser unavailable"); return; - } + }; let st = app.session.state.media_browser.clone(); - let dir = st.folder.clone().unwrap_or_else(mb::home); + let dir = st.folder.clone().unwrap_or_else(|| b.home()); // Listing cached per folder for a second. let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only)); let now = ctx.input(|i| i.time); @@ -79,7 +79,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let entries = match listing { Some((at, l)) if now - at < 1.0 => l, _ => { - let l = mb::list(&dir, st.importable_only); + let l = b.list(&dir, st.importable_only); ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone()))); l } @@ -104,6 +104,20 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if only != st.importable_only { kit::exec(app, "mediaBrowser.go", json!({"path": dir, "importableOnly": only})); } + // The browser's own actions (web: Open Folder…, Add Files…), right-aligned. + let mut ax = rect.max.x - 8.0; + for (id, label) in b.actions().iter().rev() { + let w = 14.0 + label.chars().count() as f32 * 6.4; + ax -= w; + if ax < x + 96.0 { + break; + } + if kit::button(app, ui, Rect::from_min_size(pos2(ax, y), vec2(w, 20.0)), &format!("mediaBrowser.action.{id}"), label, false) { + kit::exec(app, "mediaBrowser.action", json!({"action": id})); + ctx.data_mut(|d| d.remove::<(f64, Result, String>)>(lid)); + } + ax -= 6.0; + } // The path on its own row. let _ = x; let py = y + 24.0; @@ -114,7 +128,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator)); p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, "Favorites", Tokens::semibold(11.5), t.text_dim); let mut fy = side.min.y + 22.0; - let mut places: Vec<(String, String)> = vec![("Home".into(), mb::home())]; + let mut places: Vec<(String, String)> = b.places(); places.extend( st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())), ); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 7b750be7a..6c9bac94d 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -21,7 +21,16 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); let p = ui.painter().with_clip_rect(rect); - let y = rect.min.y + 12.0; + // The panel scrolls when it is shorter than its controls (the default workspace shows just + // the transport row, as in After Effects). + let scroll_id = egui::Id::new("preview-scroll"); + let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); + if ui.rect_contains_pointer(rect) { + scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0); + } + let panel_clip = ui.clip_rect(); + ui.set_clip_rect(rect.intersect(panel_clip)); + let y = rect.min.y + 12.0 - scroll; let bw = 30.0; let total = bw * 5.0 + 16.0; let mut x = rect.center().x - total / 2.0; @@ -223,6 +232,18 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" }; p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint); } + ui.set_clip_rect(panel_clip); + let content = yy + 30.0 - (rect.min.y - scroll); + let max = (content - rect.height()).max(0.0); + scroll = scroll.min(max); + ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); + if max > 0.0 { + // A thin scroll indicator on the right edge. + let h = (rect.height() * rect.height() / content).max(12.0); + let top = rect.min.y + (rect.height() - h) * (scroll / max); + p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, top), vec2(3.0, h)), 1.5, t.text_faint.gamma_multiply(0.6)); + app.auto.add("preview.scroll", Rect::from_min_size(pos2(rect.max.x - 6.0, rect.min.y), vec2(6.0, rect.height())), "Preview scroll"); + } } fn fmt_rate(r: f64) -> String { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d82c6d972..d43b4e793 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -14,6 +14,7 @@ pub mod expr_editor; pub mod flowchart; pub mod footage_panel; pub mod forms; +pub mod fx_editors; pub mod fx_widgets; pub mod graph; pub mod graph_tools; @@ -28,6 +29,7 @@ pub mod media_panels; pub mod misc; pub mod paint_panels; pub mod panel_kit; +pub mod path_vr_panels; pub mod precomp; pub mod project; pub mod properties; @@ -117,6 +119,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Metadata => media_panels::metadata(app, ui, rect), PanelKind::Progress => media_panels::progress(app, ui, rect), PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect), + PanelKind::CreateNullsFromPaths => path_vr_panels::create_nulls(app, ui, rect), + PanelKind::VrCompEditor => path_vr_panels::vr_editor(app, ui, rect), } } diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs new file mode 100644 index 000000000..1fc5b808a --- /dev/null +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -0,0 +1,123 @@ +//! Two built-in panels After Effects ships as scripts, written from scratch as panels: +//! +//! - **Create Nulls From Paths** (Window ▸ Create Nulls From Paths): with a mask or shape path (or +//! a layer that has one) selected, Points Follow Nulls / Nulls Follow Points / Trace Path +//! (`paths.*` commands; Trace Path can loop). +//! - **VR Comp Editor** (Window ▸ VR Comp Editor): the project's VR environments (Composition ▸ VR +//! ▸ Create VR Environment); pick one and edit its 360 view's camera orientation (pan, tilt, +//! roll) with `comp.vr.setView`; open its output. +//! +//! Automation ids: `createNulls.pointsFollowNulls`, `createNulls.nullsFollowPoints`, +//! `createNulls.tracePath`, `createNulls.loop`, `vrEditor.env.`, `vrEditor.pan`, +//! `vrEditor.tilt`, `vrEditor.roll`, `vrEditor.reset`, `vrEditor.open`. + +use egui::{Rect, RichText}; +use serde_json::{Value, json}; + +use crate::EffectcraftApp; + +fn run(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, Value)>) { + for (id, p) in actions { + match crate::menus::invoke(app, ctx, id, p) { + Ok(_) => {} + Err(e) => app.ui.status = e, + } + } +} + +fn area(ui: &mut egui::Ui, rect: Rect) -> egui::Ui { + ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min))) +} + +/// Window ▸ Create Nulls From Paths. +pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut ui = area(ui, rect); + let loop_id = egui::Id::new("createNulls.loop"); + let mut looping: bool = ui.data(|d| d.get_temp(loop_id)).unwrap_or(false); + let ready = app.session.is_enabled("paths.nullsFollowPoints") && !app.session.state.selected_layers.is_empty(); + ui.label(RichText::new("Select a layer with a mask or shape path (or the path property).").color(t.text_dim).small()); + ui.add_space(6.0); + let mut acts = vec![]; + for (id, label, tip) in [ + ("paths.pointsFollowNulls", "Points Follow Nulls", "A null on each vertex; move the nulls to reshape the path"), + ("paths.nullsFollowPoints", "Nulls Follow Points", "A null on each vertex that follows the (animated) path"), + ("paths.tracePath", "Trace Path", "A null moving along the path, driven by its Progress slider"), + ] { + let r = ui.add_enabled(ready, egui::Button::new(label).min_size(egui::vec2(170.0, 24.0))).on_hover_text(tip); + let auto = format!("createNulls.{}", id.trim_start_matches("paths.")); + app.auto.add(&auto, r.rect, label); + if r.clicked() { + acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) })); + } + } + let r = ui.checkbox(&mut looping, "Loop (Trace Path)"); + app.auto.add("createNulls.loop", r.rect, "Loop"); + ui.data_mut(|d| d.insert_temp(loop_id, looping)); + run(app, &ctx, acts); +} + +/// Window ▸ VR Comp Editor. +pub fn vr_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut ui = area(ui, rect); + let envs = app.session.execute("comp.vr.environments", json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); + if envs.is_empty() { + ui.label(RichText::new("No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…").color(t.text_faint)); + return; + } + let sel_id = egui::Id::new("vrEditor.selected"); + let active = app.session.state.active_comp.map(|c| c.0); + let mut sel: u64 = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0); + // Default: the active comp's environment, else the first. + let owns = |e: &Value, c: u64| e["output"] == c || e["cubeMap"] == c || e["faces"].as_array().is_some_and(|f| f.iter().any(|x| x["comp"] == c)); + if !envs.iter().any(|e| e["output"] == sel) { + sel = active.and_then(|c| envs.iter().find(|e| owns(e, c))).or(envs.first()).and_then(|e| e["output"].as_u64()).unwrap_or(0); + } + let mut acts = vec![]; + ui.label(RichText::new("VR Environments").strong()); + for e in &envs { + let out = e["output"].as_u64().unwrap_or(0); + let r = ui.selectable_label(out == sel, e["name"].as_str().unwrap_or("")); + app.auto.add(&format!("vrEditor.env.{out}"), r.rect, e["name"].as_str().unwrap_or("")); + if r.clicked() { + sel = out; + } + } + ui.data_mut(|d| d.insert_temp(sel_id, sel)); + let Some(env) = envs.iter().find(|e| e["output"] == sel) else { return }; + ui.separator(); + ui.label(RichText::new("360 View Orientation").strong()); + let view: Vec = env["view"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let signed = |a: f64| if a > 180.0 { a - 360.0 } else { a }; + let (mut tilt, mut pan, mut roll) = + (signed(view.first().copied().unwrap_or(0.0)), signed(view.get(1).copied().unwrap_or(0.0)), signed(view.get(2).copied().unwrap_or(0.0))); + let mut changed = false; + for (label, auto, v) in [("Pan", "vrEditor.pan", &mut pan), ("Tilt", "vrEditor.tilt", &mut tilt), ("Roll", "vrEditor.roll", &mut roll)] { + ui.horizontal(|ui| { + ui.label(label); + let r = ui.add(egui::Slider::new(v, -180.0..=180.0).suffix("°").max_decimals(1)); + app.auto.add(auto, r.rect, label); + changed |= r.changed(); + }); + } + if changed { + acts.push(("comp.vr.setView", json!({"comp": sel, "pan": pan, "tilt": tilt, "roll": roll}))); + } + ui.horizontal(|ui| { + let r = ui.button("Reset"); + app.auto.add("vrEditor.reset", r.rect, "Reset"); + if r.clicked() { + acts.push(("comp.vr.setView", json!({"comp": sel, "orientation": [0.0, 0.0, 0.0]}))); + } + let r = ui.button("Open Output"); + app.auto.add("vrEditor.open", r.rect, "Open Output"); + if r.clicked() { + acts.push(("comp.open", json!({"comp": sel}))); + } + }); + ui.label(RichText::new("All six face cameras turn together; the scene is not changed.").color(t.text_faint).small()); + run(app, &ctx, acts); +} diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 580e287b4..ddf3d35ef 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -3,9 +3,10 @@ //! computed; what the user does goes back through the `scriptui.click` / `scriptui.set` / //! `scriptui.close` commands, which run the script's handlers. Every control registers an //! automation id `scriptui..` (and `scriptui..` when the script -//! named it). +//! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and +//! sliders send live `changing` updates (onChanging) while typing / dragging. -use effectcraft_engine::scriptui::{ScriptWindow, Widget, WidgetKind, WindowKind, layout}; +use effectcraft_engine::scriptui::{DrawOp, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout}; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2}; use serde_json::{Value, json}; @@ -16,6 +17,8 @@ use crate::theme::Tokens; enum Act { Click(u32, u32), Set(u32, u32, Value), + /// A live update (each keystroke / slider step): fires onChanging. + Changing(u32, u32, Value), Close(u32), } @@ -78,6 +81,27 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, register(app, win, w, rect); let id = egui::Id::new(("sui", win, w.id)); let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) }; + // onDraw: the script paints the control. Without drawOSControl() its paint replaces the + // default look (buttons and toggles still respond to clicks); with it, it goes on top. + let custom = w.handlers.iter().any(|h| h == "onDraw"); + let os = w.draw.iter().any(|d| matches!(d, DrawOp::Os)); + if custom && !os { + if w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + draw_children(app, ui, origin, win, w, acts); + return; + } + let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton); + let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() }); + paint_draw(ui, rect, &w.draw, &t); + if tip(r).clicked() && clickable { + acts.push(Act::Click(win, w.id)); + } + return; + } + if custom && w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + } match w.kind { WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts), WidgetKind::Panel => { @@ -112,8 +136,16 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, let te = te.id(id).interactive(!w.read_only).font(Tokens::ui(12.0)); let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, te)).inner; let r = tip(r); - if r.lost_focus() && buf != w.text { + // Live updates already sent the text: the commit still runs onChange. + let dirty_id = id.with("dirty"); + let dirty = ui.data(|d| d.get_temp::(dirty_id)).unwrap_or(false); + if r.lost_focus() && (buf != w.text || dirty) { acts.push(Act::Set(win, w.id, json!(buf))); + ui.data_mut(|d| d.remove::(dirty_id)); + } else if r.changed() && w.handlers.iter().any(|h| h == "onChanging") { + // Each keystroke reaches the script's onChanging (onChange runs on commit). + acts.push(Act::Changing(win, w.id, json!(buf))); + ui.data_mut(|d| d.insert_temp(dirty_id, true)); } ui.data_mut(|d| d.insert_temp(id, buf)); } @@ -145,6 +177,10 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, .inner; let r = tip(r); if r.dragged() { + // Live: each step of the drag reaches the script (onChanging). + if r.changed() { + acts.push(Act::Changing(win, w.id, json!(v))); + } ui.data_mut(|d| d.insert_temp(id, v)); } else { ui.data_mut(|d| d.remove::(id)); @@ -203,6 +239,45 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } } + if custom && !w.kind.is_container() { + paint_draw(ui, rect, &w.draw, &t); + } +} + +/// Paint an onDraw draw list in `rect` (control coordinates → screen). +fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { + let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + let col = |c: &Option<[f32; 4]>, theme: Color32| match c { + Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), + None => theme, + }; + let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32); + for op in ops { + match op { + DrawOp::Fill { color, path } => { + let c = col(color, t.field_bg); + for (pts, _) in PathSeg::polylines(path, 48) { + let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); + p.add(egui::Shape::convex_polygon(pts, c, Stroke::NONE)); + } + } + DrawOp::Stroke { color, width, path } => { + let s = Stroke::new(*width as f32, col(color, t.text)); + for (pts, closed) in PathSeg::polylines(path, 48) { + let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); + p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) }); + } + } + DrawOp::Text { text, color, x, y, size, .. } => { + p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text)); + } + DrawOp::Image { x, y, w, h } => { + let r = Rect::from_min_size(at(*x, *y), vec2(*w as f32, *h as f32)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + DrawOp::Os => {} + } + } } /// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. @@ -216,6 +291,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { let (cmd, p) = match a { Act::Click(w, id) => ("scriptui.click", json!({"window": w, "widget": id})), Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), + Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), }; match crate::menus::invoke(app, ctx, cmd, p) { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 4b79dc05a..782cb5994 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -256,6 +256,29 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(cr.max.x + 6.0, y + 7.0), Align2::LEFT_CENTER, label, Tokens::ui(11.0), t.text_dim); } } + // Variable Font Axes: one value per axis of a variable font (`character.axis.`). + let face = effectcraft_engine::text::resolve(&doc.font, &doc.style).face; + let axes = effectcraft_engine::text::variable::font_axes(face); + if !axes.is_empty() { + y += 26.0; + p.text(pos2(x0, y + 7.0), Align2::LEFT_CENTER, "Variable Font Axes", Tokens::semibold(11.0), t.text_dim); + for (i, a) in axes.iter().enumerate() { + let (col, row) = (i % 2, i / 2); + let fx = x0 + col as f32 * (w / 2.0); + let fy = y + 20.0 + row as f32 * 24.0; + let tag = a.tag.trim_end().to_string(); + let v = doc.variations.iter().find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default); + p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim); + let (r, nv, _) = + widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::new(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t); + app.auto.add(&format!("character.axis.{tag}"), r, &a.name); + if let Some(nv) = nv + && enabled + { + actions.push(json!({"variations": {tag.clone(): nv}, "merge": format!("char-axis-{tag}")})); + } + } + } match &target { None => { p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4b657f527..574345262 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -533,6 +533,10 @@ pub struct UiState { /// The panel maximized to fill the dock area (`~`), if any. #[serde(default)] pub maximized: Option, + /// Locked Composition / Timeline tabs (panel ids): opening another composition does not + /// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock). + #[serde(default)] + pub locked_tabs: BTreeSet, pub focused: PanelKind, pub viewer: ViewerState, pub timeline: TimelineState, @@ -634,6 +638,7 @@ impl Default for UiState { floating: vec![], saved_floating: Default::default(), maximized: None, + locked_tabs: BTreeSet::new(), focused: PanelKind::Composition, viewer: ViewerState::default(), timeline: TimelineState::default(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9703d6ba6..184bf9ec3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -154,8 +154,27 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) - let resp = ui.interact(rect, id, Sense::click()); ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text); + // Text that doesn't fit before the arrow is elided (narrow panels). + let room = rect.width() - 7.0 - 18.0; + let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), Tokens::ui(11.5), t.text).size().x <= room; + let shown = if fits(text) { + text.to_string() + } else { + let chars: Vec = text.chars().collect(); + let mut n = chars.len(); + loop { + n = n.saturating_sub(1); + let s: String = chars[..n].iter().collect::().trim_end().to_string() + "…"; + if n == 0 || fits(&s) { + break s; + } + } + }; + ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim); + if text.chars().count() > 0 && !fits(text) { + return resp.on_hover_text(text.to_string()); + } resp } diff --git a/crates/ui-egui/tests/ui_fx_editors.rs b/crates/ui-egui/tests/ui_fx_editors.rs new file mode 100644 index 000000000..331df8790 --- /dev/null +++ b/crates/ui-egui/tests/ui_fx_editors.rs @@ -0,0 +1,269 @@ +//! Headless checks (egui_kittest) of the visual editors for hidden / text effect parameters +//! (M13.10): Lumetri's RGB and hue/saturation curves, Colorama's output cycle wheel, Glow's +//! colour map and Reshape's correspondence points (Effect Controls buttons and viewer handles). +//! Each edit goes through `prop.set` and undoes in one step. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; +use effectcraft_engine::project::{LayerId, Node, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness(s: Session) -> Harness<'static, EffectcraftApp> { + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.toggle_maximize(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1400.0)).build_eframe(|_| app); + h.run_steps(4); + h +} + +/// A comp with one 320×180 solid carrying `effect`; returns the session, layer and effect uid. +fn with_effect(effect: &str) -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Fx", "width": 320, "height": 180, "frameRate": 24, "duration": 4})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "S", "color": "#6080a0"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [l], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(l)]; + (s, l, fx) +} + +fn group(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64) -> PropGroup { + h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().clone() +} + +/// A parameter's value by its spec-id path inside the effect (`curves/rgbCurves/master`). +fn value(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64, path: &str) -> KV { + let g = group(h, layer, fx); + let mut cur = &g; + let parts: Vec<&str> = path.split('/').collect(); + for p in &parts[..parts.len() - 1] { + cur = cur.sub(p).unwrap_or_else(|| panic!("no group {p}")); + } + cur.get(parts[parts.len() - 1]).unwrap().value.clone() +} + +fn s(v: KV) -> String { + match v { + KV::Str(s) => s, + other => panic!("{other:?}"), + } +} + +/// Close every parameter group of the effect except those named (and their parents). +fn only_groups(h: &mut Harness<'_, EffectcraftApp>, layer: u64, fx: u64, keep: &[&str]) { + fn walk(g: &PropGroup, keep: &[&str], out: &mut Vec) { + for c in &g.children { + if let Node::Group(sg) = c { + if !keep.contains(&sg.match_id.as_str()) { + out.push(sg.uid); + } + walk(sg, keep, out); + } + } + } + let g = group(h, layer, fx); + let mut closed = vec![]; + walk(&g, keep, &mut closed); + h.state_mut().ui.fx_closed.extend(closed); + h.run_steps(3); +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { + let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| { + let near: Vec = h + .state() + .auto + .elements + .iter() + .map(|e| e.id.clone()) + .filter(|i| i.starts_with("effectControls.effect") || i.starts_with("viewer.reshape")) + .collect(); + panic!("no element {id}; have {near:?}") + }); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) +} + +fn at(r: egui::Rect, fx: f32, fy: f32) -> Pos2 { + pos2(r.min.x + r.width() * fx, r.min.y + r.height() * fy) +} + +fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) { + h.event(Event::PointerMoved(p)); + h.step(); + // (all in one frame: the harness' frame time is longer than a double-click's) + for _ in 0..n { + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + h.step(); + h.run_steps(2); +} + +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + click_n(h, p, 1); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=6 { + let t = k as f32 / 6.0; + h.event(Event::PointerMoved(from + (to - from) * t)); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +fn undo(h: &mut Harness<'_, EffectcraftApp>) { + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); +} + +#[test] +fn lumetri_rgb_and_hue_saturation_curve_editors() { + let (s0, l, fx) = with_effect("Lumetri Color"); + let mut h = harness(s0); + only_groups(&mut h, l, fx, &["curves", "rgbCurves", "hueSaturationCurves"]); + let pre = format!("effectControls.effect.{fx}.lumetri.rgbCurves"); + for k in 0..4 { + rect(&h, &format!("{pre}.channel.{k}")); + } + rect(&h, &format!("{pre}.point.0")); + // Red channel: a click in the graph adds a point to the red curve only. + click_id(&mut h, &format!("{pre}.channel.1")); + let gr = rect(&h, &format!("{pre}.graph")); + click_n(&mut h, at(gr, 0.5, 0.25), 1); + let red = s(value(&h, l, fx, "curves/rgbCurves/red")); + assert_eq!(red.split_whitespace().count(), 3, "{red}"); + assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/master")), "0,0 1,1"); + rect(&h, &format!("{pre}.point.2")); + undo(&mut h); + assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/red")), "0,0 1,1"); + // Hue vs Hue: pick the tab, add a point, drag it: one undo step for the drag. + let hs = format!("effectControls.effect.{fx}.lumetri.hueSatCurves"); + click_id(&mut h, &format!("{hs}.curve.1")); + let gr = rect(&h, &format!("{hs}.graph")); + click_n(&mut h, at(gr, 0.3, 0.5), 1); + let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")); + assert_eq!(v.split_whitespace().count(), 1, "{v}"); + assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsSat")), ""); + let p0 = rect(&h, &format!("{hs}.point.0")).center(); + let undo_len = h.state().session.history.undo.len(); + drag(&mut h, p0, p0 + egui::vec2(0.0, -gr.height() * 0.25)); + let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")); + let y: f32 = v.split(',').nth(1).unwrap().parse().unwrap(); + assert!(y > 0.7, "{v}"); + assert_eq!(h.state().session.history.undo.len(), undo_len + 1, "one undo step per drag"); + // Reset empties the curve. + click_id(&mut h, &format!("{hs}.reset")); + assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")), ""); +} + +#[test] +fn colorama_output_cycle_wheel() { + let (s0, l, fx) = with_effect("Colorama"); + let mut h = harness(s0); + only_groups(&mut h, l, fx, &["outputCycle"]); + let pre = format!("effectControls.effect.{fx}.colorama"); + // The Hue Cycle preset has six stops. + for i in 0..6 { + rect(&h, &format!("{pre}.stop.{i}")); + } + // Clicking the ring adds a stop: the palette becomes Custom with seven stops. + let w = rect(&h, &format!("{pre}.wheel")); + let mid = w.width() / 2.0 - 20.0 - 9.0; + let ring = pos2(w.center().x + mid * std::f32::consts::FRAC_1_SQRT_2, w.center().y + mid * std::f32::consts::FRAC_1_SQRT_2); + click_n(&mut h, ring, 1); + assert_eq!(value(&h, l, fx, "outputCycle/usePresetPalette"), KV::Enum(8)); + let pal = s(value(&h, l, fx, "outputCycle/palette")); + assert_eq!(pal.split_whitespace().count(), 7, "{pal}"); + rect(&h, &format!("{pre}.stop.6")); + // Dragging a stop moves its position. + let st = rect(&h, &format!("{pre}.stop.0")).center(); + drag(&mut h, st, pos2(w.max.x - 6.0, w.center().y)); + let pal2 = s(value(&h, l, fx, "outputCycle/palette")); + assert_ne!(pal, pal2); + assert!(pal2.split_whitespace().any(|t| t.starts_with("0.25") || t.starts_with("0.24") || t.starts_with("0.26")), "{pal2}"); + rect(&h, &format!("{pre}.color")); +} + +#[test] +fn glow_arbitrary_map_editor() { + let (mut s0, l, fx) = with_effect("Glow"); + let colors = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().get("colors").unwrap().uid; + s0.execute("prop.set", json!({"layer": l, "prop": colors, "value": 1})).unwrap(); + let mut h = harness(s0); + let pre = format!("effectControls.effect.{fx}.glow"); + rect(&h, &format!("{pre}.colorMap")); + assert!(!h.state().auto.elements.iter().any(|e| e.id == format!("{pre}.graph")), "A & B: just the strip"); + h.state_mut().session.execute("prop.set", json!({"layer": l, "prop": colors, "value": 2})).unwrap(); + h.run_steps(3); + click_id(&mut h, &format!("{pre}.channel.2")); + let gr = rect(&h, &format!("{pre}.graph")); + click_n(&mut h, at(gr, 0.5, 0.9), 1); + let m = s(value(&h, l, fx, "arbitraryMap")); + let parts: Vec<&str> = m.split('|').map(str::trim).collect(); + assert_eq!(parts.len(), 3, "{m}"); + assert_eq!(parts[0], "0,0 1,1"); + assert_eq!(parts[2].split_whitespace().count(), 3, "{m}"); +} + +#[test] +fn reshape_correspondence_points_on_the_viewer() { + let (mut s0, l, fx) = with_effect("Reshape"); + let sq = |x0: f64, y0: f64, d: f64| json!([[x0, y0], [x0 + d, y0], [x0 + d, y0 + d], [x0, y0 + d]]); + s0.execute("mask.new", json!({"layer": l, "vertices": sq(60.0, 40.0, 60.0), "closed": true})).unwrap(); + s0.execute("mask.new", json!({"layer": l, "vertices": sq(180.0, 60.0, 80.0), "closed": true})).unwrap(); + let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("sourceMask").unwrap().uid, "value": 1})).unwrap(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("destinationMask").unwrap().uid, "value": 2})).unwrap(); + let mut h = harness(s0); + let pre = format!("effectControls.effect.{fx}.reshape"); + click_id(&mut h, &format!("{pre}.add")); + assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0"); + click_id(&mut h, &format!("{pre}.add")); + assert_eq!(s(value(&h, l, fx, "correspondencePoints")).split_whitespace().count(), 2); + click_id(&mut h, &format!("{pre}.remove")); + assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0"); + // With the effect selected, the viewer shows the handles: drag the destination one along + // its outline. + h.state_mut().toggle_maximize(PanelKind::EffectControls); + h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)]; + for _ in 0..6 { + h.step(); + } + let d0 = rect(&h, &format!("viewer.reshape.{fx}.dest.0")); + rect(&h, &format!("viewer.reshape.{fx}.source.0")); + let undo_len = h.state().session.history.undo.len(); + drag(&mut h, d0.center(), d0.center() + egui::vec2(60.0, 0.0)); + let v = s(value(&h, l, fx, "correspondencePoints")); + let d: f64 = v.split(',').nth(1).unwrap().parse().unwrap(); + assert!(d > 0.02 && d < 0.3, "moved along the top edge: {v}"); + assert_eq!(h.state().session.history.undo.len(), undo_len + 1); +} + +/// UI fixes of M13.10: the Composition / Timeline tabs' close button, swatch and lock; the +/// Preview panel scrolls in the default workspace (where it shows just the transport). +#[test] +fn composition_tabs_and_scrolling_preview() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + rect(&h, "panel.tab.Timeline.close"); + rect(&h, "preview.scroll"); + click_id(&mut h, "panel.tab.Composition.lock"); + assert!(h.state().ui.locked_tabs.contains("Composition")); + click_id(&mut h, "panel.tab.Composition.lock"); + assert!(h.state().ui.locked_tabs.is_empty()); + click_id(&mut h, "panel.tab.Timeline.close"); + assert!(!h.state().ui.dock.contains(PanelKind::Timeline)); +} diff --git a/crates/ui-egui/tests/ui_web_offload.rs b/crates/ui-egui/tests/ui_web_offload.rs new file mode 100644 index 000000000..54ae1a373 --- /dev/null +++ b/crates/ui-egui/tests/ui_web_offload.rs @@ -0,0 +1,204 @@ +//! Headless checks for M13.10 (web app depth), run natively with stand-ins for the browser's +//! workers: +//! +//! - viewer frames go to a [`RemoteFrames`] renderer (the browser's frame worker, here an +//! in-process [`FrameServer`] fed through JSON with project diffs) instead of rendering on the +//! UI thread, the viewer's frame first, and come back identical; +//! - a control-channel `wait: true` command whose job is offloaded replies only when the job +//! ends, while the UI keeps running. + +use std::sync::mpsc::TryRecvError; +use std::sync::{Arc, Mutex}; + +use effectcraft_engine::Session; +use effectcraft_engine::offload::{Inbox, Offload, Post, WorkerRequest}; +use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror}; +use effectcraft_engine::render::RenderOpts; +use effectcraft_ui_egui::frames::{FrameImage, FrameKey, Frames, RemoteDone, RemoteFrame, RemoteFrames, RemoteJob, RenderSource}; +use effectcraft_ui_egui::{ControlRequest, EffectcraftApp}; +use egui_kittest::Harness; +use serde_json::json; + +/// A frame worker in the same process: messages go through JSON like `postMessage`; rendered +/// frames are held until [`FakeWorker::deliver`] (a busy worker) or dropped by +/// [`FakeWorker::lose`]. +struct FakeWorker { + slots: usize, + state: Mutex<(Mirror, FrameServer, Vec<(RemoteDone, RemoteFrame)>, u64)>, + /// Patch / full-project syncs sent. + syncs: Mutex>, +} + +impl FakeWorker { + fn new(s: &Session, slots: usize) -> FakeWorker { + FakeWorker { slots, state: Mutex::new((Mirror::default(), FrameServer::new(s.footage.clone(), s.expr.clone()), vec![], 0)), syncs: Mutex::default() } + } + fn pending(&self) -> usize { + self.state.lock().unwrap().2.len() + } + fn take(&self) -> Vec<(RemoteDone, RemoteFrame)> { + std::mem::take(&mut self.state.lock().unwrap().2) + } + /// Post every rendered frame back. + fn deliver(&self) { + for (done, f) in self.take() { + done(Ok(f)); + } + } + /// The worker died: its frames never arrive. + fn lose(&self) { + for (done, _) in self.take() { + done(Err("worker lost".into())); + } + } +} + +fn rt serde::Deserialize<'de>>(v: &T) -> T { + serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap() +} + +impl RemoteFrames for FakeWorker { + fn slots(&self) -> usize { + self.slots + } + fn start(&self, job: RemoteJob, done: RemoteDone) { + let mut st = self.state.lock().unwrap(); + let (mirror, server, pending, next) = &mut *st; + if let Some(m) = mirror.sync(job.revision, &job.project) { + self.syncs.lock().unwrap().push(if matches!(m, FrameMsg::Patch { .. }) { "patch" } else { "project" }); + assert!(server.handle(rt(&m)).is_empty()); + } + *next += 1; + let out = server.handle(rt(&FrameMsg::Render { id: *next, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts) })); + match out.into_iter().next() { + Some((FrameReply::Frame { width, height, ms, .. }, Some(rgba))) => pending.push((done, RemoteFrame { width, height, rgba, ms })), + other => done(Err(format!("{:?}", other.map(|o| o.0)))), + } + } +} + +fn source(s: &Session) -> RenderSource { + RenderSource { + project: s.project.clone(), + footage: s.footage.clone(), + expr: s.expr.clone(), + layer_cache: s.layer_cache.clone(), + gpu: None, + gpu_display: false, + disk: None, + } +} + +#[test] +fn viewer_frames_render_remotely_from_project_diffs() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let comp = s.active_comp_id().unwrap(); + let c = s.project.comp(comp).unwrap().clone(); + let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() }; + let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0 }; + let worker = Arc::new(FakeWorker::new(&s, 2)); + let mut frames = Frames::default(); + frames.set_remote(Some(worker.clone())); + assert!(frames.remote_active()); + // Three prefetch frames, then the viewer's frame. + for f in 1..=3 { + frames.request(&source(&s), key(s.revision, f), comp, c.frame_rate.tick_of(f), opts); + } + frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts); + assert_eq!(frames.inflight(), 4); + // Nothing renders until dispatched (no frame threads with a remote renderer); the viewer's + // frame goes first, then prefetch in request order. + assert_eq!(frames.dispatch_remote(), 2); + assert_eq!(frames.remote_busy(), 2); + assert_eq!(worker.pending(), 2); + assert_eq!(frames.dispatch_remote(), 0, "both slots are busy"); + assert_eq!(*worker.syncs.lock().unwrap(), vec!["project"], "the first sync is the whole project"); + // Frames arrive: the cache holds exactly what the page would have rendered. + worker.deliver(); + assert_eq!(frames.remote_busy(), 0); + let img = match frames.get(&key(s.revision, 10)).expect("viewer frame cached") { + FrameImage::Cpu(c) => c, + FrameImage::Gpu(_) => unreachable!(), + }; + let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts)); + assert_eq!(img.size, local.size); + assert_eq!(img.pixels, local.pixels); + assert!(frames.is_cached(&key(s.revision, 1))); + assert!(!frames.is_cached(&key(s.revision, 2))); + // The rest; an edit later travels as a patch. + frames.dispatch_remote(); + worker.deliver(); + frames.dispatch_remote(); + worker.deliver(); + assert_eq!(frames.inflight(), 0); + s.execute("layer.newSolid", json!({"color": "#ff8800"})).unwrap(); + frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts); + frames.dispatch_remote(); + worker.deliver(); + assert_eq!(worker.syncs.lock().unwrap().last(), Some(&"patch")); + let FrameImage::Cpu(img) = frames.get(&key(s.revision, 10)).unwrap() else { unreachable!() }; + let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts)); + assert_eq!(img.pixels, local.pixels); + // A lost frame is released (requested again later), not cached. + frames.request(&source(&s), key(s.revision, 20), comp, c.frame_rate.tick_of(20), opts); + frames.dispatch_remote(); + worker.lose(); + assert_eq!(frames.inflight(), 0); + assert!(!frames.is_cached(&key(s.revision, 20))); + // Without slots the remote renderer is unusable (frames render here again). + frames.set_remote(Some(Arc::new(FakeWorker::new(&s, 0)))); + assert!(!frames.remote_active()); +} + +/// Holds offloaded jobs until the test runs them. +#[derive(Default)] +struct Manual(Mutex)>>); + +impl Offload for Manual { + fn start(&self, req: WorkerRequest, inbox: Arc) -> Result<(), String> { + self.0.lock().unwrap().push((req, inbox)); + Ok(()) + } + fn cancel(&self, _: u64) {} +} + +fn exporter() -> Arc { + let sink: Arc) + Send + Sync> = Arc::new(|_: &str, _: Vec| {}); + Arc::new(effectcraft_host::FileExporter { sink: Some(sink) }) +} + +#[test] +fn waiting_commands_reply_when_their_offloaded_job_ends() { + let off = Arc::new(Manual::default()); + let mut s = Session { offload: Some(off.clone()), exporter: Some(exporter()), ..Default::default() }; + s.execute("comp.new", json!({"name": "W", "width": 64, "height": 48, "frameRate": 10, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap(); + s.execute("renderQueue.add", json!({"format": "gif", "output": "/w.gif"})).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s).with_control(rx)); + h.run_steps(2); + // `renderQueue.render` waits by default: the job goes to the worker, the reply waits. + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render", "params": {}})); + tx.send(req).unwrap(); + h.run_steps(4); + assert_eq!(reply.try_recv().unwrap_err(), TryRecvError::Empty, "no reply while the job runs"); + assert!(h.state().session.is_rendering()); + let (job, inbox) = off.0.lock().unwrap().pop().expect("the render went to the worker"); + // The worker runs it. + let mut w = Session { exporter: Some(exporter()), ..Default::default() }; + let post: Post = std::rc::Rc::new(move |r| inbox.push(r)); + effectcraft_engine::offload::run_request(&mut w, job, &post); + h.run_steps(4); + let v = reply.try_recv().expect("replied once the job ended"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(v["result"]["rendering"], false, "{v}"); + assert_eq!(v["result"]["items"][0]["status"], "Done", "{v}"); + // `wait: false` replies at once. + h.state_mut().session.execute("renderQueue.add", json!({"format": "gif", "output": "/w2.gif"})).unwrap(); + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render", "params": {"wait": false}})); + tx.send(req).unwrap(); + h.run_steps(2); + let v = reply.try_recv().expect("immediate reply"); + assert_eq!(v["result"]["rendering"], true, "{v}"); +} diff --git a/docs/architecture.md b/docs/architecture.md index ed86d9b5f..d0c9267e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors | | L2 | `effects` | The effect registry (241 effects) and their CPU implementations | | L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale | -| L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, clipping, optional-content layers | +| L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, tiling patterns, clipping, text (embedded TrueType / CFF / Type 1 / Type 3 fonts; the bundled `text` fonts for the standard 14), images, blend modes, soft masks, optional-content layers | | L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation | | L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves | | L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown | @@ -43,7 +43,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L5 | `automation` | The MCP server, headless or bridged to the running app | | L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) | -Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`, +Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `pdf → svg, text`, `effects → project, text, path, track`, `media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`, `script → engine`. @@ -174,10 +174,15 @@ against the caster planes (Shadow Diffusion, Light Transmission) and blending. A run their effect stacks on the GPU-resident comp (`Renderer::run_effects_on` with an `FxTarget`): runs of GPU effects stay on the device and only non-GPU effects read back and upload. Advanced 3D compositing and wireframes still run on the CPU between GPU steps (read back, draw, upload). GPU -effects (`effects::GPU_EFFECTS`, 58 of them: blurs, colour, keying incl. Key Light, distortion, -transitions, generators, noise and grain; see [effects.md](effects.md)) repeat the CPU effect's -steps (padding, box radii, parameters, hashes) as kernels; consecutive GPU effects run as one -chain with one upload and one readback. Tests render scenes on both paths and compare them +effects (`effects::GPU_EFFECTS`, 152 of them: blurs, colour correction, keying incl. Key Light, +mattes, channel, stylize, distortion, transitions, generators, noise, grain and time; see +[effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes) +as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU +effects run as one chain with one upload and one readback. Statistics that need the whole frame +(Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's +noise level) are measured on the CPU from one readback and applied on the GPU; effects reading +other frames (Echo, Posterize Time) upload the frames the host renders. Settings a kernel cannot +match render on the CPU (`effects::catalog::gpu_supported`). Tests render scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the `Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web diff --git a/docs/effects.md b/docs/effects.md index e35ae5f6f..32eabfa15 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 59 effects also run on the GPU compositor with identical results. +- **GPU**: 152 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 306 implemented in full, 0 partial (what is missing is listed). @@ -46,85 +46,85 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Bilateral Blur | `ec.blur.bilateral` | 3 | | 32 | Implemented | +| Bilateral Blur | `ec.blur.bilateral` | 3 | GPU | 32 | Implemented | | CC Cross Blur | `ec.blur.cccross` | 3 | | 32 | Implemented | | CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented | | CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | | CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented | | Camera Lens Blur | `ec.blur.cameralens` | 16 | GPU | 32 | Implemented | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | -| Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented | -| Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented | +| Channel Blur | `ec.blur.channel` | 6 | GPU | 32 | Implemented | +| Compound Blur | `ec.blur.compound` | 4 | GPU | 32 | Implemented | | Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented | | Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented | | Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented | | Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented | -| Sharpen | `ec.blur.sharpen` | 1 | | 32 | Implemented | -| Smart Blur | `ec.blur.smart` | 3 | | 32 | Implemented | -| Unsharp Mask | `ec.blur.unsharp` | 3 | | 32 | Implemented | +| Sharpen | `ec.blur.sharpen` | 1 | GPU | 32 | Implemented | +| Smart Blur | `ec.blur.smart` | 3 | GPU | 32 | Implemented | +| Unsharp Mask | `ec.blur.unsharp` | 3 | GPU | 32 | Implemented | ## Channel | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Arithmetic | `ec.channel.arithmetic` | 5 | | 32 | Implemented | -| Blend | `ec.channel.blend` | 4 | | 32 | Implemented | +| Arithmetic | `ec.channel.arithmetic` | 5 | GPU | 32 | Implemented | +| Blend | `ec.channel.blend` | 4 | GPU | 32 | Implemented | | CC Composite | `ec.channel.cccomposite` | 3 | | 32 | Implemented | -| Calculations | `ec.channel.calculations` | 9 | | 32 | Implemented | -| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Implemented | -| Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | | 32 | Implemented | +| Calculations | `ec.channel.calculations` | 9 | GPU | 32 | Implemented | +| Channel Combiner | `ec.channel.combiner` | 6 | GPU | 32 | Implemented | +| Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | GPU | 32 | Implemented | | Invert | `ec.channel.invert` | 2 | GPU | 32 | Implemented | -| Minimax | `ec.channel.minimax` | 5 | | 32 | Implemented | -| Remove Color Matting | `ec.channel.removecolormatting` | 2 | | 32 | Implemented | -| Set Channels | `ec.channel.setchannels` | 9 | | 32 | Implemented | -| Set Matte | `ec.channel.setmatte` | 6 | | 32 | Implemented | -| Shift Channels | `ec.channel.shiftchannels` | 4 | | 32 | Implemented | -| Solid Composite | `ec.channel.solidcomposite` | 4 | | 32 | Implemented | +| Minimax | `ec.channel.minimax` | 5 | GPU | 32 | Implemented | +| Remove Color Matting | `ec.channel.removecolormatting` | 2 | GPU | 32 | Implemented | +| Set Channels | `ec.channel.setchannels` | 9 | GPU | 32 | Implemented | +| Set Matte | `ec.channel.setmatte` | 6 | GPU | 32 | Implemented | +| Shift Channels | `ec.channel.shiftchannels` | 4 | GPU | 32 | Implemented | +| Solid Composite | `ec.channel.solidcomposite` | 4 | GPU | 32 | Implemented | ## Color Correction | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Auto Color | `ec.color.autocolor` | 6 | | 32 | Implemented | -| Auto Contrast | `ec.color.autocontrast` | 5 | | 32 | Implemented | -| Auto Levels | `ec.color.autolevels` | 5 | | 32 | Implemented | +| Auto Color | `ec.color.autocolor` | 6 | GPU | 32 | Implemented | +| Auto Contrast | `ec.color.autocontrast` | 5 | GPU | 32 | Implemented | +| Auto Levels | `ec.color.autolevels` | 5 | GPU | 32 | Implemented | | Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented | | Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented | -| Broadcast Colors | `ec.color.broadcast` | 3 | | 32 | Implemented | -| CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | | 32 | Implemented | -| CC Color Offset | `ec.color.cccoloroffset` | 4 | | 32 | Implemented | -| CC Kernel | `ec.color.cckernel` | 12 | | 32 | Implemented | -| CC Toner | `ec.color.cctoner` | 7 | | 32 | Implemented | -| Change Color | `ec.color.changecolor` | 9 | | 32 | Implemented | -| Change to Color | `ec.color.changetocolor` | 9 | | 32 | Implemented | +| Broadcast Colors | `ec.color.broadcast` | 3 | GPU | 32 | Implemented | +| CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | GPU | 32 | Implemented | +| CC Color Offset | `ec.color.cccoloroffset` | 4 | GPU | 32 | Implemented | +| CC Kernel | `ec.color.cckernel` | 12 | GPU | 32 | Implemented | +| CC Toner | `ec.color.cctoner` | 7 | GPU | 32 | Implemented | +| Change Color | `ec.color.changecolor` | 9 | GPU | 32 | Implemented | +| Change to Color | `ec.color.changetocolor` | 9 | GPU | 32 | Implemented | | Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented | | Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented | -| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented | +| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | GPU | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | -| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | +| Color Stabilizer | `ec.color.colorstabilizer` | 6 | GPU | 32 | Implemented | | Colorama | `ec.color.colorama` | 20 | GPU | 32 | Implemented | | Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented | -| Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | +| Equalize | `ec.color.equalize` | 2 | GPU | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | -| Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | | 32 | Implemented | +| Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | GPU | 32 | Implemented | | Hue/Saturation | `ec.color.huesaturation` | 50 | GPU | 32 | Implemented | -| Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented | +| Leave Color | `ec.color.leavecolor` | 5 | GPU | 32 | Implemented | | Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented | -| Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented | +| Levels (Individual Controls) | `ec.color.levelsic` | 27 | GPU | 32 | Implemented | | Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | | OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented | | OCIO File Transform | `ec.color.ociofile` | 4 | | 32 | Implemented | | OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | -| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | -| Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented | +| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | GPU | 32 | Implemented | +| Photo Filter | `ec.color.photofilter` | 4 | GPU | 32 | Implemented | | Selective Color | `ec.color.selectivecolor` | 38 | GPU | 32 | Implemented | -| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented | +| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | GPU | 32 | Implemented | | Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented | | Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented | | Vibrance | `ec.color.vibrance` | 2 | GPU | 32 | Implemented | -| Video Limiter | `ec.color.videolimiter` | 5 | | 32 | Implemented | +| Video Limiter | `ec.color.videolimiter` | 5 | GPU | 32 | Implemented | ## Distort @@ -135,37 +135,37 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Bend It | `ec.distort.ccbendit` | 4 | | 32 | Implemented | | CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented | | CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented | -| CC Flo Motion | `ec.distort.ccflomotion` | 7 | | 32 | Implemented | -| CC Griddler | `ec.distort.ccgriddler` | 5 | | 32 | Implemented | +| CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented | +| CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented | | CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | | CC Page Turn | `ec.distort.ccpageturn` | 7 | | 32 | Implemented | -| CC Power Pin | `ec.distort.ccpowerpin` | 10 | | 32 | Implemented | -| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | | 32 | Implemented | -| CC Slant | `ec.distort.ccslant` | 4 | | 32 | Implemented | -| CC Smear | `ec.distort.ccsmear` | 4 | | 32 | Implemented | -| CC Split | `ec.distort.ccsplit` | 3 | | 32 | Implemented | -| CC Split 2 | `ec.distort.ccsplit2` | 4 | | 32 | Implemented | -| CC Tiler | `ec.distort.cctiler` | 3 | | 32 | Implemented | -| Corner Pin | `ec.distort.cornerpin` | 4 | | 32 | Implemented | +| CC Power Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented | +| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented | +| CC Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented | +| CC Smear | `ec.distort.ccsmear` | 4 | GPU | 32 | Implemented | +| CC Split | `ec.distort.ccsplit` | 3 | GPU | 32 | Implemented | +| CC Split 2 | `ec.distort.ccsplit2` | 4 | GPU | 32 | Implemented | +| CC Tiler | `ec.distort.cctiler` | 3 | GPU | 32 | Implemented | +| Corner Pin | `ec.distort.cornerpin` | 4 | GPU | 32 | Implemented | | Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented | | Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented | -| Liquify | `ec.distort.liquify` | 12 | | 32 | Implemented | -| Magnify | `ec.distort.magnify` | 10 | | 32 | Implemented | +| Liquify | `ec.distort.liquify` | 12 | GPU | 32 | Implemented | +| Magnify | `ec.distort.magnify` | 10 | GPU | 32 | Implemented | | Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented | -| Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented | -| Offset | `ec.distort.offset` | 2 | | 32 | Implemented | -| Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented | -| Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented | +| Mirror | `ec.distort.mirror` | 2 | GPU | 32 | Implemented | +| Offset | `ec.distort.offset` | 2 | GPU | 32 | Implemented | +| Optics Compensation | `ec.distort.opticscompensation` | 6 | GPU | 32 | Implemented | +| Polar Coordinates | `ec.distort.polar` | 2 | GPU | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | | Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented | | Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented | | Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented | | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | -| Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | +| Spherize | `ec.distort.spherize` | 2 | GPU | 32 | Implemented | | Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | | Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | -| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | +| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | GPU | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | | 32 | Implemented | | Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented | | Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented | @@ -236,29 +236,29 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | | 32 | Implemented | +| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | GPU | 32 | Implemented | | CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | | 32 | Implemented | -| Color Difference Key | `ec.key.colordifference` | 16 | | 32 | Implemented | -| Color Range | `ec.key.colorrange` | 8 | | 32 | Implemented | -| Difference Matte | `ec.key.differencematte` | 6 | | 32 | Implemented | -| Extract | `ec.key.extract` | 6 | | 32 | Implemented | +| Color Difference Key | `ec.key.colordifference` | 16 | GPU | 32 | Implemented | +| Color Range | `ec.key.colorrange` | 8 | GPU | 32 | Implemented | +| Difference Matte | `ec.key.differencematte` | 6 | GPU | 32 | Implemented | +| Extract | `ec.key.extract` | 6 | GPU | 32 | Implemented | | Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented | -| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | +| Key Cleaner | `ec.key.keycleaner` | 4 | GPU | 32 | Implemented | | Key Light | `ec.keying.keylight` | 56 | GPU | 32 | Implemented | | Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented | -| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented | -| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented | +| Screen Key | `ec.key.screen` | 11 | GPU | 32 | Implemented | +| Unmult | `ec.key.unmult` | 6 | GPU | 32 | Implemented | ## Matte | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Matte Choker | `ec.matte.mattechoker` | 7 | | 32 | Implemented | +| Matte Choker | `ec.matte.mattechoker` | 7 | GPU | 32 | Implemented | | Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented | -| Refine Hard Matte | `ec.matte.refinehard` | 16 | | 32 | Implemented | -| Refine Soft Matte | `ec.matte.refinesoft` | 18 | | 32 | Implemented | +| Refine Hard Matte | `ec.matte.refinehard` | 16 | GPU | 32 | Implemented | +| Refine Soft Matte | `ec.matte.refinesoft` | 18 | GPU | 32 | Implemented | | Roto Brush & Refine Edge | `ec.matte.rotobrush` | 24 | | 32 | Implemented | -| Simple Choker | `ec.matte.simplechoker` | 2 | | 32 | Implemented | +| Simple Choker | `ec.matte.simplechoker` | 2 | GPU | 32 | Implemented | ## Noise & Grain @@ -266,17 +266,17 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Add Grain | `ec.noise.addgrain` | 32 | GPU | 32 | Implemented | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | -| Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | +| Dust & Scratches | `ec.noise.dustscratches` | 3 | GPU | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | | Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented | -| Median | `ec.noise.median` | 2 | | 32 | Implemented | -| Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | +| Median | `ec.noise.median` | 2 | GPU | 32 | Implemented | +| Median (Legacy) | `ec.noise.medianlegacy` | 2 | GPU | 32 | Implemented | | Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented | -| Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | -| Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | -| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | -| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented | -| Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented | +| Noise Alpha | `ec.noise.noisealpha` | 8 | GPU | 32 | Implemented | +| Noise HLS | `ec.noise.noisehls` | 6 | GPU | 32 | Implemented | +| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | GPU | 32 | Implemented | +| Remove Grain | `ec.noise.removegrain` | 20 | GPU | 32 | Implemented | +| Turbulent Noise | `ec.noise.turbulent` | 22 | GPU | 32 | Implemented | ## Obsolete @@ -284,13 +284,13 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Basic 3D | `ec.obsolete.basic3d` | 5 | | 32 | Implemented | | Basic Text | `ec.obsolete.basictext` | 11 | | 32 | Implemented | -| Color Key | `ec.key.colorkey` | 4 | | 32 | Implemented | +| Color Key | `ec.key.colorkey` | 4 | GPU | 32 | Implemented | | Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented | | Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented | -| Luma Key | `ec.key.luma` | 5 | | 32 | Implemented | +| Luma Key | `ec.key.luma` | 5 | GPU | 32 | Implemented | | Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented | | Reduce Interlace Flicker | `ec.blur.reduceflicker` | 1 | | 32 | Implemented | -| Spill Suppressor | `ec.key.spill` | 3 | | 32 | Implemented | +| Spill Suppressor | `ec.key.spill` | 3 | GPU | 32 | Implemented | ## Paint @@ -340,31 +340,31 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Brush Strokes | `ec.stylize.brushstrokes` | 7 | | 32 | Implemented | +| Brush Strokes | `ec.stylize.brushstrokes` | 7 | GPU | 32 | Implemented | | CC Block Load | `ec.stylize.ccblockload` | 5 | | 32 | Implemented | | CC Burn Film | `ec.stylize.ccburnfilm` | 3 | | 32 | Implemented | | CC Glass | `ec.stylize.ccglass` | 17 | | 32 | Implemented | | CC HexTile | `ec.stylize.cchextile` | 5 | | 32 | Implemented | -| CC Kaleida | `ec.stylize.cckaleida` | 4 | | 32 | Implemented | +| CC Kaleida | `ec.stylize.cckaleida` | 4 | GPU | 32 | Implemented | | CC Mr. Smoothie | `ec.stylize.ccmrsmoothie` | 7 | | 32 | Implemented | | CC Plastic | `ec.stylize.ccplastic` | 18 | | 32 | Implemented | -| CC RepeTile | `ec.stylize.ccrepetile` | 5 | | 32 | Implemented | -| CC Threshold | `ec.stylize.ccthreshold` | 4 | | 32 | Implemented | -| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | | 32 | Implemented | -| CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented | +| CC RepeTile | `ec.stylize.ccrepetile` | 5 | GPU | 32 | Implemented | +| CC Threshold | `ec.stylize.ccthreshold` | 4 | GPU | 32 | Implemented | +| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | GPU | 32 | Implemented | +| CC Vignette | `ec.stylize.ccvignette` | 4 | GPU | 32 | Implemented | | Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented | | Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented | | Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented | | Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented | | Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented | | Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented | -| Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented | -| Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented | -| Roughen Edges | `ec.stylize.roughenedges` | 13 | | 32 | Implemented | -| Scatter | `ec.stylize.scatter` | 3 | | 32 | Implemented | -| Strobe Light | `ec.stylize.strobe` | 7 | | 32 | Implemented | -| Texturize | `ec.stylize.texturize` | 4 | | 32 | Implemented | -| Threshold | `ec.stylize.threshold` | 1 | | 32 | Implemented | +| Motion Tile | `ec.stylize.motiontile` | 8 | GPU | 32 | Implemented | +| Posterize | `ec.stylize.posterize` | 1 | GPU | 32 | Implemented | +| Roughen Edges | `ec.stylize.roughenedges` | 13 | GPU | 32 | Implemented | +| Scatter | `ec.stylize.scatter` | 3 | GPU | 32 | Implemented | +| Strobe Light | `ec.stylize.strobe` | 7 | GPU | 32 | Implemented | +| Texturize | `ec.stylize.texturize` | 4 | GPU | 32 | Implemented | +| Threshold | `ec.stylize.threshold` | 1 | GPU | 32 | Implemented | ## Text @@ -379,9 +379,9 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | | 32 | Implemented | | CC Wide Time | `ec.time.ccwidetime` | 3 | | 32 | Implemented | -| Echo | `ec.time.echo` | 5 | | 32 | Implemented | +| Echo | `ec.time.echo` | 5 | GPU | 32 | Implemented | | Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | | 32 | Implemented | -| Posterize Time | `ec.time.posterizetime` | 1 | | 32 | Implemented | +| Posterize Time | `ec.time.posterizetime` | 1 | GPU | 32 | Implemented | | Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented | | Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented | | Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented | diff --git a/docs/parity.md b/docs/parity.md index 93a24c737..946039504 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,16 +10,22 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much | Unweighted | ≈ 94% (half-credit) / 98% (fractions) | | P0 / P1 / P2 | ≈ 96% / 92% / 85% (half-credit); 99% / 97% / 96% (fractions) | | Features done / partial / missing | 80 / 12 / 0 of 92 | -| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 58 run on the GPU | +| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 152 run on the GPU | | Disabled menu entries left | 0 (the stub list is empty) | | Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) | | **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 | -Partial features: effect implementations (42 simplified effects), GPU coverage (Advanced 3D, more -GPU effects), the web app (no threads for viewer frames and Roto Brush), the Learn panel, ScriptUI resource -strings and `onDraw`, Essential Graphics font and scale controls, and the learned-model quality of -Roto Brush and face tracking (both are classical). Menu items still absent: Save Frame As ▸ -Photoshop Layers / ProEXR, Open in Essential Graphics, Vanishing Point import, Watch Folder. +Partial features: GPU coverage (Advanced 3D, more GPU effects), the web app (GPU effects and +the disk cache in the browser; M13.10 moved viewer frames and Roto Brush off the page's thread), Essential Graphics mirrored properties, and the learned-model quality +of Roto Brush and face tracking (both are classical). Since this audit, M9.13 completed all 42 +simplified effects, M13.5 added the Learn panel, and M13.8 the Preview panel settings, Align to +Selection / Distribute and Advanced 3D iris DOF, collapsed precomps and extruded strokes. M13.7 added ScriptUI resource strings, +`onDraw` (ScriptUIGraphics draw lists), live `onChanging`, `Socket`, the Essential Graphics +scripting hooks and Font / uniform Scale controls, and the last missing menu items: Save Frame As ▸ +Photoshop Layers / ProEXR, Composition ▸ Open in Essential Graphics, File ▸ Watch Folder, +Window ▸ Create Nulls From Paths / VR Comp Editor (built-in panels) and Help ▸ In-App / Online +Tutorials. File ▸ Import ▸ Vanishing Point (.vpe) is present but permanently disabled: the format +has no public specification, so a clean-room implementation is not possible (its tooltip says so). ### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93% @@ -29,8 +35,8 @@ Tracking 96%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; -approximated effects (Key Cleaner) and Liquify's viewer brush; text in imported -PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp +approximated effects (Key Cleaner) and Liquify's viewer brush; mesh shadings and JPX images in +imported PDF/AI files and Illustrator procset EPS; and more codecs. Face tracking and a true Subspace Warp landed in M13.3. ScriptUI (dialogs that block `show()`, palettes, dockable ScriptUI panels, File ▸ Scripts install and sample scripts), puppet pin recording and the remaining "better than After Effects" items — a versioned effect plug-in API with sandboxed WebAssembly plug-ins ([plugins.md](plugins.md)), @@ -122,19 +128,19 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Layers | 88% | 4.8 | frame blending, collapse transformations, slip edit | | Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation | | Audio | 85% | 0.5 | audio to keyframes | -| Import | 94% | 1.0 | text and images inside PDF/AI files, Illustrator EPS relying on Adobe procsets, smart-object perspective/warps, PSD 3D layers (PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | -| Automation | ≈ 97% | 0.3 | `.jsxbin`, sockets, ScriptUI resource strings and custom `onDraw` graphics (the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | +| Import | 96% | 0.6 | mesh shadings (types 4–7), JPX / CCITT / JBIG2 images and non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | +| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | -| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | +| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | -| Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | -| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | +| Text | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | +| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) | | 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | -| Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | +| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | -| Preview | 72% | 3.5 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | +| Preview | 74% | 3.2 | wireframes and Advanced 3D compositing on the GPU (Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -174,8 +180,8 @@ Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends (ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry -taper/wave and variable axes don't change advances (Advanced 3D's DOF has iris shapes since -M13.8). +taper/wave and the Variable Font Axes animator doesn't change advances (the Character panel's +axes do since M13.6; Advanced 3D's DOF has iris shapes since M13.8). ## Update: M13.8 Preview panel, Align panel, Advanced 3D completion @@ -233,7 +239,7 @@ precompose), **Auto-trace** (alpha / RGB / luminance, current frame or work area or a new layer) and **Align Video to Data** (JSON / CSV / TSV time keys) replace their disabled menu entries. Not yet: creating a reference frame by editing a still (After Effects hands it to Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media -Browser in the web app. +Browser in the web app (landed in M13.10: browser storage and File System Access folders). ## Highest-value gaps, in order @@ -241,7 +247,7 @@ Browser in the web app. 2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors. 3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13) 4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; M12.7: - Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles). + Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles; M13.9: 152 GPU effects). 5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer. 6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8) 7. Point tracking and stabilization (in progress). diff --git a/docs/web.md b/docs/web.md index 72eb75ff6..52d3419ca 100644 --- a/docs/web.md +++ b/docs/web.md @@ -41,14 +41,15 @@ with every L0–L4 crate and `effectcraft-ui-egui`, for `wasm32-unknown-unknown` URL flags: `?empty` (a blank project; the session is not restored or recorded), `?demo` (the demo project instead of the last session), `?home` (show the start screen), `?storage=indexeddb` / -`?storage=memory` (force a storage backend), `?noworkers` (render and analyse on the page's -thread), `?nosw` (don't register the service worker). +`?storage=memory` (force a storage backend), `?noworkers` (render, analyse and draw viewer +frames on the page's thread), `?frameworkers=N` (frame workers, default 2; 0 = viewer frames on +the page's thread), `?nosw` (don't register the service worker). ## How the desktop pieces map to the browser | Desktop | Web | |---|---| -| frame render thread pool (`ui-egui/src/frames.rs`) | `Frames::pump`: queued frames render on the UI thread after each egui frame, most important first (the viewer's frame, then prefetch), within 40 ms (24 ms while playing) | +| frame render thread pool (`ui-egui/src/frames.rs`) | frame workers (below): `Frames::pump` hands queued frames to them, most important first (the viewer's frame, then prefetch); frames the GPU compositor can keep on the GPU render on the page. Without workers, queued frames render on the UI thread after each egui frame within 40 ms (24 ms while playing) | | Render Queue on a background thread | a Web Worker running a second engine instance (below); progress streams back, the page never waits | | tracker / mask tracker / Warp Stabilizer / 3D Camera Tracker / Roto Brush Freeze threads | the same workers; the analysed property group comes back as one undo step | | `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial | @@ -59,6 +60,7 @@ thread), `?nosw` (don't register the service worker). | media reads (`MediaPool`, `probe`) | `files::WebImporter`: bytes from the file table, handed to `MediaPool::add_bytes` / `probe_bytes` | | export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` | | rfd file dialogs | `` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name | +| Media Browser on the file system | browser storage and folders opened with the File System Access API (below) | | system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there | | TCP control channel / MCP | `window.effectcraft` (below) | | cpal audio output | Web Audio (below) | @@ -109,10 +111,65 @@ compiled twice) and its own memory (`crates/engine/src/offload.rs`, `src/worker. tracker / mask group is replaced; other edits made meanwhile are kept) and the downloads start. Stopping a job terminates its worker (a worker can't be interrupted mid-frame): the item being -rendered becomes "User Stopped", an analysis writes nothing. `renderQueue.render {wait: true}` (the -default for agents) and analysis commands with `wait: true` still run on the page's thread and -return when done. Roto Brush propagation fills the session's segmentation cache rather than the -project, so it runs on the page's thread too, as do viewer frames (`Frames::pump`). +rendered becomes "User Stopped", an analysis writes nothing. Every job shows in Window ▸ Progress +(`jobs.list`) with its progress and a cancel button (`jobs.cancel`). + +**Roto Brush propagation** runs in a job worker too (`WorkerJob::RotoPropagate`): the worker +streams the segmentations it computes back at most every 250 ms (`WorkerReply::Segs`: each +`FrameSeg` in a compact binary form, run-length coded planes, base64), the page puts them into its +segmentation cache and relays them to the frame workers. Stopping it keeps the frames propagated +so far. + +**`wait: true`** (the default of `renderQueue.render` for agents, and an option of the analysis +and Roto Brush commands) no longer blocks the page: over `window.effectcraft` (the control +channel) such a command runs with `wait: false`, and the promise resolves when the job it started +ends, with what the blocking command returns (final render item statuses, the analysis status) +or rejects with the job's error (`ui-egui/src/control.rs`, `JobWaiter`). Engine commands executed +directly on the session (scripts) still block. + +### Viewer frames: frame workers + +Viewer frames (the frame on screen, RAM preview and prefetch) render in long-lived **frame +workers** (`src/frames.rs`, `crates/engine/src/remote.rs`), two by default. Each holds a replica +of the project: + +1. Before a render, the page sends a worker the footage files it lacks, then a sync: the whole + project the first time, afterwards a **diff** of the serialized project from the revision the + worker holds (`remote::diff`: object members added / changed / removed, arrays element-wise + when their length is unchanged; a slider drag sends a few hundred bytes, not the project). +2. Then the render request (`FrameMsg::Render`: revision, comp, time, render options). The worker + renders on its CPU renderer and posts the frame back as premultiplied RGBA8 in a transferred + buffer (`FrameReply::Frame`); the page puts it in the frame cache. +3. One frame per worker at a time, so the page always picks what renders next (the viewer's frame + before prefetch); a request for a revision the worker does not hold is refused, a worker that + lost its replica asks for the whole project again (`FrameReply::Resync`). + +Frames the WebGPU compositor can finish alone still render on the page (they stay on the GPU, no +readback); everything else (CPU-only effects, 3D runs, the Software Only renderer, WebGL2) goes to +the workers. If no worker is usable, frames render on the page's thread as before. +`effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered, failed, lastMs, +syncs, syncBytes}`. + +**Why workers and not wasm threads:** shared-memory threads (SharedArrayBuffer + atomics, a +`wasm-bindgen-rayon`-style pool) need the standard library rebuilt with atomics (`build-std`, +nightly Rust); the repository builds on stable, so each worker runs its own engine instance with +its own memory. The page is still cross-origin isolated (`--serve` and the service worker send +COOP / COEP), so a threaded build can be added later without changing the deployment. + +### Media Browser + +Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): + +- **Browser Storage**: every file in browser storage (imported media, saved projects, files added + with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is + the persistent recent list where folders can't be opened. +- **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a + folder from the user's disk; only the listing is read, and a file's bytes are read when it is + imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after + a reload the folders come back if the browser still grants access, else **Reconnect ** + asks again. + +`mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. ### Audio @@ -192,15 +249,24 @@ cargo xtask web --serve 8765 & node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke ``` +It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the +CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that +replies when done while the page keeps running, and the Media Browser over browser storage. + Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the browser store) and the worker protocol (`crates/engine/src/offload.rs`: serde round trips of -every request and reply, a render through an in-process offload). +every request and reply, a render through an in-process offload; `tests_roto.rs`: Roto Brush +propagation through an offload streaming segmentations), the frame-worker protocol +(`crates/engine/src/remote.rs`: diff / patch, a replica following edits renders the same pixels; +`crates/ui-egui/tests/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true` +deferred until the offloaded job ends) and the Media Browser's virtual tree (`media_browser.rs`, +`tests_panels.rs`). ## Gaps -- Viewer frames, RAM preview and Roto Brush propagation still render on the page's thread (one - frame at a time between UI frames). +- No shared-memory threads: each worker is a separate engine instance (memory per worker: the + project replica and the footage it reads). - GPU effects run only in the desktop GPU path: in the browser, effects run on the CPU (inside workers for renders) because the page's thread can't wait for mid-render readbacks. - No disk cache (Settings ▸ Media & Disk Cache) in the browser: the layer cache stays in memory. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 11899dc4b..01cb44b4b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -61,6 +61,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ("path", "raster"), ("text", "path"), ("pdf", "svg"), + ("pdf", "text"), ("effects", "project"), ("effects", "text"), ("effects", "path"),