547 lines
22 KiB
HTML
547 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>H3 Studio</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0b1013; --panel: #10161a; --line: #24343c; --line2: #1d2b32;
|
||
--ink: #dfe6e3; --dim: #6d8580; --teal: #56c8ad; --teal2: #8fe3cd;
|
||
--blue: #a9c8ef; --red: #e8a9b1;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; background: var(--bg); color: var(--ink);
|
||
font: 14px/1.5 ui-sans-serif, "Segoe UI", system-ui, sans-serif;
|
||
}
|
||
header {
|
||
display: flex; align-items: center; gap: 12px; padding: 12px 18px;
|
||
border-bottom: 1px solid var(--line); background: var(--panel);
|
||
position: sticky; top: 0; z-index: 5;
|
||
}
|
||
header .logo { width: 26px; height: 26px; }
|
||
header h1 { font-size: 15px; letter-spacing: .18em; text-transform: uppercase; color: var(--teal2); margin: 0; }
|
||
header .dot { width: 9px; height: 9px; border-radius: 50%; background: #3a4a4a; margin-left: auto; }
|
||
header .dot.on { background: var(--teal); box-shadow: 0 0 8px var(--teal); }
|
||
main { display: grid; grid-template-columns: minmax(360px, 500px) 1fr; gap: 16px; padding: 16px 18px; align-items: start; }
|
||
@media (max-width: 940px) { main { grid-template-columns: 1fr; } }
|
||
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
||
.card + .card { margin-top: 16px; }
|
||
.card h2 { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--dim); margin: 0 0 10px; }
|
||
select, input[type=text], input[type=number], textarea {
|
||
width: 100%; background: var(--bg); color: var(--ink); border: 1px solid var(--line2);
|
||
border-radius: 6px; padding: 6px 8px; font: inherit;
|
||
}
|
||
textarea { min-height: 64px; resize: vertical; font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; }
|
||
details { border: 1px solid var(--line2); border-radius: 8px; margin: 8px 0; background: #0d1317; }
|
||
details summary { cursor: pointer; padding: 7px 10px; color: var(--teal2); font-weight: 600; font-size: 12.5px; }
|
||
details .fields { padding: 4px 10px 10px; display: grid; gap: 8px; }
|
||
label.f { display: grid; gap: 3px; font-size: 11.5px; color: var(--dim); }
|
||
.uprow { display: flex; gap: 6px; }
|
||
.uprow input[type=text] { flex: 1; }
|
||
button {
|
||
background: #16222a; color: #cfe0da; border: 1px solid var(--line); border-radius: 7px;
|
||
font: 600 13px/1.2 inherit; padding: 8px 12px; cursor: pointer;
|
||
}
|
||
button:hover:not(:disabled) { filter: brightness(1.3); }
|
||
button:disabled { opacity: .35; cursor: default; }
|
||
button.go { background: #14322a; border-color: #2c6b57; color: #9fe8cf; width: 100%; padding: 11px; font-size: 14px; }
|
||
button.warn { background: #33191d; border-color: #6b3038; color: var(--red); }
|
||
button.blue { background: #1d2a3a; border-color: #3c5a80; color: var(--blue); }
|
||
.cast-row { border: 1px solid var(--line2); border-radius: 8px; background: #0d1317; padding: 9px 10px; display: grid; gap: 7px; margin-bottom: 8px; }
|
||
.cast-row .rowhead { display: flex; align-items: center; gap: 8px; }
|
||
.cast-row .rowhead .who { font: 600 12px/1.2 inherit; color: var(--teal2); flex: 1; }
|
||
.cast-row .rowhead button { padding: 3px 8px; font-size: 11px; }
|
||
.cast-hint { font-size: 11.5px; color: var(--dim); margin: 2px 0 8px; }
|
||
.thumb { max-height: 64px; border-radius: 6px; border: 1px solid var(--line2); align-self: start; }
|
||
.bar { height: 6px; background: var(--line2); border-radius: 3px; overflow: hidden; margin-top: 8px; }
|
||
.bar i { display: block; height: 100%; width: 0; background: var(--teal); transition: width .2s; }
|
||
#status { font: 12px/1.5 ui-monospace, Consolas, monospace; color: var(--dim); min-height: 18px; margin-top: 8px; white-space: pre-wrap; }
|
||
#log { font: 11px/1.5 ui-monospace, Consolas, monospace; color: var(--dim); max-height: 160px; overflow: auto; margin-top: 6px; }
|
||
#gate { display: none; border-color: var(--teal); box-shadow: 0 0 0 1px rgba(86,200,173,.35), 0 0 24px rgba(86,200,173,.12); }
|
||
#gate.live { display: block; }
|
||
#gate .grow { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||
#gate .grow button { flex: 1 1 auto; }
|
||
#takesField { width: 90px; text-align: center; }
|
||
#results { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; margin-top: 8px; }
|
||
#results video { width: 100%; border-radius: 8px; border: 1px solid var(--line2); background: #000; }
|
||
a { color: var(--teal2); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<svg class="logo" viewBox="0 0 32 32" fill="none">
|
||
<rect x="2" y="7" width="28" height="18" rx="3" stroke="#56c8ad" stroke-width="2"/>
|
||
<path d="M13 12.5v7l6.5-3.5-6.5-3.5z" fill="#8fe3cd"/>
|
||
<rect x="5" y="3" width="4" height="3" rx="1" fill="#2b4a52"/>
|
||
<rect x="14" y="3" width="4" height="3" rx="1" fill="#2b4a52"/>
|
||
<rect x="23" y="3" width="4" height="3" rx="1" fill="#2b4a52"/>
|
||
</svg>
|
||
<h1>H3 Studio</h1>
|
||
<span id="wsdot" class="dot" title="websocket"></span>
|
||
</header>
|
||
<main>
|
||
<section>
|
||
<div class="card">
|
||
<h2>Workflow</h2>
|
||
<select id="wfPick"></select>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Cast & reference images</h2>
|
||
<div class="cast-hint" id="castHint"></div>
|
||
<div id="castRows"></div>
|
||
<button id="castAdd">+ Add character</button>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Inputs</h2>
|
||
<div id="form"></div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<div class="card">
|
||
<h2>Run</h2>
|
||
<button id="runBtn" class="go">▶ Queue & watch</button>
|
||
<div class="bar"><i id="barFill"></i></div>
|
||
<div id="status">idle</div>
|
||
<div id="log"></div>
|
||
</div>
|
||
<div class="card" id="gate">
|
||
<h2>Dailies desk — the run is holding</h2>
|
||
<textarea id="gateText" style="min-height:220px"></textarea>
|
||
<div class="grow">
|
||
<textarea id="gateNotes" placeholder="Director's notes for a punch-up…" style="min-height:44px;flex:3"></textarea>
|
||
<input type="text" id="takesField" placeholder="takes: all">
|
||
</div>
|
||
<div class="grow">
|
||
<button id="gApprove" class="go" style="flex:2">▶ Print it</button>
|
||
<button id="gPunch" class="blue">✍ Punch-up</button>
|
||
<button id="gTake" class="blue">🎲 New take</button>
|
||
<button id="gUndo">↩</button>
|
||
<button id="gCut" class="warn">✋</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Clips</h2>
|
||
<div id="results"></div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const clientId = crypto.randomUUID();
|
||
const CAST_CUSTOM = "✏️ custom (type in custom_character)";
|
||
const state = { workflows: {}, wf: null, roster: [], cast: [], gateToken: null, promptId: null };
|
||
|
||
// which writer classes exist, whether they take cast lines, and where their
|
||
// image_1 pass-through OUTPUT starts (for wiring the video node's ref slots)
|
||
const WRITERS = {
|
||
H3ClaudeCodeMusicVideoWriter: { cast: true, imgBase: 12, maxCast: 4 },
|
||
H3ClaudeCodePresentationWriter: { cast: true, imgBase: 11, maxCast: 4 },
|
||
H3ClaudeCodeShortFilmWriter: { cast: true, imgBase: 11, maxCast: 4 },
|
||
H3MusicVideoMinimal: { cast: false, imgBase: 7, maxCast: 9 },
|
||
};
|
||
// nodes rendered by the generic Inputs form (Characters/LoadImage are managed
|
||
// by the Cast card instead)
|
||
const INTERESTING = new Set([
|
||
"LoadAudio", "H3ClaudeCodeMusicVideoWriter", "H3MusicVideoMinimal",
|
||
"H3ClaudeCodePresentationWriter", "H3ClaudeCodeShortFilmWriter",
|
||
"H3SceneBrief", "H3ScenesReviewGate", "H3ScenesReview", "H3LLMBackend",
|
||
]);
|
||
const UPLOAD_FIELDS = { LoadAudio: "audio" };
|
||
|
||
function log(line) {
|
||
const el = $("log");
|
||
el.textContent += line + "\n";
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
function status(text) { $("status").textContent = text; }
|
||
|
||
function writerOf(wf) {
|
||
for (const [nid, node] of Object.entries(wf)) {
|
||
if (WRITERS[node.class_type]) return [nid, node, WRITERS[node.class_type]];
|
||
}
|
||
return [null, null, null];
|
||
}
|
||
|
||
async function upload(file) {
|
||
const form = new FormData();
|
||
form.append("image", file, file.name);
|
||
const r = await fetch("/upload/image", { method: "POST", body: form });
|
||
const data = await r.json();
|
||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name;
|
||
}
|
||
|
||
/* ---------------- workflows + generic form ---------------- */
|
||
|
||
async function loadWorkflows() {
|
||
const [wfs, roster] = await Promise.all([
|
||
fetch("/apnext/h3/api_workflows").then((r) => r.json()),
|
||
fetch("/apnext/h3/characters").then((r) => r.json()).catch(() => ({ characters: [] })),
|
||
]);
|
||
state.workflows = wfs.workflows || {};
|
||
state.roster = roster.characters || [];
|
||
$("wfPick").innerHTML = Object.keys(state.workflows)
|
||
.map((n) => `<option value="${n}">${n}</option>`).join("");
|
||
pickWorkflow();
|
||
}
|
||
|
||
function pickWorkflow() {
|
||
const name = $("wfPick").value;
|
||
state.wf = structuredClone(state.workflows[name] || {});
|
||
seedCastFromWorkflow();
|
||
renderCast();
|
||
const form = $("form");
|
||
form.innerHTML = "";
|
||
for (const [nid, node] of Object.entries(state.wf)) {
|
||
if (!INTERESTING.has(node.class_type)) continue;
|
||
const prims = Object.entries(node.inputs)
|
||
.filter(([, v]) => !Array.isArray(v) && (typeof v !== "object" || v === null));
|
||
if (!prims.length) continue;
|
||
const d = document.createElement("details");
|
||
d.open = node.class_type === "LoadAudio" || node.class_type.includes("Writer") || node.class_type.includes("Minimal");
|
||
d.innerHTML = `<summary>${node.class_type} <span style="color:var(--dim)">#${nid}</span></summary>`;
|
||
const fields = document.createElement("div");
|
||
fields.className = "fields";
|
||
for (const [key, value] of prims) {
|
||
const label = document.createElement("label");
|
||
label.className = "f";
|
||
label.append(key);
|
||
let input;
|
||
if (typeof value === "boolean") {
|
||
input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.checked = value;
|
||
input.onchange = () => (node.inputs[key] = input.checked);
|
||
} else if (typeof value === "number") {
|
||
input = document.createElement("input");
|
||
input.type = "number";
|
||
input.step = "any";
|
||
input.value = value;
|
||
input.onchange = () => (node.inputs[key] = Number(input.value));
|
||
} else if (String(value).includes("\n") || String(value).length > 70) {
|
||
input = document.createElement("textarea");
|
||
input.value = value;
|
||
input.oninput = () => (node.inputs[key] = input.value);
|
||
} else {
|
||
input = document.createElement("input");
|
||
input.type = "text";
|
||
input.value = value;
|
||
input.oninput = () => (node.inputs[key] = input.value);
|
||
}
|
||
if (UPLOAD_FIELDS[node.class_type] === key) {
|
||
const row = document.createElement("div");
|
||
row.className = "uprow";
|
||
const up = document.createElement("button");
|
||
up.textContent = "Upload…";
|
||
up.onclick = (e) => {
|
||
e.preventDefault();
|
||
const f = document.createElement("input");
|
||
f.type = "file";
|
||
f.onchange = async () => {
|
||
if (!f.files[0]) return;
|
||
const path = await upload(f.files[0]);
|
||
input.value = path;
|
||
node.inputs[key] = path;
|
||
log(`uploaded ${path}`);
|
||
};
|
||
f.click();
|
||
};
|
||
row.append(input, up);
|
||
label.append(row);
|
||
} else {
|
||
label.append(input);
|
||
}
|
||
fields.append(label);
|
||
}
|
||
d.append(fields);
|
||
form.append(d);
|
||
}
|
||
}
|
||
|
||
/* ---------------- cast editor ---------------- */
|
||
|
||
function seedCastFromWorkflow() {
|
||
state.cast = [];
|
||
const [, writer] = writerOf(state.wf);
|
||
for (const node of Object.values(state.wf)) {
|
||
if (node.class_type !== "H3Characters") continue;
|
||
state.cast.push({
|
||
mode: node.inputs.character || CAST_CUSTOM,
|
||
custom: node.inputs.custom_character || "",
|
||
wardrobe: node.inputs.wardrobe || "",
|
||
image: "",
|
||
});
|
||
}
|
||
// images already wired to the writer (e.g. the minimal workflow's LoadImage)
|
||
if (writer) {
|
||
for (let i = 1; i <= 9; i++) {
|
||
const v = writer.inputs[`image_${i}`];
|
||
if (Array.isArray(v) && state.wf[v[0]]?.class_type === "LoadImage") {
|
||
const path = state.wf[v[0]].inputs.image || "";
|
||
const row = state.cast.find((r) => !r.image);
|
||
if (row) row.image = path;
|
||
else state.cast.push({ mode: CAST_CUSTOM, custom: "", wardrobe: "", image: path });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderCast() {
|
||
const [, , caps] = writerOf(state.wf);
|
||
const box = $("castRows");
|
||
box.innerHTML = "";
|
||
if (!caps) {
|
||
$("castHint").textContent = "This workflow has no compatible writer node.";
|
||
$("castAdd").disabled = true;
|
||
return;
|
||
}
|
||
$("castHint").textContent = caps.cast
|
||
? "Each character becomes a cast line (cast_N) and, with a picture, a reference image (image_N → the video node)."
|
||
: "This writer invents the performer itself — rows here contribute REFERENCE IMAGES only (image_N → the video node).";
|
||
$("castAdd").disabled = state.cast.length >= caps.maxCast;
|
||
state.cast.forEach((row, idx) => {
|
||
const el = document.createElement("div");
|
||
el.className = "cast-row";
|
||
const head = document.createElement("div");
|
||
head.className = "rowhead";
|
||
const who = document.createElement("span");
|
||
who.className = "who";
|
||
who.textContent = caps.cast ? `Character ${idx + 1}` : `Reference image ${idx + 1}`;
|
||
const del = document.createElement("button");
|
||
del.className = "warn";
|
||
del.textContent = "✕";
|
||
del.onclick = () => { state.cast.splice(idx, 1); renderCast(); };
|
||
head.append(who, del);
|
||
el.append(head);
|
||
|
||
if (caps.cast) {
|
||
const sel = document.createElement("select");
|
||
sel.innerHTML = `<option>${CAST_CUSTOM}</option>` +
|
||
state.roster.map((c) => `<option>${c.label}</option>`).join("");
|
||
sel.value = state.roster.some((c) => c.label === row.mode) ? row.mode : CAST_CUSTOM;
|
||
sel.onchange = () => { row.mode = sel.value; custom.style.display = sel.value === CAST_CUSTOM ? "" : "none"; };
|
||
el.append(sel);
|
||
const custom = document.createElement("textarea");
|
||
custom.placeholder = "Custom character, e.g. `Lena: a singer in her early 30s, platinum pixie cut`";
|
||
custom.style.minHeight = "44px";
|
||
custom.value = row.custom;
|
||
custom.style.display = sel.value === CAST_CUSTOM ? "" : "none";
|
||
custom.oninput = () => (row.custom = custom.value);
|
||
el.append(custom);
|
||
const ward = document.createElement("input");
|
||
ward.type = "text";
|
||
ward.placeholder = "Wardrobe lock (optional): red leather biker jacket, white tank top…";
|
||
ward.value = row.wardrobe;
|
||
ward.oninput = () => (row.wardrobe = ward.value);
|
||
el.append(ward);
|
||
}
|
||
|
||
const up = document.createElement("div");
|
||
up.className = "uprow";
|
||
const path = document.createElement("input");
|
||
path.type = "text";
|
||
path.placeholder = "reference image (upload or type an input-folder filename)";
|
||
path.value = row.image;
|
||
path.oninput = () => (row.image = path.value);
|
||
const btn = document.createElement("button");
|
||
btn.textContent = "Upload…";
|
||
btn.onclick = () => {
|
||
const f = document.createElement("input");
|
||
f.type = "file";
|
||
f.accept = "image/*";
|
||
f.onchange = async () => {
|
||
if (!f.files[0]) return;
|
||
row.image = await upload(f.files[0]);
|
||
renderCast();
|
||
log(`uploaded ${row.image}`);
|
||
};
|
||
f.click();
|
||
};
|
||
up.append(path, btn);
|
||
el.append(up);
|
||
if (row.image) {
|
||
const img = document.createElement("img");
|
||
img.className = "thumb";
|
||
img.src = `/view?${new URLSearchParams({ filename: row.image.split("/").pop(), subfolder: row.image.includes("/") ? row.image.split("/")[0] : "", type: "input" })}`;
|
||
el.append(img);
|
||
}
|
||
box.append(el);
|
||
});
|
||
}
|
||
|
||
$("castAdd").onclick = () => {
|
||
state.cast.push({ mode: CAST_CUSTOM, custom: "", wardrobe: "", image: "" });
|
||
renderCast();
|
||
};
|
||
|
||
/** Clone the workflow and synthesize the cast: fresh H3Characters + LoadImage
|
||
* nodes wired to the writer's cast_N / image_N and the video node's
|
||
* ref_image slots. The export's original character/image nodes are replaced. */
|
||
function buildPrompt() {
|
||
const wf = structuredClone(state.wf);
|
||
const [wid, writer, caps] = writerOf(wf);
|
||
if (!writer) return wf;
|
||
|
||
for (const [nid, node] of Object.entries(wf)) {
|
||
if (node.class_type === "H3Characters" || node.class_type === "LoadImage") delete wf[nid];
|
||
}
|
||
for (let i = 1; i <= 4; i++) if (Array.isArray(writer.inputs[`cast_${i}`])) delete writer.inputs[`cast_${i}`];
|
||
for (let i = 1; i <= 9; i++) if (Array.isArray(writer.inputs[`image_${i}`])) delete writer.inputs[`image_${i}`];
|
||
const video = Object.values(wf).find((n) => n.class_type === "MiniMaxH3ReferenceToVideo");
|
||
if (video) {
|
||
for (const key of Object.keys(video.inputs)) {
|
||
if (key.startsWith("ref_images.ref_image_")) delete video.inputs[key];
|
||
}
|
||
}
|
||
|
||
let imgSlot = 0;
|
||
state.cast.forEach((row, idx) => {
|
||
if (caps.cast && idx < 4) {
|
||
const id = String(900 + idx);
|
||
wf[id] = {
|
||
class_type: "H3Characters",
|
||
inputs: {
|
||
character: row.mode, franchise_filter: "(all)", seed: 0,
|
||
custom_character: row.custom, wardrobe: row.wardrobe,
|
||
},
|
||
};
|
||
writer.inputs[`cast_${idx + 1}`] = [id, 4];
|
||
}
|
||
if (row.image && imgSlot < 9) {
|
||
imgSlot += 1;
|
||
const id = String(920 + imgSlot);
|
||
wf[id] = { class_type: "LoadImage", inputs: { image: row.image } };
|
||
writer.inputs[`image_${imgSlot}`] = [id, 0];
|
||
if (video) {
|
||
video.inputs[`ref_images.ref_image_${imgSlot - 1}`] = [wid, caps.imgBase + imgSlot - 1];
|
||
}
|
||
}
|
||
});
|
||
return wf;
|
||
}
|
||
|
||
/* ---------------- gate ---------------- */
|
||
|
||
function gateDecision(action, extra = {}) {
|
||
if (!state.gateToken) return;
|
||
fetch("/apnext/h3/review_gate", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
token: state.gateToken,
|
||
text: $("gateText").value,
|
||
feedback: $("gateNotes").value,
|
||
scenes: $("takesField").value,
|
||
action, ...extra,
|
||
}),
|
||
});
|
||
if (action === "reroll") status(extra.variant ? "rolling a new take…" : "punch-up in progress…");
|
||
}
|
||
$("gApprove").onclick = () => gateDecision("approve");
|
||
$("gPunch").onclick = () => gateDecision("reroll");
|
||
$("gTake").onclick = () => gateDecision("reroll", { variant: true });
|
||
$("gUndo").onclick = () => gateDecision("undo");
|
||
$("gCut").onclick = () => gateDecision("stop");
|
||
|
||
function showGate(d) {
|
||
state.gateToken = d.token;
|
||
$("gateText").value = d.text || "";
|
||
$("gate").classList.add("live");
|
||
$("gPunch").disabled = $("gTake").disabled = !d.can_reroll;
|
||
$("gUndo").disabled = !d.can_undo;
|
||
status(`gate: ${d.count} take(s) on the desk — ${d.status || "your call"}`);
|
||
}
|
||
function hideGate(text) {
|
||
state.gateToken = null;
|
||
$("gate").classList.remove("live");
|
||
if (text) status(text);
|
||
}
|
||
|
||
/* ---------------- run + results ---------------- */
|
||
|
||
async function collectOutputs(promptId) {
|
||
const history = (await (await fetch(`/history/${promptId}`)).json())[promptId];
|
||
const box = $("results");
|
||
for (const out of Object.values(history?.outputs || {})) {
|
||
for (const kind of ["video", "videos", "images", "gifs"]) {
|
||
for (const f of out?.[kind] || []) {
|
||
const q = new URLSearchParams({ filename: f.filename, subfolder: f.subfolder || "", type: f.type || "output" });
|
||
const url = `/view?${q}`;
|
||
if (/\.(mp4|webm|mov)$/i.test(f.filename)) {
|
||
const v = document.createElement("video");
|
||
v.src = url; v.controls = true;
|
||
box.prepend(v);
|
||
} else {
|
||
const a = document.createElement("a");
|
||
a.href = url; a.textContent = f.filename; a.target = "_blank";
|
||
box.prepend(a);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function connectWs() {
|
||
const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws?clientId=${clientId}`);
|
||
ws.onopen = () => $("wsdot").classList.add("on");
|
||
ws.onclose = () => { $("wsdot").classList.remove("on"); setTimeout(connectWs, 2000); };
|
||
ws.onmessage = (msg) => {
|
||
if (typeof msg.data !== "string") return;
|
||
const e = JSON.parse(msg.data);
|
||
if (e.type === "progress") {
|
||
$("barFill").style.width = `${(100 * e.data.value) / e.data.max}%`;
|
||
} else if (e.type === "executing" && e.data?.node) {
|
||
status(`running node ${e.data.node}…`);
|
||
} else if (e.type === "apnext.h3.review_gate") {
|
||
showGate(e.data);
|
||
} else if (e.type === "apnext.h3.review_gate_resolved") {
|
||
hideGate(e.data?.status);
|
||
} else if (e.type === "execution_success" && e.data?.prompt_id === state.promptId) {
|
||
status("done — collecting clips");
|
||
$("barFill").style.width = "100%";
|
||
$("runBtn").disabled = false;
|
||
collectOutputs(state.promptId);
|
||
} else if (e.type === "execution_error") {
|
||
status(`error: ${e.data?.exception_message || "see server log"}`);
|
||
$("runBtn").disabled = false;
|
||
} else if (e.type === "execution_interrupted") {
|
||
status("interrupted");
|
||
$("runBtn").disabled = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
$("runBtn").onclick = async () => {
|
||
if (!state.wf) return;
|
||
$("runBtn").disabled = true;
|
||
$("barFill").style.width = "0";
|
||
status("queueing…");
|
||
const r = await fetch("/prompt", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ prompt: buildPrompt(), client_id: clientId }),
|
||
});
|
||
if (!r.ok) {
|
||
const err = await r.json().catch(() => ({}));
|
||
status(`queue rejected: ${JSON.stringify(err.error || err).slice(0, 300)}`);
|
||
$("runBtn").disabled = false;
|
||
return;
|
||
}
|
||
state.promptId = (await r.json()).prompt_id;
|
||
log(`queued ${state.promptId}`);
|
||
status("queued — writing scenes…");
|
||
};
|
||
|
||
$("wfPick").onchange = pickWorkflow;
|
||
connectWs();
|
||
loadWorkflows();
|
||
// re-attach to a gate that is already waiting (page opened mid-run)
|
||
fetch("/apnext/h3/review_gate/pending").then((r) => r.json()).then((d) => {
|
||
const pending = (d.reviews || [])[0];
|
||
if (pending) showGate(pending);
|
||
}).catch(() => {});
|
||
</script>
|
||
</body>
|
||
</html>
|