dzmdd's picture
Upload folder using huggingface_hub
e488ce7 verified
Raw History Blame Contribute Delete
52.5 kB
// H3 Motion Editor — DAW-style timeline + mask painter for the Motion Lab.
// Edits the H3MotionEditor node's serialized state (see the node docstring
// for the contract). Queue once to load the filmstrip; edits then happen
// entirely client-side until the next queue.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const LEGAL_STEP = 17;
// per-step cost goes as tokens**COST_EXP (attention dominates); see motion.py
const COST_EXP = 1.7;
const tokensFor = (n) => Math.floor((n - 5) / 17) * 5 + 2;
function tokStartFrame(t) {
const c = Math.floor(t / 5), i = t % 5;
return c * 17 + (i === 0 ? 0 : 4 * (i - 1) + 1);
}
function tokenStarts(len) {
const out = [];
for (let t = 0; tokStartFrame(t) < len; t++) out.push(tokStartFrame(t));
return out;
}
function legalCeil(n) {
const k = Math.max(2, Math.ceil((n - 5) / LEGAL_STEP));
return LEGAL_STEP * k + 5;
}
function uid() { return Math.random().toString(36).slice(2, 8); }
function clamp(v, a, b) { return Math.min(b, Math.max(a, v)); }
const PARAM_RANGE = { hold: [0, 8], feather: [0, 256], strength: [0, 1] };
const BLOCK_COLORS = ["#e06f6f", "#6fa8e0", "#7fce7f", "#d8b45a", "#b98fd6",
"#5ec8c0"];
function envValue(auto, param, frame, dflt) {
const pts = auto?.[param];
if (!pts || !pts.length) return dflt;
const s = [...pts].sort((a, b) => a[0] - b[0]);
if (frame <= s[0][0]) return s[0][1];
if (frame >= s[s.length - 1][0]) return s[s.length - 1][1];
for (let i = 0; i < s.length - 1; i++) {
const [f0, v0] = s[i], [f1, v1] = s[i + 1];
if (f0 <= frame && frame <= f1)
return f1 === f0 ? v1 : v0 + (frame - f0) / (f1 - f0) * (v1 - v0);
}
return dflt;
}
class MotionEditor {
constructor(node) {
this.node = node;
this.state = { v: 1, blocks: [] };
this.frames = { paint: [], strip: [] };
this.profile = [];
this.length = 124;
this.fps = 24;
this.cur = 0;
this.sel = null; // selected block id
this.tool = "brush";
this.brushR = 0.035;
this.target = "frame"; // "frame" | "block"
this.laneParam = null;
this.onion = true;
this.snap = true;
this.undoStack = [];
this.imgCache = new Map();
this.report = "";
this.holds = []; // compiled hold map (1 per world frame), from the last run
this.clock = "world"; // "world" | "dilated": how the timeline's x-axis reads time
this.playTimer = null;
this.drag = null;
this.sw = node.widgets.find((w) => w.name === "editor_state");
if (this.sw) {
try { const s = JSON.parse(this.sw.value || "{}"); if (s.blocks) this.state = s; } catch (e) { /* fresh */ }
this.sw.computeSize = () => [0, -4];
if (this.sw.element) this.sw.element.style.display = "none";
if (this.sw.inputEl) this.sw.inputEl.style.display = "none";
}
this.buildDOM();
this.redrawAll();
// the node's id is final only after the loader has placed it; ask then
setTimeout(() => this.restoreLast(), 300);
setTimeout(() => { if (!this.frames.strip.length) this.restoreLast(); }, 2000);
}
// A fresh page load asks the server for this node's last payload (filmstrip,
// profile, hold curve). Same lifetime as ComfyUI's cache: gone after a restart.
async restoreLast() {
try {
const r = await api.fetchApi(`/mainodes/editor/${encodeURIComponent(String(this.node.id))}`);
if (!r.ok) return;
const msg = await r.json();
if (msg && (msg.h3_strip || msg.h3_profile)) { this.onExecuted(msg); this.status("restored the last run's filmstrip from the server"); }
} catch (e) { /* no stash, fine */ }
}
// ---------- state ----------
pushUndo() {
this.undoStack.push(JSON.stringify(this.state));
if (this.undoStack.length > 60) this.undoStack.shift();
this.redoStack = [];
}
undo() {
const s = this.undoStack.pop();
if (!s) { this.status("nothing to undo"); return; }
(this.redoStack = this.redoStack || []).push(JSON.stringify(this.state));
this.state = JSON.parse(s);
if (this.sel && !this.block(this.sel)) this.sel = null;
this.commit(false);
this.redrawAll();
this.status(`undo (${this.undoStack.length} left)`);
}
redo() {
const s = (this.redoStack || []).pop();
if (!s) { this.status("nothing to redo"); return; }
this.undoStack.push(JSON.stringify(this.state));
this.state = JSON.parse(s);
this.commit(false);
this.redrawAll();
this.status("redo");
}
commit(push = true) {
if (push === "undo") this.pushUndo();
this.lastCommitted = JSON.stringify(this.state);
if (this.sw) this.sw.value = this.lastCommitted;
this.node.graph?.setDirtyCanvas(true, true);
this.updatePrice();
}
// The hidden editor_state widget IS the state: a graph-level undo (ComfyUI's
// ctrl+z) rolls the widget back without telling us, so we watch it and
// re-render from it instead of going blank.
watchWidget() {
if (!this.sw || this._watch) return;
this.lastCommitted = this.sw.value;
this._watch = setInterval(() => {
const v = this.sw.value;
if (v === this.lastCommitted) return;
try {
const st = JSON.parse(v || "{}");
if (st && st.blocks) {
this.undoStack.push(JSON.stringify(this.state));
this.state = st; this.lastCommitted = v;
if (this.sel && !this.block(this.sel)) this.sel = null;
this.redrawAll(); this.status("state restored from the workflow (graph undo/redo)");
}
} catch (e) { /* mid-edit text, ignore */ }
}, 400);
}
idx(b) { return this.state.blocks.indexOf(b); }
// The hold map the CURRENT blocks would compile to, client-side: explicit
// holds from the rows (with their automation), oracle blocks (hold 0) from the
// last run's compiled map where we have it (estimated x4 where we do not),
// everything outside blocks at 1. This drives the dilated clock, the play
// loop and the price, so an edit is seen before it is rendered.
previewHolds() {
const n = this.length, last = this.holds.length === n ? this.holds : null;
const out = new Array(n).fill(1);
for (const b of this.state.blocks) {
if (b.mute) continue;
for (let f = Math.max(0, b.start); f <= Math.min(b.end, n - 1); f++) {
let h = Math.round(envValue(b.auto, "hold", f, b.hold || 0));
if (h <= 0) h = last ? last[f] : 4;
out[f] = Math.max(out[f], h);
}
}
return this.state.blocks.some((b) => !b.mute) ? out : (last || out);
}
status(t) { if (this.statusEl) this.statusEl.textContent = t; }
block(id) { return this.state.blocks.find((b) => b.id === id); }
defaultBlock(a, z) {
return { id: uid(), start: a, end: z, hold: 0,
dials: { feather: 48, profile: "smoothstep", direction: "centered",
grow: 0, fade: 6, strength: 1.0 },
auto: {}, strokes: {}, static_strokes: [] };
}
snapRange(a, z) {
if (!this.snap) return [a, z];
const ts = tokenStarts(this.length);
let s = 0, e = this.length - 1;
for (const t of ts) if (t <= a) s = t;
for (let i = 0; i < ts.length; i++)
if (ts[i] > z) { e = ts[i] - 1; break; }
return [s, e];
}
updatePrice() {
const holds = new Array(this.length).fill(1);
for (const b of this.state.blocks)
for (let f = b.start; f <= Math.min(b.end, this.length - 1); f++) {
let h = Math.round(envValue(b.auto, "hold", f, b.hold || 0));
if (h <= 0) h = 4; // oracle value unknown client-side; estimate at peak
holds[f] = Math.max(holds[f], h);
}
const dil = this.state.blocks.length
? legalCeil(holds.reduce((x, y) => x + y, 0)) : this.length;
const t = (n) => (n / this.fps).toFixed(1);
const timeX = Math.pow(
tokensFor(dil) / Math.max(tokensFor(legalCeil(this.length)), 1), COST_EXP);
this.priceEl.textContent =
`${this.length}f (${t(this.length)}s) -> ~${dil}f (${t(dil)}s), ` +
`${(dil / this.length).toFixed(2)}x frames / ~${timeX.toFixed(1)}x time per step` +
(this.report ? ` | last run: ${this.report}` : "");
}
// ---------- server data ----------
onExecuted(msg) {
if (msg?.h3_paint) this.frames.paint = msg.h3_paint;
if (msg?.h3_strip) this.frames.strip = msg.h3_strip;
if (msg?.h3_profile) this.profile = msg.h3_profile;
if (msg?.h3_length?.length) this.length = msg.h3_length[0];
if (msg?.h3_fps?.length) this.fps = msg.h3_fps[0];
if (msg?.h3_report?.length) this.report = msg.h3_report[0];
if (msg?.h3_holds) this.holds = msg.h3_holds;
if (msg?.h3_held?.[0]) this.status("held: no blocks yet, nothing downstream ran. Lay out blocks (drag on the block lane) and run again");
this.imgCache.clear();
this.cur = clamp(this.cur, 0, this.length - 1);
this.redrawAll();
}
img(list, i, cb) {
const f = list[i];
if (!f) return null;
const key = f.filename;
if (this.imgCache.has(key)) return this.imgCache.get(key);
const im = new Image();
im.src = api.apiURL(
`/view?filename=${encodeURIComponent(f.filename)}` +
`&type=${f.type}&subfolder=${encodeURIComponent(f.subfolder)}`);
im.onload = () => cb && cb();
this.imgCache.set(key, im);
if (this.imgCache.size > 300) {
const k0 = this.imgCache.keys().next().value;
this.imgCache.delete(k0);
}
return im;
}
// ---------- DOM ----------
el(tag, style, parent, text) {
const e = document.createElement(tag);
Object.assign(e.style, style || {});
if (text !== undefined) e.textContent = text;
parent?.appendChild(e);
return e;
}
btn(parent, label, cb, title) {
const b = this.el("button", {
background: "#333", color: "#ddd", border: "1px solid #555",
borderRadius: "3px", padding: "2px 7px", cursor: "pointer",
fontSize: "11px" }, parent, label);
if (title) b.title = title;
b.onclick = (e) => { e.stopPropagation(); cb(b); };
return b;
}
buildDOM() {
const root = this.el("div", {
display: "flex", flexDirection: "column", gap: "4px",
background: "#1b1b1b", padding: "6px", borderRadius: "6px",
fontFamily: "sans-serif", fontSize: "11px", color: "#ccc",
width: "100%", height: "100%", boxSizing: "border-box", overflowY: "auto", overflowX: "hidden" });
root.dataset.h3 = "editor-root";
// A DEFINITE width. The DOM widget's container shrink-wraps to content and
// the canvases size themselves from their client width, so without this
// the rows panel and the timeline collapse each other (seen headless
// 2026-08-22: 558 -> 268 px, and drags overshooting to the last frame).
root.style.width = Math.max(600, (this.node.size?.[0] || 640) - 24) + "px";
root.style.minWidth = "0";
root.addEventListener("pointerdown", (e) => e.stopPropagation());
root.addEventListener("keydown", (e) => e.stopPropagation());
root.tabIndex = 0;
root.addEventListener("pointerdown", () => root.focus({ preventScroll: true }));
root.addEventListener("keydown", (e) => {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA")) return;
const k = e.key.toLowerCase();
if (e.key === " ") { this.setPlay(!this.playTimer); e.preventDefault(); }
else if (e.key === "ArrowLeft") { this.setFrame(this.cur - (e.shiftKey ? 12 : 1)); e.preventDefault(); }
else if (e.key === "ArrowRight") { this.setFrame(this.cur + (e.shiftKey ? 12 : 1)); e.preventDefault(); }
else if ((e.ctrlKey || e.metaKey) && k === "z" && e.shiftKey) { this.redo(); e.preventDefault(); }
else if ((e.ctrlKey || e.metaKey) && k === "z") { this.undo(); e.preventDefault(); }
else if ((e.ctrlKey || e.metaKey) && k === "y") { this.redo(); e.preventDefault(); }
else if (e.key === "Delete" && this.sel) this.deleteBlock(this.sel);
else if (k === "i" && this.sel) { const b = this.block(this.sel); this.pushUndo(); b.start = clamp(this.cur, 0, b.end); this.commit(); this.redrawAll(); }
else if (k === "o" && this.sel) { const b = this.block(this.sel); this.pushUndo(); b.end = clamp(this.cur, b.start, this.length - 1); this.commit(); this.redrawAll(); }
else if (k === "e" && this.sel) this.openModal(this.sel);
else if (/^[1-9]$/.test(e.key)) { const b = this.state.blocks[parseInt(e.key) - 1]; if (b) { this.sel = b.id; this.redrawAll(); } }
});
// toolbar
const tb = this.el("div", { display: "flex", gap: "5px", flexWrap: "wrap",
alignItems: "center", flex: "0 0 auto" }, root);
this.btn(tb, "+ block", () => {
this.pushUndo();
const a = clamp(this.cur, 0, this.length - 9);
const [s, e] = this.snapRange(a, a + 16);
const b = this.defaultBlock(s, e);
this.state.blocks.push(b);
this.sel = b.id;
this.commit();
this.redrawAll();
}, "add a time block at the playhead (or drag on the block lane)");
this.btn(tb, "delete", () => this.sel && this.deleteBlock(this.sel));
this.snapBtn = this.btn(tb, "snap: on", (b) => {
this.snap = !this.snap;
b.textContent = `snap: ${this.snap ? "on" : "off"}`;
}, "snap block edges to the model's token grid");
this.el("span", { width: "8px" }, tb);
this.toolBtns = {};
for (const t of ["brush", "erase"]) {
this.toolBtns[t] = this.btn(tb, t, () => this.setTool(t));
}
const bs = this.el("input", { width: "70px" }, tb);
bs.type = "range"; bs.min = "0.005"; bs.max = "0.15"; bs.step = "0.005";
bs.value = String(this.brushR);
bs.title = "brush size";
bs.oninput = () => { this.brushR = parseFloat(bs.value); this.drawPaint(); };
this.targetBtn = this.btn(tb, "paint: this frame", (b) => {
this.target = this.target === "frame" ? "block" : "frame";
b.textContent = this.target === "frame" ? "paint: this frame"
: "paint: whole block";
}, "whether strokes apply to the current frame or the whole block");
this.btn(tb, "clear frame", () => {
const b = this.block(this.sel); if (!b) return;
this.pushUndo();
delete (b.strokes || {})[String(this.cur)];
this.commit(); this.drawPaint();
});
this.onionBtn = this.btn(tb, "onion: on", (b) => {
this.onion = !this.onion;
b.textContent = `onion: ${this.onion ? "on" : "off"}`;
this.drawPaint();
});
this.btn(tb, "undo", () => this.undo(), "ctrl+z inside the editor; a graph-level ctrl+z is picked up too");
this.btn(tb, "redo", () => this.redo(), "ctrl+shift+z / ctrl+y");
this.el("span", { width: "8px" }, tb);
this.playBtn = this.btn(tb, "Play", () => this.setPlay(!this.playTimer),
"play the clip from the filmstrip at fps (space); in the dilated clock each frame lingers for its hold");
this.el("span", { width: "8px" }, tb);
this.btn(tb, "run to here", () => this.run(true),
"queue only up to this node: base pass + oracle + filmstrip, nothing downstream (partial execution)");
this.btn(tb, "run from here", () => this.run(false),
"queue the graph; unchanged upstream nodes come from ComfyUI's cache, so it resumes at this node");
this.clockBtn = this.btn(tb, "clock: world", (b) => {
this.clock = this.clock === "world" ? "dilated" : "world";
b.textContent = `clock: ${this.clock}`;
this.redrawAll();
}, "world: x = when (frame). dilated: x = how long it costs (cumulative holds), the slowdown seen");
this.setTool("brush");
// edit rows: one line per block, above the timeline (DAW-style clip list)
this.rowsWrap = this.el("div", { display: "flex", flexDirection: "column", gap: "2px", flex: "0 0 auto" }, root);
this.rowsWrap.dataset.h3 = "rows";
this.statusEl = this.el("div", { color: "#9ab", minHeight: "14px", fontSize: "11px", flex: "0 0 auto" }, root,
"drag on the block lane to create a block; rows above list every edit");
// timeline
this.tl = this.el("canvas", { width: "100%", cursor: "crosshair",
borderRadius: "4px", flex: "0 0 auto" }, root);
this.tl.dataset.h3 = "timeline";
this.tl.height = 130;
this.bindTimeline();
// automation lane
this.laneWrap = this.el("div", { display: "none", flexDirection: "column",
gap: "2px", flex: "0 0 auto" }, root);
this.laneLabel = this.el("div", {}, this.laneWrap, "automation");
this.lane = this.el("canvas", { width: "100%", borderRadius: "4px",
cursor: "crosshair" }, this.laneWrap);
this.lane.height = 66;
this.bindLane();
// paint area
this.paintWrap = this.el("div", { position: "relative", width: "100%", flex: "0 0 auto",
display: "flex", justifyContent: "center", background: "#000", borderRadius: "4px" }, root);
this.paint = this.el("canvas", { height: "340px", width: "auto", maxWidth: "100%", borderRadius: "4px",
cursor: "none", display: "block" },
this.paintWrap);
this.paint.height = 300;
this.hint = this.el("div", {
position: "absolute", inset: "0", display: "flex",
alignItems: "center", justifyContent: "center", color: "#888",
pointerEvents: "none", textAlign: "center" }, this.paintWrap,
"queue once to load the filmstrip, then drag a block on the timeline and paint");
this.bindPaint();
// dials
this.dials = this.el("div", { display: "flex", gap: "10px",
flexWrap: "wrap", alignItems: "center",
minHeight: "22px", flex: "0 0 auto" }, root);
this.priceEl = this.el("div", { color: "#9c9", minHeight: "14px", flex: "0 0 auto" }, root);
this.priceEl.dataset.h3 = "price"; this.statusEl.dataset.h3 = "status";
this.node.addDOMWidget("h3_editor_ui", "div", root, { serialize: false });
const sz = this.node.size;
this.node.setSize([Math.max(sz[0], 640), Math.max(sz[1], 980)]);
this.root = root;
new ResizeObserver(() => this.redrawAll()).observe(root);
this.watchWidget();
}
// ---------- rows: every edit as a line item ----------
buildRows() {
const wrap = this.rowsWrap; if (!wrap) return;
wrap.innerHTML = "";
const blocks = this.state.blocks;
if (!blocks.length) return;
wrap.style.minWidth = "0"; wrap.style.overflowX = "auto";
const head = this.el("div", { display: "grid", gridTemplateColumns: "22px minmax(120px,1fr) 64px 64px 56px 56px 56px 36px 40px 46px",
gap: "4px", color: "#777", fontSize: "10px", padding: "0 2px" }, wrap);
for (const h of ["#", "range", "in", "out", "hold", "feather", "strength", "mute", "", ""]) this.el("span", {}, head, h);
const t = (f) => (f / this.fps).toFixed(2) + "s";
blocks.forEach((b, i) => {
const col = BLOCK_COLORS[i % BLOCK_COLORS.length];
const row = this.el("div", { display: "grid", gridTemplateColumns: "22px minmax(120px,1fr) 64px 64px 56px 56px 56px 36px 40px 46px",
gap: "4px", alignItems: "center", padding: "2px", borderRadius: "3px",
background: b.id === this.sel ? "#2a3a2a" : "#222", opacity: b.mute ? "0.55" : "1" }, wrap);
row.onclick = () => { this.sel = b.id; this.redrawAll(); };
const sw = this.el("span", { display: "inline-block", width: "14px", height: "14px", borderRadius: "3px", background: col }, row);
sw.textContent = ""; this.el("span", { color: "#bbb" }, row, `${i + 1}: ${b.start}-${b.end} (${t(b.start)} to ${t(b.end + 1)}, ${b.end - b.start + 1} f)`);
const num = (get, set, min, max, step, width) => {
const inp = this.el("input", { width: width || "56px", background: "#1a1a1a", color: "#ddd", border: "1px solid #444", fontSize: "11px" }, row);
inp.type = "number"; inp.min = min; inp.max = max; inp.step = step; inp.value = String(get());
inp.onclick = (e) => e.stopPropagation();
inp.onchange = (e) => { e.stopPropagation(); this.pushUndo(); set(parseFloat(inp.value)); this.commit(); this.redrawAll(); };
return inp;
};
num(() => b.start, (v) => { b.start = clamp(Math.round(v), 0, b.end); }, 0, this.length - 1, 1);
num(() => b.end, (v) => { b.end = clamp(Math.round(v), b.start, this.length - 1); }, 0, this.length - 1, 1);
const hold = this.el("select", { background: "#1a1a1a", color: "#ddd", border: "1px solid #444", fontSize: "11px" }, row);
for (const [v, l] of [[0, "oracle"], [1, "x1"], [2, "x2"], [3, "x3"], [4, "x4"], [6, "x6"], [8, "x8"]]) {
const o = this.el("option", {}, hold, l); o.value = String(v); if ((b.hold || 0) === v) o.selected = true;
}
hold.onclick = (e) => e.stopPropagation();
hold.onchange = (e) => { e.stopPropagation(); this.pushUndo(); b.hold = parseInt(hold.value); this.commit(); this.redrawAll(); };
b.dials = b.dials || {};
num(() => b.dials.feather ?? 48, (v) => { b.dials.feather = clamp(Math.round(v), 0, 256); }, 0, 256, 1);
num(() => b.dials.strength ?? 1, (v) => { b.dials.strength = clamp(v, 0, 1); }, 0, 1, 0.05);
const mute = this.el("input", {}, row); mute.type = "checkbox"; mute.checked = !!b.mute;
mute.title = "keep the row, leave it out of the next run";
mute.onclick = (e) => e.stopPropagation();
mute.onchange = () => { this.pushUndo(); b.mute = mute.checked; this.commit(); this.redrawAll(); };
this.btn(row, "edit", () => this.openModal(b.id), "open this block in the editor (E)");
this.btn(row, "delete", () => this.deleteBlock(b.id), "remove this block (Delete)");
});
// rows scroll past four; resizing the node from inside a redraw collapsed
// the widget to a third of its width (seen headless 2026-08-22), so the
// node's size is left to the user and to the one setSize at build time.
wrap.style.maxHeight = "120px"; wrap.style.overflowY = "auto";
}
// ---------- modal: one block, zoomed, apply or cancel ----------
openModal(id) {
const src = this.block(id); if (!src) return;
this.closeModal();
const b = JSON.parse(JSON.stringify(src)); // edit a copy; apply writes it back
const i = this.idx(src), col = BLOCK_COLORS[i % BLOCK_COLORS.length];
const ov = this.el("div", { position: "fixed", inset: "0", background: "rgba(0,0,0,.6)", zIndex: "10000",
display: "flex", alignItems: "center", justifyContent: "center" }, document.body);
const box = this.el("div", { background: "#1b1b1b", color: "#ddd", border: "1px solid #555", borderRadius: "8px",
padding: "12px", width: "min(900px, 92vw)", fontFamily: "sans-serif", fontSize: "12px",
display: "flex", flexDirection: "column", gap: "8px" }, ov);
ov.addEventListener("pointerdown", (e) => { if (e.target === ov) this.closeModal(); });
ov.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Escape") this.closeModal(); if (e.key === "Enter" && e.ctrlKey) apply(); });
box.tabIndex = 0;
this.el("div", { fontSize: "14px", color: col }, box, `block ${i + 1}: ${b.start}-${b.end}`);
const cv = this.el("canvas", { width: "100%", borderRadius: "4px", cursor: "ew-resize" }, box);
cv.height = 150;
const pad = 12; // frames of context either side
let v0 = Math.max(0, b.start - pad), v1 = Math.min(this.length - 1, b.end + pad);
const fx = (f) => (f - v0) / Math.max(1, v1 - v0 + 1) * cv.width;
const xf = (x) => clamp(Math.round(v0 + x / cv.width * (v1 - v0 + 1)), 0, this.length - 1);
const price = this.el("div", { color: "#9c9" }, box);
const dialsRow = this.el("div", { display: "flex", gap: "12px", flexWrap: "wrap", alignItems: "center" }, box);
const draw = () => {
cv.width = cv.clientWidth || 800;
const g = cv.getContext("2d"), W = cv.width, H = cv.height;
g.fillStyle = "#111"; g.fillRect(0, 0, W, H);
const SH = 80, tw = SH;
if (this.frames.strip.length) {
const per = Math.max(1, Math.ceil((v1 - v0 + 1) / Math.floor(W / tw)));
for (let f = v0; f <= v1; f += per) {
const im = this.img(this.frames.strip, f, draw);
if (im?.complete) g.drawImage(im, fx(f), 0, Math.max(fx(f + per) - fx(f), tw), SH);
}
}
// hold curve of THIS block as it would compile (ramps drawn by the server; here the plateau)
g.fillStyle = col + "33"; g.fillRect(fx(b.start), SH + 4, fx(b.end + 1) - fx(b.start), 40);
g.fillStyle = col; g.fillRect(fx(b.start), SH + 4, 3, 40); g.fillRect(fx(b.end + 1) - 3, SH + 4, 3, 40);
g.fillStyle = "#ccc"; g.font = "11px sans-serif";
g.fillText(`in ${b.start} (${(b.start / this.fps).toFixed(2)}s)`, fx(b.start) + 6, SH + 18);
g.fillText(`out ${b.end} (${((b.end + 1) / this.fps).toFixed(2)}s)`, Math.max(6, fx(b.end + 1) - 110), SH + 36);
// ruler
g.fillStyle = "#888"; g.font = "9px sans-serif";
for (let f = v0; f <= v1; f++) if (f % Math.round(this.fps / 2) === 0) { g.fillRect(fx(f), SH + 48, 1, 5); g.fillText(String(f), fx(f) + 2, SH + 62); }
g.fillStyle = "#fff"; g.fillRect(fx(this.cur), 0, 1.5, H);
const n = b.end - b.start + 1, h = b.hold || 4;
price.textContent = `this block: ${n} frames x ~${h} = ~${n * h} dilated frames (${(n * h / this.fps).toFixed(1)} s of regeneration)` +
(b.hold ? "" : " (hold = oracle; estimated at x4)");
};
let drag = null;
cv.onpointerdown = (e) => {
e.stopPropagation(); cv.setPointerCapture(e.pointerId);
const x = (e.clientX - cv.getBoundingClientRect().left) * (cv.width / cv.getBoundingClientRect().width);
const f = xf(x);
if (Math.abs(x - fx(b.start)) < 10) drag = "in";
else if (Math.abs(x - fx(b.end + 1)) < 10) drag = "out";
else if (f > b.start && f < b.end) drag = { grab: f - b.start, span: b.end - b.start };
else { this.setFrame(f); drag = "scrub"; }
};
cv.onpointermove = (e) => {
if (!drag) return;
const x = (e.clientX - cv.getBoundingClientRect().left) * (cv.width / cv.getBoundingClientRect().width);
const f = xf(x);
if (drag === "in") b.start = clamp(f, 0, b.end);
else if (drag === "out") b.end = clamp(f - 1, b.start, this.length - 1);
else if (drag === "scrub") this.setFrame(f);
else { b.start = clamp(f - drag.grab, 0, this.length - 1 - drag.span); b.end = b.start + drag.span; }
draw();
};
cv.onpointerup = () => { if (drag && drag !== "scrub" && this.snap) [b.start, b.end] = this.snapRange(b.start, b.end); drag = null; draw(); };
const num = (label, get, set, min, max, step) => {
const wrap = this.el("span", { display: "inline-flex", gap: "4px", alignItems: "center" }, dialsRow);
this.el("span", { color: "#999" }, wrap, label);
const inp = this.el("input", { width: "64px", background: "#222", color: "#ddd", border: "1px solid #444" }, wrap);
inp.type = "number"; inp.min = min; inp.max = max; inp.step = step; inp.value = String(get());
inp.onchange = () => { set(parseFloat(inp.value)); draw(); };
};
num("in", () => b.start, (v) => { b.start = clamp(Math.round(v), 0, b.end); }, 0, this.length - 1, 1);
num("out", () => b.end, (v) => { b.end = clamp(Math.round(v), b.start, this.length - 1); }, 0, this.length - 1, 1);
num("hold (0 = oracle)", () => b.hold || 0, (v) => { b.hold = clamp(Math.round(v), 0, 8); }, 0, 8, 1);
b.dials = b.dials || {};
num("feather", () => b.dials.feather ?? 48, (v) => { b.dials.feather = clamp(Math.round(v), 0, 256); }, 0, 256, 1);
num("strength", () => b.dials.strength ?? 1, (v) => { b.dials.strength = clamp(v, 0, 1); }, 0, 1, 0.05);
num("fade", () => b.dials.fade ?? 6, (v) => { b.dials.fade = clamp(Math.round(v), 0, 48); }, 0, 48, 1);
this.el("div", { color: "#777" }, box, "drag the edges for in/out, the middle to move, click elsewhere to scrub. Painted masks and automation lanes stay on the main view of this block. Ctrl+Enter applies, Esc cancels.");
const btns = this.el("div", { display: "flex", gap: "8px", justifyContent: "flex-end" }, box);
const apply = () => {
this.pushUndo();
Object.assign(src, b);
this.commit(); this.closeModal(); this.redrawAll();
this.status(`block ${i + 1} applied: ${src.start}-${src.end}`);
};
this.btn(btns, "cancel", () => this.closeModal(), "esc");
const ab = this.btn(btns, "apply", apply, "ctrl+enter"); ab.style.background = "#3a6";
this.modal = ov; box.focus(); draw();
}
closeModal() { if (this.modal) { this.modal.remove(); this.modal = null; } }
// "run from here" means the same seeds: pin every seed widget whose
// control-after-generate would bump it (the frontend's default for nodes it
// builds is randomize, which re-renders the base pass on every queue).
pinSeeds() {
let n = 0;
for (const node of this.node.graph?._nodes || [])
for (const w of node.widgets || [])
if (w.name === "control_after_generate" && w.value !== "fixed") { w.value = "fixed"; n++; }
return n;
}
async run(toHere) {
try {
this.commit(false);
const pinned = this.pinSeeds();
if (pinned) this.status(`pinned ${pinned} seed control(s) to fixed so the base pass stays cached`);
const opts = toHere ? { partialExecutionTargets: [this.node.id] } : undefined;
this.status(toHere ? "queued: run to here (filmstrip + oracle)" : "queued: run from here (upstream from cache if unchanged)");
await app.queuePrompt(0, 1, opts);
} catch (e) { this.status("queue failed: " + e); console.error(e); }
}
setTool(t) {
this.tool = t;
for (const k in this.toolBtns)
this.toolBtns[k].style.background = k === t ? "#5a5" : "#333";
}
setFrame(f) {
this.cur = clamp(f, 0, this.length - 1);
this.drawTimeline(); this.drawPaint(); this.drawLane();
}
deleteBlock(id) {
this.pushUndo();
this.state.blocks = this.state.blocks.filter((b) => b.id !== id);
if (this.sel === id) this.sel = null;
this.commit(); this.redrawAll();
}
// ---------- timeline ----------
// x-axis: world clock (frame / length) or dilated clock (cumulative holds /
// dilated length), so the same block reads as "when" or as "how long it costs".
cum() {
const key = JSON.stringify(this.state.blocks) + "|" + this.holds.length;
if (this._cumKey === key && this._cum) return this._cum;
const ph = this.previewHolds();
const c = [0]; for (const h of ph) c.push(c[c.length - 1] + (h || 1));
this._cumKey = key; this._cum = c; this._ph = ph; return c;
}
fx(f, w) {
if (this.clock === "dilated" && this.length) {
const c = this.cum(); return c[clamp(Math.round(f), 0, this.length)] / Math.max(1, c[this.length]) * w;
}
return f / Math.max(1, this.length) * w;
}
xf(x, w) {
if (this.clock === "dilated" && this.length) {
const c = this.cum(), t = x / w * c[this.length];
let f = 0; while (f < this.length - 1 && c[f + 1] <= t) f++;
return clamp(f, 0, this.length - 1);
}
return clamp(Math.floor(x / w * this.length), 0, this.length - 1);
}
setPlay(on) {
if (this.playTimer) { clearInterval(this.playTimer); this.playTimer = null; }
if (this.playBtn) this.playBtn.textContent = on ? "Pause" : "Play";
if (!on) return;
// the strip/paint frames ARE the clip: stepping the playhead at fps plays it,
// and in the dilated clock each frame lingers for its hold (the slowdown, seen)
const tick = () => {
const h = this.clock === "dilated" ? (this.previewHolds()[this.cur] || 1) : 1;
this._playAcc = (this._playAcc || 0) + 1;
if (this._playAcc >= h) { this._playAcc = 0; this.setFrame(this.cur + 1 >= this.length ? 0 : this.cur + 1); }
};
this.playTimer = setInterval(tick, 1000 / Math.max(1, this.fps));
}
bindTimeline() {
const c = this.tl;
const pos = (e) => {
const r = c.getBoundingClientRect();
return [(e.clientX - r.left) * (c.width / r.width),
(e.clientY - r.top) * (c.height / r.height)];
};
c.onpointerdown = (e) => {
e.stopPropagation();
const [x, y] = pos(e);
const w = c.width;
c.setPointerCapture(e.pointerId);
const G = this.geo();
if (y < G.B0 || y > G.B1) { // strip or ruler: scrub
this.drag = { kind: "scrub" };
this.setFrame(this.xf(x, w));
return;
}
// block lane
const f = this.xf(x, w);
for (const b of this.state.blocks) {
const x0 = this.fx(b.start, w), x1 = this.fx(b.end + 1, w);
if (x >= x0 - 4 && x <= x0 + 4) {
this.pushUndo(); this.sel = b.id;
this.drag = { kind: "resizeL", id: b.id }; return;
}
if (x >= x1 - 4 && x <= x1 + 4) {
this.pushUndo(); this.sel = b.id;
this.drag = { kind: "resizeR", id: b.id }; return;
}
if (x > x0 && x < x1) {
this.pushUndo(); this.sel = b.id;
this.drag = { kind: "move", id: b.id, grab: f - b.start };
this.buildDials(); this.redrawAll(); return;
}
}
this.drag = { kind: "create", a: f, z: f };
};
c.onpointermove = (e) => {
if (!this.drag) { // hover: say what a press would do
const [hx, hy] = pos(e), G = this.geo(), w = c.width;
let cur = "crosshair", what = "drag to create a block";
if (hy < G.B0 || hy > G.B1) { cur = "col-resize"; what = "scrub"; }
else for (const b of this.state.blocks) {
const x0 = this.fx(b.start, w), x1 = this.fx(b.end + 1, w);
if (Math.abs(hx - x0) <= 4 || Math.abs(hx - x1) <= 4) { cur = "ew-resize"; what = `resize block ${this.idx(b) + 1}`; break; }
if (hx > x0 && hx < x1) { cur = "grab"; what = `move block ${this.idx(b) + 1} (click to select)`; break; }
}
c.style.cursor = cur; this.status(what);
return;
}
const [x] = pos(e);
const f = this.xf(x, c.width);
const d = this.drag;
if (d.kind === "scrub") { this.setFrame(f); return; }
if (d.kind === "create") { d.z = f; this.drawTimeline(); this.status(`drag: new block ${Math.min(d.a, d.z)}-${Math.max(d.a, d.z)} (release to create)`); return; }
const b = this.block(d.id);
if (!b) return;
this.status(`${d.kind === "move" ? "moving" : "resizing"} block ${this.idx(b) + 1}: ${b.start}-${b.end}`);
if (d.kind === "move") {
const span = b.end - b.start;
b.start = clamp(f - d.grab, 0, this.length - 1 - span);
b.end = b.start + span;
} else if (d.kind === "resizeL") b.start = clamp(f, 0, b.end);
else if (d.kind === "resizeR") b.end = clamp(f, b.start, this.length - 1);
this.drawTimeline(); this.updatePrice();
};
c.onpointerup = () => {
const d = this.drag;
this.drag = null;
if (!d) return;
if (d.kind === "create") {
const a = Math.min(d.a, d.z), z = Math.max(d.a, d.z);
if (z - a >= 2) {
this.pushUndo();
const [s, e2] = this.snapRange(a, z);
const b = this.defaultBlock(s, e2);
this.state.blocks.push(b);
this.sel = b.id;
}
} else if (d.kind === "resizeL" || d.kind === "resizeR") {
const b = this.block(d.id);
if (b) [b.start, b.end] = this.snapRange(b.start, b.end);
}
this.commit(); this.redrawAll();
};
}
// timeline geometry: the filmstrip scales with the node; everything below
// is laid out from SH (strip height), so nothing is a magic number.
geo() {
const W = this.tl?.clientWidth || 600;
const SH = clamp(Math.round(W / 9), 54, 140);
return { SH, P0: SH + 2, P1: SH + 48, B0: SH + 2, B1: SH + 54, R0: SH + 56, H: SH + 78 };
}
drawTimeline() {
const c = this.tl, g = c.getContext("2d");
c.width = c.clientWidth || 600;
const G = this.geo(); if (c.height !== G.H) c.height = G.H;
const W = c.width, H = c.height, SH = G.SH;
g.fillStyle = "#111"; g.fillRect(0, 0, W, H);
// filmstrip 0..SH
if (this.frames.strip.length) {
const tw = SH;
const per = Math.max(1, Math.ceil(this.length / Math.floor(W / tw)));
for (let f = 0; f < this.length; f += per) {
const im = this.img(this.frames.strip, f, () => this.drawTimeline());
const x = this.fx(f, W);
if (im?.complete)
g.drawImage(im, x, 0, Math.max(this.fx(f + per, W) - x, tw), SH);
}
} else {
g.fillStyle = "#666";
g.fillText("filmstrip loads after the first queue", 8, SH / 2);
}
// jerk profile P0..P1
g.fillStyle = "#182018"; g.fillRect(0, G.P0, W, G.P1 - G.P0);
if (this.profile.length) {
const mx = Math.max(...this.profile, 0.001);
g.strokeStyle = "#7fce7f"; g.beginPath();
this.profile.forEach((v, t) => {
const x = this.fx(tokStartFrame(t), W);
const y = G.P1 - 2 - v / mx * 40;
t ? g.lineTo(x, y) : g.moveTo(x, y);
});
g.stroke();
}
// hold curves: the LIVE envelope from the rows (solid) and the last run's
// compiled map (ghost), so an edit is visible before it is rendered
const curves = [];
if (this.holds.length === this.length) curves.push([this.holds, "#e0b05066", 1, "last run"]);
if (this.length) curves.push([this.previewHolds(), "#ffd060", 1.8, "live envelope"]);
if (curves.length) {
const hmax = Math.max(4, ...curves.flatMap((c) => c[0]));
for (const [holds, col, lw] of curves) {
g.strokeStyle = col; g.lineWidth = lw; g.beginPath();
for (let f = 0; f < this.length; f++) {
const y = G.P1 - 2 - ((holds[f] || 1) - 1) / (hmax - 1) * 40;
const x0 = this.fx(f, W), x1 = this.fx(f + 1, W);
f ? g.lineTo(x0, y) : g.moveTo(x0, y); g.lineTo(x1, y);
}
g.stroke();
}
g.lineWidth = 1; g.fillStyle = "#ffd060"; g.font = "9px sans-serif";
g.fillText((this.clock === "dilated" ? "dilated clock (width = cost): " : "world clock: ") +
"live envelope" + (this.holds.length === this.length ? " / ghost = last run" : ""), 4, G.P0 + 9);
}
// token grid ticks
g.strokeStyle = "#2c2c2c";
for (const t of tokenStarts(this.length)) {
const x = this.fx(t, W);
g.beginPath(); g.moveTo(x, G.P0); g.lineTo(x, G.P1); g.stroke();
}
// blocks overlay on 56..108
this.state.blocks.forEach((b, i) => {
const col = BLOCK_COLORS[i % BLOCK_COLORS.length];
const x0 = this.fx(b.start, W), x1 = this.fx(b.end + 1, W);
g.fillStyle = col + (b.id === this.sel ? "66" : b.mute ? "14" : "33");
g.fillRect(x0, G.B0, x1 - x0, G.B1 - G.B0);
g.fillStyle = col;
g.fillRect(x0, G.B0, 3, G.B1 - G.B0); g.fillRect(x1 - 3, G.B0, 3, G.B1 - G.B0);
g.font = "10px sans-serif";
const painted = Object.keys(b.strokes || {}).length ||
(b.static_strokes || []).length;
g.fillText(`${i + 1} ${b.hold ? "h" + b.hold : "oracle"}${painted ? " *" : ""}${b.mute ? " (muted)" : ""}`,
x0 + 6, G.B0 + 12);
});
// create preview
if (this.drag?.kind === "create") {
const x0 = this.fx(Math.min(this.drag.a, this.drag.z), W);
const x1 = this.fx(Math.max(this.drag.a, this.drag.z) + 1, W);
g.fillStyle = "#ffffff22"; g.fillRect(x0, G.B0, x1 - x0, G.B1 - G.B0);
g.fillStyle = "#fff"; g.font = "10px sans-serif";
g.fillText(`new block ${Math.min(this.drag.a, this.drag.z)}-${Math.max(this.drag.a, this.drag.z)}`, x0 + 4, G.B0 + 12);
}
// ruler + playhead
g.fillStyle = "#222"; g.fillRect(0, G.R0, W, 20);
g.fillStyle = "#888"; g.font = "9px sans-serif";
for (let s = 0; s <= this.length / this.fps; s++) {
const x = this.fx(s * this.fps, W);
g.fillRect(x, G.R0, 1, 6); g.fillText(`${s}s`, x + 2, G.R0 + 16);
}
g.fillStyle = "#fff";
g.fillRect(this.fx(this.cur, W), 0, 1.5, H);
g.fillText(`f${this.cur}`, clamp(this.fx(this.cur, W) + 4, 4, W - 30), 8);
}
// ---------- automation lane ----------
bindLane() {
const c = this.lane;
const pos = (e) => {
const r = c.getBoundingClientRect();
return [(e.clientX - r.left) * (c.width / r.width),
(e.clientY - r.top) * (c.height / r.height)];
};
const toVal = (y) => {
const [lo, hi] = PARAM_RANGE[this.laneParam];
return clamp(lo + (1 - y / c.height) * (hi - lo), lo, hi);
};
const toFrame = (x) => this.xf(x, c.width);
c.onpointerdown = (e) => {
e.stopPropagation();
const b = this.block(this.sel);
if (!b || !this.laneParam) return;
c.setPointerCapture(e.pointerId);
const [x, y] = pos(e);
b.auto = b.auto || {};
const pts = (b.auto[this.laneParam] = b.auto[this.laneParam] || []);
const f = toFrame(x);
let hit = pts.findIndex((p) =>
Math.abs(this.fx(p[0], c.width) - x) < 7 &&
Math.abs((1 - (p[1] - PARAM_RANGE[this.laneParam][0]) /
(PARAM_RANGE[this.laneParam][1] - PARAM_RANGE[this.laneParam][0]))
* c.height - y) < 9);
this.pushUndo();
if (e.detail === 2 && hit >= 0) { // double click: delete
pts.splice(hit, 1);
this.commit(); this.drawLane(); return;
}
if (hit < 0) {
pts.push([clamp(f, b.start, b.end), toVal(y)]);
pts.sort((p, q) => p[0] - q[0]);
hit = pts.findIndex((p) => p[0] === clamp(f, b.start, b.end));
}
this.drag = { kind: "env", idx: hit };
};
c.onpointermove = (e) => {
if (this.drag?.kind !== "env") return;
const b = this.block(this.sel);
const pts = b?.auto?.[this.laneParam];
if (!pts) return;
const [x, y] = pos(e);
pts[this.drag.idx] = [clamp(toFrame(x), b.start, b.end), toVal(y)];
this.drawLane();
};
c.onpointerup = () => {
if (this.drag?.kind !== "env") return;
const b = this.block(this.sel);
b?.auto?.[this.laneParam]?.sort((p, q) => p[0] - q[0]);
this.drag = null;
this.commit(); this.drawLane();
};
}
drawLane() {
if (this.laneWrap.style.display === "none") return;
const c = this.lane, g = c.getContext("2d");
c.width = c.clientWidth || 600;
const W = c.width, H = c.height;
g.fillStyle = "#141420"; g.fillRect(0, 0, W, H);
const b = this.block(this.sel);
if (!b || !this.laneParam) return;
const [lo, hi] = PARAM_RANGE[this.laneParam];
const x0 = this.fx(b.start, W), x1 = this.fx(b.end + 1, W);
g.fillStyle = "#ffffff10"; g.fillRect(x0, 0, x1 - x0, H);
const yFor = (v) => (1 - (v - lo) / (hi - lo)) * H;
const dflt = this.laneParam === "hold" ? (b.hold || 4)
: (b.dials?.[this.laneParam] ?? (this.laneParam === "strength" ? 1 : 48));
g.strokeStyle = "#89a"; g.beginPath();
for (let f = b.start; f <= b.end; f++) {
const v = envValue(b.auto, this.laneParam, f, dflt);
const x = this.fx(f, W), y = yFor(v);
f === b.start ? g.moveTo(x, y) : g.lineTo(x, y);
}
g.stroke();
for (const [f, v] of b.auto?.[this.laneParam] || []) {
g.fillStyle = "#fff";
g.beginPath(); g.arc(this.fx(f, W), yFor(v), 4, 0, 7); g.fill();
}
g.fillStyle = "#fff";
g.fillRect(this.fx(this.cur, W), 0, 1, H);
g.fillStyle = "#889";
g.fillText(`${this.laneParam} [${lo}..${hi}] dbl-click point to delete`,
6, 10);
}
// ---------- paint ----------
bindPaint() {
const c = this.paint;
const pos = (e) => {
const r = c.getBoundingClientRect();
return [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height];
};
c.onpointerdown = (e) => {
e.stopPropagation();
const b = this.block(this.sel);
if (!b || this.cur < b.start || this.cur > b.end) return;
c.setPointerCapture(e.pointerId);
this.pushUndo();
const [x, y] = pos(e);
this.stroke = { t: this.tool, r: this.brushR, pts: [[x, y]] };
this.drawPaint();
};
c.onpointermove = (e) => {
const [x, y] = pos(e);
this.cursor = [x, y];
if (this.stroke) {
const [px, py] = this.stroke.pts[this.stroke.pts.length - 1];
if (Math.hypot(x - px, y - py) > this.brushR / 4)
this.stroke.pts.push([x, y]);
}
this.drawPaint();
};
const done = () => {
if (!this.stroke) return;
const b = this.block(this.sel);
if (b) {
if (this.target === "block") {
(b.static_strokes = b.static_strokes || []).push(this.stroke);
} else {
b.strokes = b.strokes || {};
(b.strokes[String(this.cur)] =
b.strokes[String(this.cur)] || []).push(this.stroke);
}
}
this.stroke = null;
this.commit(); this.drawTimeline(); this.drawPaint();
};
c.onpointerup = done;
c.onpointerleave = () => { this.cursor = null; this.drawPaint(); };
c.onwheel = (e) => {
e.preventDefault(); e.stopPropagation();
this.setFrame(this.cur + Math.sign(e.deltaY));
};
}
rasterizeTo(g, strokes, W, H, color) {
for (const s of strokes || []) {
g.globalCompositeOperation =
s.t === "erase" ? "destination-out" : "source-over";
g.strokeStyle = g.fillStyle = color;
g.lineWidth = s.r * 2 * W;
g.lineCap = g.lineJoin = "round";
g.beginPath();
s.pts.forEach(([x, y], i) =>
i ? g.lineTo(x * W, y * H) : g.moveTo(x * W, y * H));
g.stroke();
if (s.pts.length === 1) {
g.beginPath();
g.arc(s.pts[0][0] * W, s.pts[0][1] * H, s.r * W, 0, 7);
g.fill();
}
}
g.globalCompositeOperation = "source-over";
}
frameStrokes(b, f) {
return [...(b.static_strokes || []), ...((b.strokes || {})[String(f)] || [])];
}
drawPaint() {
const c = this.paint, g = c.getContext("2d");
const im = this.frames.paint.length
? this.img(this.frames.paint, this.cur, () => this.drawPaint()) : null;
const cw = c.clientWidth || 600;
const ar = im?.complete && im.naturalWidth
? im.naturalHeight / im.naturalWidth : 9 / 16;
c.width = cw; c.height = Math.round(cw * ar);
const W = c.width, H = c.height;
g.fillStyle = "#000"; g.fillRect(0, 0, W, H);
if (im?.complete) { g.drawImage(im, 0, 0, W, H); this.hint.style.display = "none"; }
else if (this.frames.paint.length) this.hint.style.display = "none";
const b = this.block(this.sel);
if (b && this.cur >= b.start && this.cur <= b.end) {
const off = document.createElement("canvas");
off.width = W; off.height = H;
const og = off.getContext("2d");
const strokes = this.frameStrokes(b, this.cur);
if (this.stroke) strokes.push(this.stroke);
if (strokes.length) {
this.rasterizeTo(og, strokes, W, H, "#ff3030");
g.globalAlpha = 0.45; g.drawImage(off, 0, 0); g.globalAlpha = 1;
} else {
g.fillStyle = "#ff303018"; g.fillRect(0, 0, W, H);
g.fillStyle = "#f88"; g.font = "11px sans-serif";
g.fillText("whole frame regenerates in this block; paint to narrow it",
8, H - 10);
}
if (this.onion) {
for (const [df, col] of [[-1, "#30a0ff"], [1, "#30ff80"]]) {
const f2 = this.cur + df;
if (f2 < b.start || f2 > b.end) continue;
const s2 = this.frameStrokes(b, f2)
.filter((s) => (b.strokes || {})[String(f2)]?.includes(s));
if (!s2.length) continue;
const o2 = document.createElement("canvas");
o2.width = W; o2.height = H;
this.rasterizeTo(o2.getContext("2d"), s2, W, H, col);
g.globalAlpha = 0.18; g.drawImage(o2, 0, 0); g.globalAlpha = 1;
}
}
} else if (b) {
g.fillStyle = "#ccc8";
g.fillText("playhead is outside the selected block", 8, 16);
}
if (this.cursor && b) {
g.strokeStyle = this.tool === "erase" ? "#3af" : "#f33";
g.beginPath();
g.arc(this.cursor[0] * W, this.cursor[1] * H, this.brushR * W, 0, 7);
g.stroke();
}
g.fillStyle = "#fff"; g.font = "11px sans-serif";
g.fillText(`frame ${this.cur}/${this.length - 1}`, W - 92, 14);
}
// ---------- dials ----------
buildDials() {
this.dials.innerHTML = "";
const b = this.block(this.sel);
if (!b) {
this.laneWrap.style.display = "none";
this.el("span", { color: "#777" }, this.dials,
"select or drag out a block to edit its dials");
return;
}
b.dials = b.dials || {};
const num = (label, get, set, min, max, step, autoKey) => {
const wrap = this.el("span", { display: "inline-flex", gap: "3px",
alignItems: "center" }, this.dials);
this.el("span", { color: "#999" }, wrap, label);
const inp = this.el("input", { width: "52px", background: "#222",
color: "#ddd", border: "1px solid #444" },
wrap);
inp.type = "number"; inp.min = min; inp.max = max; inp.step = step;
inp.value = String(get());
inp.onchange = () => {
this.pushUndo(); set(parseFloat(inp.value)); this.commit();
this.drawTimeline(); this.drawLane();
};
if (autoKey) {
const ab = this.btn(wrap, "A", () => {
this.laneParam = this.laneParam === autoKey ? null : autoKey;
this.laneWrap.style.display = this.laneParam ? "flex" : "none";
this.laneLabel.textContent =
`automation: ${autoKey} (click to add points, drag to shape, dbl-click to delete)`;
for (const bt of this.autoBtns) bt.style.background = "#333";
if (this.laneParam) ab.style.background = "#a80";
this.drawLane();
}, "draw an automation envelope for this dial");
if ((b.auto || {})[autoKey]?.length) ab.style.borderColor = "#a80";
this.autoBtns.push(ab);
}
};
const sel = (label, key, opts) => {
const wrap = this.el("span", { display: "inline-flex", gap: "3px",
alignItems: "center" }, this.dials);
this.el("span", { color: "#999" }, wrap, label);
const s = this.el("select", { background: "#222", color: "#ddd",
border: "1px solid #444" }, wrap);
for (const o of opts) {
const op = this.el("option", {}, s, o); op.value = o;
}
s.value = b.dials[key];
s.onchange = () => { this.pushUndo(); b.dials[key] = s.value; this.commit(); };
};
this.autoBtns = [];
num("hold (0=oracle)", () => b.hold || 0, (v) => (b.hold = Math.round(v)),
0, 8, 1, "hold");
num("feather px", () => b.dials.feather ?? 48,
(v) => (b.dials.feather = Math.round(v)), 0, 256, 4, "feather");
sel("profile", "profile", ["linear", "smoothstep", "gaussian"]);
sel("direction", "direction", ["centered", "inward", "outward"]);
num("grow", () => b.dials.grow ?? 0, (v) => (b.dials.grow = Math.round(v)),
0, 64, 2);
num("fade f", () => b.dials.fade ?? 6, (v) => (b.dials.fade = Math.round(v)),
0, 24, 1);
num("strength", () => b.dials.strength ?? 1,
(v) => (b.dials.strength = v), 0, 1, 0.05, "strength");
}
redrawAll() {
if (this.root) {
const want = Math.max(600, (this.node.size?.[0] || 640) - 24) + "px";
if (this.root.style.width !== want) this.root.style.width = want;
}
this.buildRows();
this.drawTimeline();
this.drawLane();
this.drawPaint();
this.buildDials();
this.updatePrice();
}
}
app.registerExtension({
name: "MAINodes.MotionEditor",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "H3MotionEditor") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
try { this.h3 = new MotionEditor(this); (window.__h3editors = window.__h3editors || []).push(this.h3); }
catch (e) { console.error("H3MotionEditor widget failed:", e); }
return r;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
this.h3?.onExecuted(message);
};
},
});