mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-15 03:37:04 -04:00
M13.30: Content-Aware Fill in a job worker on its GPU
contentFill.generate sends its plan (FillPlan) to a browser job worker: the layer's frames render in passes on the worker's WebGPU device, the PNG sequence goes back to browser storage (Services::store_file), and the page adds the fill layer when the plan returns (WorkerReply::Fill). The smoke test checks it; docs updated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
91f4a603a9
commit
3921e6c680
10 files changed
+350
-71
No files matched your search
@@ -311,7 +311,8 @@ function spawn(base) {
|
||||
}
|
||||
|
||||
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
|
||||
/// path, bytes)` receives "reply" (json), "file" (path, bytes) and "error" (json = message).
|
||||
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
|
||||
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
|
||||
export function workerRun(id, json, files, base, onMessage) {
|
||||
const w = idle.pop() ?? spawn(base);
|
||||
running.set(id, w);
|
||||
@@ -326,6 +327,8 @@ export function workerRun(id, json, files, base, onMessage) {
|
||||
}
|
||||
} else if (m.type === "file") {
|
||||
onMessage("file", null, m.path, new Uint8Array(m.bytes));
|
||||
} else if (m.type === "store") {
|
||||
onMessage("store", null, m.path, new Uint8Array(m.bytes));
|
||||
} else if (m.type === "stats") {
|
||||
jobStats.push({ kind: m.kind, gpu: m.gpu, passes: m.passes, readbacks: m.readbacks, ms: m.ms });
|
||||
if (jobStats.length > 16) jobStats.shift();
|
||||
|
||||
@@ -126,6 +126,10 @@ impl Services for WebServices {
|
||||
}
|
||||
fn store_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
put(path, data.into());
|
||||
// A job worker's files (Content-Aware Fill) go to the page's storage.
|
||||
if crate::is_worker() {
|
||||
crate::worker::post_store(path, data);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,12 @@ impl Offload for WorkerOffload {
|
||||
crate::files::add_output(&p, b.to_vec().into());
|
||||
}
|
||||
}
|
||||
"store" => {
|
||||
// A file the job keeps (Content-Aware Fill's sequence): browser storage.
|
||||
if let (Some(p), Ok(b)) = (path.as_string(), bytes.dyn_into::<js_sys::Uint8Array>()) {
|
||||
crate::files::put(&p, b.to_vec().into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
inbox.push(WorkerReply::Failed { error: json.as_string().unwrap_or_else(|| "worker failed".into()) });
|
||||
inbox.push(WorkerReply::Done);
|
||||
@@ -135,6 +141,13 @@ fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
|
||||
o.into()
|
||||
}
|
||||
|
||||
/// Send a file the job keeps to the page's storage (`Services::store_file` in a job worker).
|
||||
pub(crate) fn post_store(path: &str, data: &[u8]) {
|
||||
let bytes = js_sys::Uint8Array::from(data);
|
||||
let buf = bytes.buffer();
|
||||
post(&obj(&[("type", "store".into()), ("path", path.into()), ("bytes", buf.clone().into())]), Some(&js_sys::Array::of1(&buf)));
|
||||
}
|
||||
|
||||
/// Set up the worker's engine session (called once by `web/worker.js`).
|
||||
#[wasm_bindgen(js_name = workerInit)]
|
||||
pub fn worker_init() {
|
||||
|
||||
@@ -321,6 +321,8 @@ try {
|
||||
report.steps.gpuJobs = await js(`(async () => {
|
||||
let maxGapMs = 0, last = performance.now();
|
||||
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
|
||||
const gaps = {};
|
||||
const phase = (n) => { gaps[n] = maxGapMs; maxGapMs = 0; };
|
||||
await effectcraft.execute("layer.newSolid", {name: "GPU Job Solid", color: "#3366cc", width: 320, height: 180});
|
||||
await effectcraft.execute("effect.apply", {effect: "Fractal Noise"});
|
||||
await effectcraft.execute("prop.addKey", {path: "effects/#1/transform/offset", time: 0, value: [0.5, 0.5]});
|
||||
@@ -333,6 +335,7 @@ try {
|
||||
const t0 = performance.now();
|
||||
const r = await effectcraft.execute("renderQueue.render", {});
|
||||
const renderMs = performance.now() - t0;
|
||||
phase("render");
|
||||
// Applying Warp Stabilizer starts its analysis in a job worker (as in After Effects).
|
||||
await effectcraft.execute("effect.apply", {effect: "Warp Stabilizer"});
|
||||
const t1 = performance.now();
|
||||
@@ -342,10 +345,30 @@ try {
|
||||
warp = await effectcraft.execute("warp.status", {});
|
||||
} while (!warp.analyzed && performance.now() - t1 < 180000);
|
||||
const warpMs = performance.now() - t1;
|
||||
phase("warp");
|
||||
// Content-Aware Fill (a job worker too): its input is the layer's source, so the noise and
|
||||
// blur go into a precomp (rendered on the worker's GPU); cut a hole, fill half a second.
|
||||
// (Editing on the page — pre-composing, the mask — is not part of the measured jobs.)
|
||||
await effectcraft.execute("effect.remove", {effect: "Warp Stabilizer"});
|
||||
await effectcraft.execute("layer.precompose", {name: "GPU Fill Source", mode: "move", open: false});
|
||||
await effectcraft.execute("layer.addMask", {rect: [140, 70, 40, 40], mode: "subtract"});
|
||||
const wa = await effectcraft.execute("comp.info", {}).then((c) => c.workArea).catch(() => null);
|
||||
await effectcraft.execute("comp.workArea", {start: 0, end: 0.5});
|
||||
phase("prepareFill");
|
||||
const t2 = performance.now();
|
||||
const fill = await effectcraft.execute("contentFill.generate", {method: "edgeBlend", wait: true}).catch((e) => ({error: String(e)}));
|
||||
const fillMs = performance.now() - t2;
|
||||
const fillLayers = await effectcraft.execute("comp.info", {}).then((c) => c.layers.map((l) => l.name).filter((n) => n.startsWith("Fill ")));
|
||||
phase("fill");
|
||||
clearInterval(timer);
|
||||
maxGapMs = Math.max(gaps.render, gaps.warp, gaps.fill);
|
||||
// Back to the project as it was.
|
||||
const c = await effectcraft.execute("comp.info", {});
|
||||
const ids = c.layers.filter((l) => l.name === "GPU Fill Source" || l.name.startsWith("Fill ")).map((l) => l.id);
|
||||
await effectcraft.execute("edit.clear", {layers: ids});
|
||||
if (wa) await effectcraft.execute("comp.workArea", {start: wa[0], end: wa[1]});
|
||||
const workers = effectcraft.info().workers;
|
||||
for (let k = 0; k < 7; k++) await effectcraft.execute("edit.undo", {});
|
||||
return {status: r.items.map((i) => i.status), renderMs, warp, warpMs, workers, maxGapMs};
|
||||
return {status: r.items.map((i) => i.status), renderMs, warp, warpMs, fill, fillMs, fillLayers, workArea: wa, workers, maxGapMs, gaps};
|
||||
})()`);
|
||||
const gj = report.steps.gpuJobs;
|
||||
gj.download = await waitDownload((n) => n.startsWith("gpujob"));
|
||||
@@ -357,7 +380,10 @@ try {
|
||||
check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
|
||||
check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`);
|
||||
check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
|
||||
check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs`);
|
||||
const fj = jobs.filter((j) => j.kind === "ContentFill").pop();
|
||||
check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`);
|
||||
check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
|
||||
check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`);
|
||||
} else {
|
||||
report.steps.gpuJobs = { note: "no WebGPU in workers here: job workers render on their CPU" };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
//! whole layer, render the result as a PNG sequence and add it as a "Fill" layer above the
|
||||
//! source, like After Effects. The algorithms are `effectcraft_raster::inpaint` (Object:
|
||||
//! flow-guided propagation + PatchMatch; Surface: propagation + one synthesised fill carried by
|
||||
//! the flow; Edge Blend: membrane fill). It runs as a background job (Window ▸ Progress).
|
||||
//! the flow; Edge Blend: membrane fill). It runs as a background job (Window ▸ Progress); in
|
||||
//! the browser the job goes to a job worker ([`FillPlan`], `offload::WorkerJob::ContentFill`),
|
||||
//! whose own WebGPU device renders the layer's frames in passes, and the files come back to
|
||||
//! browser storage.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -11,7 +14,7 @@ use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerId, LayerSource, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::inpaint::{FillInput, FillMethod, FillOpts, dilate, fill_sequence};
|
||||
use effectcraft_render::{EvalCtx, RenderOpts, Renderer};
|
||||
use effectcraft_render::{Accelerator, EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -127,22 +130,118 @@ fn png(img: &Image) -> std::result::Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
fn default_dir(s: &Session, name: &str) -> String {
|
||||
let base = s
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| std::path::Path::new(p).parent().map(|d| d.join("Fill")))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("effectcraft-fill"));
|
||||
let base = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent().map(|d| d.join("Fill"))).unwrap_or_else(|| {
|
||||
// (the browser has no temporary folder: its storage)
|
||||
if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/Fill") } else { std::env::temp_dir().join("effectcraft-fill") }
|
||||
});
|
||||
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
|
||||
let mut k = 1;
|
||||
loop {
|
||||
let d = base.join(format!("{safe}_Fill_{k}"));
|
||||
if !d.exists() {
|
||||
return d.to_string_lossy().to_string();
|
||||
let ds = d.to_string_lossy().to_string();
|
||||
if !s.services.exists(&ds) && !s.services.exists(&format!("{ds}/fill_00000.png")) {
|
||||
return ds;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill job, detached from the session (it also travels to a browser job worker).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FillPlan {
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
/// The source layer's name.
|
||||
pub name: String,
|
||||
/// The layer's size (the frames' size).
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Comp times of the filled frames.
|
||||
pub times: Vec<Tick>,
|
||||
/// `object`, `surface` or `edgeBlend`.
|
||||
pub method: String,
|
||||
pub lighting: f32,
|
||||
/// Alpha expansion (px).
|
||||
pub expand: usize,
|
||||
/// Reference frame: (layer, comp time).
|
||||
pub reference: Option<(LayerId, Tick)>,
|
||||
/// Folder of the PNG sequence.
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
|
||||
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn run_plan(
|
||||
plan: &FillPlan,
|
||||
project: &effectcraft_project::Project,
|
||||
footage: &dyn FootageSource,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
cache: &LayerCache,
|
||||
services: &dyn crate::Services,
|
||||
accel: Option<&dyn Accelerator>,
|
||||
progress: &mut dyn FnMut(u64, u64) -> bool,
|
||||
message: &mut dyn FnMut(&str),
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
message("Reading frames");
|
||||
let (cid, w, h) = (plan.comp, plan.width, plan.height);
|
||||
let comp = project.comp(cid).ok_or("composition missing")?;
|
||||
let layer = comp.layer(plan.layer).ok_or("layer missing")?;
|
||||
let mut r = Renderer::new(project, footage, crate::offload::analysis_opts(accel));
|
||||
r.expr = expr;
|
||||
r.cache = Some(cache);
|
||||
let r = &r;
|
||||
let ctx0 = EvalCtx { expr, ..EvalCtx::new(project, cid, comp, Tick::ZERO) };
|
||||
let times = &plan.times;
|
||||
let n = times.len() as u64;
|
||||
let mut frames = Vec::with_capacity(times.len());
|
||||
let mut holes = Vec::with_capacity(times.len());
|
||||
for (k, t) in times.iter().enumerate() {
|
||||
let (img, hole) = crate::offload::in_passes(accel, |a| masked_frame(&r.with_accel(a), &ctx0.at(*t), layer, w, h)).await;
|
||||
holes.push(dilate(&hole, w as usize, h as usize, plan.expand));
|
||||
frames.push(img);
|
||||
if !progress(k as u64 + 1, n * 3) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
}
|
||||
if !holes.iter().any(|h| h.iter().any(|b| *b)) {
|
||||
return Err("the layer has no transparent area to fill: draw a mask (Subtract) around what to remove".into());
|
||||
}
|
||||
let mut references = vec![];
|
||||
if let Some((rl, rt)) = plan.reference
|
||||
&& let Some(l) = comp.layer(rl)
|
||||
{
|
||||
let (img, hole) = crate::offload::in_passes(accel, |a| masked_frame(&r.with_accel(a), &ctx0.at(rt), l, w, h)).await;
|
||||
if hole.iter().all(|b| !*b) {
|
||||
let k = times.iter().enumerate().min_by_key(|(_, t)| (t.0 - rt.0).abs()).map(|(k, _)| k).unwrap_or(0);
|
||||
references.push((k, img));
|
||||
}
|
||||
}
|
||||
message("Filling");
|
||||
let opts = FillOpts { method: FillMethod::from_name(&plan.method).unwrap_or_default(), lighting: plan.lighting, ..Default::default() };
|
||||
let input = FillInput { frames, holes, references };
|
||||
let out = fill_sequence(&input, &opts, &mut |d, t| progress(n + (d as u64 * n / t.max(1) as u64), n * 3)).ok_or(crate::render_queue::CANCELLED)?;
|
||||
message("Writing the fill layer");
|
||||
let dir = &plan.dir;
|
||||
let mut paths = vec![];
|
||||
for (k, img) in out.iter().enumerate() {
|
||||
let path = std::path::Path::new(dir).join(format!("fill_{k:05}.png")).to_string_lossy().to_string();
|
||||
services.store_file(&path, &png(img)?).map_err(|e| format!("{path}: {e}"))?;
|
||||
paths.push(path);
|
||||
if !progress(2 * n + k as u64 + 1, n * 3) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// Add the fill layer of a finished plan (one undo step).
|
||||
pub(crate) fn apply_plan(s: &mut Session, plan: &FillPlan, paths: Vec<String>) -> Result<Value> {
|
||||
let importer = s.importer.clone();
|
||||
add_fill_layer(s, plan.comp, plan.layer, &plan.name, paths, (plan.width, plan.height), plan.times[0], plan.times.len(), importer)
|
||||
}
|
||||
|
||||
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "contentFill.generate";
|
||||
let (cid, lid) = layer_p(s, p, c)?;
|
||||
@@ -184,61 +283,42 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expand = st.alpha_expansion.round().max(0.0) as usize;
|
||||
let reference = st.reference_layer.filter(|r| comp.layer(*r).is_some()).map(|r| (r, Tick::from_seconds_f64(st.reference_time.unwrap_or(0.0))));
|
||||
let dir = str_p(p, "outputDir").map(str::to_string).unwrap_or_else(|| default_dir(s, &layer.name));
|
||||
let plan = FillPlan {
|
||||
comp: cid,
|
||||
layer: lid,
|
||||
name: layer.name.clone(),
|
||||
width: w,
|
||||
height: h,
|
||||
times,
|
||||
method: opts.method.name().to_string(),
|
||||
lighting: opts.lighting,
|
||||
expand,
|
||||
reference,
|
||||
dir,
|
||||
};
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
// The browser: a job worker (its own GPU), the page keeps running.
|
||||
if !wait && s.offloads() {
|
||||
s.offload_analysis(crate::offload::WorkerJob::ContentFill { plan: Box::new(plan) }).map_err(EngineError::Other)?;
|
||||
return Ok(json!({"job": "contentFill", "started": true, "worker": true}));
|
||||
}
|
||||
let lf = LayerFrames::new(s, cid, lid);
|
||||
let services = s.services.clone();
|
||||
let importer = s.importer.clone();
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
let name = layer.name.clone();
|
||||
s.spawn_task("contentFill", format!("Content-Aware Fill: {name}"), wait, move |ctl| {
|
||||
ctl.message("Reading frames");
|
||||
let project = lf.project.clone();
|
||||
let comp = project.comp(cid).ok_or("composition missing")?;
|
||||
let layer = comp.layer(lid).ok_or("layer missing")?;
|
||||
let mut r = Renderer::new(&project, lf.footage.as_ref(), RenderOpts::default());
|
||||
r.expr = lf.expr.as_deref();
|
||||
r.cache = Some(&lf.cache);
|
||||
let ctx0 = EvalCtx { expr: lf.expr.as_deref(), ..EvalCtx::new(&project, cid, comp, Tick::ZERO) };
|
||||
let n = times.len() as u64;
|
||||
let mut frames = Vec::with_capacity(times.len());
|
||||
let mut holes = Vec::with_capacity(times.len());
|
||||
for (k, t) in times.iter().enumerate() {
|
||||
let (img, hole) = masked_frame(&r, &ctx0.at(*t), layer, w, h);
|
||||
holes.push(dilate(&hole, w as usize, h as usize, expand));
|
||||
frames.push(img);
|
||||
if !ctl.progress(k as u64 + 1, n * 3) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
}
|
||||
if !holes.iter().any(|h| h.iter().any(|b| *b)) {
|
||||
return Err("the layer has no transparent area to fill: draw a mask (Subtract) around what to remove".into());
|
||||
}
|
||||
let mut references = vec![];
|
||||
if let Some((rl, rt)) = reference
|
||||
&& let Some(l) = comp.layer(rl)
|
||||
{
|
||||
let (img, hole) = masked_frame(&r, &ctx0.at(rt), l, w, h);
|
||||
if hole.iter().all(|b| !*b) {
|
||||
let k = times.iter().enumerate().min_by_key(|(_, t)| (t.0 - rt.0).abs()).map(|(k, _)| k).unwrap_or(0);
|
||||
references.push((k, img));
|
||||
}
|
||||
}
|
||||
ctl.message("Filling");
|
||||
let input = FillInput { frames, holes, references };
|
||||
let out = fill_sequence(&input, &opts, &mut |d, t| ctl.progress(n + (d as u64 * n / t.max(1) as u64), n * 3)).ok_or(crate::render_queue::CANCELLED)?;
|
||||
ctl.message("Writing the fill layer");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{dir}: {e}"))?;
|
||||
let mut paths = vec![];
|
||||
for (k, img) in out.iter().enumerate() {
|
||||
let path = std::path::Path::new(&dir).join(format!("fill_{k:05}.png")).to_string_lossy().to_string();
|
||||
services.write_file(&path, &png(img)?).map_err(|e| format!("{path}: {e}"))?;
|
||||
paths.push(path);
|
||||
if !ctl.progress(2 * n + k as u64 + 1, n * 3) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
}
|
||||
let start = times[0];
|
||||
let count = times.len();
|
||||
let apply: Apply = Box::new(move |s: &mut Session| add_fill_layer(s, cid, lid, &name, paths, (w, h), start, count, importer));
|
||||
let paths = effectcraft_render::passes::block_on(run_plan(
|
||||
&plan,
|
||||
&lf.project,
|
||||
lf.footage.as_ref(),
|
||||
lf.expr.as_deref(),
|
||||
&lf.cache,
|
||||
services.as_ref(),
|
||||
None,
|
||||
&mut |d, t| ctl.progress(d, t),
|
||||
&mut |m| ctl.message(m),
|
||||
))?;
|
||||
let apply: Apply = Box::new(move |s: &mut Session| add_fill_layer(s, cid, lid, &name, paths, (w, h), plan.times[0], plan.times.len(), importer));
|
||||
Ok(apply)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,6 +262,9 @@ impl Session {
|
||||
{
|
||||
out.push(info("roto", "roto", "Roto Brush propagation".into(), p.done, p.total, true, String::new()));
|
||||
}
|
||||
if let Some(j) = self.offloaded(crate::offload::JobKind::ContentFill) {
|
||||
out.push(info("contentFill", "contentFill", "Content-Aware Fill".into(), j.progress.done, j.progress.total, true, String::new()));
|
||||
}
|
||||
for t in &self.tasks {
|
||||
let s = lock(&t.ctl.state).clone();
|
||||
out.push(info(&format!("task:{}", t.id), t.kind, t.label.clone(), s.done, s.total, !s.finished, s.message));
|
||||
@@ -278,6 +281,7 @@ impl Session {
|
||||
"warp" => self.stop_warp(),
|
||||
"camera" => self.stop_camera(),
|
||||
"roto" => self.stop_roto(),
|
||||
"contentFill" => self.cancel_offloaded(crate::offload::JobKind::ContentFill),
|
||||
"all" => {
|
||||
let ids: Vec<String> = self.jobs().into_iter().map(|j| j.id).collect();
|
||||
ids.iter().fold(false, |a, j| self.cancel_job(j) | a)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
//! | Roto Brush Freeze | the freeze | the effect group |
|
||||
//! | Roto Brush propagation | the propagation | segmentations ([`WorkerReply::Segs`], streamed while it runs) go into the page's segmentation cache |
|
||||
//! | 3D Camera Tracker analysis | [`Session::start_camera`] | the effect's property group |
|
||||
//! | Content-Aware Fill | the fill ([`crate::commands::content_fill::FillPlan`]) | its PNG files (to the host's storage), then the fill layer (one undo step) |
|
||||
//!
|
||||
//! Viewer frames go to a separate, long-lived frame worker fed with project diffs
|
||||
//! ([`crate::remote`]).
|
||||
@@ -107,6 +108,11 @@ pub enum WorkerJob {
|
||||
method: MaskMethod,
|
||||
times: Vec<Tick>,
|
||||
},
|
||||
/// Content-Aware Fill: the files go to the host's storage, the plan and their paths come
|
||||
/// back as [`WorkerReply::Fill`].
|
||||
ContentFill {
|
||||
plan: Box<crate::commands::content_fill::FillPlan>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkerJob {
|
||||
@@ -119,6 +125,7 @@ impl WorkerJob {
|
||||
WorkerJob::RotoPropagate { .. } => JobKind::RotoPropagate,
|
||||
WorkerJob::Track { .. } => JobKind::Track,
|
||||
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
|
||||
WorkerJob::ContentFill { .. } => JobKind::ContentFill,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +139,7 @@ impl WorkerJob {
|
||||
| WorkerJob::RotoPropagate { comp, layer, effect, .. } => Some((comp, layer, effect)),
|
||||
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
|
||||
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
|
||||
WorkerJob::ContentFill { ref plan } => Some((plan.comp, plan.layer, 0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +154,7 @@ pub enum JobKind {
|
||||
RotoPropagate,
|
||||
Track,
|
||||
MaskTrack,
|
||||
ContentFill,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
@@ -159,6 +168,7 @@ impl JobKind {
|
||||
JobKind::RotoPropagate => "Roto Brush Propagation",
|
||||
JobKind::Track => "Analyze Track",
|
||||
JobKind::MaskTrack => "Track Mask",
|
||||
JobKind::ContentFill => "Content-Aware Fill",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,6 +201,12 @@ pub enum WorkerReply {
|
||||
Segs {
|
||||
segs: Vec<SegData>,
|
||||
},
|
||||
/// A Content-Aware Fill finished: its plan and the PNG files written (already in the
|
||||
/// host's storage).
|
||||
Fill {
|
||||
plan: Box<crate::commands::content_fill::FillPlan>,
|
||||
files: Vec<String>,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
@@ -448,6 +464,7 @@ async fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), Stri
|
||||
.await
|
||||
}
|
||||
WorkerJob::RotoPropagate { comp, layer, effect, direction } => propagate(s, comp, layer, effect, direction, post).await,
|
||||
WorkerJob::ContentFill { plan } => fill(s, *plan, post).await,
|
||||
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => {
|
||||
analysis(s, kind, target, post, |s| {
|
||||
let work = crate::tracking::Work {
|
||||
@@ -485,6 +502,36 @@ async fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-Aware Fill in a worker: the layer's frames render on the worker's GPU (in passes),
|
||||
/// the files are written through the session's services (the browser: back to the page's
|
||||
/// storage) and the plan comes back for the page to add the fill layer.
|
||||
async fn fill(s: &mut Session, plan: crate::commands::content_fill::FillPlan, post: Post) -> Result<(), String> {
|
||||
let p = post.clone();
|
||||
let mut last = None::<web_time::Instant>;
|
||||
let mut progress = move |done: u64, total: u64| {
|
||||
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 100) {
|
||||
last = Some(web_time::Instant::now());
|
||||
p(WorkerReply::Progress { done, total });
|
||||
}
|
||||
true
|
||||
};
|
||||
let accel = job_accel();
|
||||
let files = crate::commands::content_fill::run_plan(
|
||||
&plan,
|
||||
&s.project,
|
||||
s.footage.as_ref(),
|
||||
s.expr.as_deref(),
|
||||
&s.layer_cache,
|
||||
s.services.as_ref(),
|
||||
accel.as_deref(),
|
||||
&mut progress,
|
||||
&mut |_| {},
|
||||
)
|
||||
.await?;
|
||||
post(WorkerReply::Fill { plan: Box::new(plan), files });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Roto Brush propagation in a worker: the segmentations stream back (at most every 250 ms)
|
||||
/// as they are computed, so stopping the job keeps what was propagated.
|
||||
async fn propagate(s: &mut Session, comp: ItemId, layer: LayerId, effect: Uid, dir: crate::roto::Direction, post: Post) -> Result<(), String> {
|
||||
@@ -674,6 +721,7 @@ impl Session {
|
||||
}
|
||||
let mut changed = false;
|
||||
let mut results = vec![];
|
||||
let mut fills = vec![];
|
||||
for j in &mut self.offloaded {
|
||||
if j.progress.cancelled {
|
||||
continue;
|
||||
@@ -686,6 +734,7 @@ impl Session {
|
||||
j.progress.total = total;
|
||||
}
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
|
||||
WorkerReply::Fill { plan, files } => fills.push((plan, files)),
|
||||
WorkerReply::Segs { segs } => {
|
||||
for sd in &segs {
|
||||
sd.store();
|
||||
@@ -699,6 +748,13 @@ impl Session {
|
||||
}
|
||||
j.progress.elapsed = j.started.elapsed().as_secs_f64();
|
||||
}
|
||||
for (plan, files) in fills {
|
||||
let n = files.len();
|
||||
match crate::commands::content_fill::apply_plan(self, &plan, files) {
|
||||
Ok(_) => self.events.push(Event::Toast { message: format!("Content-Aware Fill: {n} frame(s) filled"), error: false }),
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("Content-Aware Fill: {e}"), error: true }),
|
||||
}
|
||||
}
|
||||
for (kind, comp, layer, group, message) in results {
|
||||
let uid = group.uid;
|
||||
let r = self.edit(kind.label(), None, |p, _| {
|
||||
@@ -821,6 +877,21 @@ mod tests {
|
||||
method: MaskMethod::Perspective,
|
||||
times: vec![Tick::from_seconds_f64(1.0)],
|
||||
},
|
||||
WorkerJob::ContentFill {
|
||||
plan: Box::new(crate::commands::content_fill::FillPlan {
|
||||
comp,
|
||||
layer: LayerId(2),
|
||||
name: "Clip".into(),
|
||||
width: 4,
|
||||
height: 3,
|
||||
times: vec![Tick::ZERO],
|
||||
method: "object".into(),
|
||||
lighting: 0.5,
|
||||
expand: 1,
|
||||
reference: Some((LayerId(1), Tick::ZERO)),
|
||||
dir: "/Fill/Clip_Fill_1".into(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
for job in reqs {
|
||||
let kind = job.kind();
|
||||
@@ -839,6 +910,22 @@ mod tests {
|
||||
WorkerReply::Progress { done: 1, total: 2 },
|
||||
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
|
||||
WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] },
|
||||
WorkerReply::Fill {
|
||||
plan: Box::new(crate::commands::content_fill::FillPlan {
|
||||
comp,
|
||||
layer: LayerId(2),
|
||||
name: "Clip".into(),
|
||||
width: 4,
|
||||
height: 3,
|
||||
times: vec![Tick::ZERO],
|
||||
method: "object".into(),
|
||||
lighting: 0.5,
|
||||
expand: 1,
|
||||
reference: Some((LayerId(1), Tick::ZERO)),
|
||||
dir: "/Fill/Clip_Fill_1".into(),
|
||||
}),
|
||||
files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()],
|
||||
},
|
||||
WorkerReply::Failed { error: "boom".into() },
|
||||
WorkerReply::Done,
|
||||
] {
|
||||
|
||||
@@ -335,6 +335,58 @@ fn content_aware_fill_runs_in_the_background() {
|
||||
std::fs::remove_dir_all(&out).unwrap();
|
||||
}
|
||||
|
||||
/// The browser: Content-Aware Fill goes to a job worker (its files written through the
|
||||
/// worker's services), and the page adds the fill layer when the plan comes back.
|
||||
#[test]
|
||||
fn content_aware_fill_runs_in_a_job_worker() {
|
||||
#[derive(Default)]
|
||||
struct Manual(std::sync::Mutex<Vec<(crate::offload::WorkerRequest, Arc<crate::offload::Inbox>)>>);
|
||||
impl crate::offload::Offload for Manual {
|
||||
fn start(&self, req: crate::offload::WorkerRequest, inbox: Arc<crate::offload::Inbox>) -> std::result::Result<(), String> {
|
||||
self.0.lock().unwrap().push((req, inbox));
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
let (mut s, cid, clip, _) = setup(48, 32, 0.2, |f| {
|
||||
let mut img = Image::new(48, 32);
|
||||
for y in 0..32 {
|
||||
for x in 0..48 {
|
||||
img.set(x, y, bg(x as f32 + f as f32, y as f32));
|
||||
}
|
||||
}
|
||||
img
|
||||
});
|
||||
let off = Arc::new(Manual::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.execute("layer.addMask", json!({"rect": [20.0, 10.0, 8.0, 8.0], "mode": "subtract"})).unwrap();
|
||||
let out = tmp("cafworker");
|
||||
let r = s.execute("contentFill.generate", json!({"method": "edgeBlend", "outputDir": out.to_string_lossy()})).unwrap();
|
||||
assert_eq!((r["job"].as_str(), r["worker"].as_bool()), (Some("contentFill"), Some(true)), "{r}");
|
||||
assert!(s.jobs().iter().any(|j| j.id == "contentFill"));
|
||||
let (req, inbox) = off.0.lock().unwrap().pop().unwrap();
|
||||
let back: crate::offload::WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
// The worker: the same footage, the default (file system) services.
|
||||
let mut w = Session { footage: s.footage.clone(), ..Default::default() };
|
||||
let post: crate::offload::Post = std::rc::Rc::new(move |r| inbox.push(r));
|
||||
crate::offload::run_request(&mut w, back, &post);
|
||||
s.poll_offload();
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let i = comp.layers.iter().position(|l| l.id == clip).unwrap();
|
||||
assert!(i > 0, "a fill layer above the source");
|
||||
let fill = &comp.layers[i - 1];
|
||||
assert!(fill.name.starts_with("Fill 1"), "{}", fill.name);
|
||||
let LayerSource::Footage { item } = fill.source else { panic!("footage") };
|
||||
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { panic!() };
|
||||
assert_eq!(f.sequence.len(), 5);
|
||||
assert!(f.sequence.iter().all(|p| std::path::Path::new(p).exists()));
|
||||
assert!(s.jobs().is_empty());
|
||||
// One undo step.
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.project.comp(cid).unwrap().layers.len(), comp.layers.len() - 1);
|
||||
std::fs::remove_dir_all(&out).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Scene Edit Detection
|
||||
|
||||
fn shot_frame(f: u32) -> Image {
|
||||
|
||||
+5
-3
@@ -151,7 +151,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
||||
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates (CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`); Content-Aware Fill runs on the page (M13.30: Render Queue and analyses render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
|
||||
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |
|
||||
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (243 run on the GPU since M13.29, EFF-5: M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; audio-driven effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
@@ -496,8 +496,10 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
|
||||
tracking and Roto Brush (prefetched before each step) use the GPU. GPU particles and
|
||||
Advanced 3D (`raster_3d`, `render_3d`) read back under keys, so they run on the device in
|
||||
frame and job workers too (a depth-of-field run still resolves on the CPU, its scenes on
|
||||
the GPU). `effectcraft.info().workers` reports the job workers' adapter and each job's passes
|
||||
and readbacks; the headless-Chrome smoke test measures them.
|
||||
the GPU). Content-Aware Fill became a job-worker job too (its files go back to browser
|
||||
storage; before, it ran on the page and could not write its folder). `effectcraft.info().workers`
|
||||
reports the job workers' adapter and each job's passes and readbacks; the headless-Chrome
|
||||
smoke test measures them.
|
||||
- **Layer buffers in the browser's disk cache**: frame workers back their layer caches with a
|
||||
`PrefetchStore`; the page plans which layer entries to read before a request from the
|
||||
previous frames' misses (`LayerPrefetch` over the shared `DiskIndex`, one LRU order for
|
||||
|
||||
+13
-5
@@ -52,7 +52,7 @@ device of their own), `?nosw` (don't register the service worker).
|
||||
|---|---|
|
||||
| frame render thread pool (`ui-egui/src/frames.rs`) | frame workers (below), each with its own WebGPU device: `Frames::pump` hands queued frames to them, most important first (the viewer's frame, then prefetch); frames without effects that the page's GPU compositor can keep on the GPU render on the page. Without workers, queued frames render on the UI thread after each egui frame within 40 ms (24 ms while playing) |
|
||||
| Render Queue on a background thread | a Web Worker running a second engine instance with its own WebGPU device (below); progress streams back, the page never waits |
|
||||
| tracker / mask tracker / Warp Stabilizer / 3D Camera Tracker / Roto Brush Freeze threads | the same workers; the analysed property group comes back as one undo step |
|
||||
| tracker / mask tracker / Warp Stabilizer / 3D Camera Tracker / Roto Brush Freeze threads, Content-Aware Fill tasks | the same workers; the analysed property group (or the fill layer) comes back as one undo step |
|
||||
| `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial |
|
||||
| `std::time::Instant` / `SystemTime` (panic on wasm32) | `web-time` (re-exports `std::time` on native) |
|
||||
| `std::fs` project reads/writes (`FsServices`) | `files::WebServices`: the virtual file table, persisted (below); saving also downloads the `.ecproj` |
|
||||
@@ -103,7 +103,8 @@ compiled twice) and its own memory (`crates/engine/src/offload.rs`, `src/worker.
|
||||
|
||||
1. The session's `Offload` serializes a `WorkerRequest`: the project JSON, the footage paths it
|
||||
reads, and the job (`render` with the resolved queue items and output paths; `warp`, `camera`,
|
||||
`track`, `maskTrack`, `rotoFreeze` with the target and analysis parameters).
|
||||
`track`, `maskTrack`, `rotoFreeze`, `rotoPropagate` with the target and analysis parameters;
|
||||
`contentFill` with its plan).
|
||||
2. `js/host.js` takes an idle worker from its pool (or starts one), sends it the footage bytes it
|
||||
doesn't have yet, then the request.
|
||||
3. The worker runs the job on a plain engine session and posts `WorkerReply`s: render progress
|
||||
@@ -185,8 +186,16 @@ the same futures with `passes::block_on`; they never wait there).
|
||||
render on the device in frame workers and job workers alike.
|
||||
|
||||
`effectcraft.info().workers` reports `{running, idle, gpu: {adapter} | {cpu: why}, jobs:
|
||||
[{kind, gpu, passes, readbacks, ms}]}` (the last jobs). Content-Aware Fill runs on the page
|
||||
(it is not a worker job).
|
||||
[{kind, gpu, passes, readbacks, ms}]}` (the last jobs).
|
||||
|
||||
**Content-Aware Fill in a job worker (M13.30).** `contentFill.generate` (the panel's Generate
|
||||
Fill Layer) sends its plan (`content_fill::FillPlan`: layer, frames, method, folder) to a job
|
||||
worker (`WorkerJob::ContentFill`, `jobs.list` id `contentFill`): the worker renders the
|
||||
layer's frames in passes on its device, fills them and writes the PNG sequence through its
|
||||
services, which send each file to the page's browser storage (`{type: "store"}` messages, under
|
||||
`/Fill/<layer>_Fill_<n>/`); then the plan comes back (`WorkerReply::Fill`) and the page adds
|
||||
the fill layer as one undo step. (Before, the fill ran on the page's thread and could not
|
||||
write its folder.)
|
||||
|
||||
**Threading design: one engine instance per worker (decided, M13.24).** Shared-memory threads
|
||||
inside one instance (SharedArrayBuffer + atomics, a `wasm-bindgen-rayon`-style pool) need the
|
||||
@@ -434,7 +443,6 @@ layer buffers through the disk store and the prefetch plan (`remote.rs`
|
||||
project replica and the footage it reads); see the threading design above.
|
||||
- Browsers without WebGPU in workers render frames, Render Queue jobs and analyses on the
|
||||
workers' CPU. A frame renders at most 24 passes on the GPU, then on the CPU.
|
||||
- Content-Aware Fill runs on the page's thread (it is not offloaded to a job worker).
|
||||
- A layer buffer is fetched from disk only after some worker missed it in a recent frame (the
|
||||
first frame after a reload renders its layers).
|
||||
- Cancelling an analysis drops its partial result (the worker is terminated).
|
||||
Reference in new issue
Block a user