Fast_deploy / ComfyUI-MiniMaxH3-Context-Loop /web /h3_chain_review_final.js
dzmdd's picture
Upload folder using huggingface_hub (part 3)
8fb6433 verified
Raw History Blame Contribute Delete
91 kB
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?.();
}
},
});