Files

547 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &amp; 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 &amp; 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>