import {app} from "/scripts/app.js";
import {api} from "/scripts/api.js";
import {
MAX_SHOTS,
makeShot,
parsePlanJson,
planToJson,
promptTextToLines,
promptValueToText,
sharedPrompt,
} from "./h3_chain_plan_core.mjs?v=0.6.5";
import {
PROMPT_ASSIST_DEFAULT_INSTRUCTIONS,
PROMPT_ASSIST_MODES,
buildPromptAssistantContext,
draftConflict,
makePromptAssistRequest,
promptSceneKey,
promptSourceRevision,
} from "./h3_prompt_assistant_core.mjs?v=0.6.5";
import {PromptAssistantClient} from "./h3_prompt_assistant_client.mjs?v=0.6.2";
import {
promptRevisionHelp,
promptRevisionLabel,
promptRevisionNavigation,
promptRevisionTree,
} from "./h3_prompt_history_core.mjs?v=0.6.2";
import {
availableReferenceRecords,
convertTaggedPictureReference,
referenceReplacementToken,
replacePromptReferenceOccurrence,
taggedPictureReferenceMode,
taggedPictureReferenceToken,
} from "./h3_reference_preview_core.mjs?v=0.6.2";
import {
PromptUndoHistory,
promptUndoDirection,
tokenizeRichPrompt,
} from "./h3_rich_prompt_editor_core.mjs?v=0.6.5";
import {createPromptCompletionController} from "./h3_prompt_completion_core.mjs?v=0.6.5";
import {bindPromptMarkerInteractions} from "./h3_prompt_marker_ui.mjs?v=0.6.5";
import {isWorkflowSaveShortcut, promptEditorRichText} from "./h3_prompt_editor_settings_core.mjs?v=0.6.5";
import {promptEditorPreferences} from "./h3_prompt_editor_settings.js";
import {createH3PromptSchemaController} from "./h3_prompt_schema_ui.mjs?v=0.6.5";
import * as promptCompanionSync from "./h3_prompt_companion_sync.mjs?v=0.6.2";
import {
PROJECT_ASSET_CATALOG_CHANGED_EVENT,
} from "./h3_project_asset_sync_core.mjs?v=0.6.2";
const {
publishCompanionScene,
rebaseScenePrompt,
} = promptCompanionSync;
const activeSceneIndexAfterRefresh =
typeof promptCompanionSync.activeSceneIndexAfterRefresh === "function"
? promptCompanionSync.activeSceneIndexAfterRefresh
: (_previousPlan, nextPlan, sceneIndex) => Math.min(
Math.max(0, Math.trunc(Number(sceneIndex) || 0)),
Math.max(0, (nextPlan?.shots?.length ?? 1) - 1),
);
const planHasNonPromptChanges =
typeof promptCompanionSync.planHasNonPromptChanges === "function"
? promptCompanionSync.planHasNonPromptChanges
: () => true;
function publishCompanionPrompt(...args) {
return promptCompanionSync.publishCompanionPrompt?.(...args) ?? 0;
}
// The compact reference and dialogue authoring interactions are inspired by
// nkxx188/ComfyUI-MiniMaxH3-Easy (MIT); see THIRD_PARTY_NOTICES.md.
const NODE_NAME = "MiniMaxH3ChainScenePromptEditor";
const PLAN_NAME = "MiniMaxH3ChainPlan";
const PLAN_NAMES = new Set([PLAN_NAME, "MiniMaxH3ChainPlanModern"]);
const ACTIVE_SCENE_PROPERTY = "h3_scene_prompt_editor_active_scene";
const FONT_SIZE_PROPERTY = "h3_scene_prompt_editor_font_size";
const PRESENTATION_PROPERTY = "h3_scene_prompt_editor_rich_text";
const ASSIST_PROVIDER_PROPERTY = "h3_scene_prompt_editor_assist_provider";
const ASSIST_MODE_PROPERTY = "h3_scene_prompt_editor_assist_mode";
// Keep the complete prompt-assistant implementation available for a future
// revisit, but ship the original focused editor experience for now. Re-enabling
// it is intentionally a one-line change.
const PROMPT_ASSISTANT_ENABLED = false;
const DEFAULT_FONT_SIZE = 18;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 36;
const PROMPT_SYNC_DELAY_MS = 140;
const PROMPT_ANALYSIS_DELAY_MS = 90;
const ICONS = Object.freeze({
picture: '',
video: '',
audio: '',
subject: '',
dialogue: '',
reference: '',
section: '',
flow: '',
speaker: '',
});
function injectStyles() {
if (document.getElementById("h3-scene-prompt-editor-style")) return;
const style = document.createElement("style");
style.id = "h3-scene-prompt-editor-style";
style.textContent = `
.h3sp-root {
--h3sp-bg: color-mix(in srgb, var(--comfy-menu-bg, #202124) 92%, #101827);
--h3sp-panel: color-mix(in srgb, var(--comfy-input-bg, #111827) 84%, #263552);
--h3sp-border: color-mix(in srgb, var(--border-color, #555) 68%, #7891bf);
--h3sp-text: var(--input-text, #eef1f7);
--h3sp-muted: color-mix(in srgb, var(--h3sp-text) 58%, transparent);
/* Blend semantic foregrounds with the active ComfyUI text color.
This keeps the pastel identity in dark themes and makes the
same controls dark enough to read in light themes. */
--h3sp-accent: color-mix(in srgb, var(--h3sp-text) 38%, #4f83ff);
--h3sp-token-picture: color-mix(in srgb, var(--h3sp-text) 42%, #139be8);
--h3sp-token-video: color-mix(in srgb, var(--h3sp-text) 42%, #9355d6);
--h3sp-token-audio: color-mix(in srgb, var(--h3sp-text) 42%, #d47700);
--h3sp-token-subject: color-mix(in srgb, var(--h3sp-text) 42%, #26934a);
--h3sp-token-dialogue: color-mix(in srgb, var(--h3sp-text) 42%, #cf3976);
--h3sp-token-section: color-mix(in srgb, var(--h3sp-text) 38%, #5f87ff);
--h3sp-token-flow: color-mix(in srgb, var(--h3sp-text) 42%, #00a99d);
--h3sp-token-speaker: color-mix(in srgb, var(--h3sp-text) 42%, #d268b7);
--h3sp-token-danger: color-mix(in srgb, var(--h3sp-text) 42%, #d44747);
--h3sp-font-size: 18px;
box-sizing:border-box; width:100%; height:100%; min-height:420px;
display:flex; flex-direction:column; gap:8px; overflow:hidden; padding:10px;
border:1px solid var(--h3sp-border); border-radius:8px; background:var(--h3sp-bg);
color:var(--h3sp-text); font:12px/1.35 system-ui,sans-serif;
}
.h3sp-root *, .h3sp-root *::before, .h3sp-root *::after { box-sizing:border-box; }
.h3sp-head, .h3sp-nav, .h3sp-tools, .h3sp-font, .h3sp-footer {
display:flex; align-items:center; gap:6px;
}
.h3sp-head { justify-content:space-between; }
.h3sp-title { color:var(--h3sp-accent); font-size:15px; font-weight:750; }
.h3sp-context { color:var(--h3sp-muted); white-space:nowrap; overflow:hidden;
text-overflow:ellipsis; text-align:right; }
.h3sp-nav select { flex:1; min-width:0; }
.h3sp-root button, .h3sp-root select {
color:var(--h3sp-text); font:inherit; border:1px solid var(--h3sp-border);
border-radius:5px; background:var(--comfy-input-bg,#171a21);
}
.h3sp-root button { padding:6px 9px; cursor:pointer; white-space:nowrap; }
.h3sp-root button:hover { border-color:var(--h3sp-accent); }
.h3sp-root button:disabled { cursor:not-allowed; opacity:.4; }
.h3sp-root select { min-height:30px; padding:5px 7px; }
.h3sp-font { margin-left:auto; }
.h3sp-font-value { min-width:38px; color:var(--h3sp-muted); text-align:center; }
.h3sp-textarea {
width:100%; min-height:240px; flex:1 1 auto; resize:none; padding:12px 14px;
border:1px solid var(--h3sp-border); border-radius:7px;
outline:none; background:var(--comfy-input-bg,#11141a); color:var(--h3sp-text);
font:var(--h3sp-font-size)/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;
tab-size:4; white-space:pre-wrap;
}
.h3sp-textarea:focus { border-color:var(--h3sp-accent);
box-shadow:0 0 0 1px color-mix(in srgb,var(--h3sp-accent) 45%,transparent); }
.h3sp-hidden { display:none !important; }
.h3sp-editor-shell { position:relative; width:100%; min-height:240px;
flex:1 1 auto; overflow:hidden; border:1px solid var(--h3sp-border);
border-radius:7px; background:var(--comfy-input-bg,#11141a); }
.h3sp-editor-shell:focus-within { border-color:var(--h3sp-accent);
box-shadow:0 0 0 1px color-mix(in srgb,var(--h3sp-accent) 45%,transparent); }
.h3sp-rich-editor { width:100%; height:100%; min-height:240px; overflow:auto;
padding:12px 14px; outline:none; white-space:pre-wrap; overflow-wrap:anywhere;
caret-color:var(--h3sp-text);
font:var(--h3sp-font-size)/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }
.h3sp-rich-editor:empty::before { content:attr(data-placeholder);
color:var(--h3sp-muted); pointer-events:none; }
.h3sp-token { display:inline-flex; align-items:center; gap:3px; max-width:320px;
margin:0 1px; padding:1px 4px 1px 2px; border:1px solid currentColor;
border-radius:5px; vertical-align:1px; line-height:1.25; cursor:pointer;
user-select:all; background:color-mix(in srgb,currentColor 14%,transparent); }
.h3sp-token-picture { color:var(--h3sp-token-picture); }
.h3sp-token-video { color:var(--h3sp-token-video); }
.h3sp-token-audio { color:var(--h3sp-token-audio); }
.h3sp-token-subject { color:var(--h3sp-token-subject); }
.h3sp-token-dialogue { color:var(--h3sp-token-dialogue); }
.h3sp-token-section { display:inline; margin:0; padding:0; border:0;
border-radius:0; color:var(--h3sp-token-section); background:none;
font-weight:650; cursor:text; user-select:text; }
.h3sp-token-flow { color:var(--h3sp-token-flow); }
.h3sp-token-speaker { color:var(--h3sp-token-speaker); }
.h3sp-token-unknown, .h3sp-token-inactive { color:var(--h3sp-token-danger);
border-style:dashed; }
.h3sp-token-icon { width:16px; height:16px; display:inline-flex;
flex:0 0 16px; color:currentColor; }
.h3sp-token-icon svg { width:100%; height:100%; fill:none; stroke:currentColor;
stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
.h3sp-token-thumb { width:18px; height:18px; flex:0 0 18px;
object-fit:cover; border-radius:3px; background:rgba(255,255,255,.09); }
.h3sp-token-label { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.h3sp-presentation-active { border-color:var(--h3sp-accent) !important;
color:var(--h3sp-accent) !important; }
.h3sp-popover { position:fixed; z-index:100000; width:min(360px,calc(100vw - 24px));
padding:9px; border:1px solid #60718c; border-radius:9px; background:#171a20;
color:#eef2f8; box-shadow:0 14px 38px rgba(0,0,0,.48);
font:12px/1.4 system-ui,sans-serif; }
.h3sp-popover[hidden] { display:none; }
.h3sp-popover-title { margin-bottom:6px; font-weight:750; }
.h3sp-popover-media { display:block; width:100%; max-height:240px;
object-fit:contain; border-radius:6px; background:#08090c; }
.h3sp-popover audio.h3sp-popover-media { height:42px; background:transparent; }
.h3sp-popover-detail { margin-top:6px; color:rgba(238,242,248,.62);
white-space:pre-wrap; overflow-wrap:anywhere; }
.h3sp-popover-editor { display:grid; gap:8px; }
.h3sp-popover-field { display:grid; grid-template-columns:82px minmax(0,1fr);
align-items:center; gap:7px; }
.h3sp-popover-field > span { color:rgba(238,242,248,.68); }
.h3sp-popover-field select,.h3sp-popover-field input { width:100%; min-width:0;
padding:5px 7px; border:1px solid #60718c; border-radius:5px;
background:#0e1117; color:#eef2f8; }
.h3sp-popover-actions { display:flex; justify-content:flex-end; gap:6px; }
.h3sp-popover-actions button { padding:4px 9px; }
.h3sp-root.h3sp-assistant-enabled { overflow:auto; }
.h3sp-root.h3sp-assistant-enabled .h3sp-textarea {
min-height:220px; resize:vertical;
}
.h3sp-tools { position:relative; flex-wrap:wrap; }
.h3sp-hint { color:var(--h3sp-muted); margin-left:auto; }
.h3sp-refs { display:none; flex:0 0 auto; max-height:340px; overflow:auto;
padding:7px; gap:5px; flex-wrap:wrap; border:1px solid var(--h3sp-border);
border-radius:6px; background:var(--h3sp-panel); }
.h3sp-refs.h3sp-open { display:flex; }
.h3sp-refs button { padding:4px 7px; }
.h3sp-ref-help { flex:1 0 100%; color:var(--h3sp-muted); }
.h3sp-ref-chip.h3sp-inactive { opacity:.48; }
.h3sp-ref-chip.h3sp-active { border-color:#658b77; }
.h3sp-ref-entry { display:flex; align-items:stretch; gap:2px; }
.h3sp-ref-mode { display:flex; align-items:stretch; gap:1px; }
.h3sp-ref-mode button { min-width:28px; padding:2px 5px; font-size:10px; }
.h3sp-ref-mode button.h3sp-selected { border-color:var(--h3sp-accent);
color:var(--h3sp-accent); }
.h3sp-ref-preview { display:none; flex:1 0 100%; min-height:58px;
padding:8px; border:1px solid color-mix(in srgb,var(--h3sp-border) 74%,transparent);
border-radius:6px; background:var(--comfy-input-bg,#11141a); }
.h3sp-ref-preview.h3sp-visible { display:grid; grid-template-columns:minmax(120px,220px) 1fr;
align-items:start; gap:9px; }
.h3sp-ref-preview-media { width:100%; max-height:190px; display:block;
border-radius:5px; object-fit:contain; background:#08090c; }
audio.h3sp-ref-preview-media { min-width:190px; height:38px; background:transparent; }
.h3sp-ref-preview-copy { min-width:0; color:var(--h3sp-muted);
white-space:pre-wrap; overflow-wrap:anywhere; }
.h3sp-ref-preview-title { color:var(--h3sp-text); font-weight:700; }
.h3sp-footer { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);
align-items:center; gap:8px; color:var(--h3sp-muted); }
.h3sp-footer-status { min-width:0; overflow:hidden; text-align:right;
text-overflow:ellipsis; white-space:nowrap; }
.h3sp-history { min-width:0; display:flex; align-items:center;
justify-content:center; gap:6px; color:var(--h3sp-muted); }
.h3sp-history-nav { display:flex; align-items:center; gap:2px;
border:1px solid color-mix(in srgb,var(--h3sp-border) 72%,transparent);
border-radius:999px; padding:1px 3px; background:var(--comfy-input-bg,#11141a); }
.h3sp-history-nav button { min-width:25px; padding:2px 5px; border:0;
border-radius:999px; background:transparent; }
.h3sp-history-count { min-width:42px; text-align:center; color:var(--h3sp-text);
font-variant-numeric:tabular-nums; }
.h3sp-history-meta { min-width:0; max-width:280px; overflow:hidden;
text-overflow:ellipsis; white-space:nowrap; font-size:11px; }
.h3sp-history-error { color:#ffb3b3; }
.h3sp-history-tree { position:relative; flex:0 0 auto; }
.h3sp-history-tree > summary { list-style:none; cursor:pointer; padding:3px 7px;
border:1px solid color-mix(in srgb,var(--h3sp-border) 72%,transparent);
border-radius:999px; background:var(--comfy-input-bg,#11141a);
color:var(--h3sp-text); user-select:none; }
.h3sp-history-tree > summary::-webkit-details-marker { display:none; }
.h3sp-history-tree[open] > summary { border-color:var(--h3sp-accent); }
.h3sp-history-tree-panel { position:absolute; z-index:20; bottom:calc(100% + 7px);
left:50%; transform:translateX(-50%); width:min(440px,calc(100vw - 30px));
max-height:310px; overflow:auto; padding:7px; border:1px solid var(--h3sp-border);
border-radius:8px; background:var(--h3sp-bg); box-shadow:0 14px 36px rgba(0,0,0,.48); }
.h3sp-history-tree-row { display:flex; align-items:center; gap:4px; min-width:0;
margin:2px 0; border-radius:5px; }
.h3sp-history-tree-row.h3sp-history-active { background:rgba(132,170,255,.12); }
.h3sp-history-tree-row.h3sp-history-archived { opacity:.58; }
.h3sp-history-tree-select { flex:1 1 auto; min-width:0; justify-content:flex-start !important;
min-height:26px !important; padding:3px 6px !important; border-color:transparent !important;
background:transparent !important; text-align:left; }
.h3sp-history-branch { flex:0 0 auto; color:var(--h3sp-muted); }
.h3sp-history-tree-label { min-width:0; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; color:var(--h3sp-text); }
.h3sp-history-tree-badge { flex:0 0 auto; color:var(--h3sp-muted); font-size:10px; }
.h3sp-history-tree-action { min-width:25px !important; min-height:25px !important;
padding:2px 5px !important; }
.h3sp-history-tree-tools { display:flex; justify-content:space-between; align-items:center;
gap:8px; margin-top:6px; padding-top:6px; border-top:1px solid var(--h3sp-border);
color:var(--h3sp-muted); font-size:10px; }
.h3sp-history-tree-tools button { min-height:24px; padding:2px 6px; }
.h3sp-error { padding:12px; border:1px solid #a76565; border-radius:6px;
color:#ffb3b3; background:#351f24; white-space:pre-wrap; }
.h3sp-assist { flex:0 0 auto; display:flex; flex-direction:column; gap:7px;
padding:9px; border:1px solid var(--h3sp-border); border-radius:7px;
background:var(--h3sp-panel); }
.h3sp-assist-head, .h3sp-assist-controls, .h3sp-assist-actions,
.h3sp-assist-contexts { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.h3sp-assist-head { justify-content:space-between; }
.h3sp-assist-title { color:var(--h3sp-accent); font-size:13px; font-weight:750; }
.h3sp-assist-status { color:var(--h3sp-muted); font-size:11px; }
.h3sp-assist-controls select { flex:1 1 120px; min-width:100px; }
.h3sp-assist-contexts label { display:flex; align-items:center; gap:4px;
color:var(--h3sp-muted); cursor:pointer; }
.h3sp-assist-contexts input { accent-color:var(--h3sp-accent); }
.h3sp-assist-chat { display:flex; flex-direction:column; gap:5px; max-height:180px;
overflow:auto; padding:6px; border:1px solid color-mix(in srgb,var(--h3sp-border) 70%,transparent);
border-radius:6px; background:color-mix(in srgb,var(--comfy-input-bg,#11141a) 82%,transparent); }
.h3sp-assist-message { padding:6px 8px; border-radius:6px; white-space:pre-wrap;
overflow-wrap:anywhere; }
.h3sp-assist-message-user { margin-left:12%; background:#23375b; }
.h3sp-assist-message-agent { margin-right:7%; background:#263c34; }
.h3sp-assist-message-system { color:var(--h3sp-muted); font-size:11px;
border:1px dashed var(--h3sp-border); }
.h3sp-assist-empty { color:var(--h3sp-muted); padding:5px; }
.h3sp-assist-compose { display:flex; align-items:stretch; gap:6px; }
.h3sp-assist-compose textarea, .h3sp-assist-draft textarea {
width:100%; resize:vertical; min-height:58px; padding:7px 8px;
border:1px solid var(--h3sp-border); border-radius:6px;
outline:none; background:var(--comfy-input-bg,#11141a); color:var(--h3sp-text);
font:12px/1.4 system-ui,sans-serif; }
.h3sp-assist-compose textarea:focus, .h3sp-assist-draft textarea:focus {
border-color:var(--h3sp-accent); }
.h3sp-assist-compose button { align-self:stretch; }
.h3sp-assist-draft { display:flex; flex-direction:column; gap:6px; padding:8px;
border:1px solid #5d8a72; border-radius:6px; background:#182b25; }
.h3sp-assist-draft-head { display:flex; justify-content:space-between;
align-items:center; gap:6px; }
.h3sp-assist-draft-title { font-weight:700; color:#9ad7b7; }
.h3sp-assist-stale { color:#ffc08a; font-size:11px; }
.h3sp-assist-original { color:var(--h3sp-muted); }
.h3sp-assist-original pre { max-height:120px; overflow:auto; white-space:pre-wrap;
padding:6px; border-radius:5px; background:var(--comfy-input-bg,#11141a); }
.h3sp-assist-error { color:#ffb3b3; white-space:pre-wrap; }
`;
document.head.appendChild(style);
}
function element(tag, className = "", text) {
const item = document.createElement(tag);
if (className) item.className = className;
if (text !== undefined) item.textContent = text;
return item;
}
function button(label, title, action) {
const item = element("button", "", label);
item.type = "button";
item.title = title;
// Preserve a contenteditable selection while clicking toolbar/reference
// controls. Keyboard focus via Tab remains available.
item.addEventListener("pointerdown", (event) => event.preventDefault());
item.addEventListener("click", action);
return item;
}
function icon(kind) {
const item = element("span", "h3sp-token-icon");
item.innerHTML = ICONS[kind] ?? ICONS.reference;
return item;
}
function nodeType(node) {
return node?.comfyClass ?? node?.type ?? null;
}
function allNodes(graph, output = []) {
for (const node of graph?._nodes ?? []) {
output.push(node);
if (node.subgraph) allNodes(node.subgraph, output);
}
return output;
}
function upstreamPlanNode(start) {
const queue = [start];
const seen = new Set();
while (queue.length) {
const node = queue.shift();
if (!node || seen.has(node)) continue;
seen.add(node);
if (node !== start && PLAN_NAMES.has(nodeType(node))) 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 inputSource(node, name) {
const input = node?.inputs?.find((item) => item.name === name);
const link = input?.link == null ? null : node.graph?.links?.[input.link];
return link ? node.graph?.getNodeById?.(link.origin_id) ?? null : null;
}
function mediaExtension(kind) {
if (kind === "image") return /\.(?:avif|bmp|gif|jpe?g|png|webp)$/i;
if (kind === "video") return /\.(?:m4v|mkv|mov|mp4|webm)$/i;
return /\.(?:aac|flac|m4a|mp3|ogg|opus|wav)$/i;
}
function widgetAsset(value, kind) {
if (value && typeof value === "object" && value.filename) {
return {
filename: String(value.filename),
subfolder: String(value.subfolder ?? ""),
type: String(value.type ?? "input"),
};
}
let text = typeof value === "string" ? value.trim() : "";
if (!text) return null;
if (/^(?:blob:|data:|https?:|\/api\/view\?|\/view\?)/i.test(text)) {
return {url: text};
}
let type = "input";
const annotated = text.match(/\s+\[(input|output|temp)\]\s*$/i);
if (annotated) {
type = annotated[1].toLowerCase();
text = text.slice(0, annotated.index).trim();
}
text = text.replaceAll("\\", "/").replace(/^\/+/, "");
if (!mediaExtension(kind).test(text)) return null;
const slash = text.lastIndexOf("/");
return {
filename: slash >= 0 ? text.slice(slash + 1) : text,
subfolder: slash >= 0 ? text.slice(0, slash) : "",
type,
};
}
function assetUrl(asset) {
if (!asset) return null;
if (asset.url) return asset.url;
const query = new URLSearchParams({
filename: asset.filename,
subfolder: asset.subfolder ?? "",
type: asset.type ?? "input",
});
return api.apiURL(`/view?${query.toString()}`);
}
function previewFromNode(node, kind) {
if (kind === "image") {
const rendered = node?.imgs?.[0];
const src = typeof rendered === "string" ? rendered : rendered?.src;
if (src) return src;
}
for (const widget of node?.widgets ?? []) {
const asset = widgetAsset(widget.value, kind);
if (asset) return assetUrl(asset);
}
return null;
}
function findMediaPreview(start, kind) {
const queue = [start];
const seen = new Set();
while (queue.length) {
const node = queue.shift();
if (!node || seen.has(node)) continue;
seen.add(node);
const url = previewFromNode(node, kind);
if (url) return {url, source: node};
for (const input of node.inputs ?? []) {
const parent = inputSource(node, input.name);
if (parent) queue.push(parent);
}
}
return {url: null, source: start};
}
function referenceMediaPreview(record, kind) {
if (record?.previewUrl) {
return {
url: api.apiURL(record.previewUrl),
source: record.source ?? record.node ?? null,
};
}
return findMediaPreview(record?.source, kind);
}
function insertText(textarea, text, selectionOffset = text.length) {
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
textarea.setRangeText(text, start, end, "end");
const caret = start + selectionOffset;
textarea.setSelectionRange(caret, caret);
textarea.dispatchEvent(new Event("input", {bubbles: true}));
textarea.focus();
}
function insertDialogue(textarea) {
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
const selected = textarea.value.slice(start, end);
const markup = `${selected}`;
insertText(textarea, markup, selected ? markup.length : 3);
}
function editorPlainText(editor, {trimFinalNewline = true} = {}) {
function read(current, root) {
if (current.nodeType === Node.TEXT_NODE) return current.textContent ?? "";
if (current.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
let text = "";
for (const child of current.childNodes) text += read(child, root);
return text;
}
if (current.nodeType !== Node.ELEMENT_NODE) return "";
if (current.classList?.contains("h3sp-token")) {
return current.dataset.token ?? current.textContent ?? "";
}
if (current.tagName === "BR") return "\n";
let text = "";
for (const child of current.childNodes) text += read(child, root);
if (["DIV", "P"].includes(current.tagName)
&& current !== root && !text.endsWith("\n")) text += "\n";
return text;
}
const text = read(editor, editor);
return trimFinalNewline ? text.replace(/\n$/, "") : text;
}
function selectedEditorPlainText(editor) {
const selection = globalThis.getSelection?.();
if (!selection?.rangeCount || selection.isCollapsed) return null;
const inside = (current) => current === editor || editor.contains(current);
if (!inside(selection.anchorNode) || !inside(selection.focusNode)) return null;
const fragment = selection.getRangeAt(0).cloneContents();
return editorPlainText(fragment);
}
function copyEditorSelection(editor, event, cut = false) {
const text = selectedEditorPlainText(editor);
if (text == null || !event.clipboardData) return false;
// A decorated tag contains icons/thumbnails as well as its visible label.
// Copy its canonical data-token value so another prompt editor receives
// exactly the original prompt markup rather than browser-generated HTML.
event.clipboardData.setData("text/plain", text);
event.preventDefault();
if (cut) {
const selection = globalThis.getSelection?.();
const range = selection.getRangeAt(0);
range.deleteContents();
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
editor.dispatchEvent(new Event("input", {bubbles: true}));
}
return true;
}
function editorPointTextOffset(editor, node, offset) {
if (!node || (node !== editor && !editor.contains(node))) return null;
const range = document.createRange();
range.selectNodeContents(editor);
try {
range.setEnd(node, offset);
} catch (_error) {
return null;
}
return editorPlainText(range.cloneContents()).length;
}
function editorSelectionOffsets(editor) {
const selection = globalThis.getSelection?.();
if (!selection?.rangeCount) return null;
const selected = selection.getRangeAt(0);
const start = editorPointTextOffset(
editor, selected.startContainer, selected.startOffset,
);
const end = editorPointTextOffset(
editor, selected.endContainer, selected.endOffset,
);
return start == null || end == null ? null : {start, end};
}
function selectionTextOffset(editor) {
const selection = globalThis.getSelection?.();
const offset = selection?.rangeCount
? editorPointTextOffset(editor, selection.focusNode, selection.focusOffset)
: null;
return offset == null ? editorPlainText(editor).length : offset;
}
function restoreCaret(editor, requested) {
const target = Math.max(0, Number(requested) || 0);
const range = document.createRange();
const selection = globalThis.getSelection?.();
let consumed = 0;
let placed = false;
function visit(current) {
if (placed) return;
if (current.nodeType === Node.TEXT_NODE) {
const length = current.textContent?.length ?? 0;
if (target <= consumed + length) {
range.setStart(current, Math.max(0, target - consumed));
placed = true;
return;
}
consumed += length;
return;
}
if (current.nodeType !== Node.ELEMENT_NODE) return;
if (current.classList?.contains("h3sp-token")) {
const length = String(current.dataset.token ?? "").length;
if (target <= consumed + length) {
if (target <= consumed) range.setStartBefore(current);
else range.setStartAfter(current);
placed = true;
return;
}
consumed += length;
return;
}
for (const child of current.childNodes) visit(child);
}
visit(editor);
if (!placed) {
range.selectNodeContents(editor);
range.collapse(false);
} else {
range.collapse(true);
}
selection?.removeAllRanges();
selection?.addRange(range);
}
function restoreTextSelection(editor, requestedStart, requestedEnd) {
const start = Math.max(0, Number(requestedStart) || 0);
const end = Math.max(start, Number(requestedEnd) || start);
let consumed = 0;
let startPoint = null;
let endPoint = null;
function visit(current) {
if (startPoint && endPoint) return;
if (current.nodeType === Node.TEXT_NODE) {
const length = current.textContent?.length ?? 0;
if (!startPoint && start >= consumed && start <= consumed + length) {
startPoint = [current, start - consumed];
}
if (!endPoint && end >= consumed && end <= consumed + length) {
endPoint = [current, end - consumed];
}
consumed += length;
return;
}
if (current.nodeType !== Node.ELEMENT_NODE) return;
if (current.classList?.contains("h3sp-token")) {
consumed += String(current.dataset.token ?? "").length;
return;
}
for (const child of current.childNodes) visit(child);
}
visit(editor);
if (!startPoint || !endPoint) {
restoreCaret(editor, end);
return;
}
const range = document.createRange();
range.setStart(...startPoint);
range.setEnd(...endPoint);
const selection = globalThis.getSelection?.();
selection?.removeAllRanges();
selection?.addRange(range);
}
function insertPlainText(editor, text) {
editor.focus();
const selection = globalThis.getSelection?.();
const inside = Boolean(selection?.rangeCount && editor.contains(selection.anchorNode));
const range = inside ? selection.getRangeAt(0) : document.createRange();
if (!inside) {
range.selectNodeContents(editor);
range.collapse(false);
}
range.deleteContents();
const textNode = document.createTextNode(String(text ?? ""));
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
editor.dispatchEvent(new Event("input", {bubbles: true}));
}
function clamp(value, minimum, maximum, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.round(numeric))) : fallback;
}
function promptAssistantIdentityKey(node) {
// Node ids are only unique inside one workflow. ComfyUI can keep several
// workflows open in the same browser/sessionStorage, so include the active
// workflow identity or two editors with (for example) node id 7 would share
// one agent route and pending-request record.
const workflow = app.extensionManager?.workflow?.activeWorkflow;
const workflowIdentity = workflow?.path
?? workflow?.activeState?.id
?? workflow?.filename
?? "legacy-workflow";
const nodeIdentity = node.id == null
? `new-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`
: String(node.id);
return `workflow-${workflowIdentity}-node-${nodeIdentity}`;
}
function mount(node) {
if (node._h3ScenePromptEditorMounted || typeof node.addDOMWidget !== "function") return;
node._h3ScenePromptEditorMounted = true;
injectStyles();
node.properties ??= {};
const root = element("div", "h3sp-root");
root.classList.toggle("h3sp-assistant-enabled", PROMPT_ASSISTANT_ENABLED);
root.title = "Edit the active scene prompt stored in the connected H3 Chain Plan.";
for (const eventName of [
"pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick",
]) {
root.addEventListener(eventName, (event) => event.stopPropagation());
}
// Keep ComfyUI/LiteGraph's canvas shortcuts from claiming Ctrl/Cmd+V (and
// the other native editing shortcuts) while focus is inside this DOM
// widget. stopPropagation deliberately preserves the browser's default
// textarea/contenteditable action; preventDefault here would break paste.
for (const eventName of ["keydown", "keyup", "keypress", "copy", "cut", "paste"]) {
root.addEventListener(eventName, (event) => {
// Saving the workflow must remain available while typing; all
// editing/undo shortcuts keep their existing scene-local routing.
if (isWorkflowSaveShortcut(event)) {
if (event.type === "keydown") flushPlanEffects();
return;
}
event.stopPropagation();
});
}
root.addEventListener("wheel", (event) => event.stopPropagation());
const state = {
plan: null,
planNode: null,
planWidget: null,
lastValue: "",
lastRunName: "",
active: Math.max(0, Number(node.properties[ACTIVE_SCENE_PROPERTY]) || 0),
fontSize: clamp(
node.properties[FONT_SIZE_PROPERTY], MIN_FONT_SIZE, MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
),
decorated: promptEditorRichText(node.properties, PRESENTATION_PROPERTY, promptEditorPreferences()),
records: [],
referenceMode: null,
referenceSyntax: new Map(),
completion: null,
schema: null,
promptTextarea: null,
richEditor: null,
promptSelection: null,
referenceTray: null,
popover: null,
popoverTimer: null,
popoverPinned: false,
assistant: {
client: null,
host: null,
status: "idle",
statusDetail: "Connects through comfyui-mcp on the first request",
provider: ["codex", "hermes"].includes(
node.properties[ASSIST_PROVIDER_PROPERTY],
) ? node.properties[ASSIST_PROVIDER_PROPERTY] : "codex",
mode: PROMPT_ASSIST_MODES.some(
(item) => item.id === node.properties[ASSIST_MODE_PROPERTY],
) ? node.properties[ASSIST_MODE_PROPERTY] : "rewrite",
includeShared: true,
includeAdjacent: true,
composer: "",
messagesByProvider: {codex: [], hermes: []},
drafts: new Map(),
requestContexts: new Map(),
activeRequest: null,
preparingRequest: null,
pendingStorageKey: "",
reconnectTimer: null,
lastApplied: null,
providers: null,
error: "",
},
history: {
sceneKey: "",
data: null,
revisionId: null,
host: null,
textarea: null,
status: null,
loadToken: 0,
loadPromise: null,
saveTimer: null,
pendingDraft: null,
savePromise: null,
error: "",
treeOpen: false,
showArchived: false,
},
undoByScene: new Map(),
promptUndo: null,
richInputType: "",
pollTimer: null,
planSyncTimer: null,
planSyncPending: null,
analysisTimer: null,
applyPromptHistoryShortcut: null,
ownsPromptHistoryTarget: null,
disposed: false,
};
node._h3ScenePromptEditorState = state;
// Intercept prompt undo before ComfyUI's workflow history can restore an
// older node snapshot (which commonly resets the editor to scene 1).
root.addEventListener("keydown", (event) => {
const direction = promptUndoDirection(event);
if (!direction || !state.ownsPromptHistoryTarget?.(event.target)) return;
event.preventDefault();
event.stopImmediatePropagation();
state.applyPromptHistoryShortcut?.(direction, event.target);
}, true);
const assistant = state.assistant;
if (PROMPT_ASSISTANT_ENABLED) {
assistant.client = new PromptAssistantClient({
identityKey: promptAssistantIdentityKey(node),
onFrame: (frame) => handleAssistantFrame(frame),
onStatus: (status, detail) => {
assistant.status = status;
if (status === "connected") {
clearAssistantReconnect();
assistant.statusDetail = "Connected · isolated prompt session";
} else if (status === "connecting") {
assistant.statusDetail = "Connecting to comfyui-mcp…";
} else if (assistant.activeRequest) {
assistant.statusDetail = "Disconnected · reconnecting to recover the active draft…";
scheduleAssistantReconnect();
} else {
assistant.statusDetail = "Disconnected · send to reconnect";
}
if (detail?.providers) assistant.providers = detail.providers;
refreshAssistant();
},
});
assistant.pendingStorageKey = `h3.prompt-assistant.pending.${assistant.client.identity}`;
}
function dirty() {
node.graph?.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
}
function persistView() {
node.properties[ACTIVE_SCENE_PROPERTY] = state.active;
node.properties[FONT_SIZE_PROPERTY] = state.fontSize;
node.properties[PRESENTATION_PROPERTY] = state.decorated;
dirty();
}
function rebaseActivePromptOntoLivePlan() {
if (!state.plan || !state.planWidget) return false;
let live;
try { live = parsePlanJson(String(state.planWidget.value ?? "")); }
catch (_error) { return false; }
const index = rebaseScenePrompt(state.plan, live, state.active);
if (index < 0) return false;
state.active = index;
node.properties[ACTIVE_SCENE_PROPERTY] = index;
return true;
}
function flushPlanEffects() {
if (state.planSyncTimer != null) {
window.clearTimeout(state.planSyncTimer);
state.planSyncTimer = null;
}
const pending = state.planSyncPending;
state.planSyncPending = null;
if (!pending || !state.planWidget || !state.planNode) return;
const liveValue = String(state.planWidget.value ?? "");
if (liveValue !== pending.value) {
if (!rebaseActivePromptOntoLivePlan()) {
if (pending.status) {
pending.status.textContent =
"Plan structure changed; waiting to resynchronize";
}
return;
}
pending.value = planToJson(state.plan);
pending.sceneIndex = state.active;
pending.prompt = promptValueToText(
state.plan.shots[state.active]?.prompt,
);
state.lastValue = pending.value;
state.planWidget.value = pending.value;
}
if (typeof state.planWidget.callback === "function") {
state.planWidget.callback(pending.value);
} else {
state.planNode._h3ChainEditorRefresh?.();
}
state.planNode.graph?.setDirtyCanvas?.(true, true);
publishCompanionPrompt(
node, state.planNode, pending.sceneIndex, pending.prompt,
);
if (pending.status) pending.status.textContent = pending.message;
dirty();
}
function schedulePlanEffects(pending) {
if (state.disposed) return;
state.planSyncPending = pending;
if (state.planSyncTimer != null) {
window.clearTimeout(state.planSyncTimer);
}
state.planSyncTimer = window.setTimeout(
flushPlanEffects, PROMPT_SYNC_DELAY_MS,
);
if (pending.status) pending.status.textContent = "Editing…";
}
function schedulePromptAnalysis() {
if (state.disposed) return;
if (state.analysisTimer != null) {
window.clearTimeout(state.analysisTimer);
}
state.analysisTimer = window.setTimeout(() => {
state.analysisTimer = null;
refreshAssistant();
state.schema?.refresh();
}, PROMPT_ANALYSIS_DELAY_MS);
}
function flushPromptAnalysis() {
if (state.analysisTimer == null) return;
window.clearTimeout(state.analysisTimer);
state.analysisTimer = null;
refreshAssistant();
state.schema?.refresh();
}
function commitPlan(
status, message = "Saved to connected Plan",
{deferEffects = false} = {},
) {
const value = planToJson(state.plan);
state.lastValue = value;
state.planWidget.value = value;
const pending = {
value,
sceneIndex:state.active,
prompt:promptValueToText(state.plan.shots[state.active]?.prompt),
status,
message,
};
if (deferEffects) schedulePlanEffects(pending);
else {
state.planSyncPending = pending;
flushPlanEffects();
}
}
function writePlan(status, options = {}) {
if (!state.plan || !state.planWidget || !state.planNode) return;
// This node owns only one scene prompt. Rebase when another UI changed
// the live Plan so concurrent Studio edits are preserved, while
// consecutive keystrokes avoid reparsing the full JSON.
const liveValue = String(state.planWidget.value ?? "");
if (liveValue !== state.lastValue && !rebaseActivePromptOntoLivePlan()) {
if (status) status.textContent = "Plan structure changed; waiting to resynchronize";
return;
}
commitPlan(status, "Saved to connected Plan", options);
}
function planRunName() {
return String(state.planNode?.widgets?.find(
(item) => item.name === "run_name",
)?.value ?? "").trim();
}
function historySceneKey(runName, shotId) {
return `${runName}\u0000${shotId}`;
}
function promptUndoForScene(shotId, text, {external = false} = {}) {
const key = historySceneKey(planRunName(), shotId);
let history = state.undoByScene.get(key);
if (!history) {
history = new PromptUndoHistory(text);
state.undoByScene.set(key, history);
} else if (external) {
// Plan Studio republishes the active prompt after non-prompt edits.
// Preserve undo when that synchronized text did not actually change.
history.align(text);
} else {
history.align(text);
}
return history;
}
async function historyRequest(query = {}, body = null) {
const suffix = new URLSearchParams(query).toString();
const response = await api.fetchApi(
`/minimax_h3_context_loop/prompt-history${suffix ? `?${suffix}` : ""}`,
body == null ? undefined : {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
},
);
let payload = {};
try {
payload = await response.json();
} catch (_error) {
// A proxy can replace a backend error with a non-JSON response.
}
if (!response.ok) {
throw new Error(payload.error || `Prompt history request failed (HTTP ${response.status}).`);
}
return payload;
}
function renderHistory() {
const history = state.history;
const host = history.host;
if (!host) return;
host.replaceChildren();
if (history.error) {
host.append(element("span", "h3sp-history-meta h3sp-history-error", history.error));
return;
}
if (!history.data) {
host.append(element("span", "h3sp-history-meta", "Loading prompt versions…"));
return;
}
const navigation = promptRevisionNavigation(history.data, history.revisionId);
const controls = element("span", "h3sp-history-nav");
const previous = button("‹", "Activate previous prompt version in the Plan", () => {
if (navigation.previous) void selectHistoryRevision(navigation.previous.id);
});
const count = element(
"span", "h3sp-history-count",
`Active ${navigation.position} / ${navigation.total}`,
);
const next = button("›", "Activate next prompt version in the Plan", () => {
if (navigation.next) void selectHistoryRevision(navigation.next.id);
});
previous.disabled = !navigation.previous;
next.disabled = !navigation.next;
controls.append(previous, count, next);
const treeDetails = renderHistoryTree();
const metadata = element(
"span", "h3sp-history-meta", promptRevisionLabel(navigation),
);
metadata.title = promptRevisionHelp(navigation);
host.append(controls, treeDetails, metadata);
}
function renderHistoryTree() {
const history = state.history;
const details = element("details", "h3sp-history-tree");
details.open = history.treeOpen;
const tree = promptRevisionTree(history.data, {
includeArchived: history.showArchived,
});
const summary = element("summary", "", `History (${tree.rows.length})`);
summary.title = "Show prompt ancestry, labels, and revision actions";
const panel = element("div", "h3sp-history-tree-panel");
for (const row of tree.rows) {
const rowHost = element(
"div",
`h3sp-history-tree-row${row.isActive ? " h3sp-history-active" : ""}${row.isArchived ? " h3sp-history-archived" : ""}`,
);
rowHost.style.paddingLeft = `${row.depth * 15}px`;
const select = button("", `Activate ${row.displayLabel} in the Plan`, () => {
if (!row.isActive) void selectHistoryRevision(row.revision.id);
});
select.className = "h3sp-history-tree-select";
select.disabled = row.isActive;
select.append(
element("span", "h3sp-history-branch", row.depth ? "↳" : "●"),
element("span", "h3sp-history-tree-label", row.displayLabel),
element(
"span", "h3sp-history-tree-badge",
row.isArchived ? "Archived"
: row.isActive ? "Active"
: row.isExecuted ? "Executed" : "Draft",
),
);
const rename = button("Label", `Label ${row.displayLabel}`, () => {
const label = window.prompt(
"Short label for this prompt revision (leave blank to clear):",
String(row.revision.label ?? ""),
);
if (label != null) void mutateHistoryRevision(
"label", row.revision.id, {label});
});
rename.className = "h3sp-history-tree-action";
rowHost.append(select, rename);
if (row.canRestore) {
const restore = button("Restore", `Restore ${row.displayLabel} to visible history`, () => {
void mutateHistoryRevision("archive", row.revision.id, {archived:false});
});
restore.className = "h3sp-history-tree-action";
rowHost.append(restore);
} else if (row.canArchive) {
const archive = button("Archive", `Archive ${row.displayLabel}`, () => {
void mutateHistoryRevision("archive", row.revision.id, {archived:true});
});
archive.className = "h3sp-history-tree-action";
rowHost.append(archive);
}
if (row.canDelete) {
const remove = button("Delete", `Delete unexecuted leaf ${row.displayLabel}`, () => {
if (window.confirm(`Delete ${row.displayLabel}? This cannot be undone.`)) {
void mutateHistoryRevision("delete", row.revision.id);
}
});
remove.className = "h3sp-history-tree-action";
rowHost.append(remove);
}
panel.append(rowHost);
}
if (!tree.rows.length) {
panel.append(element("div", "h3sp-history-tree-tools", "No visible revisions"));
}
const tools = element("div", "h3sp-history-tree-tools");
tools.append(element(
"span", "",
"↳ shows parent → child progression",
));
if (tree.archivedCount) {
tools.append(button(
history.showArchived ? "Hide archived" : `Show archived (${tree.archivedCount})`,
"Toggle archived prompt revisions",
() => {
history.showArchived = !history.showArchived;
history.treeOpen = true;
renderHistory();
},
));
}
panel.append(tools);
details.append(summary, panel);
details.addEventListener("toggle", () => {
history.treeOpen = details.open;
});
return details;
}
async function mutateHistoryRevision(action, revisionId, fields = {}) {
await flushHistoryDraft();
const history = state.history;
const shot = state.plan?.shots?.[state.active];
if (!shot) return;
const shotId = String(shot.id || `clip_${String(state.active + 1).padStart(4, "0")}`);
const runName = planRunName();
const key = historySceneKey(runName, shotId);
try {
const payload = await historyRequest({}, {
action,
run_name: runName,
scene_id: shotId,
revision: revisionId,
...fields,
});
if (history.sceneKey !== key) return;
history.data = payload.history;
history.revisionId = payload.history?.active_revision ?? history.revisionId;
history.error = "";
renderHistory();
} catch (error) {
if (history.sceneKey !== key) return;
history.error = error?.message || String(error);
renderHistory();
}
}
async function loadHistory(shotId, prompt, synchronize = true) {
const runName = planRunName();
const history = state.history;
const key = historySceneKey(runName, shotId);
const token = ++history.loadToken;
history.sceneKey = key;
history.data = null;
history.revisionId = null;
history.error = "";
renderHistory();
if (!runName) {
history.error = "Set a Plan run_name to enable prompt history.";
renderHistory();
return;
}
const request = synchronize
? historyRequest({}, {
action: "save",
run_name: runName,
scene_id: shotId,
prompt,
parent_revision: null,
})
: historyRequest({run_name: runName, scene_id: shotId});
history.loadPromise = request;
try {
const payload = await request;
if (token !== history.loadToken || history.sceneKey !== key) return;
history.data = payload.history ?? payload;
history.revisionId = payload.revision?.id
?? history.data.active_revision ?? history.revisionId;
history.error = "";
} catch (error) {
if (token !== history.loadToken || history.sceneKey !== key) return;
history.error = error?.message || String(error);
} finally {
if (history.loadPromise === request) history.loadPromise = null;
if (token === history.loadToken && history.sceneKey === key) renderHistory();
}
}
function scheduleHistoryDraft(shotId, prompt) {
if (state.disposed) return;
const history = state.history;
const runName = planRunName();
if (!runName) return;
history.pendingDraft = {
key: historySceneKey(runName, shotId),
runName,
shotId,
prompt,
};
history.error = "";
if (history.saveTimer != null) window.clearTimeout(history.saveTimer);
history.saveTimer = window.setTimeout(() => {
history.saveTimer = null;
void flushHistoryDraft();
}, 650);
}
async function flushHistoryDraft() {
const history = state.history;
if (history.saveTimer != null) {
window.clearTimeout(history.saveTimer);
history.saveTimer = null;
}
if (history.savePromise) {
await history.savePromise;
return history.pendingDraft ? flushHistoryDraft() : undefined;
}
const draft = history.pendingDraft;
if (!draft) return;
history.pendingDraft = null;
if (history.loadPromise && history.sceneKey === draft.key) {
await history.loadPromise;
}
const parent = history.sceneKey === draft.key ? history.revisionId : null;
const request = historyRequest({}, {
action: "save",
run_name: draft.runName,
scene_id: draft.shotId,
prompt: draft.prompt,
parent_revision: parent,
});
history.savePromise = request;
try {
const payload = await request;
if (history.sceneKey === draft.key) {
history.data = payload.history;
history.revisionId = payload.revision?.id ?? payload.history?.active_revision;
history.error = "";
renderHistory();
}
} catch (error) {
if (history.sceneKey === draft.key) {
history.error = error?.message || String(error);
renderHistory();
}
} finally {
if (history.savePromise === request) history.savePromise = null;
}
if (history.pendingDraft) await flushHistoryDraft();
}
async function selectHistoryRevision(revisionId) {
await flushHistoryDraft();
const history = state.history;
const shot = state.plan?.shots?.[state.active];
if (!shot || !history.textarea) return;
const shotId = String(shot.id || `clip_${String(state.active + 1).padStart(4, "0")}`);
const runName = planRunName();
const key = historySceneKey(runName, shotId);
try {
const payload = await historyRequest({}, {
action: "activate",
run_name: runName,
scene_id: shotId,
revision: revisionId,
});
if (history.sceneKey !== key) return;
history.data = payload.history;
history.revisionId = payload.revision.id;
history.error = "";
history.textarea.value = String(payload.revision.prompt ?? "");
state.promptUndo?.record(history.textarea.value, {
inputType: "insertReplacementText",
});
renderRichEditorText(history.textarea.value);
shot.prompt = promptTextToLines(history.textarea.value);
writePlan(history.status);
if (history.status) history.status.textContent = "Loaded prompt version";
renderHistory();
history.textarea.focus();
} catch (error) {
if (history.sceneKey !== key) return;
history.error = error?.message || String(error);
renderHistory();
}
}
function messagesForProvider(provider = assistant.provider) {
const key = provider === "hermes" ? "hermes" : "codex";
assistant.messagesByProvider[key] ??= [];
return assistant.messagesByProvider[key];
}
function assistantMessage(role, text, provider = assistant.provider) {
const value = String(text ?? "").trim();
if (!value) return;
const messages = messagesForProvider(provider);
messages.push({role, text: value});
assistant.messagesByProvider[provider === "hermes" ? "hermes" : "codex"] = messages.slice(-24);
}
function clearAssistantReconnect() {
if (assistant.reconnectTimer != null) {
window.clearTimeout(assistant.reconnectTimer);
assistant.reconnectTimer = null;
}
}
function scheduleAssistantReconnect(delay = 500) {
if (!assistant.activeRequest || assistant.reconnectTimer != null) return;
assistant.reconnectTimer = window.setTimeout(async () => {
assistant.reconnectTimer = null;
if (!assistant.activeRequest) return;
try {
await assistant.client.connect();
} catch (_error) {
if (assistant.activeRequest) scheduleAssistantReconnect(1_000);
}
}, delay);
}
function persistPendingRequest(requestId, meta) {
try {
globalThis.sessionStorage?.setItem(assistant.pendingStorageKey, JSON.stringify({
requestId,
...meta,
}));
} catch (_error) {
// Recovery across reload is best-effort; live correlation still works.
}
}
function clearPendingRequest(requestId = null) {
if (requestId && assistant.activeRequest && assistant.activeRequest !== requestId) return;
try { globalThis.sessionStorage?.removeItem(assistant.pendingStorageKey); } catch (_error) { /* unavailable */ }
}
function restorePendingRequest() {
let stored = null;
try {
stored = JSON.parse(globalThis.sessionStorage?.getItem(assistant.pendingStorageKey) || "null");
} catch (_error) {
clearPendingRequest();
}
if (!stored || typeof stored !== "object" || typeof stored.requestId !== "string") return;
const meta = {
sceneKey: String(stored.sceneKey || ""),
sceneId: String(stored.sceneId || ""),
sceneIndex: Number(stored.sceneIndex),
sourcePrompt: String(stored.sourcePrompt ?? ""),
sourceRevision: String(stored.sourceRevision || ""),
provider: stored.provider === "hermes" ? "hermes" : "codex",
};
if (!meta.sceneKey || !meta.sceneId || !Number.isInteger(meta.sceneIndex) || !meta.sourceRevision) {
clearPendingRequest();
return;
}
assistant.provider = meta.provider;
assistant.activeRequest = stored.requestId;
assistant.requestContexts.set(stored.requestId, meta);
assistant.status = "connecting";
assistant.statusDetail = "Reconnecting to recover the active draft…";
refreshAssistant();
scheduleAssistantReconnect(0);
}
function reconcileAssistantProvider() {
// A restored in-flight request remains owned by its original provider.
// Switching the visible lane during recovery would hide its reply in a
// different provider transcript.
if (assistant.activeRequest) return;
const selected = assistant.providers?.find((item) => item.id === assistant.provider);
if (selected?.available !== false) return;
const fallback = assistant.providers?.find((item) => item.available !== false);
if (!fallback || !["codex", "hermes"].includes(fallback.id)) return;
const unavailable = assistant.provider === "hermes" ? "Hermes" : "Codex";
assistant.provider = fallback.id;
node.properties[ASSIST_PROVIDER_PROPERTY] = assistant.provider;
assistantMessage("system", `${unavailable} is unavailable; using ${fallback.label || fallback.id}.`, assistant.provider);
dirty();
}
function assistantProviderAvailable(provider = assistant.provider) {
return assistant.providers?.find((item) => item.id === provider)?.available !== false;
}
function refreshAssistant() {
const host = assistant.host;
const promptTextarea = root.querySelector(".h3sp-textarea");
if (!host || !promptTextarea || !state.plan?.shots?.length) return;
renderAssistant(host, promptTextarea);
}
function handleAssistantFrame(frame) {
if (!frame || typeof frame !== "object") return;
if (frame.type === "prompt_assist_ready") {
assistant.providers = Array.isArray(frame.providers) ? frame.providers : null;
reconcileAssistantProvider();
assistant.error = "";
} else if (frame.type === "prompt_assist_started") {
if (frame.request_id === assistant.activeRequest) assistant.status = "working";
} else if (frame.type === "prompt_assist_progress") {
if (frame.request_id === assistant.activeRequest) assistant.statusDetail = "Agent is drafting…";
} else if (frame.type === "prompt_assist_result") {
// Accept only a request this editor incarnation has in its live or
// sessionStorage-restored correlation map. A disconnected "New
// chat" can reconnect to a buffered result before its reset frame
// reaches the server; reconstructing metadata from that result
// would resurrect a draft the user explicitly discarded.
const meta = assistant.requestContexts.get(frame.request_id);
if (!meta) return;
assistant.requestContexts.delete(frame.request_id);
if (assistant.activeRequest === frame.request_id) assistant.activeRequest = null;
clearPendingRequest(frame.request_id);
clearAssistantReconnect();
assistant.status = "connected";
assistant.statusDetail = "Connected · isolated prompt session";
assistant.error = "";
assistantMessage("agent", frame.message || "Draft ready.", meta.provider);
if (typeof frame.rewritten_prompt === "string" && frame.rewritten_prompt.trim()) {
assistant.drafts.set(meta.sceneKey, {
sceneId: meta.sceneId,
sceneIndex: meta.sceneIndex,
sourcePrompt: meta.sourcePrompt,
sourceRevision: meta.sourceRevision,
proposed: frame.rewritten_prompt,
provider: frame.provider || meta.provider,
});
} else if (typeof frame.rewritten_prompt === "string") {
assistant.error = "The agent returned an empty draft, so it was not staged.";
}
} else if (frame.type === "prompt_assist_error") {
const meta = assistant.requestContexts.get(frame.request_id);
if (frame.request_id && !meta && assistant.activeRequest !== frame.request_id) return;
if (meta) assistant.requestContexts.delete(frame.request_id);
if (!frame.request_id || assistant.activeRequest === frame.request_id) {
assistant.activeRequest = null;
}
clearPendingRequest(frame.request_id);
clearAssistantReconnect();
assistant.status = assistant.client?.socket?.readyState === WebSocket.OPEN
? "connected" : "disconnected";
assistant.error = String(frame.error || "Prompt assistant failed.");
assistantMessage("system", `Agent error: ${assistant.error}`, meta?.provider);
} else if (frame.type === "prompt_assist_cancelled") {
const meta = assistant.requestContexts.get(frame.request_id);
if (!meta && assistant.activeRequest !== frame.request_id) return;
assistant.requestContexts.delete(frame.request_id);
if (assistant.activeRequest === frame.request_id) assistant.activeRequest = null;
clearPendingRequest(frame.request_id);
clearAssistantReconnect();
assistant.status = "connected";
assistant.statusDetail = "Stopped · ready for another request";
assistantMessage("system", "Request stopped. The scene prompt was not changed.", meta?.provider);
} else if (
frame.type === "prompt_assist_cancel_ack"
&& frame.cancelled === false
&& frame.request_id === assistant.activeRequest
) {
// The orchestrator may have restarted while the browser retained a
// pending request. A negative correlated ack proves there is no
// server-side turn left to wait for.
const meta = assistant.requestContexts.get(frame.request_id);
assistant.requestContexts.delete(frame.request_id);
assistant.activeRequest = null;
clearPendingRequest(frame.request_id);
clearAssistantReconnect();
assistant.status = assistant.client?.socket?.readyState === WebSocket.OPEN
? "connected" : "disconnected";
assistant.statusDetail = "No active server request · ready for another request";
assistant.error = "The prior request is no longer active, likely because the bridge restarted.";
assistantMessage("system", assistant.error, meta?.provider);
}
refreshAssistant();
}
async function sendAssistant(promptTextarea) {
if (assistant.activeRequest || assistant.preparingRequest || !state.plan?.shots?.length) return;
// Snapshot the selected scene before the asynchronous bridge handshake.
// Navigation remains usable while connecting; reading state.active and
// an old textarea after await could otherwise pair scene B's id with
// scene A's prompt.
const requestSceneIndex = state.active;
const requestShot = state.plan.shots[requestSceneIndex];
const sourcePrompt = promptTextarea.value;
const selectedText = sourcePrompt.slice(
promptTextarea.selectionStart ?? 0,
promptTextarea.selectionEnd ?? 0,
);
const context = buildPromptAssistantContext(
state.plan,
requestSceneIndex,
sourcePrompt,
{
includeShared: assistant.includeShared,
includeAdjacent: assistant.includeAdjacent,
selectedText,
},
);
const preparation = {};
assistant.preparingRequest = preparation;
assistant.status = "connecting";
assistant.statusDetail = "Connecting to comfyui-mcp…";
assistant.error = "";
refreshAssistant();
try {
// Wait for prompt_assist_ready before freezing the provider into the
// request. The ready frame may switch a persisted unavailable Hermes
// selection to Codex.
await assistant.client.connect();
} catch (error) {
// New chat may have invalidated this connect attempt while it was
// awaiting the handshake. In that case it owns no UI state.
if (assistant.preparingRequest !== preparation) return;
assistant.preparingRequest = null;
assistant.status = "disconnected";
assistant.error = error.message || String(error);
assistantMessage("system", `Could not connect: ${assistant.error}`);
refreshAssistant();
return;
}
if (assistant.preparingRequest !== preparation) return;
assistant.preparingRequest = null;
if (!assistantProviderAvailable()) {
assistant.error = `${assistant.provider === "hermes" ? "Hermes" : "Codex"} is unavailable.`;
refreshAssistant();
return;
}
const sceneId = String(requestShot.id || `clip_${String(requestSceneIndex + 1).padStart(4, "0")}`);
const sceneKey = promptSceneKey(sceneId, requestSceneIndex);
const requestId = `pa-${globalThis.crypto?.randomUUID?.()
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`;
const request = makePromptAssistRequest({
requestId,
conversationId: assistant.client.conversationId,
provider: assistant.provider,
mode: assistant.mode,
instruction: assistant.composer,
context,
});
assistant.requestContexts.set(requestId, {
sceneKey,
sceneId,
sceneIndex: requestSceneIndex,
sourcePrompt,
sourceRevision: request.source_revision,
provider: assistant.provider,
});
const requestMeta = assistant.requestContexts.get(requestId);
assistant.activeRequest = requestId;
persistPendingRequest(requestId, requestMeta);
assistant.status = "working";
assistant.statusDetail = `Asking ${assistant.provider === "hermes" ? "Hermes" : "Codex"}…`;
assistant.error = "";
assistantMessage("user", request.instruction);
assistant.composer = "";
refreshAssistant();
try {
await assistant.client.send(request);
} catch (error) {
if (assistant.activeRequest !== requestId) return;
assistant.activeRequest = null;
assistant.requestContexts.delete(requestId);
clearPendingRequest(requestId);
clearAssistantReconnect();
assistant.status = "disconnected";
assistant.error = error.message || String(error);
assistantMessage("system", `Could not send: ${assistant.error}`);
refreshAssistant();
}
}
function stopAssistant() {
if (!assistant.activeRequest) return;
if (!assistant.client.cancel(assistant.activeRequest)) {
assistant.error = "The bridge is disconnected; the local request may finish without being delivered.";
refreshAssistant();
} else {
assistant.statusDetail = "Stopping agent…";
refreshAssistant();
}
}
function resetAssistantChat() {
if (assistant.activeRequest) assistant.client.cancel(assistant.activeRequest);
assistant.client.reset();
assistant.activeRequest = null;
assistant.preparingRequest = null;
clearPendingRequest();
clearAssistantReconnect();
assistant.requestContexts.clear();
assistant.messagesByProvider = {codex: [], hermes: []};
assistant.error = "";
assistant.statusDetail = "New isolated conversation";
refreshAssistant();
}
function copyAssistantDraft(draft) {
const operation = navigator.clipboard?.writeText?.(draft.proposed);
if (!operation) {
assistant.error = "Clipboard access is unavailable. Select the proposed text and copy it manually.";
refreshAssistant();
return;
}
operation.then(() => {
assistantMessage("system", "Proposed prompt copied.");
refreshAssistant();
}).catch(() => {
assistant.error = "Clipboard access was refused. Select the proposed text and copy it manually.";
refreshAssistant();
});
}
function applyAssistantDraft(draft, promptTextarea, conflict) {
if (conflict.stale && !window.confirm(
`${conflict.reason}\n\nApply this draft anyway and replace the current scene prompt?`,
)) return;
const before = promptTextarea.value;
const after = draft.proposed;
if (!String(after).trim()) {
assistant.error = "An empty assistant draft cannot replace the scene prompt.";
refreshAssistant();
return;
}
assistant.lastApplied = {
sceneKey: promptSceneKey(draft.sceneId, draft.sceneIndex),
before,
after,
};
promptTextarea.value = after;
promptTextarea.dispatchEvent(new Event("input", {bubbles: true}));
assistant.drafts.delete(promptSceneKey(draft.sceneId, draft.sceneIndex));
assistantMessage("system", "Draft applied to the active scene and saved in the connected Plan.");
refreshAssistant();
}
function undoAssistantApply(promptTextarea, sceneKey) {
const undo = assistant.lastApplied;
if (!undo || undo.sceneKey !== sceneKey || promptTextarea.value !== undo.after) return;
promptTextarea.value = undo.before;
promptTextarea.dispatchEvent(new Event("input", {bubbles: true}));
assistant.lastApplied = null;
assistantMessage("system", "The last assistant apply was undone.");
refreshAssistant();
}
function renderAssistant(host, promptTextarea) {
host.replaceChildren();
const shot = state.plan.shots[state.active];
const sceneId = String(shot.id || `clip_${String(state.active + 1).padStart(4, "0")}`);
const sceneKey = promptSceneKey(sceneId, state.active);
const head = element("div", "h3sp-assist-head");
const heading = element("span", "h3sp-assist-title", "Prompt Assistant");
const busy = Boolean(assistant.activeRequest || assistant.preparingRequest);
const statusText = busy
? assistant.statusDetail || "Agent is working…"
: assistant.statusDetail;
head.append(heading, element("span", "h3sp-assist-status", statusText));
const controls = element("div", "h3sp-assist-controls");
const provider = element("select");
provider.title = "Choose which isolated local agent handles this prompt turn.";
for (const [id, label] of [["codex", "Codex"], ["hermes", "Hermes"]]) {
const option = element("option", "", label);
option.value = id;
const available = assistant.providers?.find((item) => item.id === id)?.available;
if (available === false) {
option.textContent = `${label} (not found)`;
option.disabled = true;
}
provider.append(option);
}
provider.value = assistant.provider;
provider.disabled = busy;
provider.addEventListener("change", () => {
assistant.provider = provider.value;
node.properties[ASSIST_PROVIDER_PROPERTY] = assistant.provider;
persistView();
refreshAssistant();
});
const mode = element("select");
mode.title = "Choose the kind of help to request.";
for (const item of PROMPT_ASSIST_MODES) {
const option = element("option", "", item.label);
option.value = item.id;
mode.append(option);
}
mode.value = assistant.mode;
mode.disabled = busy;
mode.addEventListener("change", () => {
assistant.mode = mode.value;
node.properties[ASSIST_MODE_PROPERTY] = assistant.mode;
persistView();
refreshAssistant();
});
controls.append(provider, mode, button("New chat", "Clear agent conversation; staged drafts remain.", resetAssistantChat));
const chat = element("div", "h3sp-assist-chat");
const messages = messagesForProvider();
if (!messages.length) {
chat.append(element(
"div", "h3sp-assist-empty",
"Ask for a rewrite, continuity pass, critique, or a specific change. Nothing is applied until you press Apply.",
));
} else {
for (const message of messages) {
chat.append(element(
"div",
`h3sp-assist-message h3sp-assist-message-${message.role}`,
message.text,
));
}
setTimeout(() => { chat.scrollTop = chat.scrollHeight; }, 0);
}
const draft = assistant.drafts.get(sceneKey);
let draftPanel = null;
if (draft) {
const conflict = draftConflict(draft, sceneId, promptTextarea.value);
draftPanel = element("div", "h3sp-assist-draft");
const draftHead = element("div", "h3sp-assist-draft-head");
draftHead.append(
element("span", "h3sp-assist-draft-title", `Staged ${draft.provider || "agent"} proposal`),
conflict.stale ? element("span", "h3sp-assist-stale", conflict.reason) : element("span"),
);
const proposed = element("textarea");
proposed.value = draft.proposed;
proposed.title = "You can edit the staged proposal before applying it.";
proposed.addEventListener("input", () => { draft.proposed = proposed.value; });
const original = element("details", "h3sp-assist-original");
original.append(
element("summary", "", "Compare with original"),
element("pre", "", draft.sourcePrompt),
);
const actions = element("div", "h3sp-assist-actions");
actions.append(
button(
conflict.stale ? "Apply anyway…" : "Apply to scene",
"Replace the real active scene prompt in the connected Plan.",
() => applyAssistantDraft(draft, promptTextarea, conflict),
),
button("Copy", "Copy the proposed prompt without changing the Plan.", () => copyAssistantDraft(draft)),
button("Discard", "Remove this staged proposal without changing the Plan.", () => {
assistant.drafts.delete(sceneKey);
refreshAssistant();
}),
);
draftPanel.append(draftHead, proposed, original, actions);
}
const contexts = element("div", "h3sp-assist-contexts");
const contextToggle = (label, checked, change, title) => {
const wrapper = element("label");
wrapper.title = title;
const input = element("input");
input.type = "checkbox";
input.checked = checked;
input.disabled = busy;
input.addEventListener("change", () => change(input.checked));
wrapper.append(input, document.createTextNode(label));
return wrapper;
};
contexts.append(
contextToggle("Shared prompt", assistant.includeShared, (value) => {
assistant.includeShared = value;
}, "Include the Plan's shared/global prompt as read-only context."),
contextToggle("Previous + next", assistant.includeAdjacent, (value) => {
assistant.includeAdjacent = value;
}, "Include adjacent scene prompts for continuity advice."),
element("span", "h3sp-assist-status", "Selected text is included automatically"),
);
const compose = element("div", "h3sp-assist-compose");
const composer = element("textarea");
composer.value = assistant.composer;
composer.placeholder = PROMPT_ASSIST_DEFAULT_INSTRUCTIONS[assistant.mode];
composer.disabled = busy;
composer.addEventListener("input", () => { assistant.composer = composer.value; });
composer.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void sendAssistant(promptTextarea);
}
});
const send = button("Ask agent", "Send (Ctrl/Cmd+Enter). The response is staged, never auto-applied.", () => {
void sendAssistant(promptTextarea);
});
send.disabled = busy || !assistantProviderAvailable();
compose.append(composer, send);
if (assistant.activeRequest) {
compose.append(button("Stop", "Interrupt this prompt-assist request.", stopAssistant));
}
const error = assistant.error
? element("div", "h3sp-assist-error", assistant.error) : null;
const undo = assistant.lastApplied;
const undoButton = undo?.sceneKey === sceneKey && promptTextarea.value === undo.after
? button("Undo last apply", "Restore the prompt that existed before the last assistant Apply.", () => {
undoAssistantApply(promptTextarea, sceneKey);
}) : null;
host.append(head, controls, chat);
if (draftPanel) host.append(draftPanel);
host.append(contexts, compose);
if (undoButton) host.append(undoButton);
if (error) host.append(error);
}
function showFailure(message) {
state.completion?.destroy();
state.completion = null;
assistant.host = null;
state.promptTextarea = null;
state.richEditor = null;
state.history.host = null;
state.history.textarea = null;
state.history.status = null;
root.replaceChildren();
root.append(
element("div", "h3sp-title", "MiniMax H3 Scene Prompt Editor"),
element("div", "h3sp-error", message),
element("div", "h3sp-context", "Connect the Plan output to this node's plan input."),
);
hidePopover(true);
}
function navigate(offset, absolute = null, {synchronize = true, focus = true} = {}) {
if (!state.plan?.shots?.length) return;
flushPlanEffects();
flushPromptAnalysis();
const pending = state.sceneNavigation;
const from = pending && pending.planNode === state.planNode
&& pending.runName === planRunName() ? pending.index : state.active;
const requested = absolute == null ? from + offset : Number(absolute);
if (!Number.isFinite(requested)) return;
const index = Math.max(0, Math.min(state.plan.shots.length - 1, Math.trunc(requested)));
const selection = {
planNode:state.planNode, runName:planRunName(),
sceneId:String(state.plan.shots[index]?.id ?? ""), index,
};
state.sceneNavigation = selection;
void (async () => {
await flushHistoryDraft();
// History IO can complete out of order. Only the latest selection
// may move this editor or broadcast a scene to its companions.
if (state.disposed || state.sceneNavigation !== selection
|| state.planNode !== selection.planNode
|| planRunName() !== selection.runName) return;
state.sceneNavigation = null;
const target = selection.sceneId
&& String(state.plan.shots[selection.index]?.id ?? "") !== selection.sceneId
? state.plan.shots.findIndex(shot => String(shot?.id ?? "") === selection.sceneId)
: selection.index;
if (!state.plan.shots[target]) return;
state.active = target;
persistView();
render();
if (synchronize) publishCompanionScene(node, state.planNode, state.active);
if (focus) (state.decorated ? state.richEditor : state.promptTextarea)?.focus();
})();
}
async function appendScene() {
if (!state.plan || state.plan.shots.length >= MAX_SHOTS) return;
state.sceneNavigation = null;
flushPlanEffects();
flushPromptAnalysis();
await flushHistoryDraft();
// This is the one structural operation exposed by the focused editor.
// Start from the live Plan so Studio/timing edits cannot be overwritten,
// then append instead of inserting into the middle of an active chain.
if (!rebaseActivePromptOntoLivePlan()) return;
state.plan.shots.push(makeShot(state.plan.shots));
state.active = state.plan.shots.length - 1;
persistView();
commitPlan(null);
render();
publishCompanionScene(node, state.planNode, state.active);
(state.decorated ? state.richEditor : state.promptTextarea)?.focus();
}
function ensureTokenPopover() {
if (state.popover) return state.popover;
const popover = element("div", "h3sp-popover");
popover.hidden = true;
popover.addEventListener("mouseenter", () => {
if (state.popoverTimer != null) window.clearTimeout(state.popoverTimer);
});
popover.addEventListener("mouseleave", scheduleHidePopover);
for (const eventName of [
"pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick",
"keydown", "keyup", "keypress", "wheel",
]) {
popover.addEventListener(eventName, (event) => event.stopPropagation());
}
document.body.append(popover);
state.popover = popover;
return popover;
}
function hidePopover(force = false) {
if (state.popoverPinned && !force) return;
if (state.popoverTimer != null) {
window.clearTimeout(state.popoverTimer);
state.popoverTimer = null;
}
state.popoverPinned = false;
for (const media of state.popover?.querySelectorAll?.("audio,video") ?? []) {
media.pause?.();
}
if (state.popover) state.popover.hidden = true;
}
function scheduleHidePopover() {
if (state.popoverPinned) return;
if (state.popoverTimer != null) window.clearTimeout(state.popoverTimer);
state.popoverTimer = window.setTimeout(hidePopover, 140);
}
function positionTokenPopover(popover, anchor) {
popover.hidden = false;
const rect = anchor.getBoundingClientRect();
const width = Math.min(360, globalThis.innerWidth - 24);
popover.style.left = `${Math.max(
12, Math.min(globalThis.innerWidth - width - 12, rect.left),
)}px`;
popover.style.top = `${Math.max(12, Math.min(
globalThis.innerHeight - popover.offsetHeight - 12, rect.bottom + 7,
))}px`;
}
function showTokenPopover(record, anchor) {
if (state.popoverPinned) return;
hidePopover(true);
const popover = ensureTokenPopover();
popover.replaceChildren(element("div", "h3sp-popover-title", record.token));
const mediaKind = record.kind === "picture" ? "image" : record.kind;
const media = referenceMediaPreview(record, mediaKind);
if (media.url) {
const mediaElement = element(
mediaKind === "image" ? "img" : mediaKind === "video" ? "video" : "audio",
"h3sp-popover-media",
);
mediaElement.src = media.url;
if (mediaKind !== "image") {
mediaElement.controls = true;
mediaElement.preload = "metadata";
} else {
mediaElement.alt = `Preview for ${record.token}`;
}
popover.append(mediaElement);
} else {
popover.append(element(
"div", "h3sp-popover-detail", "No browser-playable file preview was found upstream.",
));
}
const sourceTitle = media.source?.title || nodeType(media.source) || "unresolved source";
popover.append(element(
"div", "h3sp-popover-detail",
`${record.kind.toUpperCase()} · scenes ${record.selector}\n` +
`${record.active ? "Active" : "Inactive"} in scene ${state.active + 1}\n` +
`Source: ${sourceTitle}`,
));
positionTokenPopover(popover, anchor);
}
function recordSupportsNative(record) {
return record?.nativeToken !== null
&& Boolean(String(record?.nativeToken ?? record?.token ?? ""));
}
function recordSupportsSemantic(record) {
return record?.kind === "picture" && Boolean(record?.tag)
&& Boolean(record?.supportsSemantic || record?.semanticOnly);
}
function inlineReferenceCandidates(part) {
return state.records.filter((record) => {
if (state.referenceMode !== "tagged" && !record.active) return false;
return part.kind === "unknown" || record.kind === part.kind;
});
}
function showReferenceEditor(part, start, end, anchor) {
const candidates = inlineReferenceCandidates(part);
if (!candidates.length) return;
hidePopover(true);
state.popoverPinned = true;
const popover = ensureTokenPopover();
const editor = element("div", "h3sp-popover-editor");
editor.append(element("div", "h3sp-popover-title", "Replace reference"));
const referenceField = element("label", "h3sp-popover-field");
referenceField.append(element("span", "", "Reference"));
const referenceSelect = element("select");
for (let index = 0; index < candidates.length; index += 1) {
const record = candidates[index];
const option = element("option", "", record.token);
option.value = String(index);
if (record.label && record.label !== record.token) {
option.textContent = `${record.token} → ${record.label}`;
}
referenceSelect.append(option);
}
const currentIndex = Math.max(0, candidates.findIndex(
(record) => record === part.record
|| (record.tag && record.tag === part.record?.tag)
|| record.token === part.record?.token,
));
referenceSelect.value = String(currentIndex);
referenceField.append(referenceSelect);
const modeField = element("label", "h3sp-popover-field");
modeField.append(element("span", "", "Syntax"));
const modeSelect = element("select");
modeField.append(modeSelect);
const timestampField = element("label", "h3sp-popover-field");
timestampField.append(element("span", "", "Time (sec)"));
const timestampInput = element("input");
timestampInput.type = "number";
timestampInput.min = "0";
timestampInput.step = "0.01";
timestampInput.placeholder = "blank = untimed";
timestampInput.value = part.timestamp == null
? "" : String(part.timestamp);
timestampField.append(timestampInput);
const updateModes = (preferred = modeSelect.value) => {
const record = candidates[Number(referenceSelect.value)];
const native = recordSupportsNative(record);
const semantic = recordSupportsSemantic(record);
modeSelect.replaceChildren();
if (native) {
const option = element("option", "", "@ native");
option.value = "native";
modeSelect.append(option);
}
if (semantic) {
const option = element("option", "", "# semantic (time optional)");
option.value = "semantic";
modeSelect.append(option);
}
modeSelect.value = [...modeSelect.options].some(
(option) => option.value === preferred,
) ? preferred : semantic && !native ? "semantic" : "native";
timestampField.hidden = modeSelect.value !== "semantic";
modeField.hidden = modeSelect.options.length < 2;
};
referenceSelect.addEventListener("change", () => updateModes());
modeSelect.addEventListener("change", () => {
timestampField.hidden = modeSelect.value !== "semantic";
});
updateModes(part.semantic ? "semantic" : "native");
editor.append(referenceField, modeField, timestampField);
const actions = element("div", "h3sp-popover-actions");
const cancel = element("button", "", "Cancel");
cancel.type = "button";
cancel.addEventListener("click", () => hidePopover(true));
const apply = element("button", "", "Apply");
apply.type = "button";
apply.addEventListener("click", () => {
const record = candidates[Number(referenceSelect.value)];
const mode = modeSelect.value;
const timestampText = timestampInput.value.trim();
const timestamp = timestampText === ""
? null : Number(timestampText);
const current = activePromptText();
if (current.slice(start, end) !== part.text) {
hidePopover(true);
renderRichEditorText(current);
return;
}
const next = replacePromptReferenceOccurrence(
current, start, end, record, mode, timestamp,
);
const replacement = referenceReplacementToken(
record, mode, timestamp,
);
hidePopover(true);
if (!state.richEditor || !replacement || next === current) return;
renderRichEditorText(next, start + replacement.length);
state.richEditor.dispatchEvent(new InputEvent("input", {
bubbles:true, inputType:"insertReplacementText",
}));
const referenceData = availableReferenceRecords(
node, state.active + 1, {
includeInactive:true,
prompt:[sharedPrompt(state.plan).text.trim(), next.trim()]
.filter(Boolean).join("\n\n"),
},
);
state.records = referenceData.records;
state.referenceMode = referenceData.mode;
renderRichEditorText(next, start + replacement.length);
if (state.referenceTray) renderReferenceTray(state.referenceTray);
state.richEditor.focus();
});
actions.append(cancel, apply);
editor.append(actions);
editor.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
cancel.click();
} else if (event.key === "Enter") {
event.preventDefault();
apply.click();
}
});
popover.replaceChildren(editor);
positionTokenPopover(popover, anchor);
referenceSelect.focus();
}
function makeRichToken(part, start, end) {
if (part.type === "text") return document.createTextNode(part.text);
const kind = part.type === "reference" ? part.kind : part.type;
const token = element("span", `h3sp-token h3sp-token-${kind}`);
token.contentEditable = "false";
token.dataset.token = part.text;
if (["subject", "speaker", "dialogue", "flow"].includes(part.type)) {
token.dataset.h3PromptMarker = "";
token.tabIndex = 0;
token.setAttribute("role", "button");
token.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
token.click();
});
}
if (part.unresolved) token.classList.add("h3sp-token-unknown");
if (part.record && !part.record.active) token.classList.add("h3sp-token-inactive");
if (part.type === "section") {
token.append(element("span", "h3sp-token-label", part.text));
token.title = `H3 section · ${part.section}`;
return token;
}
const mediaKind = kind === "picture" ? "image" : kind;
const media = part.record
? referenceMediaPreview(part.record, mediaKind) : null;
if (kind === "picture" && media?.url) {
const thumbnail = element("img", "h3sp-token-thumb");
thumbnail.src = media.url;
thumbnail.alt = "";
token.append(thumbnail);
} else {
token.append(icon(kind));
}
token.append(element("span", "h3sp-token-label", part.text));
if (part.record) {
token.title = part.type === "reference"
? `${part.text} · click to replace or edit; hover for preview`
: `${part.text} · hover for ${kind} preview`;
token.addEventListener("mouseenter", () => showTokenPopover(part.record, token));
token.addEventListener("mouseleave", scheduleHidePopover);
token.addEventListener("focus", () => showTokenPopover(part.record, token));
} else {
token.title = part.type === "reference"
? "Unresolved reference in this scene · click to replace"
: token.dataset.h3PromptMarker !== undefined
? "Click to replace or remove this prompt token" : part.text;
}
if (part.type === "reference") {
token.tabIndex = 0;
token.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
showReferenceEditor(part, start, end, token);
});
token.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
showReferenceEditor(part, start, end, token);
});
}
return token;
}
function renderRichEditorText(text, caret = null, editableSelection = null) {
if (!state.richEditor) return;
const scrollTop = state.richEditor.scrollTop;
const scrollLeft = state.richEditor.scrollLeft;
const parts = tokenizeRichPrompt(String(text ?? ""), state.records);
const fragment = document.createDocumentFragment();
let sourceOffset = 0;
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
const partStart = sourceOffset;
const partEnd = partStart + part.text.length;
sourceOffset = partEnd;
const unfinished = part.type === "reference" && part.unresolved
&& part.text.startsWith("@") && index === parts.length - 1
&& document.activeElement === state.richEditor;
const editingSemanticTime = part.type === "reference" && part.semantic
&& editableSelection?.selectionStart >= partStart
&& editableSelection?.selectionEnd <= partEnd;
fragment.append(unfinished || editingSemanticTime
? document.createTextNode(part.text)
: makeRichToken(part, partStart, partEnd));
}
state.richEditor.replaceChildren(fragment);
if (editableSelection?.selectionStart != null
&& editableSelection?.selectionEnd != null) {
restoreTextSelection(
state.richEditor,
editableSelection.selectionStart,
editableSelection.selectionEnd,
);
} else if (caret != null) restoreCaret(state.richEditor, caret);
// replaceChildren resets a contenteditable's viewport. Prompt edits,
// undo/redo, and reference refreshes must not throw the reader back to
// the first line of a long scene.
state.richEditor.scrollTop = scrollTop;
state.richEditor.scrollLeft = scrollLeft;
}
function rememberPromptSelection() {
if (state.decorated && state.richEditor) {
const selected = editorSelectionOffsets(state.richEditor);
if (selected) state.promptSelection = {kind:"rich", ...selected};
} else if (state.promptTextarea) {
const start = state.promptTextarea.selectionStart;
const end = state.promptTextarea.selectionEnd;
if (start != null && end != null) {
state.promptSelection = {kind:"plain", start, end};
}
}
}
function insertPromptText(text, selectSemanticTimestamp = false) {
const inserted = String(text ?? "");
selectSemanticTimestamp = Boolean(
selectSemanticTimestamp && inserted.includes("[")
&& inserted.includes("s]"));
if (state.decorated && state.richEditor) {
const current = editorPlainText(state.richEditor);
const live = document.activeElement === state.richEditor
? editorSelectionOffsets(state.richEditor) : null;
const saved = state.promptSelection?.kind === "rich"
? state.promptSelection : null;
const selected = live ?? saved ?? {
start:current.length, end:current.length,
};
const insertionStart = Math.max(
0, Math.min(current.length, Number(selected.start) || 0),
);
const insertionEnd = Math.max(
insertionStart,
Math.min(current.length, Number(selected.end) || insertionStart),
);
const next = current.slice(0, insertionStart)
+ inserted + current.slice(insertionEnd);
refreshReferenceState(next);
if (selectSemanticTimestamp) {
const first = inserted.indexOf("[") + 1;
const last = inserted.lastIndexOf("s]");
renderRichEditorText(next, null, {
selectionStart:insertionStart + first,
selectionEnd:insertionStart + last,
});
state.promptSelection = {
kind:"rich",
start:insertionStart + first,
end:insertionStart + last,
};
} else {
const caret = insertionStart + inserted.length;
renderRichEditorText(next, caret);
state.promptSelection = {kind:"rich", start:caret, end:caret};
}
state.richEditor.dispatchEvent(new InputEvent("input", {
bubbles:true, inputType:"insertText",
}));
state.richEditor.focus();
} else if (state.promptTextarea) {
const saved = state.promptSelection?.kind === "plain"
? state.promptSelection : null;
const insertionStart = saved?.start
?? state.promptTextarea.selectionStart
?? state.promptTextarea.value.length;
const insertionEnd = saved?.end
?? state.promptTextarea.selectionEnd
?? insertionStart;
state.promptTextarea.setSelectionRange(insertionStart, insertionEnd);
insertText(state.promptTextarea, inserted);
if (selectSemanticTimestamp) {
const first = inserted.indexOf("[") + 1;
const last = inserted.lastIndexOf("s]");
state.promptTextarea.setSelectionRange(
insertionStart + first, insertionStart + last,
);
state.promptSelection = {
kind:"plain",
start:insertionStart + first,
end:insertionStart + last,
};
} else {
const caret = insertionStart + inserted.length;
state.promptSelection = {kind:"plain", start:caret, end:caret};
}
}
}
function referenceSyntaxKey(record) {
const shot = state.plan?.shots?.[state.active];
return `${String(shot?.id ?? state.active)}:${record.tag}`;
}
function activePromptText() {
return state.decorated && state.richEditor
? editorPlainText(state.richEditor)
: state.promptTextarea?.value ?? "";
}
function refreshReferenceState(prompt = activePromptText()) {
if (!state.plan?.shots?.length) {
state.records = [];
state.referenceMode = null;
return {wrapper:null, mode:null, records:[]};
}
const referenceData = availableReferenceRecords(
node, state.active + 1, {
includeInactive:true,
prompt:[
sharedPrompt(state.plan).text.trim(),
String(prompt ?? "").trim(),
].filter(Boolean).join("\n\n"),
},
);
state.records = referenceData.records;
state.referenceMode = referenceData.mode;
return referenceData;
}
function selectedReferenceSyntax(record) {
if (state.referenceMode !== "tagged") return "native";
if (record.semanticOnly) return "semantic";
if (!record.supportsSemantic) return "native";
const promptMode = taggedPictureReferenceMode(
activePromptText(), record.tag,
);
if (promptMode === "native" || promptMode === "semantic") {
return promptMode;
}
return state.referenceSyntax.get(referenceSyntaxKey(record)) ?? "native";
}
function convertReferenceSyntax(record, mode, refs) {
state.referenceSyntax.set(referenceSyntaxKey(record), mode);
const current = activePromptText();
const next = convertTaggedPictureReference(current, record.tag, mode);
if (next !== current) {
if (state.decorated && state.richEditor) {
renderRichEditorText(next, next.length);
state.richEditor.dispatchEvent(new InputEvent("input", {
bubbles:true, inputType:"insertReplacementText",
}));
} else if (state.promptTextarea) {
state.promptTextarea.value = next;
state.promptTextarea.dispatchEvent(new InputEvent("input", {
bubbles:true, inputType:"insertReplacementText",
}));
}
}
renderReferenceTray(refs);
}
function insertPromptDialogue() {
if (state.decorated && state.richEditor) {
const selection = globalThis.getSelection?.();
const selected = selection?.rangeCount ? selection.toString() : "";
insertPromptText(`${selected}`);
} else if (state.promptTextarea) {
insertDialogue(state.promptTextarea);
}
}
function showReferencePreview(record, preview) {
preview.replaceChildren();
preview.classList.add("h3sp-visible");
const mediaKind = record.kind === "picture" ? "image" : record.kind;
const media = referenceMediaPreview(record, mediaKind);
if (media.url) {
const mediaElement = element(mediaKind === "image" ? "img"
: mediaKind === "video" ? "video" : "audio",
"h3sp-ref-preview-media");
mediaElement.src = media.url;
if (mediaKind !== "image") {
mediaElement.controls = true;
mediaElement.preload = "metadata";
} else {
mediaElement.alt = `Preview for ${record.token}`;
mediaElement.loading = "lazy";
}
preview.append(mediaElement);
} else {
preview.append(element(
"div", "h3sp-ref-preview-copy",
"No browser-playable source was found. Dynamic tensors can " +
"still run in the generation graph, but need an upstream loaded file for an editor preview.",
));
}
const sourceTitle = media.source?.title || nodeType(media.source) || "unresolved source";
const mapping = record.label
? `${record.label} in scene ${state.active + 1}`
: record.semanticActive
? `semantic anchor active in scene ${state.active + 1}`
: `inactive in scene ${state.active + 1}`;
const syntax = selectedReferenceSyntax(record);
const displayToken = syntax === "semantic"
? taggedPictureReferenceToken(record.tag, "semantic")
: record.token;
const previewTitle = record.mode === "native"
? displayToken : `${displayToken} → ${mapping}`;
const copy = element("div", "h3sp-ref-preview-copy");
copy.append(
element("div", "h3sp-ref-preview-title", previewTitle),
document.createTextNode(
`\n${record.kind.toUpperCase()} · ${record.selector === "prompt tag" ? "activated by prompt tag" : `scenes ${record.selector}`}` +
`\nSource: ${sourceTitle}`,
),
);
preview.append(copy);
}
function renderReferenceTray(refs) {
refs.replaceChildren();
const referenceData = refreshReferenceState(activePromptText());
const {mode, wrapper} = referenceData;
const records = mode === "tagged"
? referenceData.records
: referenceData.records.filter((record) => record.active);
const preview = element("div", "h3sp-ref-preview");
if (!records.length) {
refs.append(element(
"div", "h3sp-ref-help",
wrapper
? `No connected references are active in scene ${state.active + 1}.`
: "No connected Tagged/Scheduled Ref2VA, core Ref2VA, or core I2V/FL2V references were found. The menu does not invent unavailable labels.",
));
return;
}
const help = mode === "tagged"
? "Prompt-driven references. Picture previews switch and convert between " +
"native @tag and Qwen-only #tag; add [time] for explicit placement. " +
"Video and audio stay native-only. " +
"Audio never autoplays."
: mode === "scheduled"
? `Scheduled references for scene ${state.active + 1}. Hover to preview; ` +
"click to insert the optional stable @alias. It compiles to a native label; " +
"the scheduler inserts no prompt text. Audio never autoplays."
: mode === "native_keyframes"
? `Core I2V/FL2V keyframes for scene ${state.active + 1}. ` +
"A first-scene gate is shown inactive on continuations. " +
"Hover to preview; click to insert the native Picture label."
: "Core Ref2VA references. Hover to preview; click to insert the " +
"native label. Audio never autoplays.";
refs.append(element("div", "h3sp-ref-help", help));
const icons = {picture: "▧", video: "▶", audio: "♫"};
for (const record of records) {
const aliasMode = mode === "scheduled" || mode === "tagged";
const mapping = aliasMode && record.label
? ` → ${record.label}` : "";
const syntax = selectedReferenceSyntax(record);
const displayToken = syntax === "semantic"
? taggedPictureReferenceToken(record.tag, "semantic")
: record.token;
const entry = element("div", "h3sp-ref-entry");
const chip = button(
`${icons[record.kind] ?? "@"} ${displayToken}${mapping}`,
aliasMode
? `Insert ${displayToken}; it activates this reference in this scene.`
: `Insert ${record.token} for the connected core conditioning input.`,
() => {
insertPromptText(displayToken, syntax === "semantic");
refs.classList.remove("h3sp-open");
},
);
chip.classList.add("h3sp-ref-chip");
chip.classList.toggle("h3sp-active", record.active);
chip.addEventListener("mouseenter", () => showReferencePreview(record, preview));
chip.addEventListener("focus", () => showReferencePreview(record, preview));
entry.append(chip);
if (mode === "tagged" && record.supportsSemantic) {
const usedMode = taggedPictureReferenceMode(
activePromptText(), record.tag,
);
const modes = element("div", "h3sp-ref-mode");
const native = button("@", "Use native @tag and convert semantic anchors in this scene", () => {
convertReferenceSyntax(record, "native", refs);
});
const semantic = button("#", "Use untimed Qwen-only #tag; add [time] for placement", () => {
convertReferenceSyntax(record, "semantic", refs);
});
native.classList.toggle(
"h3sp-selected", syntax === "native" || usedMode === "mixed",
);
semantic.classList.toggle(
"h3sp-selected", syntax === "semantic" || usedMode === "mixed",
);
modes.append(native, semantic);
entry.append(modes);
}
refs.append(entry);
}
refs.append(preview);
showReferencePreview(records[0], preview);
}
function render() {
if (!state.plan?.shots?.length) {
showFailure("The connected Plan has no scenes.");
return;
}
hidePopover(true);
state.completion?.destroy();
state.completion = null;
state.schema = null;
state.active = Math.max(0, Math.min(state.active, state.plan.shots.length - 1));
root.style.setProperty("--h3sp-font-size", `${state.fontSize}px`);
assistant.host = null;
root.replaceChildren();
const shot = state.plan.shots[state.active];
const shotId = String(shot.id || `clip_${String(state.active + 1).padStart(4, "0")}`);
const head = element("div", "h3sp-head");
head.append(
element("span", "h3sp-title", "Scene Prompt Editor"),
element("span", "h3sp-context", sharedPrompt(state.plan).text.trim()
? "Shared prompt active (unchanged)" : "No shared prompt"),
);
const nav = element("div", "h3sp-nav");
const previous = button("←", "Previous scene (Alt+Left)", () => navigate(-1));
const next = button("→", "Next scene (Alt+Right)", () => navigate(1));
const add = button("+", "Append a new scene and select it", () => void appendScene());
previous.disabled = state.active === 0;
next.disabled = state.active === state.plan.shots.length - 1;
add.disabled = state.plan.shots.length >= MAX_SHOTS;
const sceneSelect = element("select");
for (let index = 0; index < state.plan.shots.length; index += 1) {
const option = element("option", "", `Scene ${index + 1} — ${state.plan.shots[index].id || `clip_${String(index + 1).padStart(4, "0")}`}`);
option.value = String(index);
sceneSelect.append(option);
}
sceneSelect.value = String(state.active);
sceneSelect.title = "Jump directly to another scene prompt.";
sceneSelect.addEventListener("change", () => navigate(0, sceneSelect.value));
const font = element("div", "h3sp-font");
const fontValue = element("span", "h3sp-font-value", `${state.fontSize}px`);
const smaller = button("A−", "Decrease editor font size", () => {
state.fontSize = clamp(state.fontSize - 2, MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE);
persistView();
render();
});
const larger = button("A+", "Increase editor font size", () => {
state.fontSize = clamp(state.fontSize + 2, MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE);
persistView();
render();
});
smaller.disabled = state.fontSize <= MIN_FONT_SIZE;
larger.disabled = state.fontSize >= MAX_FONT_SIZE;
font.append(smaller, fontValue, larger);
nav.append(previous, sceneSelect, next, add, font);
const textarea = element("textarea", "h3sp-textarea");
textarea.value = promptValueToText(shot.prompt, `Scene ${state.active + 1} prompt`);
textarea.placeholder = "Write this scene's action, camera, performance, dialogue, and ending continuity…";
textarea.spellcheck = true;
textarea.title = "This is the actual active scene prompt in the connected H3 Chain Plan.";
textarea.classList.toggle("h3sp-hidden", state.decorated);
state.promptTextarea = textarea;
state.promptUndo = promptUndoForScene(shotId, textarea.value);
const referenceData = availableReferenceRecords(
node, state.active + 1, {includeInactive:true, prompt: [
sharedPrompt(state.plan).text.trim(), textarea.value.trim(),
].filter(Boolean).join("\n\n")});
state.records = referenceData.records;
state.referenceMode = referenceData.mode;
const editorShell = element("div", "h3sp-editor-shell");
editorShell.classList.toggle("h3sp-hidden", !state.decorated);
const richEditor = element("div", "h3sp-rich-editor");
richEditor.contentEditable = "true";
richEditor.spellcheck = true;
richEditor.tabIndex = 0;
richEditor.setAttribute("role", "textbox");
richEditor.setAttribute("aria-multiline", "true");
richEditor.dataset.placeholder = textarea.placeholder;
richEditor.title = textarea.title;
state.richEditor = richEditor;
renderRichEditorText(textarea.value);
editorShell.append(richEditor);
state.promptSelection = {
kind:state.decorated ? "rich" : "plain",
start:textarea.value.length,
end:textarea.value.length,
};
const tools = element("div", "h3sp-tools");
const refs = element("div", "h3sp-refs");
state.referenceTray = refs;
const referenceButton = button(
"@ Reference",
"Browse connected Ref2VA media; typing @ opens inline autocomplete",
() => {
state.completion?.hide();
const opening = !refs.classList.contains("h3sp-open");
if (opening) renderReferenceTray(refs);
refs.classList.toggle("h3sp-open", opening);
},
);
referenceButton.addEventListener("pointerdown", rememberPromptSelection);
const dialogueButton = button("Dialogue", "Wrap selection in dialogue tags", () => {
insertPromptDialogue();
});
const presentationButton = button(
state.decorated ? "Rich text" : "Plain text",
state.decorated
? "Show the same prompt as plain text"
: "Color recognized H3 references, subjects, and dialogue tags",
() => {
if (state.decorated && state.richEditor) {
textarea.value = editorPlainText(state.richEditor);
}
state.decorated = !state.decorated;
persistView();
render();
(state.decorated ? state.richEditor : state.promptTextarea)?.focus();
},
);
presentationButton.classList.toggle("h3sp-presentation-active", state.decorated);
tools.append(
referenceButton,
dialogueButton,
presentationButton,
element("span", "h3sp-hint",
"Alt+←/→ scenes · type @ # < [ ( · Ctrl/Cmd+Space for all"),
);
const footer = element("div", "h3sp-footer");
const identity = element(
"span", "", `Scene ${state.active + 1}/${state.plan.shots.length} · ${shotId}`,
);
const status = element("span", "h3sp-footer-status", "Synchronized with Plan");
const historyHost = element("div", "h3sp-history");
footer.append(identity, historyHost, status);
state.history.host = historyHost;
state.history.textarea = textarea;
state.history.status = status;
const applyPromptUndo = (direction, target) => {
const text = state.promptUndo?.[direction]?.();
if (text == null) return false;
const scrollTop = target.scrollTop;
const scrollLeft = target.scrollLeft;
const richCaret = target === richEditor
? selectionTextOffset(richEditor) : null;
textarea.value = text;
shot.prompt = promptTextToLines(text);
writePlan(status);
scheduleHistoryDraft(shotId, text);
renderRichEditorText(
text,
richCaret == null ? null : Math.min(richCaret, text.length),
);
if (target === textarea) textarea.setSelectionRange(text.length, text.length);
target.focus({preventScroll:true});
target.scrollTop = scrollTop;
target.scrollLeft = scrollLeft;
refreshAssistant();
state.schema?.refresh();
return true;
};
state.ownsPromptHistoryTarget = (target) => (
target === textarea || target === richEditor || richEditor.contains(target)
);
state.applyPromptHistoryShortcut = (direction, target) => applyPromptUndo(
direction, target === textarea ? textarea : richEditor,
);
const handlePromptUndo = (event, target) => {
const direction = promptUndoDirection(event);
if (!direction) return false;
event.preventDefault();
applyPromptUndo(direction, target);
return true;
};
textarea.addEventListener("input", (event) => {
state.promptUndo?.record(textarea.value, {
inputType:event.inputType || state.richInputType,
});
shot.prompt = promptTextToLines(textarea.value);
writePlan(status, {deferEffects:true});
scheduleHistoryDraft(shotId, textarea.value);
if (document.activeElement !== richEditor) renderRichEditorText(textarea.value);
state.completion?.refresh();
schedulePromptAnalysis();
});
for (const eventName of ["focus", "pointerup", "keyup", "select"]) {
textarea.addEventListener(eventName, rememberPromptSelection);
}
textarea.addEventListener("keydown", (event) => {
if (state.completion?.handleKeydown(event)) {
return;
} else if (handlePromptUndo(event, textarea)) {
return;
} else if (event.altKey && event.key === "ArrowLeft") {
event.preventDefault();
navigate(-1);
} else if (event.altKey && event.key === "ArrowRight") {
event.preventDefault();
navigate(1);
}
});
textarea.addEventListener("blur", () => {
flushPlanEffects();
flushPromptAnalysis();
});
richEditor.addEventListener("input", (event) => {
state.richInputType = event.inputType || "";
try {
textarea.value = editorPlainText(richEditor);
textarea.dispatchEvent(new Event("input", {bubbles: true}));
} finally {
state.richInputType = "";
}
});
for (const eventName of ["focus", "pointerup", "keyup"]) {
richEditor.addEventListener(eventName, rememberPromptSelection);
}
richEditor.addEventListener("beforeinput", (event) => {
if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") {
event.preventDefault();
insertPlainText(richEditor, "\n");
}
});
richEditor.addEventListener("paste", (event) => {
event.preventDefault();
insertPlainText(richEditor, event.clipboardData?.getData("text/plain") ?? "");
// Decorate pasted tokens now, retaining the existing text-level
// undo transaction and routing the save through the input handler.
renderRichEditorText(editorPlainText(richEditor, {trimFinalNewline:false}), selectionTextOffset(richEditor));
state.completion?.refresh();
});
richEditor.addEventListener("copy", (event) => copyEditorSelection(richEditor, event));
richEditor.addEventListener("cut", (event) => copyEditorSelection(richEditor, event, true));
richEditor.addEventListener("keydown", (event) => {
if (state.completion?.handleKeydown(event)) {
return;
} else if (handlePromptUndo(event, richEditor)) {
return;
} else if (event.altKey && event.key === "ArrowLeft") {
event.preventDefault();
navigate(-1);
} else if (event.altKey && event.key === "ArrowRight") {
event.preventDefault();
navigate(1);
} else if (event.key === "Escape") {
refs.classList.remove("h3sp-open");
}
});
richEditor.addEventListener("blur", () => {
flushPlanEffects();
flushPromptAnalysis();
renderRichEditorText(editorPlainText(richEditor));
});
const replacePromptText = (result, message = "H3 edit saved to Plan") => {
const text = String(result.text ?? "");
const refreshed = availableReferenceRecords(
node, state.active + 1, {
includeInactive:true,
prompt:[sharedPrompt(state.plan).text.trim(), text.trim()]
.filter(Boolean).join("\n\n"),
},
);
state.records = refreshed.records;
state.referenceMode = refreshed.mode;
textarea.value = text;
if (state.decorated) {
state.richInputType = "insertReplacementText";
try {
textarea.dispatchEvent(new Event("input", {bubbles:true}));
} finally {
state.richInputType = "";
}
renderRichEditorText(text, result.caret, result);
richEditor.focus();
} else {
textarea.setSelectionRange(
result.selectionStart ?? result.caret,
result.selectionEnd ?? result.caret,
);
textarea.dispatchEvent(new InputEvent("input", {
bubbles:true,
inputType:"insertReplacementText",
}));
textarea.focus();
}
status.textContent = message;
};
state.schema = createH3PromptSchemaController({
node,
propertyPrefix:"h3_scene_prompt_editor",
getText:() => state.decorated
? editorPlainText(richEditor) : textarea.value,
replaceText:replacePromptText,
focusAt:(caret) => {
const position = Math.max(0, Math.min(textarea.value.length, Number(caret) || 0));
if (state.decorated) {
renderRichEditorText(textarea.value, position);
richEditor.focus();
} else {
textarea.focus();
textarea.setSelectionRange(position, position);
}
},
getRecords:() => state.records,
defaultDuration:Number(shot.duration_seconds)
|| Number(shot.length) / 24 || 6,
defaultMode:["tagged", "scheduled", "native"].includes(state.referenceMode)
? "ref2va"
: state.referenceMode === "native_keyframes"
? state.records.filter((record) => record.active && record.kind === "picture").length >= 2
? "fl2va" : "i2va"
: "auto",
scopeKey:shotId,
markDirty:dirty,
});
if (state.schema) {
tools.prepend(state.schema.modeSelect, state.schema.toggle);
identity.append(document.createTextNode(" · "), state.schema.counts);
}
const activeInput = state.decorated ? richEditor : textarea;
state.completion = createPromptCompletionController({
input:activeInput,
maxItems:40,
getText:() => state.decorated
? editorPlainText(richEditor) : textarea.value,
getCaret:() => state.decorated
? selectionTextOffset(richEditor) : textarea.selectionStart,
getRecords:() => state.records,
getReferenceMode:() => state.referenceMode,
getMode:() => state.schema?.getMode() ?? "auto",
getAutomaticSuggestions:() => promptEditorPreferences().automaticSuggestions,
getAppendCompletionSpace:() => promptEditorPreferences().appendCompletionSpace,
getMarkerReplacement:() => promptEditorPreferences().markerReplacement,
replaceText:(result) => replacePromptText(result, "Completion saved to Plan"),
});
bindPromptMarkerInteractions({
input:activeInput, completion:state.completion,
getText:() => activePromptText(),
getCaret:() => state.decorated ? selectionTextOffset(richEditor) : textarea.selectionStart,
readFragment:(fragment) => editorPlainText(fragment, {trimFinalNewline:false}),
restoreCaret:(position) => state.decorated ? restoreCaret(richEditor, position)
: textarea.setSelectionRange(position, position),
getEnabled:() => promptEditorPreferences().markerReplacement,
getRecords:() => state.records,
beforeOpen:() => hidePopover(true),
});
root.append(head, nav, tools);
if (state.schema) root.append(state.schema.panel);
root.append(refs, textarea, editorShell);
if (PROMPT_ASSISTANT_ENABLED) {
const assistantHost = element("div", "h3sp-assist");
assistant.host = assistantHost;
renderAssistant(assistantHost, textarea);
root.append(assistantHost);
}
root.append(footer);
void loadHistory(shotId, textarea.value);
}
function loadPlan(force = false) {
if (state.disposed) return;
const planNode = upstreamPlanNode(node);
const planWidget = planNode?.widgets?.find((item) => item.name === "plan_json");
if (!planNode || !planWidget) {
if (force || state.planNode) {
state.plan = null;
state.planNode = null;
state.planWidget = null;
state.lastValue = "";
state.lastRunName = "";
showFailure("No connected H3 Chain Plan was found.");
}
return;
}
const value = String(planWidget.value ?? "");
const runName = String(planNode.widgets?.find(
(item) => item.name === "run_name",
)?.value ?? "").trim();
if (!force && planNode === state.planNode && value === state.lastValue
&& runName === state.lastRunName) return;
try {
const previousPlan = state.plan;
const previousActive = state.active;
const nextPlan = parsePlanJson(value);
state.active = activeSceneIndexAfterRefresh(
previousPlan, nextPlan, previousActive,
);
node.properties[ACTIVE_SCENE_PROPERTY] = state.active;
state.plan = nextPlan;
state.planNode = planNode;
state.planWidget = planWidget;
state.lastValue = value;
state.lastRunName = runName;
render();
} catch (error) {
showFailure(`Connected Plan JSON is invalid:\n${error.message}`);
}
}
const widget = node.addDOMWidget(
"h3_scene_prompt_editor", "h3-scene-prompt-editor", root,
{
serialize: false,
hideOnZoom: false,
getMinHeight: () => PROMPT_ASSISTANT_ENABLED ? 760 : 420,
},
);
widget.serialize = false;
const minimumWidth = PROMPT_ASSISTANT_ENABLED ? 760 : 700;
const minimumHeight = PROMPT_ASSISTANT_ENABLED ? 900 : 620;
const currentWidth = Number(node.size?.[0]);
const currentHeight = Number(node.size?.[1]);
// Undo only the exact assistant-era default dimensions. Preserve any node
// the user deliberately made larger.
const targetWidth = !PROMPT_ASSISTANT_ENABLED && currentWidth === 760
? minimumWidth : Math.max(currentWidth || minimumWidth, minimumWidth);
const targetHeight = !PROMPT_ASSISTANT_ENABLED && currentHeight === 900
? minimumHeight : Math.max(currentHeight || minimumHeight, minimumHeight);
node.setSize?.([
targetWidth,
targetHeight,
]);
const connectionsChanged = node.onConnectionsChange;
node.onConnectionsChange = function () {
const result = connectionsChanged?.apply(this, arguments);
setTimeout(() => loadPlan(true), 0);
return result;
};
const onPromptExecuted = (event) => {
const values = event.detail?.output?.h3_chain_active_scene;
const scene = Array.isArray(values) ? values.at(-1) : null;
if (!scene || String(scene.run_name ?? "") !== planRunName()) return;
const shot = state.plan?.shots?.[state.active];
const shotId = String(shot?.id ?? "");
if (!shotId || String(scene.shot_id ?? "") !== shotId) return;
// Current Shot marks the revision on the backend before emitting this
// event. Reload only the lightweight index; prompt content stays lazy.
window.setTimeout(() => {
if (state.history.sceneKey === historySceneKey(planRunName(), shotId)) {
void loadHistory(shotId, promptValueToText(shot.prompt), false);
}
}, 50);
};
api.addEventListener("executed", onPromptExecuted);
const onProjectAssetCatalogChanged = (event) => {
const changedProject = String(event?.detail?.project ?? "").trim();
const currentProject = planRunName();
if (changedProject && currentProject && changedProject !== currentProject) return;
if (!state.plan?.shots?.length) return;
const prompt = activePromptText();
const selection = state.decorated && state.richEditor
&& document.activeElement === state.richEditor
? editorSelectionOffsets(state.richEditor) : null;
refreshReferenceState(prompt);
if (state.decorated && state.richEditor) {
renderRichEditorText(prompt, null, selection ? {
selectionStart:selection.start,
selectionEnd:selection.end,
} : null);
}
if (state.referenceTray?.classList.contains("h3sp-open")) {
renderReferenceTray(state.referenceTray);
}
state.completion?.refresh();
state.schema?.refresh();
};
globalThis.addEventListener?.(
PROJECT_ASSET_CATALOG_CHANGED_EVENT, onProjectAssetCatalogChanged,
);
const removed = node.onRemoved;
node.onRemoved = function () {
// Workflow removal runs inside ComfyUI's tab/session teardown. The
// Plan value is already updated synchronously by commitPlan(), so only
// cancel deferred work here; callbacks and analysis can race teardown.
state.disposed = true;
const cleanups = [
() => {
if (state.planSyncTimer != null) window.clearTimeout(state.planSyncTimer);
state.planSyncTimer = null;
state.planSyncPending = null;
if (state.analysisTimer != null) window.clearTimeout(state.analysisTimer);
state.analysisTimer = null;
if (state.history.saveTimer != null) window.clearTimeout(state.history.saveTimer);
state.history.saveTimer = null;
state.history.pendingDraft = null;
state.history.loadToken += 1;
state.history.host = null;
},
() => { if (state.pollTimer != null) window.clearInterval(state.pollTimer); },
() => { if (state.popoverTimer != null) window.clearTimeout(state.popoverTimer); },
() => hidePopover(true),
() => state.popover?.remove(),
() => state.completion?.destroy(),
() => api.removeEventListener("executed", onPromptExecuted),
() => globalThis.removeEventListener?.(
PROJECT_ASSET_CATALOG_CHANGED_EVENT, onProjectAssetCatalogChanged,
),
];
if (PROMPT_ASSISTANT_ENABLED) {
assistant.preparingRequest = null;
cleanups.push(
() => clearAssistantReconnect(),
() => clearPendingRequest(),
() => assistant.client?.close(),
);
}
for (const cleanup of cleanups) {
try { cleanup(); }
catch (error) { console.warn("H3 Scene Prompt Editor cleanup failed", error); }
}
state.completion = null;
delete node._h3PromptCompanionSetActiveScene;
delete node._h3PromptCompanionSetScenePrompt;
return removed?.apply(this, arguments);
};
node._h3PromptCompanionSetActiveScene = (planNode, index) => {
if (planNode !== state.planNode || !state.plan?.shots?.length) return false;
navigate(0, index, {synchronize:false, focus:false});
return true;
};
node._h3PromptCompanionSetScenePrompt = (planNode, index, text) => {
if (planNode !== state.planNode || !state.plan?.shots?.[index]) return false;
const liveValue = String(state.planWidget?.value ?? "");
let livePlanParsed = false;
try {
const livePlan = parsePlanJson(liveValue);
livePlanParsed = true;
if (planHasNonPromptChanges(state.plan, livePlan)) {
loadPlan(true);
return true;
}
} catch (_error) {
// Leave lastValue untouched so normal polling reports invalid JSON.
}
state.plan.shots[index].prompt = promptTextToLines(text);
const shotId = String(
state.plan.shots[index].id
|| `clip_${String(index + 1).padStart(4, "0")}`,
);
const promptUndo = promptUndoForScene(shotId, text, {external:true});
if (index === state.active) state.promptUndo = promptUndo;
if (index === state.active) {
const textarea = root.querySelector(".h3sp-textarea");
if (textarea && textarea.value !== text) {
const focused = document.activeElement === textarea;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const richFocused = document.activeElement === state.richEditor;
const richCaret = richFocused ? selectionTextOffset(state.richEditor) : null;
textarea.value = text;
if (focused) textarea.setSelectionRange(
Math.min(start, text.length), Math.min(end, text.length));
renderRichEditorText(
text,
richCaret == null ? null : Math.min(richCaret, text.length),
);
scheduleHistoryDraft(
String(state.plan.shots[index].id || `clip_${String(index + 1).padStart(4, "0")}`),
text);
refreshAssistant();
state.schema?.refresh();
}
}
if (livePlanParsed) state.lastValue = liveValue;
return true;
};
node._h3ScenePromptEditorRefresh = () => loadPlan(true);
state.pollTimer = window.setInterval(() => loadPlan(false), 500);
loadPlan(true);
if (PROMPT_ASSISTANT_ENABLED) restorePendingRequest();
}
app.registerExtension({
name: "minimax_h3_context_loop.scene_prompt_editor",
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;
};
},
async nodeCreated(node) {
if (nodeType(node) === NODE_NAME) mount(node);
},
async afterConfigureGraph() {
for (const node of allNodes(app.graph)) {
if (nodeType(node) === NODE_NAME) {
setTimeout(() => node._h3ScenePromptEditorRefresh?.(), 0);
}
}
},
});