// 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); }; }, });