import {app} from "/scripts/app.js"; import {api} from "/scripts/api.js"; import { parsePlanJson, planToJson, promptValueToText, } from "./h3_chain_plan_core.mjs?v=0.6.5"; import * as promptCompanionSync from "./h3_prompt_companion_sync.mjs?v=0.6.2"; import { refreshRestoredPlanEditors, restoreConnectedPolicyInputs, } from "./h3_plan_restore_core.mjs?v=0.6.6"; import { acceptedPreviewDisposition, applyCheckpointRevisionSet, applyReviewEdit, checkpointRevisionChain, checkpointResumeOptions, reviewCountdown, reviewDuration, reviewDurationText, reviewLocalDeadline, reviewPlanScenePrompt, reviewSeed, } from "./h3_chain_review_core.mjs?v=0.6.5"; const NODE_NAME = "MiniMaxH3ChainReview"; const PLAN_NAME = "MiniMaxH3ChainPlan"; const PLAN_NAMES = new Set([PLAN_NAME, "MiniMaxH3ChainPlanModern"]); const PROMPT_EDITOR_SETTING = "MiniMaxH3ContexLoop.ReviewGate.PromptEditor"; const VIDEO_HEIGHT_PROPERTY = "h3_chain_review_video_height"; const PROMPT_HEIGHT_PROPERTY = "h3_chain_review_prompt_height"; const DEFAULT_VIDEO_HEIGHT = 300; const MIN_VIDEO_HEIGHT = 140; const MAX_VIDEO_HEIGHT = 1200; const notifiedTokens = new Set(); const mountedReviewNodes = new Set(); let notificationAudioContext = null; let pendingFetchPromise = null; let pendingPollTimer = null; const activeSceneExecutions = new Map(); const reviewInterruptionWaiters = new Map(); function sceneExecutionKey(runName, clipIndex) { return `${String(runName ?? "").trim()}\u0000${Number(clipIndex)}`; } function activeSceneFromExecutedOutput(output) { const values = output?.h3_chain_active_scene; const value = Array.isArray(values) ? values.at(-1) : null; const clipIndex = Number(value?.clip_index); if (!value || !String(value.run_name ?? "").trim() || !Number.isInteger(clipIndex) || clipIndex < 1) return null; return { runName: String(value.run_name).trim(), clipIndex, }; } function trackActiveSceneExecution(data) { const scene = activeSceneFromExecutedOutput(data?.output); const promptId = String(data?.prompt_id ?? ""); if (!scene || !promptId) return; activeSceneExecutions.set( sceneExecutionKey(scene.runName, scene.clipIndex), {promptId, displayNode: String(data?.display_node ?? "")}, ); } function waitForReviewInterruption(promptId, timeoutMilliseconds = 30000) { let timer; const promise = new Promise((resolve, reject) => { timer = window.setTimeout(() => { reviewInterruptionWaiters.delete(promptId); reject(new Error( "ComfyUI did not confirm candidate interruption within 30 seconds.")); }, timeoutMilliseconds); reviewInterruptionWaiters.set(promptId, { resolve: () => { window.clearTimeout(timer); reviewInterruptionWaiters.delete(promptId); resolve(); }, reject: (message) => { window.clearTimeout(timer); reviewInterruptionWaiters.delete(promptId); reject(new Error(message)); }, }); }); // The websocket terminal event can beat the cancellation HTTP response. void promise.catch(() => {}); return { promise, cancel() { window.clearTimeout(timer); reviewInterruptionWaiters.delete(promptId); }, }; } function finishTrackedReviewExecution(kind, data) { const promptId = String(data?.prompt_id ?? ""); if (!promptId) return; for (const [key, record] of activeSceneExecutions) { if (record.promptId !== promptId) continue; record.terminal = kind; window.setTimeout(() => { if (activeSceneExecutions.get(key) === record) { activeSceneExecutions.delete(key); } }, 60000); } const waiter = reviewInterruptionWaiters.get(promptId); if (!waiter) return; if (kind === "interrupted") waiter.resolve(); else waiter.reject( kind === "success" ? "The candidate finished before targeted cancellation." : "The H3 prompt ended before candidate cancellation was confirmed.", ); } function reviewPromptEditorEnabled() { return app.ui?.settings?.getSettingValue?.(PROMPT_EDITOR_SETTING) === true; } // A browser can briefly retain the preceding companion module after updating // a custom node. Namespace access keeps Review Gate mountable in that state; // prompt synchronization becomes a no-op until the module cache refreshes. function publishCompanionPrompt(...args) { return promptCompanionSync.publishCompanionPrompt?.(...args) ?? 0; } function publishPlanCompanionScene(...args) { return promptCompanionSync.publishPlanCompanionScene?.(...args) ?? 0; } function ensureNotificationAudioContext() { const AudioContext = window.AudioContext ?? window.webkitAudioContext; if (!AudioContext) return null; notificationAudioContext ??= new AudioContext(); return notificationAudioContext; } function unlockNotificationAudio() { ensureNotificationAudioContext()?.resume?.().catch(() => {}); } // Queueing a workflow is normally the needed user gesture. Prime WebAudio on // the first interaction so a much later review notification is not rejected by // browser autoplay policy. window.addEventListener("pointerdown", unlockNotificationAudio, {once: true, capture: true}); window.addEventListener("keydown", unlockNotificationAudio, {once: true, capture: true}); async function playReviewChime() { const context = ensureNotificationAudioContext(); if (!context) return; if (context.state === "suspended") { await context.resume(); } const now = context.currentTime; const gain = context.createGain(); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.12, now + 0.015); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.55); gain.connect(context.destination); for (const [frequency, delay] of [[660, 0], [880, 0.16]]) { const oscillator = context.createOscillator(); oscillator.type = "sine"; oscillator.frequency.value = frequency; oscillator.connect(gain); oscillator.start(now + delay); oscillator.stop(now + delay + 0.28); } } function injectStyles() { if (document.getElementById("h3-chain-review-style")) return; const style = document.createElement("style"); style.id = "h3-chain-review-style"; style.textContent = ` .h3r-root { box-sizing:border-box; display:flex; flex-direction:column; gap:8px; min-height:500px; padding:9px; overflow:auto; border:1px solid #56637e; border-radius:8px; background:#181a20; color:#e8eaf0; font:12px/1.35 system-ui,sans-serif; } .h3r-root * { box-sizing:border-box; } .h3r-root [hidden] { display:none !important; } .h3r-head { display:flex; align-items:center; justify-content:space-between; gap:8px; } .h3r-title { font-weight:750; color:#a9c2ff; } .h3r-badge { color:#d5d9e3; opacity:.75; } .h3r-video-panel { width:100%; height:300px; min-height:140px; max-height:1200px; flex:0 0 auto; display:flex; flex-direction:column; overflow:hidden; border:1px solid #343b4b; border-radius:6px; background:#08090c; } .h3r-video { width:100%; height:calc(100% - 11px); min-height:0; display:block; flex:1 1 auto; background:#08090c; object-fit:contain; } .h3r-video-grip { height:11px; flex:0 0 11px; cursor:ns-resize; border-top:1px solid #343b4b; background:linear-gradient(180deg,#252a35,#171a21); position:relative; touch-action:none; } .h3r-video-grip::after { content:""; position:absolute; left:calc(50% - 20px); top:4px; width:40px; height:2px; border-top:1px solid #7e899f; border-bottom:1px solid #4f586b; } .h3r-video-grip:hover { background:linear-gradient(180deg,#313848,#1d212b); } .h3r-label { display:flex; flex-direction:column; gap:4px; color:#aeb5c5; } .h3r-prompt { width:100%; min-height:120px; resize:vertical; padding:7px; border:1px solid #56637e; border-radius:5px; background:#101218; color:#eef1f7; } .h3r-prompt-notice { padding:8px 9px; border:1px solid #56637e; border-radius:6px; background:#202431; color:#cbd3e5; white-space:pre-wrap; } .h3r-row { display:flex; align-items:flex-end; gap:7px; } .h3r-field { display:flex; flex-direction:column; gap:4px; min-width:0; color:#aeb5c5; } .h3r-field-seed { flex:2 1 0; } .h3r-field-duration { flex:1 1 0; } .h3r-seed { width:100%; min-width:0; padding:6px 7px; border:1px solid #56637e; border-radius:5px; background:#101218; color:#eef1f7; } .h3r-duration { width:100%; min-width:0; padding:6px 7px; border:1px solid #56637e; border-radius:5px; background:#101218; color:#eef1f7; } .h3r-candidates { display:flex; flex-direction:column; gap:7px; padding:8px; border:1px solid #4c6388; border-radius:6px; background:#172033; } .h3r-candidate-nav { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:7px; } .h3r-candidate-title { min-width:0; text-align:center; color:#a9c2ff; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .h3r-candidate-arrow { width:38px; padding:5px; font-size:16px; } .h3r-candidate-meta { display:flex; align-items:center; justify-content:space-between; gap:8px; min-width:0; } .h3r-candidate-keep { display:flex; align-items:center; gap:6px; color:#d6e3ff; cursor:pointer; user-select:none; } .h3r-candidate-progress { color:#9ca8bc; font-size:10px; text-align:right; } .h3r-candidate-dots { display:flex; flex-wrap:wrap; justify-content:center; gap:6px; } .h3r-candidate-dot { width:11px; height:11px; padding:0; border:1px solid #71809c; border-radius:50%; background:#343b4b; cursor:pointer; } .h3r-candidate-dot:hover { background:#526078; } .h3r-candidate-dot.h3r-selected { outline:2px solid #a9c2ff; outline-offset:2px; background:#6f8fd0; } .h3r-candidate-dot.h3r-kept { border-color:#70d39c; background:#347a54; } .h3r-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } .h3r-button { padding:7px; border:1px solid #63708b; border-radius:5px; background:#292e3a; color:#eef1f7; cursor:pointer; } .h3r-button:hover { background:#343b4b; } .h3r-button:disabled { opacity:.42; cursor:not-allowed; } .h3r-button:disabled:hover { background:#292e3a; } .h3r-approve { border-color:#4b9d72; background:#204332; } .h3r-retry { border-color:#b58b45; background:#4a3820; } .h3r-stop { border-color:#8a6171; background:#3b252d; } .h3r-status { min-height:18px; color:#aeb5c5; white-space:pre-wrap; } .h3r-warning { color:#f2bd67; } .h3r-prefix { margin:0; padding:6px 7px; max-height:90px; overflow:auto; border-left:2px solid #56637e; color:#aeb5c5; white-space:pre-wrap; } .h3r-resume { display:flex; flex-direction:column; gap:6px; margin-top:2px; padding:8px; border:1px solid #3f4759; border-radius:6px; background:#14161c; } .h3r-resume-title { font-weight:700; color:#a9c2ff; } .h3r-resume-row { display:flex; gap:6px; align-items:center; } .h3r-resume-select { flex:1; min-width:0; padding:6px; border:1px solid #56637e; border-radius:5px; background:#101218; color:#eef1f7; } .h3r-resume-status { color:#8f98aa; white-space:pre-wrap; } .h3r-revisions { display:flex; flex-direction:column; gap:6px; padding-top:4px; border-top:1px solid #343b4b; } .h3r-revisions-title { font-weight:650; color:#c3cad8; } .h3r-revision-rows { display:flex; flex-direction:column; gap:6px; } .h3r-revision-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:6px; align-items:center; } .h3r-revision-label { min-width:54px; color:#aeb5c5; } .h3r-revision-select { min-width:0; padding:6px; border:1px solid #56637e; border-radius:5px; background:#101218; color:#eef1f7; } .h3r-delete { border-color:#8a6171; background:#3b252d; } .h3r-root.h3r-busy .h3r-actions .h3r-button { opacity:.45; pointer-events:none; } .h3r-root.h3r-busy .h3r-candidates { opacity:.55; pointer-events:none; } `; document.head.appendChild(style); } function nodeType(node) { return node?.comfyClass ?? node?.type ?? null; } function findNodeByQualifiedId(qid) { if (!app.graph || qid == null) return null; const parts = String(qid).split(":"); let graph = app.graph; for (let i = 0; i < parts.length - 1; i += 1) { const id = Number(parts[i]); const parent = Number.isFinite(id) ? graph?.getNodeById?.(id) : null; if (!parent?.subgraph) return null; graph = parent.subgraph; } const leaf = Number(parts.at(-1)); return Number.isFinite(leaf) ? graph?.getNodeById?.(leaf) ?? null : null; } function allNodes(graph, output = []) { for (const node of graph?._nodes ?? []) { output.push(node); if (node.subgraph) allNodes(node.subgraph, output); } return output; } function findUpstreamNode(start, wantedType) { const queue = [start]; const seen = new Set(); while (queue.length) { const node = queue.shift(); if (!node || seen.has(node)) continue; seen.add(node); const matches = wantedType instanceof Set ? wantedType.has(nodeType(node)) : nodeType(node) === wantedType; if (node !== start && matches) return node; for (const input of node.inputs ?? []) { if (input.link == null) continue; const link = node.graph?.links?.[input.link]; const parent = link ? node.graph?.getNodeById?.(link.origin_id) : null; if (parent) queue.push(parent); } } return null; } function videoUrl(item) { const query = new URLSearchParams({ filename: item.filename, subfolder: item.subfolder ?? "", type: item.type ?? "output", }); return api.apiURL(`/view?${query.toString()}`); } function upstreamPlanNode(reviewNode) { return findUpstreamNode(reviewNode, PLAN_NAMES) ?? allNodes(app.graph).find((item) => PLAN_NAMES.has(nodeType(item))); } function widgetByName(node, name) { return node?.widgets?.find((item) => item.name === name); } function planResumeContext(reviewNode) { const planNode = upstreamPlanNode(reviewNode); const planWidget = widgetByName(planNode, "plan_json"); const runWidget = widgetByName(planNode, "run_name"); if (!planWidget || !runWidget) { throw new Error("Connect this Review Gate to an H3 Chain Plan and Loop Start."); } const plan = parsePlanJson(String(planWidget.value ?? "")); const runName = String(runWidget.value ?? "").trim(); if (!runName) throw new Error("The H3 Chain Plan run_name is empty."); return {runName, clipCount: plan.shots.length}; } function updatePlan(reviewNode, index, prompt, seed, length) { const planNode = upstreamPlanNode(reviewNode); const widget = planNode?.widgets?.find((item) => item.name === "plan_json"); if (!widget) return false; const sceneIndex = Number(index) - 1; const plan = applyReviewEdit( parsePlanJson(String(widget.value ?? "")), index, prompt, seed, length, ); const value = planToJson(plan); widget.value = value; widget.callback?.(value); refreshRestoredPlanEditors(planNode); planNode.graph?.setDirtyCanvas?.(true, true); publishCompanionPrompt( reviewNode, planNode, sceneIndex, promptValueToText(plan.shots[sceneIndex]?.prompt), ); return true; } function planScenePrompt(reviewNode, review) { try { const planNode = upstreamPlanNode(reviewNode); const widget = widgetByName(planNode, "plan_json"); if (!widget) return null; return reviewPlanScenePrompt( parsePlanJson(String(widget.value ?? "")), review?.clip_index, review?.shot_id, ); } catch (_error) { return null; } } function updatePlanFromCheckpointRevisions(reviewNode, revisions) { const planNode = upstreamPlanNode(reviewNode); const widget = planNode?.widgets?.find((item) => item.name === "plan_json"); if (!widget) return false; const plan = applyCheckpointRevisionSet( parsePlanJson(String(widget.value ?? "")), revisions, ); const value = planToJson(plan); widget.value = value; widget.callback?.(value); refreshRestoredPlanEditors(planNode); planNode.graph?.setDirtyCanvas?.(true, true); for (const revision of revisions ?? []) { const sceneIndex = Number(revision?.scene) - 1; if (sceneIndex < 0 || sceneIndex >= plan.shots.length) continue; publishCompanionPrompt( reviewNode, planNode, sceneIndex, promptValueToText(plan.shots[sceneIndex]?.prompt), ); } return true; } function restoreSavedPlanInputs(reviewNode, inputs, policyInputs = {}) { const planNode = upstreamPlanNode(reviewNode); if (!planNode || !inputs || typeof inputs !== "object") { throw new Error("The saved run has no Plan inputs to restore."); } const names = Object.keys(inputs).sort((left, right) => Number(left === "plan_json") - Number(right === "plan_json")); const applied = []; const unavailable = []; const graph = planNode.graph ?? app.graph; graph?.beforeChange?.(); try { for (const name of names) { const widget = widgetByName(planNode, name); if (!widget) { unavailable.push(name); continue; } widget.value = inputs[name]; widget.callback?.(inputs[name]); applied.push(name); } } finally { graph?.afterChange?.(); } if (!applied.includes("plan_json")) { throw new Error("The connected Plan does not expose an editable plan_json widget."); } const planWidget = widgetByName(planNode, "plan_json"); const plan = parsePlanJson(String(planWidget?.value ?? "")); const policies = restoreConnectedPolicyInputs( planNode, policyInputs, inputs); refreshRestoredPlanEditors(planNode); app.graph?.setDirtyCanvas?.(true, true); for (const [sceneIndex, shot] of plan.shots.entries()) { publishCompanionPrompt( reviewNode, planNode, sceneIndex, promptValueToText(shot.prompt), ); } return {sceneCount: plan.shots.length, unavailable, policies}; } function formatBytes(value) { let size = Math.max(0, Number(value) || 0); const units = ["B", "KB", "MB", "GB", "TB"]; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; } const digits = unit === 0 || size >= 100 ? 0 : size >= 10 ? 1 : 2; return `${size.toFixed(digits)} ${units[unit]}`; } function checkpointRevisionLabel(revision) { const active = revision.active ? "Active · " : ""; const date = revision.createdAt ? new Date(revision.createdAt).toLocaleString() : "unknown time"; const seed = revision.seed ? ` · seed ${revision.seed}` : ""; return `${active}${date} · ${revision.revision.slice(0, 8)}${seed} · ${formatBytes(revision.sizeBytes)}`; } function prepareResume(reviewNode, nextIndex, endIndex = null, clipCount = null) { const startNode = findUpstreamNode(reviewNode, "MiniMaxH3ChainLoopStart") ?? allNodes(app.graph).find((item) => nodeType(item) === "MiniMaxH3ChainLoopStart"); const widget = startNode?.widgets?.find((item) => item.name === "start_clip"); if (!widget) return false; widget.value = nextIndex; widget.callback?.(nextIndex); // An explicit range overrides start_clip in the backend. Checkpoint loads // clear it; immediate candidate acceptance preserves a shorter active // range while advancing to its next scene. const rangeWidget = startNode.widgets?.find((item) => item.name === "scene_range"); if (rangeWidget) { const end = Number(endIndex); const total = Number(clipCount); const range = Number.isInteger(end) && Number.isInteger(total) && nextIndex <= end && end < total ? nextIndex === end ? String(nextIndex) : `${nextIndex}:${end}` : ""; rangeWidget.value = range; rangeWidget.callback?.(range); } startNode.graph?.setDirtyCanvas?.(true, true); return true; } async function activateAcceptedCandidate(reviewNode, submittedReview, body) { const endClip = Number(submittedReview.end_clip ?? submittedReview.clip_count); const clipIndex = Number(submittedReview.clip_index); if (!Number.isInteger(endClip) || clipIndex >= endClip) { return { immediate: false, message: "This is the last scene in the active range, so the current " + "take must reach Loop End before the selected checkpoint can finish it.", }; } const execution = activeSceneExecutions.get(sceneExecutionKey( submittedReview.run_name, clipIndex)); if (!execution?.promptId) { return { immediate: false, message: "The exact running prompt could not be identified; the " + "selection is armed and will stop at the next safe boundary.", }; } const revisions = Array.isArray(body.resume_revisions) ? body.resume_revisions : []; if (revisions.length !== clipIndex) { return { immediate: false, message: "The selected checkpoint lineage could not be activated " + "immediately; the selection remains armed at the safe boundary.", }; } const saved = updatePlan( reviewNode, clipIndex, body.scene_prompt, body.seed, body.length); if (!saved) { return { immediate: false, message: "The connected Plan could not be updated; the selection " + "remains armed at the safe boundary.", }; } const activationResponse = await api.fetchApi( "/minimax_h3_context_loop/checkpoint-revisions/restore", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ run_name: submittedReview.run_name, resume_scene: Number(body.resume_scene), revisions, activate_only: true, }), }, ); const activation = await activationResponse.json().catch(() => ({})); if (!activationResponse.ok) { return { immediate: false, message: `${activation.error || `HTTP ${activationResponse.status}`} ` + "The selection remains armed at the safe boundary.", }; } if (execution.terminal === "success") { return { immediate: false, message: "The speculative prompt already completed; its Review " + "boundary is applying the armed selection now.", }; } if (execution.terminal !== "interrupted" && execution.terminal !== "error") { const waiter = waitForReviewInterruption(execution.promptId); const cancelResponse = await api.fetchApi( `/api/jobs/${encodeURIComponent(execution.promptId)}/cancel`, {method: "POST"}, ); const cancelled = await cancelResponse.json().catch(() => ({})); if (!cancelResponse.ok) { waiter.cancel(); return { immediate: false, message: `${cancelled.error || `Targeted cancellation failed (HTTP ${cancelResponse.status}).`} ` + "The selection remains armed at the safe boundary.", }; } if (!cancelled.cancelled) { waiter.cancel(); return { immediate: false, message: "The speculative take finished during cancellation; the " + "armed selection will be applied by Review Gate now.", }; } await waiter.promise; } const nextIndex = clipIndex + 1; if (!prepareResume( reviewNode, nextIndex, endClip, Number(submittedReview.clip_count))) { throw new Error( `Candidate accepted and sampling stopped, but Loop Start could not ` + `be armed for scene ${nextIndex}. Queue it manually from that scene.`); } let cleanupWarning = ""; try { const finalizeResponse = await api.fetchApi( "/minimax_h3_context_loop/review-candidate-batch", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ token: submittedReview.token, action: "finalize", candidate_revision: body.candidate_revision, }), }, ); const finalize = await finalizeResponse.json().catch(() => ({})); if (!finalizeResponse.ok) { cleanupWarning = ` Candidate cleanup warning: ${finalize.error || `HTTP ${finalizeResponse.status}`}`; } else if (Array.isArray(finalize.cleanup_warnings) && finalize.cleanup_warnings.length) { cleanupWarning = ` Candidate cleanup warning: ${ finalize.cleanup_warnings.join(" ")}`; } } catch (error) { cleanupWarning = ` Candidate cleanup warning: ${error.message}`; } await app.queuePrompt(0, 1); return {immediate: true, nextIndex, saved, cleanupWarning}; } function fetchPending() { if (pendingFetchPromise) return pendingFetchPromise; pendingFetchPromise = (async () => { try { const response = await api.fetchApi("/minimax_h3_context_loop/reviews"); if (!response.ok) return 0; const body = await response.json(); const reviews = body.reviews ?? []; for (const review of reviews) routeReview(review); return reviews.length; } catch (error) { console.warn("[H3 Chain Review] Could not recover pending reviews:", error); return 0; } finally { pendingFetchPromise = null; } })(); return pendingFetchPromise; } function planRunNameTrusted(planNode) { // Project Assets replaces Plan.run_name server-side while the plan editor // deliberately leaves that now-hidden widget unchanged. Psylent_Gamer // (4090) identified this Review Gate routing split through Banodoco. if (!planNode) return true; const input = planNode.inputs?.find((item) => item.name === "project_assets"); return input?.link === null || input?.link === undefined; } function deliverReview(node, data) { if (!node || nodeType(node) !== NODE_NAME) return false; const expectedRun = String(data?.run_name ?? "").trim(); if (expectedRun) { const planNode = findUpstreamNode(node, PLAN_NAMES); if (planRunNameTrusted(planNode)) { const actualRun = String( widgetByName(planNode, "run_name")?.value ?? "", ).trim(); if (actualRun && actualRun !== expectedRun) return false; } } if (typeof node._h3ReviewHandler === "function") { node._h3ReviewHandler(data); } else { // A websocket event can arrive between node creation and the DOM // widget's deferred mount. Preserve it instead of leaving a live // backend future with no token in the browser. node._h3QueuedReview = data; } return true; } function reviewFallbackNode(data) { const gates = [...new Set([ ...allNodes(app.graph).filter((item) => nodeType(item) === NODE_NAME), ...[...mountedReviewNodes].filter((item) => nodeType(item) === NODE_NAME), ])]; const expectedRun = String(data?.run_name ?? "").trim(); if (expectedRun) { const matchingRun = gates.filter((item) => { const planNode = findUpstreamNode(item, PLAN_NAMES); if (!planRunNameTrusted(planNode)) return false; return String(widgetByName(planNode, "run_name")?.value ?? "").trim() === expectedRun; }); if (matchingRun.length === 1) return matchingRun[0]; } // GraphBuilder execution ids use dots while subgraph-qualified display // ids use colons. The visible LiteGraph node is always the final leaf. const leaf = String(data?.node_id ?? "").split(/[.:]/).at(-1); const matchingLeaf = gates.filter((item) => String(item.id) === leaf); if (matchingLeaf.length === 1) return matchingLeaf[0]; return gates.length === 1 ? gates[0] : null; } function routeReview(data) { const exact = findNodeByQualifiedId(data?.node_id); if (deliverReview(exact, data)) return true; const fallback = reviewFallbackNode(data); if (deliverReview(fallback, data)) { console.warn( `[H3 Chain Review] Display node ${data?.node_id} was not directly ` + "resolvable; routed the pending review to the only matching gate.", ); return true; } console.warn( `[H3 Chain Review] Pending token ${data?.token ?? "?"} could not be ` + `routed to display node ${data?.node_id ?? "?"}.`, ); return false; } function routeReviewResolved(data) { const exact = findNodeByQualifiedId(data?.node_id); const node = nodeType(exact) === NODE_NAME ? exact : reviewFallbackNode(data); node?._h3ReviewResolvedHandler?.(data); } function updatePendingPolling() { if (mountedReviewNodes.size > 0 && pendingPollTimer == null) { // send_sync targets the browser session that queued the prompt. A // reconnect, reverse proxy, sleeping tab, or websocket race can miss // that event even though the backend review remains healthy. Polling // this tiny in-memory endpoint makes the pending token recoverable. pendingPollTimer = window.setInterval(() => { if (document.visibilityState !== "hidden") fetchPending(); }, 2000); } else if (mountedReviewNodes.size === 0 && pendingPollTimer != null) { window.clearInterval(pendingPollTimer); pendingPollTimer = null; } } function mount(node) { if (node._h3ReviewMounted || typeof node.addDOMWidget !== "function") return; node._h3ReviewMounted = true; injectStyles(); const root = document.createElement("div"); root.className = "h3r-root"; root.tabIndex = 0; root.title = "Review each persisted H3 scene with synchronized sound, then approve, retry, reroll, stop, or arm a saved checkpoint for resume."; // LiteGraph listens to pointer events on the canvas. Shield the complete // pointer sequence, not only mousedown: Firefox can otherwise let the // canvas capture pointerdown before a DOM-widget button receives click. for (const eventName of [ "pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick", ]) { root.addEventListener(eventName, (event) => event.stopPropagation()); } root.addEventListener("wheel", (event) => event.stopPropagation()); const head = document.createElement("div"); head.className = "h3r-head"; const title = document.createElement("span"); title.className = "h3r-title"; title.textContent = "H3 Segment Review"; const badge = document.createElement("span"); badge.className = "h3r-badge"; badge.textContent = "waiting for segment…"; head.append(title, badge); const video = document.createElement("video"); video.className = "h3r-video"; video.controls = true; video.preload = "metadata"; video.playsInline = true; video.title = "Saved delivered scene preview. Review motion, continuity, and synchronized audio before choosing an action."; const videoPanel = document.createElement("div"); videoPanel.className = "h3r-video-panel"; videoPanel.title = "Resizable saved-scene preview."; const videoGrip = document.createElement("div"); videoGrip.className = "h3r-video-grip"; videoGrip.title = "Drag vertically to resize the video preview. Double-click to reset."; videoGrip.setAttribute("role", "separator"); videoGrip.setAttribute("aria-label", "Resize video preview"); videoGrip.setAttribute("aria-orientation", "horizontal"); videoPanel.append(video, videoGrip); node.properties ??= {}; const savedVideoHeight = Number(node.properties[VIDEO_HEIGHT_PROPERTY]); const initialVideoHeight = Number.isFinite(savedVideoHeight) ? Math.max(MIN_VIDEO_HEIGHT, Math.min(MAX_VIDEO_HEIGHT, savedVideoHeight)) : DEFAULT_VIDEO_HEIGHT; videoPanel.style.height = `${initialVideoHeight}px`; let videoResize = null; function setVideoHeight(height, persist = false) { const next = Math.round(Math.max( MIN_VIDEO_HEIGHT, Math.min(MAX_VIDEO_HEIGHT, Number(height) || DEFAULT_VIDEO_HEIGHT), )); videoPanel.style.height = `${next}px`; videoGrip.setAttribute("aria-valuenow", String(next)); if (persist) { node.properties[VIDEO_HEIGHT_PROPERTY] = next; node.graph?.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } } setVideoHeight(initialVideoHeight); videoGrip.addEventListener("pointerdown", (event) => { event.preventDefault(); const layoutHeight = videoPanel.offsetHeight; const visualHeight = videoPanel.getBoundingClientRect().height; const displayScale = layoutHeight > 0 && visualHeight > 0 ? visualHeight / layoutHeight : 1; videoResize = { pointerId: event.pointerId, startY: event.clientY, startHeight: layoutHeight || DEFAULT_VIDEO_HEIGHT, displayScale, }; videoGrip.setPointerCapture?.(event.pointerId); }); videoGrip.addEventListener("pointermove", (event) => { if (!videoResize || event.pointerId !== videoResize.pointerId) return; event.preventDefault(); setVideoHeight(videoResize.startHeight + (event.clientY - videoResize.startY) / videoResize.displayScale); }); function finishVideoResize(event) { if (!videoResize || event.pointerId !== videoResize.pointerId) return; videoResize = null; // offsetHeight is an unscaled layout value. getBoundingClientRect() // includes ComfyUI canvas zoom and caused every release to compound a // smaller screen-space height into the saved CSS height. setVideoHeight(videoPanel.offsetHeight, true); videoGrip.releasePointerCapture?.(event.pointerId); } videoGrip.addEventListener("pointerup", finishVideoResize); videoGrip.addEventListener("pointercancel", finishVideoResize); videoGrip.addEventListener("dblclick", (event) => { event.preventDefault(); setVideoHeight(DEFAULT_VIDEO_HEIGHT, true); }); const prefix = document.createElement("pre"); prefix.className = "h3r-prefix"; prefix.hidden = true; prefix.title = "Shared prompt prepended to every scene. It is shown for context and is not changed by retrying this scene."; const promptLabel = document.createElement("label"); promptLabel.className = "h3r-label"; promptLabel.append("Scene prompt (used when retrying)"); const prompt = document.createElement("textarea"); prompt.className = "h3r-prompt"; prompt.title = "The connected Prompt Editor and this fallback field share the current Plan scene. Retry regenerates it from the same accepted predecessor."; promptLabel.append(prompt); let promptEditedInGate = false; prompt.addEventListener("input", () => { promptEditedInGate = true; }); const promptNotice = document.createElement("div"); promptNotice.className = "h3r-prompt-notice"; promptNotice.textContent = "Prompt editing in Review Gate is disabled by default in 0.5. Use Scene Prompt Editor or Rich Scene Prompt Editor, then Retry or Reroll here.\n\nTo restore the old field: ComfyUI Settings → MiniMax H3 Context Loop → Interface → Review Gate."; function refreshPromptEditorSetting() { const enabled = reviewPromptEditorEnabled(); promptLabel.hidden = !enabled; prompt.disabled = !enabled; promptNotice.hidden = enabled; } node._h3ReviewRefreshPromptSetting = refreshPromptEditorSetting; refreshPromptEditorSetting(); let promptResizeObserver = null; function applySavedLayout() { node.properties ??= {}; const restoredVideoHeight = Number(node.properties[VIDEO_HEIGHT_PROPERTY]); setVideoHeight(Number.isFinite(restoredVideoHeight) ? restoredVideoHeight : DEFAULT_VIDEO_HEIGHT); const restoredPromptHeight = Number(node.properties[PROMPT_HEIGHT_PROPERTY]); if (Number.isFinite(restoredPromptHeight) && restoredPromptHeight >= 120) { prompt.style.height = `${Math.round(restoredPromptHeight)}px`; } else { prompt.style.removeProperty("height"); } } if (typeof ResizeObserver === "function") { let initialized = false; promptResizeObserver = new ResizeObserver(() => { if (!prompt.isConnected) return; const next = Math.round(prompt.offsetHeight); if (!Number.isFinite(next) || next < 120) return; if (!initialized) { initialized = true; return; } if (Number(node.properties?.[PROMPT_HEIGHT_PROPERTY]) === next) return; node.properties ??= {}; node.properties[PROMPT_HEIGHT_PROPERTY] = next; node.graph?.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); }); promptResizeObserver.observe(prompt); } node._h3ReviewApplyLayout = applySavedLayout; applySavedLayout(); const seedRow = document.createElement("div"); seedRow.className = "h3r-row"; const seedField = document.createElement("label"); seedField.className = "h3r-field h3r-field-seed"; seedField.append("Seed"); const seed = document.createElement("input"); seed.className = "h3r-seed"; seed.inputMode = "numeric"; seed.title = "Unsigned 64-bit seed for the current scene. Edit it before Retry, or use Reroll seed to generate a new value automatically."; seedField.append(seed); const durationField = document.createElement("label"); durationField.className = "h3r-field h3r-field-duration"; durationField.append("Duration (s)"); const duration = document.createElement("input"); duration.className = "h3r-duration"; duration.type = "number"; duration.inputMode = "decimal"; duration.min = String(5 / 24); duration.max = String(3592 / 24); duration.step = String(17 / 24); duration.title = "Generated scene duration. Retry and Reroll round this upward to H3's exact 17k+5 frame grid, revise the full Plan, and retime downstream scenes. Prompt wording and written timestamps are not changed."; durationField.append(duration); seedRow.append(seedField, durationField); const candidateRow = document.createElement("div"); candidateRow.className = "h3r-candidates"; candidateRow.hidden = true; candidateRow.title = "Preview each saved take. The active candidate's exact video and audio continuation tensors become the next scene's checkpoint state; checked alternatives remain in history."; const candidateNav = document.createElement("div"); candidateNav.className = "h3r-candidate-nav"; const candidatePrevious = document.createElement("button"); candidatePrevious.type = "button"; candidatePrevious.className = "h3r-button h3r-candidate-arrow"; candidatePrevious.textContent = "←"; candidatePrevious.title = "Preview the previous saved candidate."; const candidateTitle = document.createElement("span"); candidateTitle.className = "h3r-candidate-title"; candidateTitle.textContent = "Candidate"; const candidateNext = document.createElement("button"); candidateNext.type = "button"; candidateNext.className = "h3r-button h3r-candidate-arrow"; candidateNext.textContent = "→"; candidateNext.title = "Preview the next saved candidate."; candidateNav.append(candidatePrevious, candidateTitle, candidateNext); const candidateMeta = document.createElement("div"); candidateMeta.className = "h3r-candidate-meta"; const candidateKeepLabel = document.createElement("label"); candidateKeepLabel.className = "h3r-candidate-keep"; const candidateKeep = document.createElement("input"); candidateKeep.type = "checkbox"; candidateKeep.title = "Retain this take in checkpoint history even if another take becomes active. The active continuation is always retained; other unchecked alternatives are deleted after acceptance."; candidateKeepLabel.append(candidateKeep, "Keep as alternative"); const candidateProgress = document.createElement("span"); candidateProgress.className = "h3r-candidate-progress"; candidateMeta.append(candidateKeepLabel, candidateProgress); const candidateDots = document.createElement("div"); candidateDots.className = "h3r-candidate-dots"; candidateDots.setAttribute("role", "tablist"); candidateRow.append(candidateNav, candidateMeta, candidateDots); const actions = document.createElement("div"); actions.className = "h3r-actions"; const actionButtons = []; function actionButton(label, className, action) { const button = document.createElement("button"); button.className = `h3r-button ${className}`; button.textContent = label; button.type = "button"; button.title = { approve: "Accept this saved scene and continue the loop with the next scene.", next_candidate: "Keep any marked takes, resume the workflow, and generate the next candidate for this scene.", retry: "Reject this attempt and regenerate the same scene using the active Plan prompt, seed, and duration.", reroll: "Reject this attempt, assign a new random seed, and regenerate the same scene using the active Plan prompt and duration.", stop: "Accept this scene but stop before the next one. Optionally assemble a partial joined MP4 and arm the next scene for resume.", }[action] ?? "Submit this review decision."; // Keep actions clickable while waiting. If a websocket event or node-id // route was missed, submit() can recover the live token through the // pending-review endpoint and perform the requested action immediately. button.disabled = false; button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); void submit(action); }); actions.append(button); actionButtons.push(button); return button; } const approveButton = actionButton( "Approve & continue", "h3r-approve", "approve"); const nextCandidateButton = actionButton( "Generate next candidate", "h3r-retry", "next_candidate"); nextCandidateButton.hidden = true; const retryButton = actionButton( "Retry scene / seed / length", "h3r-retry", "retry"); const rerollButton = actionButton( "Reroll seed", "h3r-retry", "reroll"); const stopButton = actionButton( "Approve & stop", "h3r-stop", "stop"); const status = document.createElement("div"); status.className = "h3r-status"; status.textContent = "The loop will pause here after each saved segment."; const resume = document.createElement("section"); resume.className = "h3r-resume"; const resumeTitle = document.createElement("div"); resumeTitle.className = "h3r-resume-title"; resumeTitle.textContent = "Resume from saved checkpoint"; const resumeRow = document.createElement("div"); resumeRow.className = "h3r-resume-row"; const resumeSelect = document.createElement("select"); resumeSelect.className = "h3r-resume-select"; resumeSelect.title = "Choose the next scene to render. Its immediately preceding saved checkpoint supplies the visual and AV continuation state."; const refreshResume = document.createElement("button"); refreshResume.type = "button"; refreshResume.className = "h3r-button"; refreshResume.textContent = "Refresh"; refreshResume.title = "Scan this run's checkpoint folder again and list valid resume positions."; const loadResume = document.createElement("button"); loadResume.type = "button"; loadResume.className = "h3r-button h3r-approve"; loadResume.textContent = "Load checkpoint"; loadResume.title = "Restore the saved run's full Plan, preview the selected predecessor checkpoint, and set H3 Chain Loop Start to the chosen resume scene. Queue the workflow afterward to actually resume."; const resumeStatus = document.createElement("div"); resumeStatus.className = "h3r-resume-status"; resumeStatus.textContent = "Refresh to discover saved scenes for this run."; const revisionsPanel = document.createElement("div"); revisionsPanel.className = "h3r-revisions"; revisionsPanel.hidden = true; const revisionsTitle = document.createElement("div"); revisionsTitle.className = "h3r-revisions-title"; revisionsTitle.textContent = "Checkpoint history"; const revisionsHint = document.createElement("div"); revisionsHint.className = "h3r-resume-status"; revisionsHint.textContent = "All saved scenes are listed. Only scenes before the selected resume position are restored."; const revisionsRows = document.createElement("div"); revisionsRows.className = "h3r-revision-rows"; revisionsPanel.append(revisionsTitle, revisionsHint, revisionsRows); resumeRow.append(resumeSelect, refreshResume, loadResume); resume.append(resumeTitle, resumeRow, resumeStatus, revisionsPanel); root.append( head, videoPanel, prefix, promptNotice, promptLabel, seedRow, candidateRow, actions, status, resume, ); let current = null; let countdownTimer = null; let resumeChoices = []; let checkpointRevisions = []; let revisionChain = []; let planClipCount = 0; let resumeRefreshToken = 0; let activeCandidateRevision = ""; let keptCandidateRevisions = new Set(); let previewLoadRevision = 0; let acceptedPreviewPin = null; function setReviewVideo(item, preservePosition = false) { if (!item?.filename) return false; const source = videoUrl(item); if (video.dataset.source === source) return false; const savedTime = preservePosition && Number.isFinite(video.currentTime) ? Math.max(0, video.currentTime) : 0; const resumePlayback = preservePosition && !video.paused; const revision = ++previewLoadRevision; video.dataset.source = source; video.src = source; video.load(); if (preservePosition) { video.addEventListener("loadedmetadata", () => { if (revision !== previewLoadRevision || video.dataset.source !== source) return; const limit = Number.isFinite(video.duration) ? Math.max(0, video.duration - .02) : savedTime; try { video.currentTime = Math.min(savedTime, limit); } catch (_error) {} if (resumePlayback) void video.play().catch(() => {}); }, {once:true}); } return true; } function showAcceptedPreview() { if (!acceptedPreviewPin?.video) return; activeCandidateRevision = acceptedPreviewPin.revision; setReviewVideo(acceptedPreviewPin.video, true); badge.textContent = `accepted scene ${acceptedPreviewPin.clipIndex} · ` + `revision ${acceptedPreviewPin.revision.slice(0, 8)}`; } function pinAcceptedPreview(review, candidate) { if (!candidate?.video || !candidate?.revision) return; acceptedPreviewPin = { runName: String(review?.run_name ?? ""), clipIndex: Number(review?.clip_index), token: String(review?.token ?? ""), revision: String(candidate.revision), video: candidate.video, }; showAcceptedPreview(); } function setActionsEnabled(enabled) { for (const button of actionButtons) button.disabled = !enabled; if (enabled && current?.candidate_batch_active) { retryButton.disabled = true; rerollButton.disabled = true; stopButton.disabled = true; } } function selectedCandidate() { const candidates = Array.isArray(current?.candidates) ? current.candidates : []; return candidates.find( (candidate) => candidate.revision === activeCandidateRevision, ) ?? candidates.at(-1) ?? null; } function showCandidate(candidate, announce = false) { if (!candidate) return; const sameCandidate = activeCandidateRevision === candidate.revision; activeCandidateRevision = candidate.revision; if (candidate.video) { setReviewVideo(candidate.video, sameCandidate); } seed.value = String(candidate.seed ?? seed.value); candidateTitle.textContent = `Candidate ${candidate.number}/${current.candidate_count} · ` + `seed ${candidate.seed} · ${candidate.revision.slice(0, 8)}`; candidateKeep.checked = keptCandidateRevisions.has(candidate.revision); const candidates = Array.isArray(current?.candidates) ? current.candidates : []; const selectedIndex = Math.max(0, candidates.indexOf(candidate)); candidatePrevious.disabled = selectedIndex <= 0; candidateNext.disabled = selectedIndex >= candidates.length - 1; badge.textContent = `clip ${current.clip_index}/${current.clip_count} · ` + `candidate ${candidate.number}/${current.candidate_count} · ${current.shot_id}`; renderCandidateDots(); renderCandidateProgress(); if (announce) { status.className = `h3r-status${candidate.warning ? " h3r-warning" : ""}`; status.textContent = candidate.warning || `Previewing candidate ${candidate.number}/${current.candidate_count}, seed ${candidate.seed}.`; } } function renderCandidateDots() { const candidates = Array.isArray(current?.candidates) ? current.candidates : []; candidateDots.replaceChildren(); for (const candidate of candidates) { const dot = document.createElement("button"); dot.type = "button"; dot.className = "h3r-candidate-dot"; if (candidate.revision === activeCandidateRevision) { dot.classList.add("h3r-selected"); } if (keptCandidateRevisions.has(candidate.revision)) { dot.classList.add("h3r-kept"); } dot.title = `Preview candidate ${candidate.number}, seed ${candidate.seed}` + (keptCandidateRevisions.has(candidate.revision) ? " (kept)" : ""); dot.setAttribute("aria-label", dot.title); dot.addEventListener("click", () => showCandidate(candidate, true)); candidateDots.append(dot); } } function renderCandidateProgress() { const generated = Array.isArray(current?.candidates) ? current.candidates.length : 0; const target = Number(current?.candidate_count) || generated; const remaining = Math.max(0, target - generated); candidateProgress.textContent = `${generated}/${target} generated · ` + `${keptCandidateRevisions.size} marked to keep` + (remaining ? ` · ${remaining} remaining` : " · complete"); } function renderCandidateCarousel() { const candidates = Array.isArray(current?.candidates) ? current.candidates : []; const batch = Number(current?.candidate_count) > 1; candidateRow.hidden = !batch; const complete = Boolean(current?.candidate_generation_complete) || candidates.length >= Number(current?.candidate_count); const running = Boolean(current?.candidate_batch_active) && !complete; nextCandidateButton.hidden = !running && ( !batch || complete || !current?.review_each_candidate); nextCandidateButton.textContent = running ? "Pause candidate run" : "Generate next candidate"; nextCandidateButton.title = running ? "Let the current in-flight take finish, then pause before another candidate starts." : "Keep any marked takes, resume the workflow, and generate the next candidate for this scene."; approveButton.textContent = batch ? running ? "Use this take & stop batch" : complete ? "Use this take & continue" : "Accept now & continue" : "Approve & continue"; approveButton.title = running ? "Accept this saved take now, cancel only the speculative in-flight " + "H3 prompt, activate its checkpoint, and continue at the next scene." : "Accept this saved scene and continue the loop with the next scene."; if (!batch || !candidates.length) return null; if (!candidates.some( (candidate) => candidate.revision === activeCandidateRevision)) { activeCandidateRevision = candidates.at(-1).revision; } const selected = selectedCandidate(); renderCandidateDots(); renderCandidateProgress(); return selected; } function moveCandidate(offset) { const candidates = Array.isArray(current?.candidates) ? current.candidates : []; const index = candidates.findIndex( (candidate) => candidate.revision === activeCandidateRevision); const candidate = candidates[index + offset]; if (candidate) showCandidate(candidate, true); } candidatePrevious.addEventListener("click", () => moveCandidate(-1)); candidateNext.addEventListener("click", () => moveCandidate(1)); root.addEventListener("keydown", (event) => { if (candidateRow.hidden || event.altKey || event.ctrlKey || event.metaKey) return; if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes( event.target?.tagName)) return; if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); moveCandidate(event.key === "ArrowLeft" ? -1 : 1); }); candidateKeep.addEventListener("change", () => { const candidate = selectedCandidate(); if (!candidate) return; if (candidateKeep.checked) keptCandidateRevisions.add(candidate.revision); else keptCandidateRevisions.delete(candidate.revision); renderCandidateDots(); renderCandidateProgress(); if (current?.candidate_batch_active && current?.token) { void api.fetchApi( "/minimax_h3_context_loop/review-candidate-batch", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ token: current.token, action: "update", candidate_revisions: [...keptCandidateRevisions], }), }, ).then(async (response) => { if (response.ok) return; const body = await response.json().catch(() => ({})); throw new Error(body.error || `HTTP ${response.status}`); }).catch((error) => { status.className = "h3r-status h3r-warning"; status.textContent = `Could not retain the live keep marks: ${error.message}`; }); } }); function selectedRevisionChain() { const selectedResumeScene = Number(resumeSelect.value); if (!Number.isInteger(selectedResumeScene) || selectedResumeScene < 2) { return []; } return revisionChain.map((group) => { const select = revisionsRows.querySelector( `select[data-scene="${group.scene}"]`, ); const revision = group.revisions.find( (item) => item.revision === select?.value, ); return revision ?? group.revisions[0]; }).filter((revision) => revision.scene < selectedResumeScene); } function showRevisionPreview(revision) { if (!revision?.video) return; acceptedPreviewPin = null; setReviewVideo(revision.video); badge.textContent = `saved scene ${revision.scene} · revision ${revision.revision.slice(0, 8)}`; } async function deleteRevision(revision) { if (!revision || revision.active) return; try { const context = planResumeContext(node); const previewResponse = await api.fetchApi( "/minimax_h3_context_loop/checkpoint-revisions/delete-preview", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ run_name: context.runName, scene: revision.scene, revision: revision.revision, }), }, ); const preview = await previewResponse.json(); if (!previewResponse.ok) { throw new Error(preview.error || `HTTP ${previewResponse.status}`); } if (!preview.allowed) { throw new Error((preview.blockers ?? []).join(" ") || "This checkpoint revision cannot be deleted safely."); } const confirmed = window.confirm( `Permanently delete scene ${revision.scene} revision ` + `${revision.revision.slice(0, 8)} and its ` + `${preview.owned_file_count} owned files ` + `(${formatBytes(preview.reclaimed_bytes)})? Run archives, ` + "references, prompt history, and assembled exports are kept. " + "This cannot be undone.", ); if (!confirmed) return; const response = await api.fetchApi( "/minimax_h3_context_loop/checkpoint-revisions/delete", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ run_name: context.runName, scene: revision.scene, revision: revision.revision, snapshot: preview.snapshot, }), }, ); const body = await response.json(); if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); await refreshResumeOptions(); resumeStatus.textContent = `${body.message} Reclaimed ${formatBytes(body.reclaimed_bytes)}.`; } catch (error) { resumeStatus.textContent = error.message; } } function renderRevisionChoices() { revisionsRows.replaceChildren(); revisionChain = checkpointRevisionChain( checkpointRevisions, planClipCount + 1, ); const hasHistory = revisionChain.length > 0; revisionsPanel.hidden = !hasHistory; if (!hasHistory) { loadResume.textContent = "Load checkpoint"; return; } for (const group of revisionChain) { const row = document.createElement("div"); row.className = "h3r-revision-row"; const label = document.createElement("span"); label.className = "h3r-revision-label"; label.textContent = `Scene ${group.scene}`; const select = document.createElement("select"); select.className = "h3r-revision-select"; select.dataset.scene = String(group.scene); for (const revision of group.revisions) { const option = document.createElement("option"); option.value = revision.revision; option.textContent = checkpointRevisionLabel(revision); select.append(option); } const remove = document.createElement("button"); remove.type = "button"; remove.className = "h3r-button h3r-delete"; remove.textContent = "Delete"; remove.title = "Permanently delete the selected inactive revision and only its owned files."; const update = (preview = false) => { const revision = group.revisions.find( (item) => item.revision === select.value, ); remove.disabled = !revision || revision.active; loadResume.textContent = selectedRevisionChain().some( (item) => !item.active, ) ? "Restore & load" : "Load checkpoint"; if (preview) showRevisionPreview(revision); }; select.addEventListener("change", () => update(true)); remove.addEventListener("click", () => { const revision = group.revisions.find( (item) => item.revision === select.value, ); void deleteRevision(revision); }); row.append(label, select, remove); revisionsRows.append(row); update(); } } async function refreshResumeOptions() { const refreshToken = ++resumeRefreshToken; const previousResumeScene = Number(resumeSelect.value); resumeSelect.replaceChildren(); resumeChoices = []; checkpointRevisions = []; revisionChain = []; revisionsRows.replaceChildren(); revisionsPanel.hidden = true; loadResume.disabled = true; try { const context = planResumeContext(node); planClipCount = context.clipCount; const query = new URLSearchParams({run_name: context.runName}); const response = await api.fetchApi( `/minimax_h3_context_loop/checkpoints?${query.toString()}`, ); const body = await response.json(); if (refreshToken !== resumeRefreshToken) return; if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); resumeChoices = checkpointResumeOptions( body.checkpoints, context.clipCount); checkpointRevisions = body.revisions ?? []; for (const option of resumeChoices) { const element = document.createElement("option"); element.value = String(option.resumeScene); element.textContent = `Resume scene ${option.resumeScene} — checkpoint ${option.savedScene} · ${option.sceneId}`; resumeSelect.append(element); } if (resumeChoices.some( (option) => option.resumeScene === previousResumeScene)) { resumeSelect.value = String(previousResumeScene); } renderRevisionChoices(); loadResume.disabled = resumeChoices.length === 0; const complete = (body.checkpoints ?? []).filter((item) => item.ready).length; resumeStatus.textContent = resumeChoices.length ? `${complete} saved checkpoint${complete === 1 ? "" : "s"} found. Select the scene to resume.` : complete >= context.clipCount ? `All ${context.clipCount} scenes are saved; there is no later scene to resume.` : "No usable predecessor checkpoint was found for a later scene."; } catch (error) { if (refreshToken !== resumeRefreshToken) return; resumeStatus.textContent = error.message; } } refreshResume.addEventListener("click", refreshResumeOptions); resumeSelect.addEventListener("change", renderRevisionChoices); node._h3RefreshResume = refreshResumeOptions; loadResume.addEventListener("click", async () => { const resumeScene = Number(resumeSelect.value); if (!Number.isInteger(resumeScene)) return; try { const context = planResumeContext(node); const selections = selectedRevisionChain(); const changed = selections.some((item) => !item.active); let restored = []; if (changed) { const changedScenes = selections.filter( (item) => !item.active, ).map((item) => item.scene).join(", "); const confirmed = window.confirm( `Restore the selected checkpoint revisions through scene ` + `${resumeScene - 1} (changed scenes: ${changedScenes})? ` + "The current revisions will remain available in revision history.", ); if (!confirmed) return; } const runQuery = new URLSearchParams({ run_name: context.runName, include_assets: "false", }); const runResponse = await api.fetchApi( `/minimax_h3_context_loop/run?${runQuery.toString()}`, ); const runBody = await runResponse.json(); if (!runResponse.ok) throw new Error( runBody.error || `HTTP ${runResponse.status}`, ); let restoredPolicyInputs = runBody.policy_inputs; if (selections.length) { const response = await api.fetchApi( "/minimax_h3_context_loop/checkpoint-revisions/restore", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ run_name: context.runName, resume_scene: resumeScene, revisions: selections.map((item) => ({ scene: item.scene, revision: item.revision, })), }), }, ); const body = await response.json(); if (!response.ok) throw new Error( body.error || `HTTP ${response.status}`, ); restored = body.restored ?? []; restoredPolicyInputs = body.policy_inputs ?? restoredPolicyInputs; } const savedPlan = restoreSavedPlanInputs( node, runBody.plan_inputs, restoredPolicyInputs); if (savedPlan.sceneCount < resumeScene) { throw new Error( `The saved Plan has ${savedPlan.sceneCount} scenes and cannot resume scene ${resumeScene}.`, ); } if (restored.length && !updatePlanFromCheckpointRevisions(node, restored)) { throw new Error( "Checkpoint files were restored, but the connected Plan editor could not be updated.", ); } if (!prepareResume(node, resumeScene)) { throw new Error("Could not find the connected H3 Chain Loop Start node."); } const choice = resumeChoices.find( (item) => item.resumeScene === resumeScene); const selectedPredecessor = selections.find( (item) => item.scene === resumeScene - 1, ); const preview = selectedPredecessor?.video ?? choice?.partialVideo ?? choice?.video; if (preview) { acceptedPreviewPin = null; setReviewVideo(preview); badge.textContent = selectedPredecessor ? `saved scene ${selectedPredecessor.scene} · revision ${selectedPredecessor.revision.slice(0, 8)}` : choice?.partialVideo ? `partial through checkpoint ${choice.savedScene}` : `saved scene ${choice.savedScene} · ${choice.sceneId}`; } const unavailable = savedPlan.unavailable.length ? ` Current Plan has no ${savedPlan.unavailable.join(", ")} control. ` : " "; const unavailablePolicies = savedPlan.policies.unavailable.length ? ` Could not restore ${savedPlan.policies.unavailable.join(", ")}. ` : ""; const finalStatus = `Restored the saved ${savedPlan.sceneCount}-scene Plan and ${restored.length} checkpoint scene${restored.length === 1 ? "" : "s"}.` + unavailable + unavailablePolicies + `Checkpoint ${resumeScene - 1} loaded for preview. Loop Start is armed for scene ${resumeScene}; queue the workflow to validate and resume.`; if (changed) await refreshResumeOptions(); resumeStatus.textContent = finalStatus; } catch (error) { resumeStatus.textContent = error.message; } }); function stopCountdown() { if (countdownTimer != null) clearInterval(countdownTimer); countdownTimer = null; } function renderWaitingStatus() { if (!current) return; const generated = Array.isArray(current.candidates) ? current.candidates.length : 0; const target = Number(current.candidate_count) || generated; const complete = Boolean(current.candidate_generation_complete) || generated >= target; const batchCommand = current.candidate_batch_command_pending; const message = batchCommand ? batchCommand === "accept" ? "Selected take is being activated; Review Gate is stopping the speculative take." : "Pause queued. The current in-flight candidate will finish, then Review Gate will wait here." : current.warning || (target > 1 ? complete ? `All ${target} candidates are saved. Choose the active continuation and mark every take you want to retain.` : current.review_each_candidate ? `Candidate ${generated}/${target} is ready in per-candidate review mode. Keep it if wanted, accept a take now, or generate the next candidate.` : `Candidate ${generated}/${target} is ready in the live carousel. Candidate ${generated + 1} is generating automatically; choose a take to stop early or pause the run.` : "Review the synchronized picture and sound, then choose an action."); const countdown = reviewCountdown(current.local_deadline); status.className = `h3r-status${current.warning ? " h3r-warning" : ""}`; status.textContent = countdown ? `${message}\nAuto-continue in ${countdown.text}.` : message; if (countdown?.seconds === 0) { root.classList.add("h3r-busy"); setActionsEnabled(false); status.textContent = `${message}\nTimeout reached — continuing…`; stopCountdown(); } } function startCountdown() { stopCountdown(); renderWaitingStatus(); if (reviewCountdown(current?.local_deadline)?.seconds > 0) { countdownTimer = setInterval(renderWaitingStatus, 250); } } async function submit(action) { if (!current?.token) { status.className = "h3r-status h3r-warning"; status.textContent = "No live review token is attached. Checking the server…"; await fetchPending(); if (!current?.token) { status.textContent = "No pending review is available for this project yet."; return; } } try { // Freeze one coherent review edit before yielding to the server. // Websocket recovery can replace `current`, and a companion UI can // refresh the live controls, while this request is in flight. const submittedReview = current; const submittedToken = submittedReview.token; const submittedIndex = submittedReview.clip_index; const submittedCandidate = selectedCandidate(); const liveCandidateBatch = Boolean( submittedReview.candidate_batch_active) && !submittedReview.candidate_generation_complete; const candidateBatchAction = liveCandidateBatch ? action === "approve" ? "accept" : action === "next_candidate" ? "pause" : "" : ""; if (liveCandidateBatch && !candidateBatchAction) return; const submittedPrompt = reviewPromptEditorEnabled() && promptEditedInGate ? prompt.value : (planScenePrompt(node, submittedReview) ?? submittedReview.scene_prompt ?? prompt.value); const normalizedSeed = action === "retry" ? reviewSeed(seed.value) : seed.value; const normalizedDuration = action === "retry" || action === "reroll" ? reviewDuration(duration.value) : null; stopCountdown(); root.classList.add("h3r-busy"); setActionsEnabled(false); status.className = "h3r-status"; status.textContent = candidateBatchAction === "accept" ? "Activating this checkpoint and cancelling the speculative take…" : candidateBatchAction === "pause" ? "Queuing a pause after the in-flight candidate…" : action === "approve" ? "Sending approval…" : action === "next_candidate" ? "Resuming to generate the next candidate…" : action === "stop" ? "Sending stop decision…" : "Sending retry decision…"; const response = await api.fetchApi(candidateBatchAction ? "/minimax_h3_context_loop/review-candidate-batch" : "/minimax_h3_context_loop/review", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ token: submittedToken, action: candidateBatchAction || action, scene_prompt: submittedPrompt, seed: normalizedSeed, length: normalizedDuration?.length, candidate_revision: (candidateBatchAction === "accept" || action === "approve" || action === "stop") && Number(submittedReview.candidate_count) > 1 ? submittedCandidate?.revision ?? "" : "", candidate_revisions: [...keptCandidateRevisions], }), }); const body = await response.json(); if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); if (candidateBatchAction) { if (current?.token === submittedToken) { current.candidate_batch_command_pending = candidateBatchAction; } if (candidateBatchAction === "accept") { pinAcceptedPreview(submittedReview, submittedCandidate); const result = await activateAcceptedCandidate( node, submittedReview, body); showAcceptedPreview(); if (result.immediate) { status.textContent = `Candidate ${body.candidate_number}/${body.candidate_count} ` + `is active. The unwanted take was cancelled; scene ` + `${result.nextIndex} was queued immediately.` + (result.saved ? " The Plan seed was updated." : "") + (result.cleanupWarning || ""); } else { status.textContent = `Candidate ${body.candidate_number}/${body.candidate_count} ` + `selected. ${result.message}`; } } else { status.textContent = "Pause queued. The current in-flight take " + "will finish, then the carousel will wait for you."; } } else if (action === "next_candidate") { status.textContent = `Candidate ${submittedReview.candidates.length}/` + `${submittedReview.candidate_count} reviewed — generating the next take ` + `with seed ${body.seed}. ${body.kept_candidate_count} marked take` + `${body.kept_candidate_count === 1 ? " is" : "s are"} retained.`; } else if (action === "approve") { const selected = Number(body.candidate_count) > 1; const saved = selected && updatePlan( node, submittedIndex, body.scene_prompt, body.seed, body.length); status.textContent = submittedReview.unload_models_while_waiting ? "Approval received — workflow is resuming and reloading the model stack." : "Approval received — workflow resumed."; if (selected) { status.textContent += ` Candidate ${body.candidate_number}/${body.candidate_count} is now active.` + ` ${body.kept_candidate_count} take${body.kept_candidate_count === 1 ? "" : "s"} kept.` + (saved ? " The Plan seed was updated." : ""); } } else if (action === "retry" || action === "reroll") { const acceptedPrompt = typeof body.scene_prompt === "string" ? body.scene_prompt : submittedPrompt.trim(); const acceptedDuration = reviewDurationText(body.length); const saved = updatePlan( node, submittedIndex, acceptedPrompt, body.seed, body.length); if (current?.token === submittedToken) { prompt.value = acceptedPrompt; promptEditedInGate = false; seed.value = body.seed; duration.value = acceptedDuration; } status.textContent = `Retrying scene with seed ${body.seed} at ${body.length} frames (${acceptedDuration}s).` + (saved ? " The Plan editor was updated." : ""); } else if (action === "stop") { const selected = Number(body.candidate_count) > 1; const saved = selected && updatePlan( node, submittedIndex, body.scene_prompt, body.seed, body.length); const prepared = submittedReview.clip_index < submittedReview.clip_count && prepareResume(node, submittedReview.clip_index + 1); status.textContent = (submittedReview.assemble_partial_on_stop ? "Stop accepted — assembling the partial video…" : "Stopped at the accepted checkpoint.") + (selected ? ` Candidate ${body.candidate_number}/${body.candidate_count} is now active.` : "") + (selected ? ` ${body.kept_candidate_count} take${body.kept_candidate_count === 1 ? "" : "s"} kept.` : "") + (saved ? " The Plan seed was updated." : "") + (prepared ? ` Loop Start is ready at clip ${submittedReview.clip_index + 1}.` : ""); setTimeout(refreshResumeOptions, 0); } } catch (error) { root.classList.remove("h3r-busy"); setActionsEnabled(Boolean(current?.token)); status.className = "h3r-status h3r-warning"; status.textContent = error.message; if (reviewCountdown(current?.local_deadline)?.seconds > 0) { countdownTimer = setInterval(renderWaitingStatus, 250); } } } node._h3ReviewHandler = (data) => { const acceptedDisposition = acceptedPreviewDisposition( acceptedPreviewPin, data); if (acceptedDisposition === "ignore") return; if (acceptedDisposition === "release") acceptedPreviewPin = null; const sameToken = Boolean(current?.token) && current.token === data?.token; const carriesCandidateBatch = !sameToken && String(current?.run_name ?? "") === String(data?.run_name ?? "") && Number(current?.clip_index) === Number(data?.clip_index) && Number(current?.candidate_count) > 1; const carriedActiveCandidateRevision = carriesCandidateBatch ? activeCandidateRevision : ""; const carriedKeepRevisions = carriesCandidateBatch ? [...keptCandidateRevisions] : []; const previousRevision = Number(current?.preview_revision ?? 0); const incomingRevision = Number(data?.preview_revision ?? 0); if (sameToken && incomingRevision <= previousRevision) return; const localDeadline = sameToken ? current.local_deadline : reviewLocalDeadline(data?.deadline, data?.server_now); current = sameToken ? { ...current, ...data, local_deadline: current.local_deadline, } : { ...data, local_deadline: localDeadline, }; if (!sameToken) { root.classList.remove("h3r-busy"); setActionsEnabled(true); activeCandidateRevision = (data.candidates ?? []).some( (candidate) => candidate.revision === carriedActiveCandidateRevision, ) ? carriedActiveCandidateRevision : ""; keptCandidateRevisions = new Set( [ ...(Array.isArray(data.kept_candidate_revisions) ? data.kept_candidate_revisions : []), ...carriedKeepRevisions, ].filter((revision) => (data.candidates ?? []).some( (candidate) => candidate.revision === revision)), ); // The scene is persisted before Review Gate receives its token. // Refresh here so the current (including final) checkpoint appears // in history without requiring the user to press Refresh. setTimeout(refreshResumeOptions, 0); } if (!sameToken) { prompt.value = data.scene_prompt ?? ""; promptEditedInGate = false; seed.value = data.seed ?? ""; duration.value = reviewDurationText(data.raw_frames); prefix.textContent = data.prompt_prefix ? `Shared prompt (unchanged)\n${data.prompt_prefix}` : ""; prefix.hidden = !data.prompt_prefix; startCountdown(); if (data.play_notification_sound && !notifiedTokens.has(data.token)) { notifiedTokens.add(data.token); playReviewChime().catch((error) => { console.warn("[H3 Chain Review] Browser blocked notification sound:", error); }); } } else if (!root.classList.contains("h3r-busy")) { renderWaitingStatus(); } const candidate = renderCandidateCarousel(); if (candidate) { showCandidate(candidate); } else { badge.textContent = `clip ${data.clip_index}/${data.clip_count} · ${data.shot_id}`; setReviewVideo(data.video, sameToken); } if (acceptedDisposition === "hold") showAcceptedPreview(); const planNode = upstreamPlanNode(node); if (planNode) { publishPlanCompanionScene(node, planNode, Number(data.clip_index) - 1); } }; node._h3PromptCompanionSetScenePrompt = (planNode, index, text) => { if (root.classList.contains("h3r-busy") || planNode !== upstreamPlanNode(node) || Number(index) !== Number(current?.clip_index) - 1) { return false; } prompt.value = String(text ?? "").replace(/\r\n?/g, "\n"); promptEditedInGate = false; return true; }; node._h3ReviewResolvedHandler = (data) => { if (!current || data?.token !== current.token) return; stopCountdown(); root.classList.add("h3r-busy"); setActionsEnabled(false); status.className = "h3r-status"; status.textContent = data.status || "Review resolved; continuing…"; if (data.action === "candidate_batch_approve") { const saved = updatePlan( node, Number(data.clip_index), data.scene_prompt, data.seed, data.raw_frames); if (saved) status.textContent += " The Plan seed was updated."; } const completedVideo = data.final_video ?? data.partial_video; if (completedVideo) { setReviewVideo(completedVideo); badge.textContent = data.final_video ? "final assembled video" : "partial joined video"; } if (data.action === "approve" || data.action === "stop" || data.action === "candidate_batch_approve") { setTimeout(refreshResumeOptions, 0); } }; const widget = node.addDOMWidget("h3_chain_review", "h3-chain-review", root, { serialize: false, hideOnZoom: false, getMinHeight: () => 500, }); widget.serialize = false; mountedReviewNodes.add(node); updatePendingPolling(); const removed = node.onRemoved; node.onRemoved = function () { stopCountdown(); promptResizeObserver?.disconnect(); delete this._h3PromptCompanionSetScenePrompt; mountedReviewNodes.delete(this); updatePendingPolling(); return removed?.apply(this, arguments); }; node.setSize?.([Math.max(node.size?.[0] ?? 540, 540), Math.max(node.size?.[1] ?? 650, 650)]); const queuedReview = node._h3QueuedReview; delete node._h3QueuedReview; if (queuedReview) node._h3ReviewHandler(queuedReview); setTimeout(fetchPending, 0); setTimeout(refreshResumeOptions, 0); } api.addEventListener("minimax_h3_context_loop_review", (event) => routeReview(event.detail)); api.addEventListener("minimax_h3_context_loop_review_resolved", (event) => routeReviewResolved(event.detail)); api.addEventListener("executed", (event) => trackActiveSceneExecution(event.detail)); api.addEventListener("execution_interrupted", (event) => finishTrackedReviewExecution("interrupted", event.detail)); api.addEventListener("execution_success", (event) => finishTrackedReviewExecution("success", event.detail)); api.addEventListener("execution_error", (event) => finishTrackedReviewExecution("error", event.detail)); // A status event is sent when ComfyUI's websocket connects or reconnects. api.addEventListener("status", fetchPending); window.addEventListener("focus", fetchPending); document.addEventListener("visibilitychange", () => { if (document.visibilityState !== "hidden") fetchPending(); }); app.registerExtension({ name: "minimax_h3_context_loop.chain_review", init() { app.ui?.settings?.addSetting?.({ id: PROMPT_EDITOR_SETTING, category: ["MiniMax H3 Context Loop", "Interface", "Review Gate"], name: "Enable prompt editing inside Review Gate", tooltip: "Disabled by default in 0.5. Keep prompt authoring in Scene Prompt Editor or Rich Scene Prompt Editor. Enable this only to restore the legacy Review Gate textarea.", type: "boolean", defaultValue: false, onChange() { for (const node of mountedReviewNodes) { node._h3ReviewRefreshPromptSetting?.(); } }, }); }, async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_NAME) return; const created = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = created?.apply(this, arguments); setTimeout(() => mount(this), 0); return result; }; const configured = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = configured?.apply(this, arguments); setTimeout(() => this._h3ReviewApplyLayout?.(), 0); return result; }; const graphConfigured = nodeType.prototype.onGraphConfigured; nodeType.prototype.onGraphConfigured = function () { const result = graphConfigured?.apply(this, arguments); setTimeout(() => this._h3ReviewApplyLayout?.(), 0); return result; }; }, async nodeCreated(node) { // Official per-instance hook. Keep the prototype hook above for older // frontends; mount() is idempotent, so supporting both closes the // timing gap without creating two widgets. if (nodeType(node) === NODE_NAME) mount(node); }, async afterConfigureGraph() { await fetchPending(); for (const node of allNodes(app.graph)) { if (nodeType(node) === NODE_NAME) node._h3RefreshResume?.(); if (nodeType(node) === NODE_NAME) node._h3ReviewApplyLayout?.(); } }, });