| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { |
| el, button, select, vocab, widgetByName, refreshVocab, onVocabRefresh, |
| } from "./widget_utils.js"; |
| import { createTemplatePanel } from "./templates.js"; |
|
|
| const MODE_PROP = "h3_editor_mode"; |
| const FOLD_PROP = "h3_plan_folded"; |
| const BACKUP_PROP = "h3_plan_backup"; |
|
|
| |
|
|
| export function parsePlan(text) { |
| |
| |
| |
| |
| |
| const t = String(text ?? "").trim(); |
| if (!t) return []; |
| let data; |
| try { |
| data = JSON.parse(t); |
| } catch (_) { |
| return null; |
| } |
| |
| |
| if (data === null || typeof data !== "object") return null; |
| const shots = Array.isArray(data) ? data : (data.shots || []); |
| if (!Array.isArray(shots)) return null; |
| return shots.map((s, i) => ({ |
| id: String(s.id || `s${i + 1}`), |
| beat: String(s.beat || ""), |
| directives: Object.assign({}, s.directives || {}), |
| prose: String(s.prose || ""), |
| seed: s.seed == null ? null : Number(s.seed), |
| steps: s.steps == null ? null : Number(s.steps), |
| duration: s.duration || null, |
| locked: Boolean(s.locked), |
| })); |
| } |
|
|
| export function planToJson(shots) { |
| if (!shots || !shots.length) return ""; |
| const out = shots.map((s) => { |
| const o = { id: s.id, beat: s.beat }; |
| const dirs = {}; |
| for (const [k, v] of Object.entries(s.directives || {})) if (v) dirs[k] = v; |
| if (Object.keys(dirs).length) o.directives = dirs; |
| if (s.prose) o.prose = s.prose; |
| if (s.seed != null) o.seed = s.seed; |
| if (s.steps != null) o.steps = s.steps; |
| if (s.duration) o.duration = s.duration; |
| if (s.locked) o.locked = true; |
| return o; |
| }); |
| return JSON.stringify({ shots: out }, null, 2); |
| } |
|
|
| |
| |
| |
| |
| |
| function coherenceWarning(shot, index) { |
| if (index === 0) return null; |
| const d = shot.directives || {}; |
| const framing = d.framing || ""; |
| const camera = d.camera || ""; |
| if (d.join === "continuous" && framing && framing !== "keep" && (!camera || camera === "hold")) { |
| return `join=continuous with framing=${framing} and a held camera implies a cut. ` |
| + "Use push_in / pull_back / pan_follow to reach that framing on the move, or framing=keep."; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| function directionWarning(shot) { |
| const d = shot.directives || {}; |
| const pair = `${d.camera || ""}|${d.framing || ""}`; |
| if (pair === "push_in|wide" || pair === "pull_back|close") { |
| return `camera=${d.camera} moves the opposite way from framing=${d.framing}. ` |
| + "Pick the framing the move actually lands on."; |
| } |
| return null; |
| } |
|
|
| |
|
|
| export function createPlanEditor(node, { onChange }) { |
| const planWidget = widgetByName(node, "shot_plan"); |
| const promptWidget = widgetByName(node, "prompt"); |
| const chainsWidget = widgetByName(node, "chains"); |
| const durationWidget = widgetByName(node, "duration"); |
| const overlapWidget = widgetByName(node, "overlap"); |
|
|
| let V = null; |
| const initial = parsePlan(planWidget?.value); |
| let broken = initial === null; |
| let shots = initial || []; |
|
|
| const root = el("div", "h3e-section"); |
|
|
| |
| const head = el("div", "h3e-head"); |
| head.appendChild(el("span", "h3e-title", "SCRIPT")); |
| const summary = el("span", "h3e-count"); |
| head.appendChild(summary); |
| head.appendChild(el("span", "h3e-spacer")); |
|
|
| const modeWrap = el("div", "h3e-modes"); |
| const simpleBtn = button("Simple", "One prompt, repeated across N hops", () => setMode("simple"), "h3e-mode"); |
| const shotsBtn = button("Shots", "One card per hop, with directives", () => setMode("shots"), "h3e-mode"); |
| modeWrap.appendChild(simpleBtn); |
| modeWrap.appendChild(shotsBtn); |
| head.appendChild(modeWrap); |
| const blankShot = (i) => ({ |
| id: `s${i + 1}`, beat: "", directives: {}, prose: "", |
| seed: null, steps: null, duration: null, locked: false, |
| }); |
| const addBtn = button("+ shot", "Append a shot", () => { |
| shots.push(blankShot(shots.length)); |
| commit(); |
| }); |
| head.appendChild(addBtn); |
|
|
| |
| |
| |
| const templates = createTemplatePanel({ |
| onPick: (picked) => { |
| if (mode() !== "shots") setMode("shots"); |
| for (const sh of picked) shots.push({ ...sh, id: freeId() }); |
| templates.hide(); |
| tplBtn.classList.remove("h3e-on"); |
| commit(); |
| }, |
| }); |
| const tplBtn = button("Templates", "Insert a ready-made shot pattern", () => { |
| tplBtn.classList.toggle("h3e-on", templates.toggle()); |
| }); |
| head.appendChild(tplBtn); |
| root.appendChild(head); |
| root.appendChild(templates.root); |
|
|
| |
| |
| |
| |
| function freeId() { |
| const used = new Set(shots.map((sh) => sh.id).filter(Boolean)); |
| for (let n = 1; ; n += 1) { |
| const id = `s${n}`; |
| if (!used.has(id)) return id; |
| } |
| } |
|
|
| const simpleNote = el("div", "h3e-empty", |
| "Simple mode: the prompt widget below drives the run, repeated across `chains` hops " |
| + "(set under RUN). Switch to Shots to write one beat per hop with camera and " |
| + "join directives."); |
| root.appendChild(simpleNote); |
|
|
| const list = el("div", "h3e-shots"); |
| root.appendChild(list); |
|
|
| |
| const jsonWrap = el("details", "h3e-json-wrap"); |
| const jsonSum = el("summary", null, "JSON"); |
| jsonSum.title = "The literal shot_plan value. Editing here updates the cards."; |
| jsonWrap.appendChild(jsonSum); |
| const jsonArea = el("textarea", "h3e-json"); |
| jsonArea.spellcheck = false; |
| jsonWrap.appendChild(jsonArea); |
| const jsonErr = el("div", "h3e-note"); |
| jsonErr.style.display = "none"; |
| jsonWrap.appendChild(jsonErr); |
| jsonArea.addEventListener("input", () => { |
| const parsed = parsePlan(jsonArea.value); |
| if (parsed === null) { |
| jsonErr.textContent = "Not valid JSON — the cards are showing the last good version."; |
| jsonErr.style.display = ""; |
| return; |
| } |
| jsonErr.style.display = "none"; |
| shots = parsed; |
| writeWidget(); |
| renderCards(); |
| onChange?.(); |
| }); |
| root.appendChild(jsonWrap); |
|
|
| |
| |
| |
| |
| const offline = el("div", "h3e-offline"); |
| offline.style.display = "none"; |
| root.insertBefore(offline, root.firstChild); |
|
|
| function loadVocab() { |
| return vocab().then((v) => { |
| V = v; |
| offline.textContent = ""; |
| if (v?._failed) { |
| offline.style.display = ""; |
| const note = el("div", "h3e-note h3e-note-error", |
| "Directive vocabulary unavailable" |
| + (v._error ? ` (${v._error})` : "") |
| + " — dropdowns are empty until it loads. Beats and JSON still work."); |
| offline.appendChild(note); |
| offline.appendChild(button("Retry", "Re-fetch /h3_ref_chain/vocab", |
| () => { refreshVocab(); loadVocab(); })); |
| } else { |
| offline.style.display = "none"; |
| } |
| renderCards(); |
| }); |
| } |
| loadVocab(); |
| onVocabRefresh(() => { loadVocab(); }); |
|
|
| |
|
|
| |
| |
| |
| |
| function foldState() { |
| const v = node.properties?.[FOLD_PROP]; |
| return (v && typeof v === "object") ? v : {}; |
| } |
| function setFoldState(next) { |
| if (!node.properties) node.properties = {}; |
| node.properties[FOLD_PROP] = next; |
| } |
|
|
| function mode() { |
| const stored = node.properties?.[MODE_PROP]; |
| if (stored === "simple" || stored === "shots") return stored; |
| |
| return shots.length ? "shots" : "simple"; |
| } |
|
|
| function setMode(next) { |
| node.properties ??= {}; |
| if (next === "simple" && shots.length) { |
| |
| |
| |
| node.properties[BACKUP_PROP] = planToJson(shots); |
| } |
| if (next === "shots" && !shots.length && node.properties[BACKUP_PROP]) { |
| const restored = parsePlan(node.properties[BACKUP_PROP]); |
| if (restored && restored.length) { |
| shots = restored; |
| node.properties[MODE_PROP] = next; |
| commit(); |
| return; |
| } |
| } |
| if (next === "shots" && !shots.length) { |
| |
| |
| const text = String(promptWidget?.value || "").trim(); |
| const n = Math.max(1, parseInt(chainsWidget?.value, 10) || 1); |
| if (text) { |
| const blocks = text.split(/^\s*---\s*$/m).map((b) => b.trim()).filter(Boolean); |
| const use = blocks.length > 1 ? blocks.slice(0, n) : [text]; |
| while (use.length < n) use.push(""); |
| shots = use.map((b, i) => ({ |
| id: `s${i + 1}`, beat: b, directives: {}, prose: "", |
| seed: null, steps: null, duration: null, locked: false, |
| })); |
| } else { |
| shots = [{ id: "s1", beat: "", directives: {}, prose: "", |
| seed: null, steps: null, duration: null, locked: false }]; |
| } |
| } |
| node.properties[MODE_PROP] = next; |
| commit(); |
| } |
|
|
| function writeWidget() { |
| if (!planWidget) return; |
| |
| |
| planWidget.value = mode() === "shots" ? planToJson(shots) : ""; |
| if (document.activeElement !== jsonArea) jsonArea.value = planWidget.value; |
| } |
|
|
| function commit() { |
| writeWidget(); |
| renderCards(); |
| onChange?.(); |
| } |
|
|
| |
|
|
| function timing() { |
| const durs = V?.durations || {}; |
| const overs = V?.overlaps || {}; |
| const fps = V?.fps || 24; |
| const baseDur = durationWidget?.value; |
| const ov = overs[overlapWidget?.value] ?? 0; |
| const lengths = shots.map((s) => durs[s.duration || baseDur] ?? durs[baseDur] ?? 0); |
| const total = lengths.reduce((a, b) => a + b, 0) - ov * Math.max(0, shots.length - 1); |
| return { lengths, ov, fps, total: Math.max(0, total) }; |
| } |
|
|
| function clock(sec) { |
| if (!Number.isFinite(sec) || sec <= 0) return "—"; |
| return `${sec.toFixed(1)}s`; |
| } |
|
|
| |
|
|
| function renderCard(shot, index) { |
| const card = el("div", "h3e-card"); |
| card.dataset.index = String(index); |
|
|
| const bar = el("div", "h3e-card-head"); |
| const drag = el("span", "h3e-drag", "⠿"); |
| drag.title = "Drag to reorder"; |
| drag.draggable = true; |
| drag.addEventListener("dragstart", (e) => { |
| e.dataTransfer.setData("text/plain", String(index)); |
| e.dataTransfer.effectAllowed = "move"; |
| }); |
| bar.appendChild(drag); |
|
|
| |
| |
| const key = shot.id || `s${index + 1}`; |
| const folded = Boolean(foldState()[key]); |
| if (folded) card.classList.add("h3e-collapsed"); |
| const fold = button("", folded ? "Expand this shot" : "Collapse this shot", () => { |
| const st = foldState(); |
| st[key] = !st[key]; |
| setFoldState(st); |
| card.classList.toggle("h3e-collapsed"); |
| fold.title = card.classList.contains("h3e-collapsed") |
| ? "Expand this shot" : "Collapse this shot"; |
| }, "h3e-btn h3e-fold"); |
| fold.appendChild(el("span", "h3e-fold-mark", "▾")); |
| bar.appendChild(fold); |
|
|
| bar.appendChild(el("span", "h3e-num", String(index + 1))); |
|
|
| const t = timing(); |
| const frames = t.lengths[index] || 0; |
| const delivered = index === 0 ? frames : Math.max(0, frames - t.ov); |
| const badge = el("span", "h3e-timing", `${clock(delivered / t.fps)}`); |
| badge.title = index === 0 |
| ? `${frames} frames.` |
| : `${frames} frames, ${t.ov} dropped into the previous hop's tail.`; |
| bar.appendChild(badge); |
|
|
| |
| const peekText = (shot.beat || "").replace(/\s+/g, " ").trim(); |
| const peek = el("span", "h3e-peek", peekText.length > 70 |
| ? peekText.slice(0, 67) + "…" : (peekText || "(continues)")); |
| peek.title = peekText; |
| bar.appendChild(peek); |
|
|
| if (shot.locked) { |
| const lock = el("span", "h3e-lock", "locked"); |
| lock.title = "Reuses this shot's cached render even when its inputs changed. Needs cache_hops=on."; |
| bar.appendChild(lock); |
| } |
| bar.appendChild(button("×", "Delete this shot", () => { |
| shots.splice(index, 1); |
| commit(); |
| }, "h3e-btn h3e-x")); |
| card.appendChild(bar); |
|
|
| card.addEventListener("dragover", (e) => { e.preventDefault(); card.classList.add("h3e-drop"); }); |
| card.addEventListener("dragleave", () => card.classList.remove("h3e-drop")); |
| card.addEventListener("drop", (e) => { |
| e.preventDefault(); |
| card.classList.remove("h3e-drop"); |
| const from = parseInt(e.dataTransfer.getData("text/plain"), 10); |
| if (!Number.isFinite(from) || from === index) return; |
| const [moved] = shots.splice(from, 1); |
| shots.splice(index, 0, moved); |
| commit(); |
| }); |
|
|
| const body = el("div", "h3e-card-body"); |
| const beat = el("textarea", "h3e-beat"); |
| beat.value = shot.beat || ""; |
| beat.rows = 3; |
| beat.placeholder = index === 0 |
| ? "The whole opening: who, where, what they are doing." |
| : "Only what is NEW this hop. The identity lock and the join are added for you."; |
| beat.title = index === 0 |
| ? "Shot 1 is the full establishing prompt." |
| : "Shots after the first are the new beat only — do not re-describe the face or replay the scene."; |
| |
| |
| |
| beat.addEventListener("input", () => { shot.beat = beat.value; writeWidget(); }); |
| beat.addEventListener("change", () => { writeWidget(); onChange?.(); }); |
| body.appendChild(beat); |
|
|
| |
| const dirs = el("div", "h3e-dirs"); |
| for (const axis of (V?.axes || [])) { |
| |
| if (axis === "join" && index === 0) continue; |
| const opts = Object.keys(V.vocab[axis] || {}); |
| const wrap = el("label", "h3e-dir"); |
| wrap.appendChild(el("span", "h3e-dir-label", axis)); |
| const def = V.defaults?.[axis]; |
| const sel = select(opts, shot.directives[axis] || "", (v) => { |
| shot.directives[axis] = v; |
| commit(); |
| }, { |
| blankLabel: def ? `${def.replace(/_/g, " ")} (default)` : "—", |
| titles: V.vocab[axis] || {}, |
| }); |
| sel.title = `Hover an option to read the exact sentence it puts in the prompt.`; |
| wrap.appendChild(sel); |
| dirs.appendChild(wrap); |
| } |
| body.appendChild(dirs); |
|
|
| for (const [text, cls] of [[coherenceWarning(shot, index), "h3e-note h3e-note-hint"], |
| [directionWarning(shot), "h3e-note"]]) { |
| if (text) body.appendChild(el("div", cls, text)); |
| } |
| if (directionWarning(shot)) card.classList.add("h3e-invalid"); |
|
|
| |
| const adv = el("details", "h3e-adv"); |
| adv.appendChild(el("summary", null, "advanced")); |
| const grid = el("div", "h3e-grid"); |
|
|
| const num = (label, key, placeholder, hint) => { |
| const l = el("label", "h3e-field"); |
| l.appendChild(el("span", null, label)); |
| const i = el("input", "h3e-input"); |
| i.type = "number"; |
| i.value = shot[key] == null ? "" : String(shot[key]); |
| i.placeholder = placeholder; |
| i.title = hint; |
| i.addEventListener("input", () => { |
| shot[key] = i.value === "" ? null : Number(i.value); |
| writeWidget(); |
| }); |
| i.addEventListener("change", () => { |
| shot[key] = i.value === "" ? null : Number(i.value); |
| commit(); |
| }); |
| l.appendChild(i); |
| return l; |
| }; |
| grid.appendChild(num("seed", "seed", "chain seed", "Override the seed for this hop only.")); |
| grid.appendChild(num("steps", "steps", "chain steps", "Override the step count for this hop only.")); |
|
|
| const durL = el("label", "h3e-field"); |
| durL.appendChild(el("span", null, "duration")); |
| const durSel = select(Object.keys(V?.durations || {}), shot.duration || "", (v) => { |
| shot.duration = v || null; |
| commit(); |
| }, { blankLabel: `${durationWidget?.value || "chain"} (default)` }); |
| durSel.title = "Override this hop's length. Must be longer than the overlap."; |
| durL.appendChild(durSel); |
| grid.appendChild(durL); |
|
|
| const lockL = el("label", "h3e-field h3e-check"); |
| const cb = el("input"); |
| cb.type = "checkbox"; |
| cb.checked = Boolean(shot.locked); |
| cb.title = "Pin this hop to its cached render regardless of what changed. Needs cache_hops=on."; |
| cb.addEventListener("change", () => { shot.locked = cb.checked; commit(); }); |
| lockL.appendChild(cb); |
| lockL.appendChild(el("span", null, "locked")); |
| grid.appendChild(lockL); |
|
|
| const idL = el("label", "h3e-field"); |
| idL.appendChild(el("span", null, "id")); |
| const idI = el("input", "h3e-input"); |
| idI.type = "text"; |
| idI.value = shot.id || ""; |
| idI.title = "Stable name, used as the cache pointer for `locked`."; |
| idI.addEventListener("input", () => { shot.id = idI.value.trim() || `s${index + 1}`; writeWidget(); }); |
| idI.addEventListener("change", () => { shot.id = idI.value.trim() || `s${index + 1}`; commit(); }); |
| idL.appendChild(idI); |
| grid.appendChild(idL); |
| adv.appendChild(grid); |
|
|
| const prose = el("textarea", "h3e-beat"); |
| prose.rows = 2; |
| prose.value = shot.prose || ""; |
| prose.placeholder = "prose appended verbatim (for anything the directives lack)"; |
| prose.title = "Added to the end of this hop's body, unmodified. Affirmative phrasing only — " |
| + "sampling runs at cfg 1.0, so anything named is added, never subtracted."; |
| prose.addEventListener("input", () => { shot.prose = prose.value; writeWidget(); }); |
| prose.addEventListener("change", () => { writeWidget(); onChange?.(); }); |
| adv.appendChild(prose); |
| body.appendChild(adv); |
| card.appendChild(body); |
|
|
| return card; |
| } |
|
|
| function renderCards() { |
| const m = mode(); |
| simpleBtn.classList.toggle("h3e-on", m === "simple"); |
| shotsBtn.classList.toggle("h3e-on", m === "shots"); |
| simpleNote.style.display = m === "simple" ? "" : "none"; |
| list.style.display = m === "simple" ? "none" : ""; |
| jsonWrap.style.display = m === "simple" ? "none" : ""; |
| addBtn.style.display = m === "simple" ? "none" : ""; |
| tplBtn.style.display = m === "simple" ? "none" : ""; |
| if (m === "simple") { |
| templates.hide(); |
| tplBtn.classList.remove("h3e-on"); |
| } |
|
|
| if (m === "simple") { |
| summary.textContent = `${chainsWidget?.value || 1} hop(s)`; |
| return; |
| } |
|
|
| list.textContent = ""; |
| if (broken) { |
| const note = el("div", "h3e-note h3e-note-error", |
| "shot_plan is not valid JSON. Fix it in the JSON section below and the cards will come back."); |
| list.appendChild(note); |
| return; |
| } |
| if (!shots.length) { |
| const empty = el("div", "h3e-empty", |
| "No shots yet. Shot 1 is the whole opening; every shot after it is only what happens next."); |
| empty.appendChild(button("+ Add shot 1", "Add the opening shot", () => { |
| shots.push(blankShot(shots.length)); |
| commit(); |
| }, "h3e-btn h3e-empty-cta")); |
| list.appendChild(empty); |
| } |
| shots.forEach((s, i) => list.appendChild(renderCard(s, i))); |
|
|
| const t = timing(); |
| summary.textContent = shots.length |
| ? `${shots.length} shots · ${clock(t.total / t.fps)} master` |
| : "empty"; |
| } |
|
|
| |
| function reload() { |
| const parsed = parsePlan(planWidget?.value); |
| broken = parsed === null; |
| if (!broken) shots = parsed; |
| if (document.activeElement !== jsonArea) jsonArea.value = planWidget?.value || ""; |
| renderCards(); |
| } |
|
|
| return { root, render: renderCards, reload, mode }; |
| } |
|
|