mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-05 18:18:49 +00:00
G8: Plug-in Library: install, preview and update community WebAssembly effects (#2524)
* G8: plug-in library Community Plug-in Library for WebAssembly effect plug-ins (#376): one reviewed catalog (index.json), install / update / remove from Settings > Plug-in Library (also Effect > Plug-in Library...), library.* commands, start-up loading of installed packages with SHA-256 checks. Downloads go through the system curl (https, GitHub hosts), as trained models do. Preview before installing (library.preview): the release is downloaded, checked like an install, and rendered in the sandbox on a built-in sample frame at default settings (2 s at 12 fps) with a Before/After split, plus the effect's parameters, "Open on GitHub" and an optional author video link (catalog field preview.video). Authors need to do nothing for it. Hardening: a module that fails to load leaves the installed copy alone, Update All updates every package in one job, minApp also applies to an explicit version, curl ignores ~/.curlrc and URL globbing, unique temp files, percent-decoded file URLs. Plug-in effect slots grow from 64 to 256 with a clearer overflow error. Docs: plugins.md "Plug-in Library", publishing-plugins.md (author guide from zero to listed), gaps.md; examples/plugin-catalog is the starting content of the catalog repository (schema, validation workflow). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * contributors: credit iseemhlyfta * G8: plug-in library: doc comments on the library's functions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: plug-in catalog check: require one object with schema 1 and a packages array An empty file or several JSON values passed jq empty, and with SKIP_SCHEMA=1 nothing else checked the catalog's shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: plug-in library: one preview texture set; catalog schema requires effects The preview kept a texture set per package version previewed (about 6 MB each) for the whole session; it now keeps one, replaced when another package is previewed. Releases in the catalog schema must list at least one effect id, as the author guide says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: plug-in catalog schema: effect ids follow the manifest's id rules Letters, digits, '.', '_' and '-', and not the reserved 'ec.' prefix, so library.missing can match them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: refuse Plug-in Library jobs while automation file access is confined Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: split the Plug-in Library page into toolbar, card, links and actions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: show Plug-in Library toasts without a Roto job; cache preview frame checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: log Plug-in Library packages that fail to load at start-up Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: check an update before replacing a loaded package; Update All past failures - An update to a package that is already loaded is now compiled and its manifest checked (through the previewer, without registering it) before the new file is written and the old version's folder deleted. Before, a release with the right SHA-256 that didn't load replaced the working version and failed at the next start. - Package ids that differ only in case, or end in a dot, would share one folder on Windows and macOS: the catalog keeps the first of them, and ids can't end in a dot (schema too). - Update All: a package that can't be picked or installed no longer stops the others; the result names what was updated and what wasn't. - Downloads: at most 5 redirects, and one that ends off GitHub's hosts is refused, as the module docs say. - A job that panics no longer leaves the library busy; poisoned status locks are used, not dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * G8: rustfmt the update test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
31 files changed
+2579
-6
No files matched your search
@@ -256,6 +256,11 @@ fn main() -> eframe::Result {
|
||||
let plugins = dir.join("Plug-ins");
|
||||
if plugins.is_dir() {
|
||||
let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
|
||||
// Packages installed from the Plug-in Library (<config>/Plug-ins/Library).
|
||||
// Each package that fails to load is logged by the library itself.
|
||||
if let Err(e) = session.execute("library.loadInstalled", json!({})) {
|
||||
log::warn!("plug-in library: {e}");
|
||||
}
|
||||
}
|
||||
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
||||
}
|
||||
|
||||
@@ -104,6 +104,10 @@ sync_github_name = false
|
||||
[people.ki2pixel]
|
||||
display_name = "ki2pixel"
|
||||
|
||||
[people.iseemhlyfta]
|
||||
display_name = "iseemhlyfta"
|
||||
sync_github_name = false
|
||||
|
||||
[people.becauseno7]
|
||||
real_name = "Shuvra Basak"
|
||||
display_name = "Shuvra Basak"
|
||||
|
||||
@@ -60,6 +60,13 @@ const REFUSED: &[&str] = &[
|
||||
"layer.openSource",
|
||||
"help.revealLogFile",
|
||||
"file.autoSave",
|
||||
// The Plug-in Library downloads code, installs it to load at start-up and reads `file://` catalogs.
|
||||
"library.refresh",
|
||||
"library.install",
|
||||
"library.update",
|
||||
"library.uninstall",
|
||||
"library.loadInstalled",
|
||||
"library.preview",
|
||||
];
|
||||
|
||||
/// Commands that read files named by these parameters.
|
||||
|
||||
@@ -1474,6 +1474,14 @@ fn automation_roots_confine_file_access() {
|
||||
// Scripts can read and write anything: refused.
|
||||
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
|
||||
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
|
||||
// So can Plug-in Library jobs: they download modules, install code that loads at start-up and read `file://` catalogs.
|
||||
for c in ["library.refresh", "library.install", "library.update", "library.uninstall", "library.loadInstalled", "library.preview"] {
|
||||
let msg = denied(&mut s, "execute_command", json!({"command": c, "params": {"id": "x"}}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{msg}");
|
||||
}
|
||||
let steps = json!([{"command": "library.install", "params": {"id": "x"}}]);
|
||||
denied(&mut s, "batch", json!({"steps": steps}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "library.list"}));
|
||||
for (id, params) in [
|
||||
("file.runStartupScripts", json!({})),
|
||||
("file.runShutdownScripts", json!({})),
|
||||
|
||||
@@ -34,7 +34,7 @@ use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, RenderFn};
|
||||
pub const PLUGIN_API_VERSION: u32 = 1;
|
||||
|
||||
/// The most plug-in effects one process can register.
|
||||
pub const MAX_PLUGINS: usize = 64;
|
||||
pub const MAX_PLUGINS: usize = 256;
|
||||
|
||||
/// A plug-in parameter's kind, default and range.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -280,7 +280,10 @@ pub fn register_plugin(plugin: Arc<dyn EffectPlugin>) -> Result<&'static EffectS
|
||||
if crate::registry().iter().any(|s| s.id == m.id) || specs.iter().any(|s| s.id == m.id) {
|
||||
return Err(format!("an effect with id `{}` is already registered", m.id));
|
||||
}
|
||||
let slot = SLOTS.iter().position(|s| s.get().is_none()).ok_or_else(|| format!("too many plug-ins (at most {MAX_PLUGINS})"))?;
|
||||
let slot = SLOTS
|
||||
.iter()
|
||||
.position(|s| s.get().is_none())
|
||||
.ok_or_else(|| format!("too many plug-in effects: a session registers at most {MAX_PLUGINS}; remove some plug-ins and restart EffectCraft"))?;
|
||||
SLOTS[slot].set(plugin).map_err(|_| "plug-in slot taken".to_string())?;
|
||||
let spec: &'static EffectSpec = Box::leak(Box::new(EffectSpec {
|
||||
id: leak(&m.id),
|
||||
@@ -307,6 +310,12 @@ macro_rules! trampolines {
|
||||
const TRAMPOLINES: [RenderFn; MAX_PLUGINS] = trampolines!(
|
||||
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
||||
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
|
||||
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
|
||||
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
|
||||
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
|
||||
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191
|
||||
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
|
||||
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255
|
||||
);
|
||||
|
||||
/// Flatten an instance's evaluated parameters for the plug-in (points mapped to frame pixels).
|
||||
|
||||
@@ -854,6 +854,7 @@ impl Session {
|
||||
/// instances in the background.
|
||||
pub fn poll_roto(&mut self, auto: bool) -> bool {
|
||||
self.poll_models();
|
||||
self.poll_library();
|
||||
let changed = self.poll_roto_job();
|
||||
// Edited instances stay pending while the model they will segment with is still loading.
|
||||
let loading = self.roto_model_loading();
|
||||
|
||||
@@ -176,6 +176,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(crate::models::specs());
|
||||
v.extend(crate::library::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(comp::specs());
|
||||
v.extend(layer::specs());
|
||||
|
||||
@@ -78,6 +78,7 @@ pub use settings::config;
|
||||
pub use settings::prefs;
|
||||
pub use settings::shortcuts;
|
||||
pub use system::guard;
|
||||
pub use system::library;
|
||||
pub use system::logging;
|
||||
pub use system::perf;
|
||||
pub use system::storage;
|
||||
@@ -199,6 +200,13 @@ pub struct Session {
|
||||
/// Loads WebAssembly effect plug-ins (set by the host that links `effectcraft-plugin` with
|
||||
/// its runtime): `effect.plugins.load`.
|
||||
pub plugin_loader: Option<PluginLoader>,
|
||||
/// Renders a module without registering it (`library.preview`; set with `plugin_loader`).
|
||||
pub plugin_previewer: Option<PluginPreviewer>,
|
||||
/// Where effect plug-ins live (hosts set it; else `Plug-ins` next to the settings): the
|
||||
/// Plug-in Library installs into its `Library` folder ([`library`]).
|
||||
pub plugins_dir: Option<std::path::PathBuf>,
|
||||
/// The Plug-in Library's catalog, worker status and the packages this session loaded.
|
||||
pub library: library::Library,
|
||||
/// ScriptUI windows and panels opened by scripts (see [`scriptui`]).
|
||||
pub script_ui: scriptui::ScriptUi,
|
||||
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
|
||||
@@ -275,6 +283,11 @@ pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value;
|
||||
/// describes it (`{id, name, category, version, api, params}`).
|
||||
pub type PluginLoader = fn(&[u8], &str) -> std::result::Result<Value, String>;
|
||||
|
||||
/// Renders one WebAssembly effect module without registering it (the Plug-in Library preview):
|
||||
/// `(bytes, width, height, premultiplied RGBA pixels, times in seconds)` → (manifest JSON, one frame
|
||||
/// per time).
|
||||
pub type PluginPreviewer = fn(&[u8], u32, u32, &[[f32; 4]], &[f64]) -> std::result::Result<(Value, Vec<Vec<[f32; 4]>>), String>;
|
||||
|
||||
impl Default for Session {
|
||||
/// A new unsaved session: a default project that is both current and saved, no path, no
|
||||
/// active jobs, the plain `FsServices`/`NoFootage` defaults, default preferences and keymaps,
|
||||
@@ -330,6 +343,9 @@ impl Default for Session {
|
||||
snapshot: None,
|
||||
script: None,
|
||||
plugin_loader: None,
|
||||
plugin_previewer: None,
|
||||
plugins_dir: None,
|
||||
library: Default::default(),
|
||||
script_ui: scriptui::ScriptUi::default(),
|
||||
sys_memory: None,
|
||||
key_move: None,
|
||||
|
||||
@@ -1015,6 +1015,7 @@ Effect
|
||||
---
|
||||
Manage Effects... | effect.manage
|
||||
Load Effect Plug-in... | effect.plugins.load
|
||||
Plug-in Library... | prefs.open {"page":"library"}
|
||||
---
|
||||
@effects
|
||||
Animation
|
||||
|
||||
@@ -53,6 +53,9 @@ pub enum Item {
|
||||
/// A task's trained models (`roto.models`, `face.models`): authors, licence, size, install,
|
||||
/// choose.
|
||||
Models(effectcraft_segment::Task),
|
||||
/// The Plug-in Library (`library.*`): the community catalog's packages, installed ones,
|
||||
/// install / update / remove.
|
||||
PluginLibrary,
|
||||
}
|
||||
|
||||
/// One page of the Settings dialog.
|
||||
@@ -371,6 +374,17 @@ pub fn pages() -> Vec<Page> {
|
||||
Models(effectcraft_segment::Task::Face),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "library",
|
||||
title: "Plug-in Library",
|
||||
items: vec![
|
||||
Section("Community Effect Plug-ins"),
|
||||
Note(
|
||||
"Effect plug-ins published by the community and reviewed into one catalog. They are WebAssembly modules that run in a sandbox (no files, network or clock), downloaded only when you ask and checked against the catalog's SHA-256.",
|
||||
),
|
||||
PluginLibrary,
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -423,6 +437,7 @@ pub fn pages_json() -> Value {
|
||||
Item::AudioDevices { key } => json!({"type": "device", "key": key, "label": "Default Output"}),
|
||||
Item::Note(t) => json!({"note": t}),
|
||||
Item::BrowserStorage => json!({"browserStorage": "storage.info / storage.persist / storage.clear (web app)"}),
|
||||
Item::PluginLibrary => json!({"pluginLibrary": "library.list / library.refresh / library.install / library.update / library.uninstall"}),
|
||||
Item::Models(task) => {
|
||||
let p = if *task == effectcraft_segment::Task::Face { "face" } else { "roto" };
|
||||
json!({"models": format!("{p}.models / {p}.model.select / {p}.model.download / {p}.model.install / {p}.model.remove"), "key": format!("{p}.model")})
|
||||
|
||||
@@ -302,6 +302,7 @@ pub fn page_id(name: &str) -> Option<&'static str> {
|
||||
"scripting" | "scriptingexpressions" => "scripting",
|
||||
"roto" | "rotobrush" | "models" => "roto",
|
||||
"face" | "facetracking" => "face",
|
||||
"library" | "pluginlibrary" | "plugins" | "plugin" => "library",
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod guard;
|
||||
pub mod library;
|
||||
pub mod logging;
|
||||
pub mod perf;
|
||||
pub mod storage;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -95,6 +95,8 @@ pub fn session() -> Session {
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader),
|
||||
plugin_previewer: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::preview as effectcraft_engine::PluginPreviewer),
|
||||
plugins_dir: config_dir().map(|d| d.join("Plug-ins")),
|
||||
..Default::default()
|
||||
};
|
||||
effectcraft_script::install(&mut s);
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! The Plug-in Library in a full session, against a local catalog (`file://`): install, update,
|
||||
//! remove and start-up loading of WebAssembly packages, checksum and URL rules.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{GroupKind, LayerId, Node};
|
||||
use effectcraft_engine::segment::sha256;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::plugins::wat_plugin;
|
||||
|
||||
/// A fresh folder for one test: the plug-ins folder and the catalog's files.
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-library-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("served")).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `p` as a `file://` URL (`file:///C:/…` on Windows).
|
||||
fn file_url(p: &Path) -> String {
|
||||
let s = p.to_string_lossy().replace('\\', "/");
|
||||
if s.starts_with('/') { format!("file://{s}") } else { format!("file:///{s}") }
|
||||
}
|
||||
|
||||
/// A host session that installs into `dir`.
|
||||
fn session(dir: &Path) -> Session {
|
||||
let mut s = effectcraft_host::session();
|
||||
s.plugins_dir = Some(dir.join("Plug-ins"));
|
||||
s
|
||||
}
|
||||
|
||||
/// A served module for `effect`; returns (url, sha256).
|
||||
fn serve(dir: &Path, file: &str, effect: &str) -> (String, String) {
|
||||
let bytes = wat_plugin(effect, 1, "i32.const 0").into_bytes();
|
||||
let path = dir.join("served").join(file);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
(file_url(&path), sha256::hex(&bytes))
|
||||
}
|
||||
|
||||
/// Write the catalog (`packages`) and return its URL.
|
||||
fn write_catalog(dir: &Path, packages: Value) -> String {
|
||||
let path = dir.join("served").join("index.json");
|
||||
std::fs::write(&path, json!({"schema": 1, "packages": packages}).to_string()).unwrap();
|
||||
file_url(&path)
|
||||
}
|
||||
|
||||
/// A catalog release entry for a served module.
|
||||
fn release(version: &str, (url, sha): &(String, String), effect: &str) -> Value {
|
||||
json!({"version": version, "api": 1, "url": url, "sha256": sha, "effects": [effect]})
|
||||
}
|
||||
|
||||
/// The package `id` in a `library.list` reply.
|
||||
fn package<'a>(list: &'a Value, id: &str) -> &'a Value {
|
||||
list["packages"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap_or_else(|| panic!("no {id} in {list}"))
|
||||
}
|
||||
|
||||
/// The effect ids registered in the process.
|
||||
fn plugin_ids(s: &mut Session) -> Vec<String> {
|
||||
let l = s.execute("effect.plugins.list", json!({})).unwrap();
|
||||
l["plugins"].as_array().unwrap().iter().filter_map(|p| p["id"].as_str().map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_update_and_remove_a_package() {
|
||||
let dir = tmp("cycle");
|
||||
let fx = "org.test.library.cycle";
|
||||
let v1 = serve(&dir, "cycle-1.wasm", fx);
|
||||
let catalog = write_catalog(
|
||||
&dir,
|
||||
json!([{"id": "org.test.cycle", "name": "Cycle", "author": "Tester", "license": "MIT", "tags": ["test"], "versions": [release("1.0.0", &v1, fx)]}]),
|
||||
);
|
||||
let mut s = session(&dir);
|
||||
// Before a catalog: nothing listed, installing is refused.
|
||||
assert_eq!(s.execute("library.list", json!({})).unwrap()["packages"], json!([]));
|
||||
assert!(s.execute("library.install", json!({"id": "org.test.cycle", "wait": true})).unwrap_err().to_string().contains("library.refresh"));
|
||||
assert_eq!(s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap()["packages"], 1);
|
||||
let l = s.execute("library.list", json!({"query": "tester"})).unwrap();
|
||||
let p = package(&l, "org.test.cycle");
|
||||
assert_eq!((p["latest"].as_str(), p["installed"].as_str(), p["effects"][0].as_str()), (Some("1.0.0"), None, Some(fx)), "{l}");
|
||||
assert_eq!(l["tags"], json!(["test"]));
|
||||
assert_eq!(s.execute("library.list", json!({"query": "no such thing"})).unwrap()["packages"], json!([]));
|
||||
|
||||
let r = s.execute("library.install", json!({"id": "org.test.cycle", "wait": true})).unwrap();
|
||||
assert_eq!((r["installed"].as_bool(), r["restartNeeded"].as_bool()), (Some(true), Some(false)), "{r}");
|
||||
assert!(plugin_ids(&mut s).iter().any(|i| i == fx), "loaded right away");
|
||||
let lib = dir.join("Plug-ins").join("Library");
|
||||
assert!(lib.join("org.test.cycle").join("1.0.0").join("org.test.cycle.wasm").is_file());
|
||||
let installed: Value = serde_json::from_slice(&std::fs::read(lib.join("installed.json")).unwrap()).unwrap();
|
||||
assert_eq!(installed["packages"]["org.test.cycle"]["version"], "1.0.0");
|
||||
// The effect applies like any other.
|
||||
s.execute("comp.new", json!({"name": "L", "width": 8, "height": 8, "duration": 1})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"color": [1, 0, 0], "width": 8, "height": 8})).unwrap()["layer"].clone();
|
||||
s.execute("effect.apply", json!({"layer": layer, "effect": fx})).unwrap();
|
||||
assert_eq!(s.execute("library.missing", json!({})).unwrap()["effects"], json!([]));
|
||||
|
||||
// Version 2: an update; the loaded version stays until the next start.
|
||||
let v2 = serve(&dir, "cycle-2.wasm", fx);
|
||||
let catalog = write_catalog(&dir, json!([{"id": "org.test.cycle", "name": "Cycle", "versions": [release("1.0.0", &v1, fx), release("1.10.0", &v2, fx)]}]));
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
assert_eq!(package(&s.execute("library.list", json!({})).unwrap(), "org.test.cycle")["updateAvailable"], true);
|
||||
let r = s.execute("library.update", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["updating"], json!(["org.test.cycle"]), "{r}");
|
||||
let p = s.execute("library.list", json!({"installed": true})).unwrap();
|
||||
let p = package(&p, "org.test.cycle");
|
||||
assert_eq!((p["installed"].as_str(), p["updateAvailable"].as_bool(), p["restartNeeded"].as_bool()), (Some("1.10.0"), Some(false), Some(true)), "{p}");
|
||||
assert!(!lib.join("org.test.cycle").join("1.0.0").exists(), "the old version is removed");
|
||||
|
||||
let r = s.execute("library.uninstall", json!({"id": "org.test.cycle"})).unwrap();
|
||||
assert_eq!((r["removed"].as_bool(), r["restartNeeded"].as_bool()), (Some(true), Some(true)));
|
||||
assert!(!lib.join("org.test.cycle").exists());
|
||||
assert_eq!(s.execute("library.uninstall", json!({"id": "org.test.cycle"})).unwrap()["removed"], false);
|
||||
// Still listed by the catalog, no longer installed.
|
||||
assert!(package(&s.execute("library.list", json!({})).unwrap(), "org.test.cycle")["installed"].is_null());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_checksum_or_a_bad_module_installs_nothing() {
|
||||
let dir = tmp("checks");
|
||||
let (url, _) = serve(&dir, "a.wasm", "org.test.library.checks.a");
|
||||
let garbage = dir.join("served").join("garbage.wasm");
|
||||
std::fs::write(&garbage, b"\0asm\x01\0\0\0garbage").unwrap();
|
||||
let garbage_sha = sha256::hex(&std::fs::read(&garbage).unwrap());
|
||||
let catalog = write_catalog(
|
||||
&dir,
|
||||
json!([
|
||||
{"id": "org.test.badsum", "name": "Bad Sum", "versions": [{"version": "1", "api": 1, "url": url, "sha256": "0".repeat(64)}]},
|
||||
{"id": "org.test.garbage", "name": "Garbage", "versions": [{"version": "1", "api": 1, "url": file_url(&garbage), "sha256": garbage_sha}]},
|
||||
{"id": "org.test.web", "name": "Web", "versions": [{"version": "1", "api": 1, "url": "https://example.com/x.wasm", "sha256": "0".repeat(64)}]},
|
||||
{"id": "org.test.future", "name": "Future", "versions": [{"version": "1", "api": 99, "url": url, "sha256": "0".repeat(64)}]}
|
||||
]),
|
||||
);
|
||||
let mut s = session(&dir);
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
let e = s.execute("library.install", json!({"id": "org.test.badsum", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
let e = s.execute("library.install", json!({"id": "org.test.garbage", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("didn't load"), "{e}");
|
||||
let e = s.execute("library.install", json!({"id": "org.test.web", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("isn't on GitHub"), "{e}");
|
||||
let e = s.execute("library.install", json!({"id": "org.test.future", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("no release for this EffectCraft"), "{e}");
|
||||
assert_eq!(package(&s.execute("library.list", json!({})).unwrap(), "org.test.future")["needsNewerApp"], true);
|
||||
let lib = dir.join("Plug-ins").join("Library");
|
||||
for id in ["org.test.badsum", "org.test.garbage", "org.test.web"] {
|
||||
assert!(!lib.join(id).exists(), "{id}");
|
||||
}
|
||||
assert!(!lib.join("installed.json").exists());
|
||||
// Remote catalogs must be on GitHub too; the list still works from the cached copy.
|
||||
assert!(s.execute("library.refresh", json!({"catalog": "https://example.com/index.json"})).unwrap_err().to_string().contains("isn't on GitHub"));
|
||||
assert!(s.execute("library.refresh", json!({"catalog": "http://raw.githubusercontent.com/x"})).is_err());
|
||||
let mut fresh = session(&dir);
|
||||
assert_eq!(fresh.execute("library.list", json!({})).unwrap()["packages"].as_array().unwrap().len(), 4, "read from catalog.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_packages_load_at_start_and_changed_files_are_refused() {
|
||||
let dir = tmp("startup");
|
||||
let lib = dir.join("Plug-ins").join("Library");
|
||||
let mut entries = serde_json::Map::new();
|
||||
for (id, effect, tamper) in
|
||||
[("org.test.start.ok", "org.test.library.start.ok", false), ("org.test.start.tampered", "org.test.library.start.tampered", true)]
|
||||
{
|
||||
let bytes = wat_plugin(effect, 1, "i32.const 0").into_bytes();
|
||||
let file = format!("{id}/1.0.0/{id}.wasm");
|
||||
let path = lib.join(id).join("1.0.0");
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let written = if tamper { wat_plugin(effect, 1, "i32.const 1").into_bytes() } else { bytes.clone() };
|
||||
std::fs::write(path.join(format!("{id}.wasm")), written).unwrap();
|
||||
entries.insert(id.into(), json!({"version": "1.0.0", "file": file, "sha256": sha256::hex(&bytes), "effects": [effect]}));
|
||||
}
|
||||
entries.insert("org.test.start.escape".into(), json!({"version": "1", "file": "../../outside.wasm", "sha256": "0".repeat(64)}));
|
||||
std::fs::write(lib.join("installed.json"), json!({"packages": entries}).to_string()).unwrap();
|
||||
let mut s = session(&dir);
|
||||
let r = s.execute("library.loadInstalled", json!({})).unwrap();
|
||||
let loaded: Vec<&str> = r["loaded"].as_array().unwrap().iter().filter_map(|v| v["package"].as_str()).collect();
|
||||
assert_eq!(loaded, ["org.test.start.ok"], "{r}");
|
||||
let errors = r["errors"].to_string();
|
||||
assert!(errors.contains("org.test.start.tampered") && errors.contains("SHA-256"), "{r}");
|
||||
assert!(errors.contains("bad module path"), "{r}");
|
||||
assert!(plugin_ids(&mut s).iter().any(|i| i == "org.test.library.start.ok"));
|
||||
// Running it again loads nothing twice.
|
||||
assert_eq!(s.execute("library.loadInstalled", json!({})).unwrap()["loaded"], json!([]));
|
||||
// Without a catalog the installed packages are still listed.
|
||||
let l = s.execute("library.list", json!({})).unwrap();
|
||||
let p = package(&l, "org.test.start.ok");
|
||||
assert_eq!((p["unlisted"].as_bool(), p["loaded"].as_str()), (Some(true), Some("1.0.0")), "{l}");
|
||||
// A corrupt installed.json is an empty library, not a failure.
|
||||
std::fs::write(lib.join("installed.json"), b"{not json").unwrap();
|
||||
assert_eq!(session(&dir).execute("library.loadInstalled", json!({})).unwrap()["loaded"], json!([]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_effects_name_the_packages_that_provide_them() {
|
||||
let dir = tmp("missing");
|
||||
let fx = "org.test.library.missing.fx";
|
||||
let v = serve(&dir, "m.wasm", fx);
|
||||
let catalog = write_catalog(&dir, json!([{"id": "org.test.missing", "name": "Missing", "versions": [release("1.0.0", &v, fx)]}]));
|
||||
let mut s = session(&dir);
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
// A project whose effects include one this session doesn't have (as when a project from
|
||||
// someone with the plug-in opens here).
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "duration": 1})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"color": [1, 0, 0], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"layer": layer, "effect": "ec.blur.gaussian"})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": layer, "effect": "ec.blur.gaussian"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let proj = std::sync::Arc::make_mut(&mut s.project);
|
||||
let l = proj.comp_mut(cid).unwrap().layer_mut(LayerId(layer)).unwrap();
|
||||
let first = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n {
|
||||
Node::Group(g) => Some(g),
|
||||
_ => None,
|
||||
});
|
||||
first.unwrap().kind = GroupKind::Effect { effect: fx.into() };
|
||||
let r = s.execute("library.missing", json!({})).unwrap();
|
||||
assert_eq!(r["effects"], json!([{"effect": fx, "packages": ["org.test.missing"]}]), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_a_package_without_installing_it() {
|
||||
let dir = tmp("preview");
|
||||
let fx = "org.test.library.preview.fx";
|
||||
// The preview frame (320 × 180 RGBA f32) needs more than the test module's two pages.
|
||||
let bytes = wat_plugin(fx, 1, "i32.const 0").replace("(memory (export \"memory\") 2)", "(memory (export \"memory\") 20)").into_bytes();
|
||||
let path = dir.join("served").join("p.wasm");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let v = (file_url(&path), sha256::hex(&bytes));
|
||||
let mut bad = release("1.0.0", &v, fx);
|
||||
bad["sha256"] = json!("0".repeat(64));
|
||||
let catalog = write_catalog(
|
||||
&dir,
|
||||
json!([
|
||||
{"id": "org.test.preview", "name": "Preview", "preview": {"video": "https://www.youtube.com/watch?v=x"}, "versions": [release("1.0.0", &v, fx)]},
|
||||
{"id": "org.test.preview.bad", "name": "Bad", "preview": {"video": "javascript:alert(1)"}, "versions": [bad]}
|
||||
]),
|
||||
);
|
||||
let mut s = session(&dir);
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
let r = s.execute("library.preview", json!({"id": "org.test.preview", "wait": true})).unwrap();
|
||||
assert_eq!((r["frames"].as_u64(), r["width"].as_u64(), r["effect"]["id"].as_str()), (Some(24), Some(320), Some(fx)), "{r}");
|
||||
let st = s.library.status();
|
||||
let pv = st.preview.as_ref().unwrap();
|
||||
assert_eq!((pv.before.len(), pv.frames[0].len()), (320 * 180 * 4, 320 * 180 * 4));
|
||||
// Previewing installs and registers nothing.
|
||||
assert!(!dir.join("Plug-ins").join("Library").join("org.test.preview").exists());
|
||||
assert!(!plugin_ids(&mut s).iter().any(|i| i == fx));
|
||||
let l = s.execute("library.list", json!({})).unwrap();
|
||||
let p = package(&l, "org.test.preview");
|
||||
assert_eq!((p["previewed"].as_bool(), p["video"].as_str()), (Some(true), Some("https://www.youtube.com/watch?v=x")), "{l}");
|
||||
assert!(package(&l, "org.test.preview.bad")["video"].is_null(), "only https:// videos are kept");
|
||||
// Asking again answers from memory; a wrong checksum previews nothing.
|
||||
assert_eq!(s.execute("library.preview", json!({"id": "org.test.preview"})).unwrap()["frames"], 24);
|
||||
let e = s.execute("library.preview", json!({"id": "org.test.preview.bad", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reinstall_that_fails_to_load_keeps_the_installed_copy() {
|
||||
let dir = tmp("reinstall");
|
||||
let fx = "org.test.library.reinstall.fx";
|
||||
let v = serve(&dir, "r.wasm", fx);
|
||||
let catalog = write_catalog(&dir, json!([{"id": "org.test.reinstall", "name": "Reinstall", "versions": [release("1.0.0", &v, fx)]}]));
|
||||
let mut s = session(&dir);
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
s.execute("library.install", json!({"id": "org.test.reinstall", "wait": true})).unwrap();
|
||||
// A second session in this process: the effect is registered already, so loading fails.
|
||||
let mut other = session(&dir);
|
||||
let e = other.execute("library.install", json!({"id": "org.test.reinstall", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("didn't load"), "{e}");
|
||||
let lib = dir.join("Plug-ins").join("Library");
|
||||
assert!(lib.join("org.test.reinstall").join("1.0.0").join("org.test.reinstall.wasm").is_file());
|
||||
let installed: Value = serde_json::from_slice(&std::fs::read(lib.join("installed.json")).unwrap()).unwrap();
|
||||
assert_eq!(installed["packages"]["org.test.reinstall"]["version"], "1.0.0");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_that_would_not_load_keeps_the_loaded_version() {
|
||||
let dir = tmp("badupdate");
|
||||
let fx = "org.test.library.badupdate.fx";
|
||||
let v1 = serve(&dir, "u1.wasm", fx);
|
||||
// The right checksum for a file that isn't a plug-in.
|
||||
let junk = b"not a WebAssembly module".to_vec();
|
||||
let path = dir.join("served").join("u2.wasm");
|
||||
std::fs::write(&path, &junk).unwrap();
|
||||
let v2 = (file_url(&path), sha256::hex(&junk));
|
||||
let catalog = write_catalog(&dir, json!([{"id": "org.test.badupdate", "name": "Bad Update", "versions": [release("1.0.0", &v1, fx)]}]));
|
||||
let mut s = session(&dir);
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
s.execute("library.install", json!({"id": "org.test.badupdate", "wait": true})).unwrap();
|
||||
let catalog =
|
||||
write_catalog(&dir, json!([{"id": "org.test.badupdate", "name": "Bad Update", "versions": [release("1.0.0", &v1, fx), release("2.0.0", &v2, fx)]}]));
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
// Version 1 is loaded in this session, so version 2 would only load at the next start.
|
||||
let e = s.execute("library.update", json!({"id": "org.test.badupdate", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("didn't load"), "{e}");
|
||||
let lib = dir.join("Plug-ins").join("Library");
|
||||
assert!(lib.join("org.test.badupdate").join("1.0.0").join("org.test.badupdate.wasm").is_file());
|
||||
assert!(!lib.join("org.test.badupdate").join("2.0.0").exists());
|
||||
let installed: Value = serde_json::from_slice(&std::fs::read(lib.join("installed.json")).unwrap()).unwrap();
|
||||
assert_eq!(installed["packages"]["org.test.badupdate"]["version"], "1.0.0");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_all_updates_every_package_and_min_app_is_enforced() {
|
||||
let dir = tmp("updateall");
|
||||
let (fa, fb) = ("org.test.library.updateall.a", "org.test.library.updateall.b");
|
||||
let (a1, b1) = (serve(&dir, "a1.wasm", fa), serve(&dir, "b1.wasm", fb));
|
||||
let catalog = write_catalog(
|
||||
&dir,
|
||||
json!([
|
||||
{"id": "org.test.ua", "name": "A", "versions": [release("1.0.0", &a1, fa)]},
|
||||
{"id": "org.test.ub", "name": "B", "versions": [release("1.0.0", &b1, fb)]}
|
||||
]),
|
||||
);
|
||||
let mut s = session(&dir);
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
for id in ["org.test.ua", "org.test.ub"] {
|
||||
s.execute("library.install", json!({"id": id, "wait": true})).unwrap();
|
||||
}
|
||||
let mut future = release("3.0.0", &a1, fa);
|
||||
future["minApp"] = json!("999.0");
|
||||
let catalog = write_catalog(
|
||||
&dir,
|
||||
json!([
|
||||
{"id": "org.test.ua", "name": "A", "versions": [release("1.0.0", &a1, fa), release("2.0.0", &a1, fa), future]},
|
||||
{"id": "org.test.ub", "name": "B", "versions": [release("1.0.0", &b1, fb), release("2.0.0", &b1, fb)]}
|
||||
]),
|
||||
);
|
||||
s.execute("library.refresh", json!({"catalog": catalog, "wait": true})).unwrap();
|
||||
let e = s.execute("library.install", json!({"id": "org.test.ua", "version": "3.0.0", "wait": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("999.0"), "{e}");
|
||||
let r = s.execute("library.update", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["updating"], json!(["org.test.ua", "org.test.ub"]), "{r}");
|
||||
let l = s.execute("library.list", json!({"installed": true})).unwrap();
|
||||
for id in ["org.test.ua", "org.test.ub"] {
|
||||
assert_eq!(package(&l, id)["installed"], "2.0.0", "{l}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod determinism;
|
||||
mod exr_layers;
|
||||
mod footage_reload;
|
||||
mod gif_animates;
|
||||
mod library;
|
||||
mod pick_whip;
|
||||
mod plugins;
|
||||
mod relink;
|
||||
|
||||
@@ -110,7 +110,7 @@ fn example_wasm_plugin_loads_and_renders() {
|
||||
}
|
||||
|
||||
/// A parameterless plug-in module (WebAssembly text): `render` is `ec_render`'s body.
|
||||
fn wat_plugin(id: &str, api: i32, render: &str) -> String {
|
||||
pub(super) fn wat_plugin(id: &str, api: i32, render: &str) -> String {
|
||||
let manifest = format!(r#"{{"api":1,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
|
||||
let len = manifest.len();
|
||||
let escaped = manifest.replace('"', "\\\"");
|
||||
|
||||
@@ -64,6 +64,44 @@ pub fn loader(bytes: &[u8], source: &str) -> Result<serde_json::Value, String> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Render a module's effect without registering it (the Plug-in Library's preview): default
|
||||
/// parameters, `pixels` (premultiplied RGBA, `width × height`) at each of `times` (seconds).
|
||||
/// Returns the manifest as JSON and one frame per time.
|
||||
pub fn preview(bytes: &[u8], width: u32, height: u32, pixels: &[[f32; 4]], times: &[f64]) -> Result<(serde_json::Value, Vec<Vec<[f32; 4]>>), String> {
|
||||
#[cfg(feature = "wasm")]
|
||||
{
|
||||
use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginParamKind, PluginParams};
|
||||
let p = wasm::WasmPlugin::new(bytes, "preview")?;
|
||||
let m = p.manifest();
|
||||
let mut flat = Vec::with_capacity(m.flat_len());
|
||||
for param in &m.params {
|
||||
match ¶m.kind {
|
||||
PluginParamKind::Slider { default, .. } | PluginParamKind::Angle { default } => flat.push(*default),
|
||||
PluginParamKind::Checkbox { default } => flat.push(if *default { 1.0 } else { 0.0 }),
|
||||
PluginParamKind::Popup { default, .. } => flat.push(*default as f64),
|
||||
// Points default to fractions of the layer: the frame is the layer.
|
||||
PluginParamKind::Point { default } => flat.extend([default[0] * f64::from(width), default[1] * f64::from(height)]),
|
||||
PluginParamKind::Color { default } => flat.extend(default.iter().copied()),
|
||||
}
|
||||
}
|
||||
let params = PluginParams { manifest: m, flat: &flat };
|
||||
let mut frames = Vec::with_capacity(times.len());
|
||||
for &t in times {
|
||||
let mut px = pixels.to_vec();
|
||||
let mut frame = PluginFrame { width, height, pixels: &mut px, scale: 1.0 };
|
||||
p.render(&mut frame, ¶ms, t)?;
|
||||
frames.push(px);
|
||||
}
|
||||
let manifest = serde_json::to_value(m).map_err(|e| e.to_string())?;
|
||||
Ok((manifest, frames))
|
||||
}
|
||||
#[cfg(not(feature = "wasm"))]
|
||||
{
|
||||
let _ = (bytes, width, height, pixels, times);
|
||||
Err("WebAssembly plug-ins are not available in this build".into())
|
||||
}
|
||||
}
|
||||
|
||||
// Two attributes, so clippy sees `cfg(test)` and treats the module as test code.
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "wasm")]
|
||||
|
||||
@@ -175,6 +175,8 @@ impl EffectcraftApp {
|
||||
self.session.poll_offload();
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// Plug-in Library: a finished job's toast (also polled with Roto, for other frontends).
|
||||
self.session.poll_library();
|
||||
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
|
||||
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
|
||||
self.session.poll_warp(true);
|
||||
|
||||
@@ -37,6 +37,12 @@ pub struct DialogState {
|
||||
pub palette_sel: usize,
|
||||
/// Settings dialog page id (`general`, `appearance`…).
|
||||
pub settings_page: String,
|
||||
/// Settings ▸ Plug-in Library: the search field.
|
||||
pub library_query: String,
|
||||
/// Settings ▸ Plug-in Library: the package whose preview is open.
|
||||
pub library_preview: Option<String>,
|
||||
/// The preview's Before/After split (0 = all After, 1 = all Before).
|
||||
pub library_split: f32,
|
||||
/// Settings when the dialog opened (Cancel restores them).
|
||||
pub prefs_snapshot: Option<effectcraft_engine::prefs::Prefs>,
|
||||
/// Keyboard Shortcuts editor.
|
||||
|
||||
@@ -73,6 +73,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
Item::Labels => labels_ui(app, ui, cur, acts),
|
||||
Item::BrowserStorage => browser_storage_ui(app, ui, t, acts),
|
||||
Item::Models(task) => models_ui(app, ui, t, acts, task),
|
||||
Item::PluginLibrary => library_ui(app, ui, t, acts),
|
||||
Item::AudioDevices { key } => {
|
||||
let devices: Vec<String> = app.hooks.audio_devices.as_ref().map(|f| f()).unwrap_or_default();
|
||||
let sel = get(key).as_str().unwrap_or("").to_string();
|
||||
@@ -561,6 +562,269 @@ fn models_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, acts: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ Plug-in Library (`library.*`): search the community catalog, install, update and
|
||||
/// remove packages. Automation ids: `settings.library.query`, `settings.library.refresh`,
|
||||
/// `settings.library.updateAll`, `settings.library.<install|update|remove>.<package id>`.
|
||||
fn library_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, acts: &mut Vec<Act>) {
|
||||
let query = app.dialog_state.library_query.clone();
|
||||
let Ok(info) = app.session.execute("library.list", json!({"query": query})) else { return };
|
||||
if !info["available"].as_bool().unwrap_or(false) {
|
||||
ui.label(RichText::new(tr("The Plug-in Library is available in the desktop app.")).color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
let busy = info["busy"].as_str().map(str::to_string);
|
||||
let packages = info["packages"].as_array().cloned().unwrap_or_default();
|
||||
let has_catalog = info["catalog"].is_string();
|
||||
ui.add_space(6.0);
|
||||
library_toolbar_ui(app, ui, &query, &packages, has_catalog, busy.is_none(), acts);
|
||||
if let Some(when) = info["fetched"].as_str() {
|
||||
let when = when.get(..16).unwrap_or(when).replace('T', " ");
|
||||
ui.label(RichText::new(tr_args("Catalog fetched {} (UTC)", &[&when])).color(t.text_faint));
|
||||
}
|
||||
// A plug-in author's test catalog stays in use until Refresh fetches the community one.
|
||||
if let Some(url) = info["catalog"].as_str().filter(|u| *u != effectcraft_engine::library::CATALOG_URL) {
|
||||
ui.label(RichText::new(tr_args("Test catalog: {} (Refresh returns to the community catalog)", &[&url])).color(t.warning));
|
||||
}
|
||||
if !has_catalog {
|
||||
ui.label(RichText::new(tr("Nothing is downloaded until you fetch the catalog.")).color(t.text_dim));
|
||||
}
|
||||
for p in &packages {
|
||||
ui.add_space(6.0);
|
||||
library_card_ui(app, ui, t, p, busy.is_none(), acts);
|
||||
}
|
||||
if has_catalog && packages.is_empty() {
|
||||
let note = if query.is_empty() { tr("The catalog lists no plug-ins yet.") } else { tr("No plug-ins match the search.") };
|
||||
ui.label(RichText::new(note).color(t.text_dim));
|
||||
}
|
||||
if let Some(b) = &busy {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(b);
|
||||
});
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
if let Some(e) = info["error"].as_str() {
|
||||
ui.label(RichText::new(e).color(t.danger));
|
||||
}
|
||||
if let Some(f) = info["folder"].as_str() {
|
||||
ui.label(RichText::new(tr_args("Plug-ins folder: {}", &[&f])).color(t.text_faint));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Plug-in Library's search field, Refresh (or Fetch Catalog) and Update All.
|
||||
fn library_toolbar_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, query: &str, packages: &[Value], has_catalog: bool, idle: bool, acts: &mut Vec<Act>) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut q = query.to_string();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search plug-ins and effects")).desired_width(260.0));
|
||||
reg(app, "settings.library.query", &r, "Search");
|
||||
if q != query {
|
||||
app.dialog_state.library_query = q;
|
||||
}
|
||||
let label = if has_catalog { tr("Refresh") } else { tr("Fetch Catalog") };
|
||||
let r = ui.add_enabled(idle, egui::Button::new(label));
|
||||
reg(app, "settings.library.refresh", &r, "Refresh");
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run("library.refresh".into(), json!({})));
|
||||
}
|
||||
let updates = packages.iter().filter(|p| p["updateAvailable"].as_bool().unwrap_or(false)).count();
|
||||
if updates > 0 {
|
||||
let r = ui.add_enabled(idle, egui::Button::new(tr_args("Update All ({})", &[&updates])));
|
||||
reg(app, "settings.library.updateAll", &r, "Update All");
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run("library.update".into(), json!({})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One package of the Plug-in Library (`library.list`'s entry `p`): name, state, details, links,
|
||||
/// actions and, when shown, its preview.
|
||||
fn library_card_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, p: &Value, idle: bool, acts: &mut Vec<Act>) {
|
||||
let id = p["id"].as_str().unwrap_or_default().to_string();
|
||||
let s = |k: &str| p[k].as_str().unwrap_or_default().to_string();
|
||||
let installed = p["installed"].as_str().map(str::to_string);
|
||||
let (update, restart, newer_app) =
|
||||
(p["updateAvailable"].as_bool().unwrap_or(false), p["restartNeeded"].as_bool().unwrap_or(false), p["needsNewerApp"].as_bool().unwrap_or(false));
|
||||
egui::Frame::new().fill(t.field_bg).corner_radius(6.0).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(s("name")).font(Tokens::semibold(12.5)));
|
||||
if let Some(v) = p["latest"].as_str() {
|
||||
ui.label(RichText::new(v).color(t.text_dim));
|
||||
}
|
||||
let state = match (&installed, restart, update) {
|
||||
(_, true, _) => tr("Restart EffectCraft to finish").to_string(),
|
||||
(Some(v), _, true) => tr_args("Installed {}: update available", &[v]),
|
||||
(Some(v), _, _) => tr_args("Installed {}", &[v]),
|
||||
(None, _, _) if newer_app && p["latest"].is_null() => tr("Needs a newer EffectCraft").to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
ui.label(RichText::new(state).color(if installed.is_some() { t.accent } else { t.text_dim }));
|
||||
});
|
||||
if !s("description").is_empty() {
|
||||
ui.label(RichText::new(s("description")).color(t.text_dim));
|
||||
}
|
||||
library_links_ui(app, ui, t, p, &id);
|
||||
let effects: Vec<&str> = p["effects"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
if !effects.is_empty() {
|
||||
ui.label(RichText::new(tr_args("Effects: {}", &[&effects.join(", ")])).color(t.text_faint));
|
||||
}
|
||||
library_actions_ui(app, ui, p, &id, idle, acts);
|
||||
if app.dialog_state.library_preview.as_deref() == Some(id.as_str()) {
|
||||
library_preview_ui(app, ui, t, &id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A package's author, licence, size, and links to its source and video.
|
||||
fn library_links_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, p: &Value, id: &str) {
|
||||
let s = |k: &str| p[k].as_str().unwrap_or_default().to_string();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if !s("author").is_empty() {
|
||||
ui.label(RichText::new(tr_args("By {}", &[&s("author")])).color(t.text_dim));
|
||||
}
|
||||
if !s("license").is_empty() {
|
||||
ui.label(RichText::new(tr_args("Licence: {}", &[&s("license")])).color(t.text_dim));
|
||||
}
|
||||
if let Some(size) = p["size"].as_u64() {
|
||||
ui.label(RichText::new(format!("· {}", human_bytes(size))).color(t.text_dim));
|
||||
}
|
||||
let repo = s("repo");
|
||||
if repo.starts_with("https://") {
|
||||
let label = if repo.starts_with("https://github.com/") { tr("Open on GitHub") } else { tr("Source") };
|
||||
let r = ui.link(label);
|
||||
reg(app, &format!("settings.library.repo.{id}"), &r, "Source");
|
||||
if r.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(repo));
|
||||
}
|
||||
}
|
||||
let video = s("video");
|
||||
if video.starts_with("https://") {
|
||||
let r = ui.link(tr("Watch video"));
|
||||
reg(app, &format!("settings.library.video.{id}"), &r, "Watch video");
|
||||
if r.clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(video));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A package's Preview / Install / Update / Remove buttons.
|
||||
fn library_actions_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &Value, id: &str, idle: bool, acts: &mut Vec<Act>) {
|
||||
let installed = p["installed"].is_string();
|
||||
let available = !p["latest"].is_null();
|
||||
let showing = app.dialog_state.library_preview.as_deref() == Some(id);
|
||||
ui.horizontal(|ui| {
|
||||
if available {
|
||||
let r = ui.add_enabled(idle || showing, egui::Button::new(if showing { tr("Hide Preview") } else { tr("Preview") }).selected(showing));
|
||||
reg(app, &format!("settings.library.preview.{id}"), &r, "Preview");
|
||||
if r.clicked() {
|
||||
if showing {
|
||||
app.dialog_state.library_preview = None;
|
||||
} else {
|
||||
app.dialog_state.library_preview = Some(id.to_string());
|
||||
app.dialog_state.library_split = 0.5;
|
||||
acts.push(Act::Run("library.preview".into(), json!({"id": id})));
|
||||
}
|
||||
}
|
||||
}
|
||||
let buttons = [
|
||||
(!installed && available, tr("Install"), "Install", "install", "library.install"),
|
||||
(p["updateAvailable"].as_bool().unwrap_or(false), tr("Update"), "Update", "update", "library.update"),
|
||||
(installed, tr("Remove"), "Remove", "remove", "library.uninstall"),
|
||||
];
|
||||
for (shown, label, name, key, command) in buttons {
|
||||
if !shown {
|
||||
continue;
|
||||
}
|
||||
let r = ui.add_enabled(idle, egui::Button::new(label));
|
||||
reg(app, &format!("settings.library.{key}.{id}"), &r, name);
|
||||
if r.clicked() {
|
||||
acts.push(Act::Run(command.into(), json!({"id": id})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A package's live preview (`library.preview`): its effect at default settings on the sample
|
||||
/// frame, playing, with a Before/After split the user drags (automation id
|
||||
/// `settings.library.previewImage`), and the effect's parameters.
|
||||
fn library_preview_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str) {
|
||||
let status = app.session.library.status();
|
||||
let Some(pv) = status.preview.filter(|v| v.id == id) else {
|
||||
// A failure shows under the list (the library's error).
|
||||
if status.busy.is_none() {
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(RichText::new(tr("Rendering a preview in the sandbox…")).color(t.text_dim));
|
||||
});
|
||||
return;
|
||||
};
|
||||
let ctx = ui.ctx().clone();
|
||||
// Textures: the sample frame, then the effect's frames. One set, tagged with its package
|
||||
// version and replaced when another is previewed (the engine keeps only the last preview).
|
||||
let key = egui::Id::unique("library-preview");
|
||||
let tag = (pv.id.clone(), pv.version.clone());
|
||||
let size = [pv.width as usize, pv.height as usize];
|
||||
// Cached per preview: its textures, whether it moves, whether it changes the frame at all.
|
||||
let cached = ctx.data(|d| d.get_temp::<((String, String), Vec<egui::TextureHandle>, bool, bool)>(key));
|
||||
let (tex, animated, unchanged) = match cached {
|
||||
Some((t, v, animated, unchanged)) if t == tag => (v, animated, unchanged),
|
||||
_ => {
|
||||
let v: Vec<egui::TextureHandle> = std::iter::once(&pv.before)
|
||||
.chain(&pv.frames)
|
||||
.enumerate()
|
||||
.filter(|(_, px)| px.len() == size[0] * size[1] * 4)
|
||||
.map(|(i, px)| {
|
||||
ctx.load_texture(format!("library-preview-{i}"), egui::ColorImage::from_rgba_unmultiplied(size, px), egui::TextureOptions::LINEAR)
|
||||
})
|
||||
.collect();
|
||||
let animated = pv.frames.windows(2).any(|w| w.first() != w.last());
|
||||
let unchanged = pv.frames.iter().all(|f| *f == pv.before);
|
||||
ctx.data_mut(|d| d.insert_temp(key, (tag, v.clone(), animated, unchanged)));
|
||||
(v, animated, unchanged)
|
||||
}
|
||||
};
|
||||
let (Some(before), true) = (tex.first(), tex.len() > 1) else { return };
|
||||
let frames = tex.get(1..).unwrap_or_default();
|
||||
let index = if animated { (ctx.input(|i| i.time) * pv.fps) as usize % frames.len() } else { 0 };
|
||||
let Some(after) = frames.get(index) else { return };
|
||||
if animated {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(1.0 / pv.fps.max(1.0)));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let w = ui.available_width().min(480.0);
|
||||
let (rect, r) = ui.allocate_exact_size(vec2(w, w * pv.height as f32 / pv.width.max(1) as f32), egui::Sense::click_and_drag());
|
||||
reg(app, "settings.library.previewImage", &r, "Preview: drag to compare");
|
||||
let split = &mut app.dialog_state.library_split;
|
||||
if let Some(pos) = r.interact_pointer_pos() {
|
||||
*split = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
|
||||
}
|
||||
let x = rect.left() + rect.width() * *split;
|
||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.image(after.id(), rect, uv, Color32::WHITE);
|
||||
let left = egui::Rect::from_min_max(rect.min, egui::pos2(x, rect.bottom()));
|
||||
painter.with_clip_rect(left).image(before.id(), rect, uv, Color32::WHITE);
|
||||
painter.line_segment([egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], egui::Stroke::new(2.0, Color32::WHITE));
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
painter.text(rect.left_top() + vec2(6.0, 4.0), egui::Align2::LEFT_TOP, tr("Before"), font.clone(), Color32::WHITE);
|
||||
painter.text(rect.right_top() + vec2(-6.0, 4.0), egui::Align2::RIGHT_TOP, tr("After"), font, Color32::WHITE);
|
||||
let effect = &pv.manifest;
|
||||
let params: Vec<&str> = effect["params"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut line =
|
||||
tr_args("{} ({}) at default settings, on a sample frame", &[&effect["name"].as_str().unwrap_or(id), &effect["category"].as_str().unwrap_or("")]);
|
||||
if !params.is_empty() {
|
||||
line = format!("{line} · {}", tr_args("Parameters: {}", &[¶ms.join(", ")]));
|
||||
}
|
||||
ui.label(RichText::new(line).color(t.text_faint));
|
||||
if unchanged {
|
||||
ui.label(RichText::new(tr("At its default settings this effect leaves the sample frame unchanged.")).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ Disk ▸ Browser Storage (the web app's storage manager, `storage.*`): where the
|
||||
/// data lives, the origin's usage and quota, persistent storage, and Clear buttons.
|
||||
fn browser_storage_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, acts: &mut Vec<Act>) {
|
||||
|
||||
+2
-1
@@ -649,7 +649,8 @@ that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript
|
||||
`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes
|
||||
the project first). Scripts' `app.settings`:
|
||||
`script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`.
|
||||
Extension points and the bundled extensions: [plugins.md](plugins.md).
|
||||
Extension points and the bundled extensions: [plugins.md](plugins.md); publishing an effect plug-in
|
||||
in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md).
|
||||
|
||||
### Timeline: layer order, properties and shortcuts
|
||||
|
||||
|
||||
+5
-1
@@ -75,7 +75,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (308), Traditional Chinese (308, plus 21 categories), Japanese (297) and Georgian (308, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
|
||||
| 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
|
||||
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334); our WebAssembly API has no third-party plug-ins yet. The Plug-in Library (#376) installs community WebAssembly effects from one reviewed catalog, which is still empty | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
|
||||
| 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) |
|
||||
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
@@ -306,6 +306,9 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
|
||||
- After Effects SDK plug-ins cannot run in EffectCraft. Our own WebAssembly plug-in API exists
|
||||
([plugins.md](plugins.md)). Grow it: documentation, examples, and original effects that cover
|
||||
what the most common third-party plug-ins are used for (particles, glows, 3D objects, sabers).
|
||||
Settings ▸ Plug-in Library installs, updates and removes community WebAssembly effects listed
|
||||
in one reviewed catalog ([plugins.md](plugins.md#plug-in-library), #376); the catalog
|
||||
repository still has to be created and filled.
|
||||
- Features that come from third parties in After Effects are extensions in EffectCraft too, built
|
||||
on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins
|
||||
([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI
|
||||
@@ -349,6 +352,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-27 | minor | Gap 19 / G8: the Plug-in Library (#376) installs, updates and removes community WebAssembly effects from one reviewed catalog; up to 256 plug-in effects per session |
|
||||
| 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) |
|
||||
| 2026-10-13 | minor | Gap 25: an AccessKit audit test (per-panel counts, a ratchet for the unfixed panels) and a theme contrast test; Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested |
|
||||
| 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) |
|
||||
|
||||
+46
-1
@@ -270,7 +270,7 @@ impl EffectPlugin for Swap {
|
||||
register_plugin(Arc::new(Swap(manifest)))?; // before the UI builds its menus
|
||||
```
|
||||
|
||||
`PluginParams` reads values by parameter id (`f`, `b`, `point`, `color`). At most 64 plug-in
|
||||
`PluginParams` reads values by parameter id (`f`, `b`, `point`, `color`). At most 256 plug-in
|
||||
effects can be registered per process.
|
||||
|
||||
### Compatibility
|
||||
@@ -278,3 +278,48 @@ effects can be registered per process.
|
||||
The API version is checked when a plug-in loads: a manifest or module for another version is
|
||||
refused with a message. Within a version the contract above does not change; additions (new
|
||||
parameter types, new optional exports) bump the version.
|
||||
|
||||
## Plug-in Library
|
||||
|
||||
Settings ▸ Plug-in Library (also Effect ▸ Plug-in Library…) lists WebAssembly effect plug-ins
|
||||
published by the community, and installs, updates and removes them without bundling them into
|
||||
the app. Only WebAssembly effects are listed: they run in the same sandbox as any plug-in, so a
|
||||
package can't read files, open the network or crash the app.
|
||||
|
||||
* **One reviewed catalog.** The list comes from one `index.json` in the
|
||||
[`storytold/effectcraft-plugins`](https://github.com/storytold/effectcraft-plugins) repository;
|
||||
packages are added by pull request and reviewed there. The app has no field for another
|
||||
catalog; `library.refresh {catalog}` (for plug-in authors testing an entry) takes an `index.json`
|
||||
on GitHub or a local file, and the page then says it shows a test catalog until Refresh.
|
||||
* **Nothing is downloaded until you ask.** Fetch Catalog (`library.refresh`) reads the catalog;
|
||||
Install (`library.install`) downloads the package's `.wasm`, checks its SHA-256 (and its size, when the
|
||||
catalog gives one) against the catalog and loads it. Downloads use the system's `curl`,
|
||||
`https://` only and starting on GitHub's hosts (redirects stay on `https://`; the SHA-256 check
|
||||
is what guarantees the file).
|
||||
* **Where it goes.** `<Plug-ins>/Library/<id>/<version>/<id>.wasm` (the `Plug-ins` folder next to
|
||||
the settings), listed in `Library/installed.json`. Installed packages load at start-up, each
|
||||
checked against the SHA-256 it was installed with. The catalog last fetched is kept as
|
||||
`Library/catalog.json`, so the list works offline.
|
||||
* **Restart after removing or updating.** Registered effects can't be unloaded: removing or
|
||||
updating a package whose effects are loaded takes effect at the next start, and the list says
|
||||
"Restart EffectCraft to finish".
|
||||
* **Preview before installing.** Preview (`library.preview`) downloads the package's module
|
||||
(checked against the catalog's size and SHA-256) without installing it, and renders its
|
||||
effect in the sandbox on a built-in sample frame with default parameters: 2 seconds at 12 fps,
|
||||
with a Before/After split. The details show it playing, the effects with their parameter names,
|
||||
"Open on GitHub" (the package's `repo`) and, when the package has a `preview.video` link (an
|
||||
`https://` YouTube, Vimeo or GitHub URL), "Watch video".
|
||||
* **Missing effects.** `library.missing` lists the effects a project uses that aren't installed,
|
||||
and the catalog packages that provide them.
|
||||
|
||||
Commands (UI, CLI, control channel and MCP): `library.list {query?, tag?, installed?}`,
|
||||
`library.refresh {catalog?, wait?}`, `library.preview {id, version?, wait?}`,
|
||||
`library.install {id, version?, wait?}`, `library.update {id?, wait?}`, `library.uninstall {id}`,
|
||||
`library.missing`, `library.loadInstalled`. Downloads run in the background; `wait: true` runs
|
||||
them to the end (scripts and tests).
|
||||
|
||||
### Publishing a plug-in
|
||||
|
||||
[publishing-plugins.md](publishing-plugins.md) walks through it from an empty folder to a listed
|
||||
package: build, test, release on GitHub, the catalog entry and its fields, testing the entry with a
|
||||
local `file://` catalog, the pull request and its review, updates and removal.
|
||||
@@ -0,0 +1,346 @@
|
||||
# Publishing an effect plug-in
|
||||
|
||||
This guide takes you from an empty folder to an effect listed in EffectCraft's **Plug-in
|
||||
Library** (Settings ▸ Plug-in Library, also Effect ▸ Plug-in Library…). The reference for the
|
||||
plug-in API is [plugins.md](plugins.md#effect-plug-ins); this page is the walk-through.
|
||||
|
||||
If you only want to use plug-ins, skip to [For users](#for-users).
|
||||
|
||||
## What a plug-in is
|
||||
|
||||
* A plug-in is one **WebAssembly module** (a `.wasm` file) that adds one effect. It is listed in
|
||||
Effects & Presets under its category, its parameters are ordinary properties (keyframes,
|
||||
expressions, Effect Controls), and projects save it by its id.
|
||||
* It runs in a **sandbox**. The module may not import anything: no files, no clock, no network,
|
||||
no random numbers. Each render gets a fuel budget, and memory is capped at 1 GiB. A plug-in that
|
||||
loops forever or traps stops with an error; it can't crash the app.
|
||||
* It implements **plug-in API version 1**: a JSON manifest and five exports (`memory`,
|
||||
`ec_api_version`, `ec_manifest_ptr` / `ec_manifest_len`, `ec_alloc`, `ec_render`). The
|
||||
[ABI](plugins.md#webassembly-abi-v1) and the [contract](plugins.md#the-contract) (premultiplied
|
||||
RGBA `f32` pixels, processed in place, deterministic) are in plugins.md.
|
||||
* Any language that compiles to WebAssembly without imports works. This guide uses Rust, because
|
||||
the example is in Rust.
|
||||
* The Plug-in Library is in the desktop app and the CLI. The web build has no plug-ins.
|
||||
|
||||
## 1. Start from the example
|
||||
|
||||
[`examples/plugins/posterize-bands`](../examples/plugins/posterize-bands) is a complete plug-in
|
||||
in plain Rust with no dependencies. Copy it into a new folder (and a new Git repository):
|
||||
|
||||
```sh
|
||||
cp -r effectcraft/examples/plugins/posterize-bands my-effect
|
||||
cd my-effect
|
||||
rm -rf target
|
||||
git init
|
||||
```
|
||||
|
||||
The folder has its own `[workspace]`, so it builds on its own, outside the EffectCraft repository.
|
||||
|
||||
Then make it yours:
|
||||
|
||||
1. **`Cargo.toml`:** change `name` (the `.wasm` file is named after it, with `-` turned into
|
||||
`_`), `description`, `version` and `license`. Keep `crate-type = ["cdylib"]` and the release
|
||||
profile (`panic = "abort"` keeps the module small and import-free).
|
||||
2. **The manifest** (the `MANIFEST` string in `src/lib.rs`):
|
||||
* `id`: your own, reverse-domain (`io.github.yourname.glow-rings`): letters, digits, `.`, `_`,
|
||||
`-`. It must be unique among all plug-ins, and `ec.` is reserved for built-in effects.
|
||||
**Never change it after release**: projects find the effect by this id.
|
||||
* `name`: the name shown in Effects & Presets.
|
||||
* `category`: an existing Effects & Presets category (`Stylize`, `Blur & Sharpen`, …) or a new
|
||||
one.
|
||||
* `version`: the same version as the release (`1.0.0`).
|
||||
* `author`, `description`.
|
||||
* `params`: one entry per control. Types are `slider`, `angle`, `checkbox`, `popup`, `point` and
|
||||
`color`; the fields and limits are in [The manifest](plugins.md#the-manifest).
|
||||
3. **`ec_render`:** the parameters arrive as `f64`s in the order they are declared (a point takes
|
||||
two values, a colour four), then the pixels. Read them by position, change the pixels in place,
|
||||
return `0`. Check `nparams` before you read, as the example does, and return non-zero if
|
||||
something is wrong: the frame is then left unchanged.
|
||||
|
||||
Keep `ec_api_version`, `ec_manifest_ptr`, `ec_manifest_len` and `ec_alloc` as they are.
|
||||
|
||||
Things that break the sandbox rule (the module then imports something and won't load): printing
|
||||
(`println!`), reading the time (`std::time`), random numbers (`getrandom`, `rand` with its default
|
||||
features), and crates that need `wasm-bindgen`. If you need randomness, derive it from the pixel
|
||||
position, the time and a seed parameter, so the result stays deterministic.
|
||||
|
||||
## 2. Build
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown # once
|
||||
cargo build --release --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
The module is `target/wasm32-unknown-unknown/release/<name>.wasm`.
|
||||
|
||||
## 3. Test it in EffectCraft
|
||||
|
||||
Load the module straight from the build folder:
|
||||
|
||||
* **Desktop:** Effect ▸ Load Effect Plug-in… and pick the `.wasm`. Apply the effect to a layer,
|
||||
move every control, scrub the time, and try a 32 bpc project if your effect can see values
|
||||
above 1.
|
||||
* **CLI:**
|
||||
|
||||
```sh
|
||||
effectcraft-cli exec effect.plugins.load '{"path": "target/wasm32-unknown-unknown/release/my_effect.wasm"}'
|
||||
```
|
||||
|
||||
The reply describes the effect, or says why the module was refused (a missing export, an
|
||||
import, a bad manifest, another plug-in with the same id).
|
||||
|
||||
To have it at every start, put the `.wasm` in the `Plug-ins` folder of the
|
||||
[settings folder](#where-files-live).
|
||||
|
||||
## 4. Check the live preview
|
||||
|
||||
Every package in the Plug-in Library gets a **live preview**: you do nothing to get it. EffectCraft
|
||||
downloads the module (without installing it) and renders your effect in the sandbox on a built-in
|
||||
sample frame (320 × 180: a colour gradient, two discs and a band of fine stripes), with the
|
||||
**default parameters**, for 2 seconds of animation (12 frames per second),
|
||||
with a Before/After split.
|
||||
|
||||
So choose defaults that show what the effect does at a glance. An effect whose defaults do
|
||||
nothing (a strength of 0, an "Enable" checkbox that is off) previews as an unchanged frame. If
|
||||
your effect animates over time, the 2 seconds show it. A render that fails in the preview shows
|
||||
as an error.
|
||||
|
||||
You can see the preview before you publish, with a catalog on your disk: see
|
||||
[step 8](#8-test-the-entry-locally).
|
||||
|
||||
## 5. Publish a GitHub release
|
||||
|
||||
The source repository must be **public on GitHub**, with a licence file.
|
||||
|
||||
1. Commit everything, including `Cargo.lock`.
|
||||
2. Tag the version (`git tag v1.0.0`) and push the tag.
|
||||
3. Create a GitHub release for the tag and attach the `.wasm` as an asset.
|
||||
|
||||
The download URL is then
|
||||
`https://github.com/<you>/<repo>/releases/download/v1.0.0/my_effect.wasm`.
|
||||
|
||||
**Never replace an asset after it is listed.** The catalog pins its SHA-256; a different file
|
||||
fails every install. Fix a mistake with a new version.
|
||||
|
||||
Reviewers rebuild the module from the tagged source and compare checksums, so make the build
|
||||
reproducible: commit `Cargo.lock`, pin the Rust version with a `rust-toolchain.toml`
|
||||
(`[toolchain] channel = "1.95.0"`, `targets = ["wasm32-unknown-unknown"]`), and build with
|
||||
`cargo build --release --locked --target wasm32-unknown-unknown`. Building the release in GitHub
|
||||
Actions from the tag is the easiest way to show the asset comes from the source.
|
||||
|
||||
## 6. Compute the checksum and size
|
||||
|
||||
The catalog needs the file's SHA-256 (64 lower-case hex digits) and its size in bytes. Use the
|
||||
file you attached to the release (download it again if in doubt).
|
||||
|
||||
| System | SHA-256 | Size in bytes |
|
||||
|---|---|---|
|
||||
| Linux | `sha256sum my_effect.wasm` | `stat -c %s my_effect.wasm` |
|
||||
| macOS | `shasum -a 256 my_effect.wasm` | `stat -f %z my_effect.wasm` |
|
||||
| Windows (PowerShell) | `(Get-FileHash my_effect.wasm -Algorithm SHA256).Hash.ToLower()` | `(Get-Item my_effect.wasm).Length` |
|
||||
|
||||
## 7. Write the catalog entry
|
||||
|
||||
The catalog is one `index.json` in the
|
||||
[`storytold/effectcraft-plugins`](https://github.com/storytold/effectcraft-plugins) repository.
|
||||
A starting point for that repository (an example entry, a JSON Schema and the checks run on
|
||||
every pull request) is in [`examples/plugin-catalog`](../examples/plugin-catalog).
|
||||
|
||||
Your entry is one object in `packages`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "io.github.yourname.glow-rings",
|
||||
"name": "Glow Rings",
|
||||
"author": "Your Name",
|
||||
"description": "Adds soft concentric rings of light around bright areas.",
|
||||
"license": "MIT",
|
||||
"repo": "https://github.com/yourname/glow-rings",
|
||||
"tags": ["stylize", "light"],
|
||||
"preview": {"video": "https://www.youtube.com/watch?v=XXXXXXXXXXX"},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"api": 1,
|
||||
"minApp": "0.17.0",
|
||||
"url": "https://github.com/yourname/glow-rings/releases/download/v1.0.0/glow_rings.wasm",
|
||||
"sha256": "<64 hex digits>",
|
||||
"size": 18342,
|
||||
"effects": ["io.github.yourname.glow-rings"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Package fields:
|
||||
|
||||
| Field | Required | What it is |
|
||||
|---|---|---|
|
||||
| `id` | yes | The package id: 1 to 100 characters, letters, digits, `.`, `-` and `_`; no leading dot and no `..`. It is also the folder name on users' disks. Unique in the catalog; usually the same as the effect id. Never change it. |
|
||||
| `name` | yes | The name shown in the list. Not empty. |
|
||||
| `author` | no | You, or your group. |
|
||||
| `description` | no, but reviewed | One or two plain sentences that say what the effect does. |
|
||||
| `license` | no, but reviewed | The licence's SPDX id (`MIT`, `Apache-2.0`, `MIT OR Apache-2.0`…). |
|
||||
| `repo` | no, but reviewed | The public source repository. The details' "Open on GitHub" opens it. |
|
||||
| `tags` | no | Lower-case words for the tag filter (`blur`, `stylize`, `light`, `distort`…). Reuse tags the catalog already has. |
|
||||
| `preview.video` | no | An `https://` link to a short demo video on YouTube, Vimeo or GitHub. "Watch video" opens it in the browser. A link that isn't `https://` (or is longer than 500 characters) is left out. The live preview doesn't need it. |
|
||||
| `versions` | yes | One entry per release, newest or oldest first. |
|
||||
|
||||
Release fields (one per entry in `versions`):
|
||||
|
||||
| Field | Required | What it is |
|
||||
|---|---|---|
|
||||
| `version` | yes | Dotted numbers only, at most 32 characters: `1.0.0`, `1.2`. No `v` and no suffixes such as `-beta`. |
|
||||
| `api` | yes | The plug-in API the module implements: `1`. |
|
||||
| `minApp` | no | The oldest EffectCraft version the release needs (`0.17.0`). Leave it out if any version with this API works. |
|
||||
| `url` | yes | The release asset's `https://` URL on GitHub. |
|
||||
| `sha256` | yes | The file's SHA-256, 64 hex digits. |
|
||||
| `size` | no, but reviewed | The file's size in bytes. When it is given, a download of another size is refused. |
|
||||
| `effects` | yes | The effect ids the module registers (its manifest's `id`). EffectCraft uses them to find the package that provides an effect a project needs. |
|
||||
|
||||
The rules EffectCraft applies when it reads the catalog:
|
||||
|
||||
* Downloads are `https://` only, from GitHub's hosts only: `github.com`,
|
||||
`raw.githubusercontent.com`, and the hosts release assets redirect to
|
||||
(`objects.githubusercontent.com`, `release-assets.githubusercontent.com`). Any other URL is
|
||||
refused. Redirects may lead elsewhere but stay on `https://`; the SHA-256 check is what
|
||||
guarantees the file.
|
||||
* The catalog may be at most 4 MB and a module at most 64 MB.
|
||||
* A package with a bad id, no name, or an id another package already uses is left out; so is a
|
||||
release with a bad version, `minApp` or `sha256`, or no `url`. The rest of the catalog still
|
||||
works.
|
||||
* EffectCraft installs the newest release whose `api` it implements and whose `minApp` it meets.
|
||||
When the newest release needs a newer EffectCraft, `library.list` says so (`needsNewerApp`).
|
||||
* The download must match `sha256` (and `size`, if given) exactly, or it is refused.
|
||||
|
||||
## 8. Test the entry locally
|
||||
|
||||
Before you open a pull request, test the entry with a catalog on your own disk. A local catalog
|
||||
(`file://`) may also name local files, so you can test before you publish the release: point
|
||||
`url` at your built module, then switch to the GitHub URL.
|
||||
|
||||
Make a folder with an `index.json` that holds only your package:
|
||||
|
||||
```json
|
||||
{"schema": 1, "packages": [ { "id": "io.github.yourname.glow-rings", "…": "your entry" } ]}
|
||||
```
|
||||
|
||||
File URLs are absolute: `file:///home/me/glow-rings/index.json` on Linux and macOS,
|
||||
`file:///C:/Users/me/glow-rings/index.json` on Windows. Write a space as `%20`.
|
||||
|
||||
Use a separate settings folder, so the test doesn't touch your real installation:
|
||||
|
||||
```sh
|
||||
export EFFECTCRAFT_CONFIG_DIR="$PWD/ec-test" # PowerShell: $env:EFFECTCRAFT_CONFIG_DIR = "$PWD\ec-test"
|
||||
effectcraft-cli exec library.refresh '{"catalog": "file:///home/me/glow-rings/index.json", "wait": true}'
|
||||
effectcraft-cli exec library.list
|
||||
effectcraft-cli exec library.preview '{"id": "io.github.yourname.glow-rings", "wait": true}'
|
||||
effectcraft-cli exec library.install '{"id": "io.github.yourname.glow-rings", "wait": true}'
|
||||
```
|
||||
|
||||
* `library.refresh` reads the catalog and keeps a copy, so the next commands (separate runs of
|
||||
the CLI) see it. A malformed entry is left out and logged with the reason.
|
||||
* `library.list` shows your package as users will see it (`latest`, `effects`, `needsNewerApp`).
|
||||
* `library.preview` downloads the module, checks it against `size` and `sha256`, and renders the
|
||||
preview without installing anything. A wrong checksum fails here. The reply has the frame size
|
||||
(320 × 180), the frame count, the effect's manifest, and two checks: `changes` (the effect
|
||||
changes the sample frame with its defaults) and `animated` (the frames differ over time).
|
||||
* `library.install` installs it into the test settings folder and loads it.
|
||||
|
||||
To look at the preview in the app, start the desktop app with the control channel
|
||||
(`effectcraft --control 9877`, with the same `EFFECTCRAFT_CONFIG_DIR` if you set one), point it at
|
||||
your catalog, then open Settings ▸ Plug-in Library and press Preview on your package:
|
||||
|
||||
```sh
|
||||
effectcraft-cli exec library.refresh '{"catalog": "file:///home/me/glow-rings/index.json", "wait": true}' --bridge 9877
|
||||
```
|
||||
|
||||
The Refresh button in the app goes back to the community catalog.
|
||||
|
||||
## 9. Open a pull request to the catalog
|
||||
|
||||
1. Fork [`storytold/effectcraft-plugins`](https://github.com/storytold/effectcraft-plugins).
|
||||
2. Add your package to `packages` in `index.json`. Keep the file's formatting.
|
||||
3. Open a pull request. Say what the effect does and link the release. A screenshot or a short
|
||||
video helps the review.
|
||||
|
||||
The repository runs automatic checks on every pull request (see
|
||||
[`examples/plugin-catalog`](../examples/plugin-catalog)); a maintainer then reviews it by hand.
|
||||
|
||||
### Review checklist
|
||||
|
||||
Reviewers check, and you can check first:
|
||||
|
||||
* **Licence.** `license` is an open-source licence and the repository has its text.
|
||||
* **Public source.** `repo` is public and contains the plug-in's source at the release's tag.
|
||||
* **Reproducible.** The module builds from that source (the steps are in the repository's README)
|
||||
and the result has the SHA-256 in the entry.
|
||||
* **No imports.** The module imports nothing; EffectCraft refuses it otherwise. `library.install`
|
||||
from a local catalog shows it.
|
||||
* **Ids.** The package id and effect ids are your own, follow the rules, don't start with `ec.`,
|
||||
and `effects` matches the manifest's `id`.
|
||||
* **Sensible defaults.** The effect does something visible with its defaults, so the live
|
||||
preview shows it, and no default makes it slow.
|
||||
* **Description.** It says what the effect does, in plain words.
|
||||
* **Behaves.** It renders without errors at different sizes and preview resolutions, and the same
|
||||
frame twice gives the same result.
|
||||
|
||||
## 10. Release an update
|
||||
|
||||
1. Change the code, raise the version in `Cargo.toml` and in the manifest, build, and publish a
|
||||
new GitHub release (a new tag; never re-use an old one).
|
||||
2. Add a new object to `versions` with the new `version`, `url`, `sha256`, `size` and `effects`.
|
||||
Keep the old entries: they may stay, and users of an older EffectCraft still get a release they
|
||||
can run (set `minApp` on the new one if it needs a newer app).
|
||||
3. Test it with a local catalog as in [step 8](#8-test-the-entry-locally) (`library.update`
|
||||
updates an installed package), and open a pull request.
|
||||
|
||||
Users see "Update" on the package. Keep the effect id and the meaning of existing parameters, so
|
||||
projects made with the old version still open the same. Add parameters at the end.
|
||||
|
||||
## 11. Remove a plug-in
|
||||
|
||||
Open a pull request that deletes your package from `packages` (or one release from `versions`).
|
||||
People who installed it keep their copy: it stays in their list as unlisted until they remove it.
|
||||
If a release is broken or unsafe, say so in the pull request so it is removed quickly.
|
||||
|
||||
## For users
|
||||
|
||||
Settings ▸ Plug-in Library (also Effect ▸ Plug-in Library…):
|
||||
|
||||
* **Fetch Catalog** reads the community catalog. Nothing is downloaded before that. Refresh reads
|
||||
it again; the last one fetched is kept, so the list works offline.
|
||||
* **Preview** shows a package's effect live on a sample frame (playing, with a Before/After
|
||||
slider) without installing it. The details list the effects and their parameters, and have
|
||||
"Open on GitHub" and, when the author added one, "Watch video".
|
||||
* **Install** downloads the module, checks it against the catalog's size and SHA-256 and loads
|
||||
it: the effect is in Effects & Presets at once.
|
||||
* **Update** (or **Update All**) installs a newer release. **Remove** deletes a package.
|
||||
* **Restart after updating or removing** a package whose effects are loaded: loaded effects can't
|
||||
be unloaded, so the change takes effect at the next start, and the list says "Restart
|
||||
EffectCraft to finish".
|
||||
* A project that uses an effect you don't have: `library.missing` lists the missing effects and
|
||||
the catalog packages that provide them.
|
||||
|
||||
The same commands work from the CLI, the control channel and MCP: `library.list`,
|
||||
`library.refresh`, `library.preview`, `library.install`, `library.update`, `library.uninstall`,
|
||||
`library.missing`, `library.loadInstalled` ([plugins.md](plugins.md#plug-in-library)).
|
||||
|
||||
### Where files live
|
||||
|
||||
Packages are installed in the `Plug-ins/Library` folder of the settings folder:
|
||||
|
||||
| System | Settings folder |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/EffectCraft` |
|
||||
| Windows | `%APPDATA%\EffectCraft` |
|
||||
| Linux | `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` |
|
||||
| Portable (a `portable.txt` beside the executable) | `EffectCraftData` beside the executable |
|
||||
|
||||
`EFFECTCRAFT_CONFIG_DIR` overrides it. In `Plug-ins/Library`:
|
||||
|
||||
* `<id>/<version>/<id>.wasm`: each installed module.
|
||||
* `installed.json`: what is installed, with the SHA-256 each module was installed with. At start-up
|
||||
each module is checked against it; a module that changed on disk isn't loaded (install it
|
||||
again).
|
||||
* `catalog.json`: the catalog last fetched.
|
||||
@@ -0,0 +1,52 @@
|
||||
# EffectCraft plug-ins
|
||||
|
||||
The community catalog of WebAssembly effect plug-ins for
|
||||
[EffectCraft](https://github.com/storytold/effectcraft). The app's Plug-in Library
|
||||
(Settings ▸ Plug-in Library) reads [`index.json`](index.json) from this repository, then
|
||||
downloads, checks and installs the packages people pick. Plug-ins run in EffectCraft's sandbox:
|
||||
they can't read files, open the network or crash the app.
|
||||
|
||||
This repository holds only the catalog. Each plug-in's code and `.wasm` live in its author's
|
||||
repository, attached to a GitHub release.
|
||||
|
||||
## Submit a plug-in
|
||||
|
||||
1. Build your plug-in and attach the `.wasm` to a GitHub release. The full guide is
|
||||
[Publishing plug-ins](https://github.com/storytold/effectcraft/blob/main/docs/publishing-plugins.md).
|
||||
2. Add one entry to the `packages` array of `index.json`. [`example-entry.json`](example-entry.json)
|
||||
shows every field; [`schema.json`](schema.json) is the exact format. To publish a new version,
|
||||
add a release to your package's `versions` rather than editing an old one.
|
||||
3. Check it locally: `scripts/validate.sh` (needs bash, jq, curl, sha256sum and Node; set
|
||||
`EFFECTCRAFT_CLI` to an `effectcraft-cli` build to also install it with the real loader).
|
||||
4. Open a pull request. CI runs the same script; a maintainer reviews the rest.
|
||||
|
||||
## Review checklist (maintainers)
|
||||
|
||||
CI checks the format, unique ids, each download's size and SHA-256, and that EffectCraft installs
|
||||
and loads the package. Before merging, also check:
|
||||
|
||||
- [ ] The id is new, stable and names the author (reverse domain style: `io.github.user.name`).
|
||||
Ids never change and are never reused.
|
||||
- [ ] `repo` is a public repository with the plug-in's source, and its licence is open (the
|
||||
`license` field matches it).
|
||||
- [ ] Every `url` is a release asset of that repository (not a branch file, which can change).
|
||||
- [ ] The release builds from the tagged source; spot-check large or obfuscated modules.
|
||||
- [ ] `effects` lists the effect ids the module registers, and they don't clash with another
|
||||
package's.
|
||||
- [ ] `name`, `description` and `tags` are accurate; no Adobe names, logos or assets.
|
||||
- [ ] `preview.video`, if given, is a short demo of the effect hosted on GitHub or a well-known
|
||||
video site.
|
||||
- [ ] A change to an existing package comes from its author (or with their agreement), and old
|
||||
releases keep their url and SHA-256.
|
||||
|
||||
## Create this repository from the template
|
||||
|
||||
The template lives in EffectCraft at `examples/plugin-catalog/`.
|
||||
|
||||
1. Create the empty public repository `storytold/effectcraft-plugins` (branch `main`, so the app's
|
||||
catalog URL `https://raw.githubusercontent.com/storytold/effectcraft-plugins/main/index.json`
|
||||
resolves).
|
||||
2. Copy `README.md`, `index.json`, `example-entry.json`, `schema.json` and `scripts/` to its root,
|
||||
and `validate.yml` to `.github/workflows/validate.yml`.
|
||||
3. Keep `scripts/validate.sh` executable (`git update-index --chmod=+x scripts/validate.sh`).
|
||||
4. Protect `main`: require pull requests and the `Validate` check.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "org.effectcraft.example.posterize-bands",
|
||||
"name": "Posterize Bands",
|
||||
"author": "EffectCraft contributors",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"repo": "https://github.com/storytold/effectcraft/tree/main/examples/plugins/posterize-bands",
|
||||
"description": "Posterizes luminance into a chosen number of bands and tints them.",
|
||||
"tags": ["stylize", "color"],
|
||||
"preview": {"video": "https://github.com/storytold/effectcraft-plugins/raw/main/previews/posterize-bands.mp4"},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"api": 1,
|
||||
"minApp": "0.17.0",
|
||||
"url": "https://github.com/storytold/effectcraft/releases/download/v0.17.0/posterize_bands.wasm",
|
||||
"sha256": "d217309a695683de643678f456033677614cadb2890135362a32622e849498bf",
|
||||
"size": 20100,
|
||||
"effects": ["org.effectcraft.example.posterize-bands"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schema": 1, "packages": []}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://raw.githubusercontent.com/storytold/effectcraft-plugins/main/schema.json",
|
||||
"title": "EffectCraft Plug-in Library catalog (index.json), format 1",
|
||||
"description": "Mirrors crates/engine/src/system/library.rs (parse_catalog). Unknown fields are rejected here to catch typos; the app itself ignores them.",
|
||||
"type": "object",
|
||||
"required": ["schema", "packages"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema": {"const": 1},
|
||||
"packages": {"type": "array", "maxItems": 10000, "items": {"$ref": "#/$defs/package"}}
|
||||
},
|
||||
"$defs": {
|
||||
"version": {"type": "string", "maxLength": 32, "pattern": "^[0-9]+(\\.[0-9]+)*$"},
|
||||
"github_url": {
|
||||
"type": "string",
|
||||
"pattern": "^https://(github\\.com|raw\\.githubusercontent\\.com|objects\\.githubusercontent\\.com|release-assets\\.githubusercontent\\.com)/[^\\s]+$"
|
||||
},
|
||||
"package": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "versions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^(?!\\.)(?!.*\\.\\.)(?!.*\\.$)[A-Za-z0-9._-]{1,100}$"},
|
||||
"name": {"type": "string", "pattern": "\\S"},
|
||||
"author": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"license": {"type": "string"},
|
||||
"repo": {"type": "string", "pattern": "^https://"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
"preview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {"video": {"type": "string", "pattern": "^https://[^\\s]+$"}}
|
||||
},
|
||||
"versions": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/release"}}
|
||||
}
|
||||
},
|
||||
"release": {
|
||||
"type": "object",
|
||||
"required": ["version", "api", "url", "sha256", "effects"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"version": {"$ref": "#/$defs/version"},
|
||||
"api": {"type": "integer", "minimum": 0, "maximum": 4294967295},
|
||||
"minApp": {"$ref": "#/$defs/version"},
|
||||
"url": {"$ref": "#/$defs/github_url"},
|
||||
"sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"},
|
||||
"size": {"type": "integer", "minimum": 1, "maximum": 67108864},
|
||||
"effects": {"type": "array", "minItems": 1, "items": {"type": "string", "pattern": "^(?!ec\\.)[A-Za-z0-9._-]+$"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validate the plug-in catalog: schema, unique ids, every release's download (size + SHA-256),
|
||||
# then install every package with the real EffectCraft loader.
|
||||
#
|
||||
# scripts/validate.sh [index.json]
|
||||
#
|
||||
# Needs bash, jq, curl, sha256sum and npx (Node). Environment:
|
||||
# EFFECTCRAFT_CLI path to an effectcraft-cli build; unset: the loader step is skipped.
|
||||
# SKIP_SCHEMA=1 skip the schema step (testing with a local catalog that names file:// URLs).
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "$0")/.." && pwd)
|
||||
catalog=${1:-$here/index.json}
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
fail() { echo "error: $*" >&2; exit 1; }
|
||||
# A path as the CLI sees it (Git Bash on Windows: /tmp/x -> C:/.../x).
|
||||
native() { if command -v cygpath >/dev/null; then cygpath -m "$1"; else printf %s "$1"; fi; }
|
||||
# A file:// URL for a local path (file:///C:/x or file:///tmp/x).
|
||||
file_url() {
|
||||
local p
|
||||
p=$(native "$1")
|
||||
[[ $p == /* ]] || p="/$p"
|
||||
printf 'file://%s' "$p"
|
||||
}
|
||||
|
||||
echo "== 1. schema"
|
||||
[ "$(wc -c <"$catalog")" -le $((4 << 20)) ] || fail "the catalog is larger than 4 MB (the app's limit)"
|
||||
# Exactly one object of format 1 with a packages array (an empty file or a second value fails too),
|
||||
# checked even when the schema step is skipped.
|
||||
jq -e -s 'length == 1 and (.[0] | type == "object" and .schema == 1 and (.packages | type == "array"))' "$catalog" >/dev/null \
|
||||
|| fail "$catalog isn't one JSON object with \"schema\": 1 and a \"packages\" array"
|
||||
if [ "${SKIP_SCHEMA:-}" = 1 ]; then
|
||||
echo "skipped (SKIP_SCHEMA=1)"
|
||||
else
|
||||
npx --yes ajv-cli@5.0.0 validate --spec=draft2020 --strict=false -s "$here/schema.json" -d "$catalog"
|
||||
fi
|
||||
|
||||
echo "== 2. unique ids"
|
||||
dups=$(jq -r '.packages[].id' "$catalog" | sort | uniq -d)
|
||||
[ -z "$dups" ] || fail "duplicate package ids: $dups"
|
||||
echo "ok"
|
||||
|
||||
echo "== 3. downloads"
|
||||
# The checked files are what the loader step installs: a copy of the catalog names them by file://.
|
||||
local_catalog=$(jq '.' "$catalog")
|
||||
while IFS=$'\t' read -r p r id version url sha size; do
|
||||
file="$work/$id-$version.wasm"
|
||||
echo "$id $version: $url"
|
||||
curl -fsSL --retry 2 --max-filesize $((64 << 20)) -o "$file" "$url" || fail "$id $version: download failed"
|
||||
got_size=$(wc -c <"$file" | tr -d ' ')
|
||||
got_sha=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
[ -z "$size" ] || [ "$size" = "$got_size" ] || fail "$id $version: size is $got_size, the catalog says $size"
|
||||
[ "$got_sha" = "$(tr 'A-F' 'a-f' <<<"$sha")" ] || fail "$id $version: SHA-256 is $got_sha, the catalog says $sha"
|
||||
local_catalog=$(jq --argjson p "$p" --argjson r "$r" --arg u "$(file_url "$file")" '.packages[$p].versions[$r].url = $u' <<<"$local_catalog")
|
||||
done < <(jq -r '.packages | to_entries[] | .key as $p | .value as $pkg | $pkg.versions | to_entries[]
|
||||
| [$p, .key, $pkg.id, .value.version, .value.url, .value.sha256, (.value.size // "")] | @tsv' "$catalog")
|
||||
echo "ok"
|
||||
|
||||
echo "== 4. load with EffectCraft"
|
||||
if [ -z "${EFFECTCRAFT_CLI:-}" ]; then
|
||||
echo "skipped (set EFFECTCRAFT_CLI to an effectcraft-cli build)"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$local_catalog" >"$work/index.json"
|
||||
mkdir -p "$work/config"
|
||||
export EFFECTCRAFT_CONFIG_DIR=$(native "$work/config")
|
||||
"$EFFECTCRAFT_CLI" exec library.refresh "{\"catalog\": \"$(file_url "$work/index.json")\", \"wait\": true}"
|
||||
# shortcut: installs each package's newest release this build runs, not every release; older releases were checked when they were added.
|
||||
for id in $(jq -r '.packages[].id' "$catalog"); do
|
||||
"$EFFECTCRAFT_CLI" exec library.install "{\"id\": \"$id\", \"wait\": true}" || fail "$id didn't install"
|
||||
done
|
||||
echo "ok"
|
||||
@@ -0,0 +1,47 @@
|
||||
# Checks index.json on every pull request and push: schema, unique ids, every release's download
|
||||
# (size + SHA-256), then installs every package with the real EffectCraft loader.
|
||||
# Lives at .github/workflows/validate.yml in storytold/effectcraft-plugins.
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check out EffectCraft
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: storytold/effectcraft
|
||||
path: effectcraft
|
||||
|
||||
# The CLI links the UI crate, which needs these to build (same list as EffectCraft's CI).
|
||||
- name: System packages
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev \
|
||||
libxcursor-dev libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev libgl1-mesa-dev libasound2-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: effectcraft
|
||||
|
||||
- name: Build effectcraft-cli
|
||||
working-directory: effectcraft
|
||||
run: cargo build -p effectcraft-cli
|
||||
|
||||
- name: Validate the catalog
|
||||
env:
|
||||
EFFECTCRAFT_CLI: ${{ github.workspace }}/effectcraft/target/debug/effectcraft-cli
|
||||
run: bash scripts/validate.sh index.json
|
||||
Reference in new issue
Block a user