mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 06:01:23 +00:00
Merge main into M13.11 (bokeh kernel_spans/blur_levels, Essential Graphics as_type controls with mirrors and links)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+6
@@ -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]]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<dyn effectcraft_engine::offload::Offload>),
|
||||
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);
|
||||
|
||||
@@ -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 `<input type=file>`, so every browser) are copied
|
||||
//! there and stay across visits: the recent list.
|
||||
//! - **Folders** (`/Folders/<name>/…`): 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 <name>**).
|
||||
|
||||
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<String>,
|
||||
disconnected: Vec<String>,
|
||||
}
|
||||
|
||||
static STATE: LazyLock<Mutex<State>> = 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<String> {
|
||||
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<Vec<Entry>, 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<String> {
|
||||
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<String> = 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::<Vec<_>>().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<Value, String> {
|
||||
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::<js_sys::Uint8Array>().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}`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
busy: Option<(u64, RemoteDone)>,
|
||||
alive: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
workers: Vec<Fw>,
|
||||
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<dyn FnMut(u32, JsValue, JsValue, JsValue)>;
|
||||
|
||||
thread_local! {
|
||||
static STATE: RefCell<State> = RefCell::new(State::default());
|
||||
static HANDLER: RefCell<Option<OnMessage>> = const { RefCell::new(None) };
|
||||
/// The worker's frame server (worker side).
|
||||
static SERVER: RefCell<Option<FrameServer>> = 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::<js_sys::Uint8Array>().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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 | `<input type=file>` 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;
|
||||
|
||||
@@ -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::<WorkerReply>(&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<effectcraft_media::MediaPool> {
|
||||
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) {
|
||||
|
||||
@@ -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"})`);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<f32> {
|
||||
.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<f32>, 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<Vec<f32>> {
|
||||
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<f32>, 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));
|
||||
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
@@ -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<OffsetCurve> {
|
||||
pub fn parse(s: &str, periodic: bool) -> Option<OffsetCurve> {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -325,7 +325,9 @@ pub fn build_mesh(strokes: &[LiquifyStroke], w: f64, h: f64, frozen: &(dyn Fn(f6
|
||||
|
||||
type MeshCache = Mutex<HashMap<u64, Arc<Mesh>>>;
|
||||
|
||||
fn cached_mesh(ctx: &EffectCtx) -> Arc<Mesh> {
|
||||
/// Liquify's displacement mesh for the instance (replayed strokes, cached; also used by the
|
||||
/// GPU kernels).
|
||||
pub fn liquify_mesh(ctx: &EffectCtx) -> Arc<Mesh> {
|
||||
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();
|
||||
|
||||
+112
-4
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+23
-14
@@ -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);
|
||||
|
||||
@@ -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<BlendMode>,
|
||||
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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,12 @@ fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option<Frames> {
|
||||
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<Image>)> {
|
||||
fetch(ctx, times).map(|f| (f.grid, f.imgs))
|
||||
}
|
||||
|
||||
fn with_img(grid: Buf, img: Image) -> Buf {
|
||||
Buf { img, ..grid }
|
||||
}
|
||||
|
||||
@@ -34,3 +34,4 @@ log = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
|
||||
@@ -395,7 +395,7 @@ fn report(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// 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])
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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| {
|
||||
|
||||
@@ -88,7 +88,16 @@ fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let mirrors: Vec<u64> = 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<Value> {
|
||||
const C: &str = "essential.addMirror";
|
||||
@@ -137,7 +159,7 @@ fn add_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
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<ControlType> {
|
||||
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<Value> {
|
||||
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<ControlType>, 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<Value> {
|
||||
const C: &str = "essential.addMedia";
|
||||
@@ -227,7 +285,7 @@ fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
Ok(json!({"prop": uid, "items": items}))
|
||||
}
|
||||
|
||||
/// Composition ▸ Open in Essential Graphics.
|
||||
fn open_in_eg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
"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<Value> {
|
||||
/// Set an instance's value for a control (`value`, or `item` for Media Replacement).
|
||||
fn set_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.set";
|
||||
let (cid, lid, _) = instance(s, p, C)?;
|
||||
let (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<Value> {
|
||||
})?;
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
|
||||
@@ -102,6 +102,14 @@ pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<u8>, 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<u64>)> {
|
||||
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<u64>)> {
|
||||
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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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 `<layer name>.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<Isolated>)> {
|
||||
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<LayerId> = 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<u8>, 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>) -> 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<u8>, Vec<String>, 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<f32>; 4] {
|
||||
let mut out: [Vec<f32>; 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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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 + `<layer>.R/G/B/A` per layer (linear, premultiplied)",
|
||||
has_comp,
|
||||
save_exr
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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!(
|
||||
|
||||
@@ -35,6 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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",
|
||||
|
||||
@@ -1163,7 +1163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -29,38 +29,61 @@ fn jobs_wait(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------------------------------------------------------- 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<std::sync::Arc<dyn mb::Browser>> {
|
||||
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<Value> {
|
||||
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<Value> = b.places().into_iter().map(|(name, path)| json!({"name": name, "path": path})).collect();
|
||||
let actions: Vec<Value> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String> = 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<CommandSpec> {
|
||||
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),
|
||||
|
||||
@@ -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<PathInfo> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -188,7 +188,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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<Value> {
|
||||
Err(bad("file.importVanishingPoint", WHY))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"file.importVanishingPoint",
|
||||
"Vanishing Point (.vpe)...",
|
||||
["File", "Import"],
|
||||
None,
|
||||
"{} (always disabled: undocumented format)",
|
||||
|_| Err(WHY.into()),
|
||||
import
|
||||
)]
|
||||
}
|
||||
@@ -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<VrEnv> {
|
||||
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::<Vec<_>>(),
|
||||
"view": view_of(p, e),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(environments(&s.project).iter().map(|e| env_json(&s.project, e)).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// 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<VrEnv> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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 (`<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<Vec<PathBuf>> {
|
||||
let mut out = vec![];
|
||||
let scan = |dir: &Path, out: &mut Vec<PathBuf>, subdirs: &mut Vec<PathBuf>| -> 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<Value, String> {
|
||||
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<Value> = 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<Vec<Value>, 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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"file.watchFolder",
|
||||
"Watch Folder...",
|
||||
["File"],
|
||||
None,
|
||||
"{folder, stop?: bool} → watches the folder for .ecproj files with queued renders, renders them and writes `<project>.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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// The Footage panel: the footage shown, its time and In/Out marks (source time).
|
||||
#[serde(default)]
|
||||
pub footage_panel: Option<commands::footage_panel::FootageView>,
|
||||
@@ -349,6 +354,11 @@ pub struct Session {
|
||||
pub offload: Option<Arc<dyn offload::Offload>>,
|
||||
/// Analyses running in the [`Session::offload`] worker.
|
||||
pub offloaded: Vec<offload::OffloadedJob>,
|
||||
/// What the Media Browser browses (`None`: the local file system where there is one).
|
||||
pub browser: Option<Arc<dyn media_browser::Browser>>,
|
||||
/// 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<String>)>,
|
||||
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
|
||||
pub tasks: Vec<jobs::Task>,
|
||||
/// 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;
|
||||
|
||||
@@ -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<Vec<Entry>, String>;
|
||||
/// The folder above `dir`.
|
||||
fn parent(&self, dir: &str) -> Option<String> {
|
||||
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<Value, String> {
|
||||
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<Vec<Entry>, 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<std::sync::Arc<dyn Browser>> {
|
||||
self.browser.clone().or_else(|| available().then(|| std::sync::Arc::new(FsBrowser) as std::sync::Arc<dyn Browser>))
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of a [`VirtualTree`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct VNode {
|
||||
/// What importing it reads (`None`: a folder).
|
||||
import: Option<String>,
|
||||
size: u64,
|
||||
modified: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<String, VNode>,
|
||||
}
|
||||
|
||||
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<u64>) {
|
||||
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<Vec<Entry>, 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<Entry> = 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::<Vec<_>>();
|
||||
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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PropGroup>,
|
||||
message: String,
|
||||
},
|
||||
/// Roto Brush segmentations computed so far (propagation), not sent before.
|
||||
Segs {
|
||||
segs: Vec<SegData>,
|
||||
},
|
||||
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<u64>) -> Vec<SegData> {
|
||||
let new: Vec<(u64, Arc<effectcraft_track::roto::FrameSeg>)> =
|
||||
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<Vec<WorkerReply>>);
|
||||
@@ -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<u64> = 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::<web_time::Instant>;
|
||||
let hook = Box::new(move |done: u64, total: u64| {
|
||||
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 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<Inbox>,
|
||||
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,
|
||||
] {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<Step>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
}
|
||||
|
||||
/// The changes that turn `old` into `new` (empty when equal).
|
||||
pub fn diff(old: &Value, new: &Value) -> Vec<Op> {
|
||||
let mut ops = vec![];
|
||||
diff_into(old, new, &mut vec![], &mut ops);
|
||||
ops
|
||||
}
|
||||
|
||||
fn diff_into(old: &Value, new: &Value, path: &mut Vec<Step>, ops: &mut Vec<Op>) {
|
||||
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<Op> },
|
||||
/// Render a frame of the current project.
|
||||
Render { id: u64, revision: u64, comp: ItemId, time: Tick, opts: Box<RenderOpts> },
|
||||
/// Roto Brush segmentations computed elsewhere (a propagation worker).
|
||||
Segs { segs: Vec<SegData> },
|
||||
/// 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<u64> {
|
||||
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<FrameMsg> {
|
||||
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<u8> {
|
||||
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<dyn FootageSource>,
|
||||
pub expr: Option<Arc<dyn ExprHost>>,
|
||||
pub cache: Arc<LayerCache>,
|
||||
doc: Value,
|
||||
project: Option<Arc<Project>>,
|
||||
revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl FrameServer {
|
||||
pub fn new(footage: Arc<dyn FootageSource>, expr: Option<Arc<dyn ExprHost>>) -> FrameServer {
|
||||
FrameServer { footage, expr, cache: Arc::new(LayerCache::default()), doc: Value::Null, project: None, revision: None }
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> Option<u64> {
|
||||
self.revision
|
||||
}
|
||||
|
||||
pub fn project(&self) -> Option<&Arc<Project>> {
|
||||
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<Vec<u8>>)> {
|
||||
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<Vec<u8>>)> {
|
||||
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<T: Serialize + for<'de> 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<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-12
@@ -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<RotoProgress> {
|
||||
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<fx::Chain> {
|
||||
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() {
|
||||
|
||||
@@ -112,9 +112,103 @@ pub struct Widget {
|
||||
pub handlers: Vec<String>,
|
||||
/// 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<DrawOp>,
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
/// 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<PathSeg> },
|
||||
/// `strokePath(pen, path)`.
|
||||
Stroke { color: Option<[f32; 4]>, width: f64, path: Vec<PathSeg> },
|
||||
/// `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<Value> {
|
||||
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::<DrawOp>(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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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::<essential::EssentialGraphics>(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<essential::Override> = {
|
||||
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");
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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<u64> = 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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<crate::media_browser::VirtualTree>,
|
||||
fetched: Mutex<Vec<String>>,
|
||||
actions: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
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<Vec<crate::media_browser::Entry>, String> {
|
||||
self.tree.lock().unwrap().list(dir, only)
|
||||
}
|
||||
fn fetch(&self, paths: &[String], _: &Value) -> bool {
|
||||
// Opened folders' files arrive later.
|
||||
let pending: Vec<String> = 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<Value, String> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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<dyn effectcraft_render::FootageSource>);
|
||||
|
||||
impl crate::offload::Offload for InlineWorker {
|
||||
fn start(&self, req: crate::offload::WorkerRequest, inbox: std::sync::Arc<crate::offload::Inbox>) -> 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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+132
-16
@@ -122,48 +122,147 @@ fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> Prop
|
||||
g
|
||||
}
|
||||
|
||||
/// A Merge Paths item in `mode` (0 Merge, 3 Intersect).
|
||||
fn merge_item(ids: &mut Ids, mode: u32) -> Option<PropGroup> {
|
||||
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<PropGroup> {
|
||||
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<PropGroup>, Vec<PropGroup>) =
|
||||
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<PropGroup> {
|
||||
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine) -> Vec<PropGroup> {
|
||||
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
|
||||
let mut out = vec![];
|
||||
for n in nodes.iter().rev() {
|
||||
match n {
|
||||
Node::Group(g) => {
|
||||
let items = walk(ids, &g.children, m * g.transform);
|
||||
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<BezPath>, 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<Result<Doc, String>> {
|
||||
/// 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<Result<Doc, String>> {
|
||||
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<ItemId>), 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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<GpuImage> {
|
||||
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<f32> = 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)
|
||||
}
|
||||
@@ -136,11 +136,18 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option<Gp
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Plane textures: the layer uploads (cached per buffer; bokeh depth of field is already in
|
||||
// them).
|
||||
// Plane textures: the layer uploads (cached per buffer), with bokeh depth of field blurred
|
||||
// here when the plane has it.
|
||||
let mut texs: Vec<(GpuImage, [f64; 2])> = 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)?;
|
||||
|
||||
@@ -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)| {
|
||||
|
||||
@@ -113,14 +113,24 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
"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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<GBuf> {
|
||||
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<Option<GpuImage>> {
|
||||
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<GpuImage> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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 })
|
||||
}
|
||||
@@ -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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<f32> = 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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<u64> = 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<GBuf> {
|
||||
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<f32> = (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<GpuImage> = (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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<f64> = (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<GBuf> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<GBuf> {
|
||||
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<Vec<f32>>) -> Option<GBuf> {
|
||||
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<f32> {
|
||||
let vals: Vec<f32> = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::<f32>().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<GBuf> {
|
||||
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)
|
||||
}
|
||||
+17
-1
@@ -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;
|
||||
|
||||
@@ -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<f32>) -> vec3<f32> {
|
||||
if (px.w > 1e-6) {
|
||||
return px.xyz / px.w;
|
||||
}
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
|
||||
fn fxk_premul(c: vec3<f32>, a: f32) -> vec4<f32> {
|
||||
return vec4<f32>(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<i32>) -> vec4<f32> {
|
||||
let i = (u32(p.y) * P.u[3].x + u32(p.x)) * 4u;
|
||||
return vec4<f32>(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<f32>) -> vec3<f32> {
|
||||
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<f32>(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<f32>(0.0), vec3<f32>(1.0));
|
||||
let l = vec3<f32>(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<f32>((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<f32>, 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<f32>, ch: u32, inv: bool) -> vec4<f32> {
|
||||
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<f32>(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<u32>) {
|
||||
let dims = out_dims();
|
||||
let p = vec2<i32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(v, v, v, 1.0);
|
||||
}
|
||||
case 3u: {
|
||||
let v = m * px.w;
|
||||
o = vec4<f32>(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<f32>(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<f32>(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<i32>(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<f32>(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<f32>(l) - s) * k;
|
||||
}
|
||||
o = vec4<f32>(s * px.w, px.w);
|
||||
}
|
||||
case 9u: {
|
||||
var cc = c;
|
||||
if (u0.z != 0u) {
|
||||
let cl = clamp(cc, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
cc = vec3<f32>(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<f32>(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<f32>(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<f32>(0.0), vec3<f32>(1.0));
|
||||
}
|
||||
o = fxk_premul(oc, a * m);
|
||||
}
|
||||
}
|
||||
case 11u: {
|
||||
let v = vec4<f32>(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<f32>(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<f32>(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<f32>(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<f32>(max(b.xyz, vec3<f32>(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<f32>;
|
||||
if (ch_from <= 3u) {
|
||||
switch ch_from {
|
||||
case 0u: {
|
||||
let hsl = rgb_to_hsl(sc);
|
||||
r = vec3<f32>(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<f32>(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<f32>(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<f32>(v, 0.0, 0.0); }
|
||||
case 1u: { r = vec3<f32>(0.0, v, 0.0); }
|
||||
case 2u: { r = vec3<f32>(0.0, 0.0, v); }
|
||||
case 3u: {
|
||||
na = clamp(v, 0.0, 1.0);
|
||||
r = vec3<f32>(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<f32>(v); }
|
||||
}
|
||||
}
|
||||
if (solid) {
|
||||
na = 1.0;
|
||||
}
|
||||
o = fxk_premul(max(r, vec3<f32>(0.0)), na);
|
||||
}
|
||||
case 17u: {
|
||||
let q = textureLoad(aux, p, 0);
|
||||
var bl: vec4<f32>;
|
||||
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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(clamp(px.x * q.x + px.y, 0.0, 1.0));
|
||||
} else {
|
||||
o = vec4<f32>(q.y);
|
||||
}
|
||||
}
|
||||
case 32u: {
|
||||
o = vec4<f32>(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<f32>(v);
|
||||
}
|
||||
case 34u: {
|
||||
let v = px.x;
|
||||
if (textureLoad(aux, p, 0).x != 0.0) {
|
||||
o = vec4<f32>(0.5 + 0.5 * v, 0.5 * v, 0.5 * v, 1.0);
|
||||
} else {
|
||||
o = vec4<f32>(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<f32>(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<f32>(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<u32>) {
|
||||
let dims = out_dims();
|
||||
let p = vec2<i32>(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<u32>) {
|
||||
let dims = out_dims();
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
let sd = vec2<i32>(textureDimensions(src));
|
||||
var acc = vec4<f32>(0.0);
|
||||
for (var j = 0; j < 16; j++) {
|
||||
for (var i = 0; i < 16; i++) {
|
||||
let q = vec2<i32>(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<f32>(
|
||||
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);
|
||||
}
|
||||
@@ -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<u32>) -> vec2<i32> {
|
||||
let dims = out_dims();
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return vec2<i32>(-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<f32>, b: vec4<f32>) -> vec4<f32> {
|
||||
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<f32>) -> vec4<f32> {
|
||||
if (p.w > 1e-6) {
|
||||
return vec4<f32>(p.xyz / p.w, p.w);
|
||||
}
|
||||
return vec4<f32>(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<u32>) {
|
||||
let p = fxn_pixel(gid);
|
||||
if (p.x < 0) {
|
||||
return;
|
||||
}
|
||||
var v = textureLoad(src, p, 0);
|
||||
if (P.u[0].x == 1u) {
|
||||
v = vec4<f32>(fxn_unpremul(v).xyz, 1.0);
|
||||
}
|
||||
let q = clamp(v, vec4<f32>(0.0), vec4<f32>(1.0)) * 511.0;
|
||||
textureStore(out, p, vec4<f32>(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<u32>) {
|
||||
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<f32>(0.0);
|
||||
var hi = vec4<f32>(511.0);
|
||||
for (var it = 0; it < 9; it++) {
|
||||
let mid = floor((lo + hi) * 0.5);
|
||||
var cnt = vec4<f32>(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<f32>(0.0), vec4<f32>(1.0), q <= mid);
|
||||
}
|
||||
}
|
||||
let above = cnt > vec4<f32>(half);
|
||||
hi = select(hi, mid, above);
|
||||
lo = select(mid + 1.0, lo, above);
|
||||
}
|
||||
textureStore(out, p, fxn_div4(lo, vec4<f32>(511.0)));
|
||||
}
|
||||
|
||||
// noise::median_px.
|
||||
fn fxn_median_px(o: vec4<f32>, m: vec4<f32>, on_alpha: bool) -> vec4<f32> {
|
||||
if (on_alpha) {
|
||||
let a = clamp(m.w, 0.0, 1.0);
|
||||
return vec4<f32>(min(m.xyz, vec3<f32>(a)), a);
|
||||
}
|
||||
if (m.w <= 1e-4) {
|
||||
return o;
|
||||
}
|
||||
return vec4<f32>(min(fxn_div4(m, vec4<f32>(m.w)).xyz, vec3<f32>(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<u32>) {
|
||||
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<f32>(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<u32>) {
|
||||
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<f32>(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<f32>(vec3<f32>(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<u32>) {
|
||||
let p = fxn_pixel(gid);
|
||||
if (p.x < 0) {
|
||||
return;
|
||||
}
|
||||
var l: array<f32, 9>;
|
||||
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<f32>(vec3<f32>(select(0.0, 1.0, on) * px.w), px.w);
|
||||
} else if (on) {
|
||||
o = vec4<f32>(vec3<f32>(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<u32>) {
|
||||
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<u32>) {
|
||||
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<u32>) {
|
||||
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<f32>(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<f32>(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<u32>) {
|
||||
let p = fxn_pixel(gid);
|
||||
if (p.x < 0) {
|
||||
return;
|
||||
}
|
||||
let v = textureLoad(src, p, 0);
|
||||
textureStore(out, p, vec4<f32>(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<u32>) {
|
||||
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<f32>(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<u32>) {
|
||||
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<f32>;
|
||||
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<u32>) {
|
||||
let p = fxn_pixel(gid);
|
||||
if (p.x < 0) {
|
||||
return;
|
||||
}
|
||||
textureStore(out, p, textureLoad(src, p + vec2<i32>(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<u32>) {
|
||||
let p = fxn_pixel(gid);
|
||||
if (p.x < 0) {
|
||||
return;
|
||||
}
|
||||
var v: vec4<f32>;
|
||||
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<f32>(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<u32>) {
|
||||
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<u32>) {
|
||||
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<f32>(0.0), vec4<f32>(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<u32>) {
|
||||
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<f32>(0.0);
|
||||
}
|
||||
textureStore(out, p, vec4<f32>(max(acc.xyz, vec3<f32>(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<f32, 4>;
|
||||
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<u32>) {
|
||||
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<f32>(vec3<f32>(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<u32>) {
|
||||
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<f32>(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<u32>) {
|
||||
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<f32>(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<u32>) {
|
||||
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<f32>(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<u32>) {
|
||||
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<f32>(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<f32>(data[i], data[i + 1u], data[i + 2u], data[i + 3u]);
|
||||
}
|
||||
var o: vec4<f32>;
|
||||
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<u32>) {
|
||||
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<f32>(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<u32>) {
|
||||
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<u32>) {
|
||||
let p = fxn_pixel(gid);
|
||||
if (p.x < 0) {
|
||||
return;
|
||||
}
|
||||
let o = textureLoad(aux, p, 0);
|
||||
let a = o.w;
|
||||
var v = vec4<f32>(clamp(textureLoad(src, p, 0).xyz, vec3<f32>(0.0), vec3<f32>(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<f32>(P.f[1].xyz, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
textureStore(out, p, v);
|
||||
}
|
||||
@@ -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<i32>) -> bool {
|
||||
let d = out_dims();
|
||||
return p.x < d.x && p.y < d.y;
|
||||
}
|
||||
|
||||
fn fxs_centre(p: vec2<i32>) -> vec2<f32> {
|
||||
return vec2<f32>(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<f32>) -> vec4<f32> {
|
||||
let a = px.w;
|
||||
if (a > 1e-6) {
|
||||
return vec4<f32>(fxs_qdiv(px.xyz, a), a);
|
||||
}
|
||||
return vec4<f32>(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<f32>, a: f32) -> vec3<f32> {
|
||||
let q = x / a;
|
||||
let r = fma(-q, vec3<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(0.0), vec3<f32>(1.0)) * lv;
|
||||
let o = vec3<f32>(round_away(c.x), round_away(c.y), round_away(c.z)) / lv;
|
||||
textureStore(out, p, vec4<f32>(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<f32>(vec3<f32>(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<f32>(fxs_bin(c.x, t, inv), fxs_bin(c.y, t, inv), fxs_bin(c.z, t, inv));
|
||||
}
|
||||
case 2u: {
|
||||
o = vec3<f32>(fxs_bin(rgb_to_hsl(c).y, t, inv));
|
||||
}
|
||||
case 3u: {
|
||||
na = fxs_bin(u.w, t, inv);
|
||||
}
|
||||
default: {
|
||||
o = vec3<f32>(fxs_bin(luminance(c), t, inv));
|
||||
}
|
||||
}
|
||||
o = o + (c - o) * blend;
|
||||
na = na + (u.w - na) * blend;
|
||||
textureStore(out, p, vec4<f32>(o * na, na));
|
||||
}
|
||||
case 4u: {
|
||||
let c = fxs_qdiv(px.xyz, a);
|
||||
let t = P.f[0].xyz;
|
||||
let o = vec3<f32>(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<f32>(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<f32>(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<f32>(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<f32>(max(u.xyz * k, vec3<f32>(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<f32> {
|
||||
let k = (j * P.u[0].y + i) * 2u;
|
||||
return vec2<f32>(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<f32> {
|
||||
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<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<f32>(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<f32>;
|
||||
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<f32>(a / FXS_TAU * w, h - r * h);
|
||||
} else {
|
||||
let a = x / w * FXS_TAU;
|
||||
let r = (h - y) / h * rmax;
|
||||
pp = vec2<f32>(cx + r * sin(a), cy - r * cos(a));
|
||||
}
|
||||
let amt = P.f[1].y;
|
||||
s = vec2<f32>(x + (pp.x - x) * amt, y + (pp.y - y) * amt);
|
||||
}
|
||||
case 4u: {
|
||||
let fl = P.f[0].xy;
|
||||
s = vec2<f32>(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<f32>(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<f32>(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<f32>(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<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
rs = f * tan(th);
|
||||
}
|
||||
let k = rs / r * fit;
|
||||
s = vec2<f32>(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<f32>(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<f32>((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<f32>(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<f32>(0.0);
|
||||
if (r > 1e-9) {
|
||||
u = vec2<f32>(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<f32>(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<f32>(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<f32>(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<f32>, k: vec4<f32>) -> vec2<f32> {
|
||||
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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<f32>((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<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>;
|
||||
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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<u32>) {
|
||||
let p = vec2<i32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(0.0));
|
||||
} else {
|
||||
textureStore(out, p, px);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var m: vec4<f32>;
|
||||
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<u32>) {
|
||||
let p = vec2<i32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<f32>(0.0);
|
||||
} else if (surface == 2u) {
|
||||
base_px = vec4<f32>(1.0);
|
||||
} else if (surface == 3u) {
|
||||
base_px = vec4<f32>(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
var painted: vec4<f32>;
|
||||
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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<f32>(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<f32>(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<f32>(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<u32>) {
|
||||
let p = vec2<i32>(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<u32>) {
|
||||
let p = vec2<i32>(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<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (!fxs_inside(p)) {
|
||||
return;
|
||||
}
|
||||
textureStore(out, p, textureLoad(src, p, 0) / P.f[0].x);
|
||||
}
|
||||
@@ -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<f32>) -> vec3<f32> {
|
||||
if (px.w > 1e-6) {
|
||||
return px.xyz / px.w;
|
||||
}
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
|
||||
fn fxt_mix(a: vec3<f32>, b: vec3<f32>, t: f32) -> vec3<f32> {
|
||||
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<f32>, key: vec3<f32>, 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<f32>(c.x / sa, c.y / sa);
|
||||
let k = vec2<f32>(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<u32>) {
|
||||
let dims = out_dims();
|
||||
let p = vec2<i32>(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<f32>(0.0, 0.0, 0.0, clamp(na, 0.0, 1.0));
|
||||
}
|
||||
} else {
|
||||
let aa = max(a, 0.0);
|
||||
var nc: vec3<f32>;
|
||||
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<f32>(nc * na, na);
|
||||
}
|
||||
}
|
||||
case 2u: {
|
||||
if (straight_skip) {
|
||||
break;
|
||||
}
|
||||
let stretch = P.f[3].x;
|
||||
var r: vec3<f32>;
|
||||
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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(g), amt * (1.0 - keepk));
|
||||
o = vec4<f32>(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<f32>(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<f32>(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<f32>(0.0);
|
||||
}
|
||||
}
|
||||
case 3u: {
|
||||
if (!unsafe_px) {
|
||||
o = vec4<f32>(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<f32>(r * a, a);
|
||||
}
|
||||
}
|
||||
default: {
|
||||
if (unsafe_px) {
|
||||
let k = max_norm / (y + ch);
|
||||
o = vec4<f32>(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<f32>(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<f32>(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<f32>(r * a, a);
|
||||
}
|
||||
case 11u: {
|
||||
if (a <= 0.0) {
|
||||
break;
|
||||
}
|
||||
var acc = vec3<f32>(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<f32>(max(v, vec3<f32>(0.0)) * a, a);
|
||||
}
|
||||
case 12u: {
|
||||
let n = u0.z;
|
||||
let aa = max(a, 0.0);
|
||||
let r = vec3<f32>(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<f32>(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<f32>(0.0), vec3<f32>(level));
|
||||
if (u0.z != 0u && any(abs(c - r) > vec3<f32>(1e-4))) {
|
||||
r = P.f[1].xyz;
|
||||
}
|
||||
o = vec4<f32>(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<f32>(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<f32>(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<f32>(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<f32>;
|
||||
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<f32>(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<f32>;
|
||||
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<f32>(nl);
|
||||
}
|
||||
} else if (u0.y == 2u) {
|
||||
r = vec3<f32>(fxt_eq_look(0u, n, c.x), fxt_eq_look(0u, n, c.y), fxt_eq_look(0u, n, c.z));
|
||||
} else {
|
||||
r = vec3<f32>(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<f32>(r * na, na);
|
||||
}
|
||||
case 18u: {
|
||||
if (a <= 0.0) {
|
||||
break;
|
||||
}
|
||||
let ch = u0.y;
|
||||
var r: vec3<f32>;
|
||||
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<f32>(
|
||||
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<f32>(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<f32>(0.0),
|
||||
);
|
||||
}
|
||||
o = vec4<f32>((c + (r - c) * f0.x) * a, a);
|
||||
}
|
||||
case 19u: {
|
||||
o = vec4<f32>(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<f32>(0.0)), c, P.f[1].z);
|
||||
o = vec4<f32>(r * a, a);
|
||||
}
|
||||
case 21u: {
|
||||
o = vec4<f32>(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<f32>(0.0));
|
||||
let na = clamp(max(a, 0.0), 0.0, 1.0);
|
||||
o = vec4<f32>(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<f32>;
|
||||
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<f32>(r * a, a);
|
||||
}
|
||||
case 24u: {
|
||||
if (a <= 0.0) {
|
||||
break;
|
||||
}
|
||||
var r: vec3<f32>;
|
||||
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<f32>(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);
|
||||
}
|
||||
@@ -844,3 +844,118 @@ fn adjust_mix(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
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<u32>) {
|
||||
let dims = out_dims();
|
||||
let p = vec2<i32>(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<f32>(l) + (rgb - vec3<f32>(l)) * sat, vec3<f32>(0.0)) * k;
|
||||
c = vec4<f32>(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<u32>) {
|
||||
let dims = vec2<i32>(textureDimensions(src));
|
||||
let y = i32(gid.x);
|
||||
if (y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
var acc = vec4<f32>(0.0);
|
||||
textureStore(out, vec2<i32>(0, y), acc);
|
||||
for (var x = 0; x < dims.x; x = x + 1) {
|
||||
acc = acc + textureLoad(src, vec2<i32>(x, y), 0);
|
||||
textureStore(out, vec2<i32>(x + 1, y), acc);
|
||||
}
|
||||
}
|
||||
|
||||
fn bokeh_prefix_at(y: i32, t: f32) -> vec4<f32> {
|
||||
let n = i32(textureDimensions(src).x) - 1;
|
||||
if (t <= 0.0) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
if (t >= f32(n)) {
|
||||
return textureLoad(src, vec2<i32>(n, y), 0);
|
||||
}
|
||||
let i = i32(floor(t));
|
||||
let f = t - f32(i);
|
||||
let a = textureLoad(src, vec2<i32>(i, y), 0);
|
||||
let b = textureLoad(src, vec2<i32>(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<u32>) {
|
||||
let dims = out_dims();
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
var acc = vec4<f32>(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<f32>(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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<String>) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<bool, Stri
|
||||
s.opentype.figure_style = st;
|
||||
s.opentype.figure_width = w;
|
||||
}
|
||||
// Variable font axes: `{wght: 650, wdth: 90}` (null removes an axis), or `{}` / null to
|
||||
// reset every axis to its default.
|
||||
"variations" => 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::<u32>().is_ok_and(|n| (1..=20).contains(&n)) => {
|
||||
let n = k[2..].parse::<u32>().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::<Vec<_>>(),
|
||||
"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::<serde_json::Map<String, J>>(),
|
||||
"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" },
|
||||
})
|
||||
|
||||
+30
-12
@@ -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<std::result::Result<effectcraft_svg::Doc, String>> {
|
||||
/// 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<std::result::Result<effectcraft_svg::Doc, String>> {
|
||||
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<Image> {
|
||||
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<Image> {
|
||||
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))
|
||||
|
||||
@@ -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<Arc<effectcraft_raster::AuxChannels>> {
|
||||
|
||||
+11
-1
@@ -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 }
|
||||
|
||||
+53
-9
@@ -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).
|
||||
|
||||
+16
-2
@@ -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<Stroke>) {
|
||||
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<Stroke>) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+238
-2
@@ -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<Func>),
|
||||
/// Type 4: a PostScript calculator program (§7.10.5).
|
||||
Calc {
|
||||
domain: Vec<f64>,
|
||||
range: Vec<f64>,
|
||||
code: Vec<PsOp>,
|
||||
},
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// A PostScript calculator token; procedures nest for `if` / `ifelse`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PsOp {
|
||||
Num(f64),
|
||||
Op(String),
|
||||
Proc(Vec<PsOp>),
|
||||
}
|
||||
|
||||
fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec<PsOp> {
|
||||
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<f64>, 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<f64>| 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<fn(f64) -> 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<f64> {
|
||||
if let Func::Calc { domain, range, code } = self {
|
||||
let mut st: Vec<f64> = 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<char> {
|
||||
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<Base> {
|
||||
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<char> {
|
||||
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<String> {
|
||||
self.unicode(code).map(unicode_to_name)
|
||||
}
|
||||
}
|
||||
|
||||
fn ascii(code: u8) -> Option<u32> {
|
||||
(0x20..0x7F).contains(&code).then_some(code as u32)
|
||||
}
|
||||
|
||||
fn standard(code: u8) -> Option<u32> {
|
||||
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<u32> {
|
||||
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<u32> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>>),
|
||||
Cff(Box<Cff>),
|
||||
Type1(Box<Type1>),
|
||||
/// A bundled stand-in (not embedded).
|
||||
Fallback(&'static [u8]),
|
||||
/// Glyph procedures: (name → content stream, resources).
|
||||
Type3 {
|
||||
procs: HashMap<String, Vec<u8>>,
|
||||
resources: Option<Dict>,
|
||||
},
|
||||
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<f64>,
|
||||
cid_widths: HashMap<u32, f64>,
|
||||
default_width: f64,
|
||||
/// Simple fonts: code → glyph name.
|
||||
names: Vec<Option<String>>,
|
||||
symbolic: bool,
|
||||
to_unicode: HashMap<u32, String>,
|
||||
cid_to_gid: Option<Vec<u16>>,
|
||||
/// 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<HashMap<u32, Option<Arc<BezPath>>>>,
|
||||
/// 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<BezPath> {
|
||||
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<f64> {
|
||||
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<u16> {
|
||||
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<GlyphId>| 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<String>]) {
|
||||
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<u32, String>,
|
||||
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<u16> = 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<Obj> = 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<String>) -> 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<u8>)> {
|
||||
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<Vec<Option<String>>> = 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<u16> {
|
||||
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<Arc<BezPath>> {
|
||||
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<BezPath> {
|
||||
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<BezPath> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<usize> {
|
||||
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<u8>, Option<usize>), 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<Obj> = 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<u8>, 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<u16> {
|
||||
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<u8> {
|
||||
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<u8>, 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<Decoded, String> {
|
||||
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<Vec<[f64; 3]>> = 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<f64> = 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 })
|
||||
}
|
||||
+10
-1
@@ -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<Node>, flags: Vec<bool>, skipped: Vec<String>, 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<Doc, Error> {
|
||||
@@ -170,6 +178,7 @@ pub fn layer_names(doc: &Doc) -> Vec<String> {
|
||||
.map(|n| match n {
|
||||
Node::Group(g) => g.name.clone(),
|
||||
Node::Shape(s) => s.name.clone(),
|
||||
Node::Image(i) => i.name.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+553
-84
@@ -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<u8> {
|
||||
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<u8>,
|
||||
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<Tiling>),
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -91,6 +112,20 @@ struct GState {
|
||||
dash: Option<(Vec<f64>, f64)>,
|
||||
fill_alpha: f64,
|
||||
stroke_alpha: f64,
|
||||
// Text state (§9.3).
|
||||
font: Option<Rc<Font>>,
|
||||
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<usize>,
|
||||
mask_depth: Option<usize>,
|
||||
/// 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<BezPath>,
|
||||
fonts: HashMap<String, Rc<Font>>,
|
||||
}
|
||||
|
||||
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<u8>),
|
||||
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<Obj> = 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::<Vec<f64>>();
|
||||
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<TextItem> = 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<String> = 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<String> {
|
||||
// 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<Paint> {
|
||||
fn paint_of(&mut self, f: &Fill) -> Option<Paint> {
|
||||
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<FillRule>, stroke: bool) {
|
||||
fn paint(&mut self, fill: bool, rule: Option<FillRule>, 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<Rc<Font>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+360
-1
@@ -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<Vec<u8>>)>;
|
||||
|
||||
/// 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<u8> {
|
||||
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<String> {
|
||||
fn walk(g: &effectcraft_svg::Group, out: &mut Vec<String>) {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8>, extra: Objs| -> Vec<u8> {
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8, String>,
|
||||
subrs: Vec<Vec<u8>>,
|
||||
charstrings: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
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<u8> {
|
||||
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<Item = &[u8]> {
|
||||
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<Type1> {
|
||||
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<f64> = 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::<u8>().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<u8> = if hex {
|
||||
let digits: Vec<u8> = 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::<i64>().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: `<n> RD <n bytes>` (RD may be spelt `-|`).
|
||||
let read_binary = |p: usize| -> Option<(Vec<u8>, 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::<usize>().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<f64>,
|
||||
/// The PostScript operand stack shared with `callothersubr` / `pop`.
|
||||
ps: Vec<f64>,
|
||||
width: f64,
|
||||
sbx: f64,
|
||||
open: bool,
|
||||
/// Flex points being collected (othersubr 1 … 0).
|
||||
flex: Option<Vec<(f64, f64)>>,
|
||||
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<f64> = 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<u8>)], encoding: &[(u8, &str)]) -> Vec<u8> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>>)], root: u32) -> Vec<u8> {
|
||||
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<Vec<u8>>)], root: u32) -> Vec<u8> {
|
||||
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());
|
||||
|
||||
@@ -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<ControlType>,
|
||||
}
|
||||
|
||||
#[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> {
|
||||
[
|
||||
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<ControlType> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<ControlType> {
|
||||
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<ControlType>) -> 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<crate::Keyframe>)> = 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)) {
|
||||
|
||||
@@ -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<String>,
|
||||
/// PDF / Illustrator footage: the page shown (0-based; File ▸ Import ▸ Page).
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub page: u32,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(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<String>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -30,6 +30,9 @@ pub enum DValue {
|
||||
Class(String, String),
|
||||
Raw(Vec<u8>),
|
||||
Reference(Vec<RefItem>),
|
||||
/// 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> {
|
||||
}
|
||||
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<u8>, 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());
|
||||
|
||||
@@ -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<placed::Warp>,
|
||||
}
|
||||
|
||||
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()?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user