import {app} from "/scripts/app.js"; import {api} from "/scripts/api.js"; import { H3_CONTEXT_LENGTHS, MAX_SHOTS, automaticSceneColor, calculatePlanTiming, derivedSceneSeed, duplicateShot, formatClock, makeShot, moveShot, removePlanShot, parsePlanJson, planToJson, promptTextToLines, promptValueToText, randomSceneSeed, safeShotId, sceneContextLength, sceneContinuationMode, sceneLoRARoute, sceneVisualContextSource, sceneVisualContextLeadFrames, sceneVisualContextLeadSource, scenePromptSeedMode, sceneVideoBlendFrames, setScenePromptSeedMode, setShotLengthMode, setSharedPrompt, shotLengthMode, sharedPrompt, visualContextCompositions, } from "./h3_chain_plan_core.mjs?v=0.6.5"; import {availableReferenceRecords} from "./h3_reference_preview_core.mjs?v=0.6.2"; import { applySceneAudioOverride, applySceneLipSync, sceneLipSyncMode, applySceneTransitionPreset, primaryTransitionOptions, sceneAudioOverride, sceneAudioPolicy, sceneTransitionPreset, transitionPresetLabel, } from "./h3_policy_core.mjs?v=0.6.6"; import { resolveAudioContextLength, resolveAudioPolicy, resolveTransitionPolicy, } from "./h3_socket_presentation_core.mjs?v=0.6.6"; import { availableLoRARoutes, loraRouteLabel, } from "./h3_lora_scheduler_core.mjs?v=0.6.2"; import { MODERN_PLAN_NODE as MODERN_NODE_NAME, MODERN_PLAN_WIDGET_NAMES as MODERN_BACKING_WIDGETS, upgradeLegacyPlanNode, } from "./h3_plan_upgrade_core.mjs?v=0.6.2"; // This scene editor is an original implementation. Its quick @ reference and // # dialogue interactions are inspired by nkxx188/ComfyUI-MiniMaxH3-Easy, // distributed under the MIT License. See THIRD_PARTY_NOTICES.md. const EXTENSION = "minimax_h3_context_loop.chain_plan_editor"; const NODE_NAME = "MiniMaxH3ChainPlan"; const NODE_NAMES = new Set([NODE_NAME, MODERN_NODE_NAME]); const MIN_WIDTH = 700; const EDITOR_HEIGHT = 650; const SCENE_COLOR_PROPERTY = "h3_chain_scene_colors"; const LAYOUT_PROPERTY = "h3_chain_plan_layout"; function injectStyles() { if (document.getElementById("h3-chain-plan-editor-style")) return; const style = document.createElement("style"); style.id = "h3-chain-plan-editor-style"; style.textContent = ` .h3c-editor { --h3c-bg: color-mix(in srgb, var(--comfy-menu-bg, #202124) 90%, #111827); --h3c-panel: color-mix(in srgb, var(--comfy-input-bg, #111827) 84%, #24304a); --h3c-border: color-mix(in srgb, var(--border-color, #555) 70%, #7c8db5); --h3c-text: var(--input-text, #e8eaf0); --h3c-muted: color-mix(in srgb, var(--h3c-text) 58%, transparent); --h3c-accent: #7fa8ff; box-sizing: border-box; width: 100%; height: 100%; max-height: 100%; min-height: 0; overflow: auto; contain: layout paint; padding: 10px; border: 1px solid var(--h3c-border); border-radius: 8px; background: var(--h3c-bg); color: var(--h3c-text); font: 12px/1.35 system-ui, sans-serif; scrollbar-gutter: stable; } .h3c-editor *, .h3c-editor *::before, .h3c-editor *::after { box-sizing: border-box; } .h3c-editor input, .h3c-editor textarea, .h3c-editor select, .h3c-editor button { color: var(--h3c-text); font: inherit; } .h3c-editor input, .h3c-editor textarea, .h3c-editor select { width: 100%; min-width: 0; padding: 6px 7px; border: 1px solid var(--h3c-border); border-radius: 5px; background: var(--comfy-input-bg, #15171d); } .h3c-editor textarea { resize: vertical; line-height: 1.45; } .h3c-editor button { padding: 5px 8px; border: 1px solid var(--h3c-border); border-radius: 5px; background: var(--comfy-input-bg, #252832); cursor: pointer; white-space: nowrap; } .h3c-editor button:hover { border-color: var(--h3c-accent); } .h3c-editor button:disabled { cursor: not-allowed; opacity: .4; } .h3c-header, .h3c-toolbar, .h3c-card-head, .h3c-prompt-tools, .h3c-json-actions, .h3c-footer { display: flex; align-items: center; gap: 6px; } .h3c-header { justify-content: space-between; margin-bottom: 8px; } .h3c-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:7px; min-width:0; } .h3c-open-output { display:inline-flex; align-items:center; gap:5px; padding:4px 7px !important; } .h3c-folder-icon { width:15px; height:15px; flex:none; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; } .h3c-title { font-size: 15px; font-weight: 700; } .h3c-summary { color: var(--h3c-muted); text-align: right; } .h3c-external-plan { margin-bottom: 8px; padding: 7px 9px; border: 1px solid color-mix(in srgb, var(--h3c-accent) 65%, var(--h3c-border)); border-radius: 6px; background: color-mix(in srgb, var(--h3c-accent) 12%, var(--h3c-panel)); color: var(--h3c-text); } .h3c-external-plan strong { color: var(--h3c-accent); } .h3c-section { margin-bottom: 9px; padding: 9px; border: 1px solid var(--h3c-border); border-radius: 7px; background: var(--h3c-panel); } .h3c-settings { display:none; } .h3c-settings.h3c-open { display:block; } .h3c-settings-head { display:flex; align-items:center; gap:8px; margin-bottom:8px; } .h3c-settings-head strong { font-size:13px; } .h3c-policy-status { margin-left:auto; color:var(--h3c-muted); } .h3c-policy-status.h3c-missing { color:#ffb4b8; } .h3c-settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .h3c-settings-group { min-width:0; padding:8px; border:1px solid var(--h3c-border); border-radius:6px; background:var(--h3c-bg); } .h3c-settings-group-title { margin-bottom:7px; color:var(--h3c-accent); font-weight:700; } .h3c-settings-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } .h3c-settings-fields .h3c-wide { grid-column:1 / -1; } .h3c-managed-note { margin-top:6px; color:var(--h3c-muted); } .h3c-label { display: block; margin-bottom: 4px; color: var(--h3c-muted); font-weight: 650; } .h3c-help { margin-top: 4px; color: var(--h3c-muted); } .h3c-prefix { min-height: 88px; } .h3c-toolbar { position: sticky; top: -10px; z-index: 4; padding: 7px 0; background: var(--h3c-bg); } .h3c-toolbar .h3c-spacer { flex: 1; } .h3c-card { --h3c-scene-color: var(--h3c-accent); margin-bottom: 9px; padding: 8px; border: 1px solid color-mix(in srgb, var(--h3c-scene-color) 58%, var(--h3c-border)); border-left: 4px solid var(--h3c-scene-color); border-radius: 7px; background: linear-gradient(90deg, color-mix(in srgb, var(--h3c-scene-color) 7%, transparent), transparent 140px), var(--h3c-panel); } .h3c-card.h3c-invalid { border-left-color: #ff6b72; } .h3c-card.h3c-drag-over { outline: 2px solid var(--h3c-accent); } .h3c-card-head { margin-bottom: 7px; } .h3c-drag { cursor: grab; user-select: none; padding: 4px 3px; color: var(--h3c-muted); } .h3c-index { min-width: 58px; font-weight: 700; } .h3c-color { width: 23px !important; min-width: 23px !important; height: 23px; padding: 1px !important; border-radius: 50% !important; cursor: pointer; } .h3c-color::-webkit-color-swatch-wrapper { padding: 1px; } .h3c-color::-webkit-color-swatch { border: 0; border-radius: 50%; } .h3c-color::-moz-color-swatch { border: 0; border-radius: 50%; } .h3c-id { flex: 1; } .h3c-timing { color: var(--h3c-muted); white-space: nowrap; } .h3c-length-row { display: grid; grid-template-columns: 115px minmax(120px, 1fr) 190px; gap: 7px; align-items: center; margin-bottom: 7px; } .h3c-prompt { min-height: 112px; } .h3c-prompt-tools { position: relative; margin: 5px 0 2px; } .h3c-prompt-tools .h3c-hint { color: var(--h3c-muted); margin-left: auto; } .h3c-ref-menu { display: none; flex-wrap: wrap; gap: 4px; margin-top: 6px; padding: 6px; border: 1px solid var(--h3c-border); border-radius: 6px; background: var(--h3c-bg); } .h3c-ref-menu.h3c-open { display: flex; } .h3c-ref-menu button { padding: 3px 6px; color: var(--h3c-accent); } .h3c-seed-control { display:grid; grid-template-columns:minmax(210px,1fr) auto auto; align-items:center; gap:6px; } .h3c-seed-status { grid-column:1 / -1; color:var(--h3c-muted); overflow-wrap:anywhere; } .h3c-boundary-fields { display:grid; grid-template-columns:minmax(220px,1fr) minmax(180px,.7fr); gap:7px; margin-top:8px; } .h3c-audio-fields { display:grid; grid-template-columns:repeat(3,minmax(150px,1fr)); gap:7px; margin-top:8px; } .h3c-advanced-fields { display: none; grid-template-columns:repeat(4, minmax(140px, 1fr)); gap: 7px; margin-top: 8px; } .h3c-editor.h3c-show-advanced .h3c-advanced-fields { display: grid; } .h3c-errors { display: none; margin: 7px 0; padding: 7px; border-radius: 5px; color: #ffb4b8; background: #5d202866; white-space: pre-wrap; } .h3c-errors.h3c-open { display: block; } .h3c-json-panel { display: none; } .h3c-json-panel.h3c-open { display: block; } .h3c-json { min-height: 260px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; } .h3c-json-actions { margin-top: 6px; } .h3c-json-status { color: var(--h3c-muted); } .h3c-footer { justify-content: space-between; padding-top: 4px; color: var(--h3c-muted); } .h3c-footer a { color: var(--h3c-accent); } @media (max-width: 650px) { .h3c-length-row, .h3c-advanced-fields, .h3c-seed-control, .h3c-boundary-fields, .h3c-audio-fields, .h3c-settings-grid, .h3c-settings-fields { grid-template-columns: 1fr; } .h3c-settings-fields .h3c-wide { grid-column:1; } .h3c-seed-status { grid-column:1; } .h3c-card-head { flex-wrap: wrap; } .h3c-timing { width: 100%; } } `; 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"; if (title) item.title = title; item.addEventListener("click", action); return item; } function folderOpenIcon() { const namespace = "http://www.w3.org/2000/svg"; const icon = document.createElementNS(namespace, "svg"); icon.classList.add("h3c-folder-icon"); icon.setAttribute("viewBox", "0 0 24 24"); icon.setAttribute("aria-hidden", "true"); const folder = document.createElementNS(namespace, "path"); folder.setAttribute( "d", "M3.5 8.5V6.25A1.75 1.75 0 0 1 5.25 4.5h4.1l2.1 2.25h7.3a1.75 1.75 0 0 1 1.75 1.75v1", ); const opening = document.createElementNS(namespace, "path"); opening.setAttribute( "d", "M4.5 9.5h15.35a1.35 1.35 0 0 1 1.3 1.72l-2.05 7.1a1.6 1.6 0 0 1-1.54 1.18H5.4a1.6 1.6 0 0 1-1.57-1.3L2.9 13.15A3.1 3.1 0 0 1 4.5 9.5Z", ); icon.append(folder, opening); return icon; } function setOutputButtonLabel(item, label, showIcon = false) { item.replaceChildren(); if (showIcon) item.append(folderOpenIcon()); item.append(document.createTextNode(label)); } function field(label, control) { const wrap = element("label", ""); wrap.append(element("span", "h3c-label", label), control); return wrap; } function numberInput(value, options = {}) { const input = element("input"); input.type = "number"; input.value = value ?? ""; for (const [key, setting] of Object.entries(options)) input[key] = setting; return input; } function selectInput(value, choices) { const select = element("select"); for (const [choice, label] of choices) { const option = element("option", "", label); option.value = choice; select.append(option); } select.value = String(value ?? ""); return select; } function widgetValue(node, name, fallback) { const widget = node.widgets?.find((item) => item.name === name); return widget?.value ?? fallback; } function inputConnected(node, name) { const input = node.inputs?.find((item) => item.name === name); return input?.link !== null && input?.link !== undefined; } function setWidgetValue(node, name, value) { const widget = node.widgets?.find((item) => item.name === name); if (!widget || Object.is(widget.value, value)) return; widget.value = value; widget.callback?.(value); node.graph?.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } function collapseWidget(widget) { widget._h3OriginalType ??= widget.type; widget._h3OriginalComputeSize ??= widget.computeSize; widget._h3OriginalDraw ??= widget.draw; widget.hidden = true; widget.type = "hidden"; widget.computeSize = () => [0, -4]; // Modern and legacy canvas paths do not agree on whether a hidden widget // may still draw. This field is internal editor state; preserve its value // for serialization while ensuring it can never cover native settings. widget.draw = () => {}; // Multiline STRING widgets are real DOM textareas in current ComfyUI. // Collapsing only the LiteGraph geometry leaves that textarea floating at // its previous coordinates, over the scene editor. Hide both possible DOM // handles while retaining widget.value for workflow serialization. const elements = new Set([widget.inputEl, widget.element]); for (const item of elements) { if (!item?.style) continue; item.style.setProperty("display", "none", "important"); item.style.setProperty("pointer-events", "none", "important"); item.setAttribute?.("aria-hidden", "true"); } // Current ComfyUI places DOM widgets in a separate Vue-owned wrapper. // Its widget object is markRaw, so changing `hidden` can leave that empty // wrapper pointer-active until a later renderer transition. Unregister // only the DOM surface: this widget deliberately remains in node.widgets // with its value and callback intact for workflow serialization. if (widget.element && widget.id && typeof widget.onRemove === "function") { widget.onRemove(); } } function collapseModernBackingWidgets(node) { if ((node.comfyClass ?? node.type) !== MODERN_NODE_NAME) return; for (const name of MODERN_BACKING_WIDGETS) { const widget = node.widgets?.find((item) => item.name === name); if (widget) collapseWidget(widget); } } function setProjectAssetManagedWidget(widget, managed) { if (!widget) return; widget._h3ProjectAssetOriginal ??= { hidden: widget.hidden, type: widget.type, computeSize: widget.computeSize, draw: widget.draw, disabled: widget.disabled, }; if (widget._h3ProjectAssetManaged === managed) return; widget._h3ProjectAssetManaged = managed; const original = widget._h3ProjectAssetOriginal; if (managed) { widget.hidden = true; widget.type = "hidden"; widget.computeSize = () => [0, -4]; widget.draw = () => {}; widget.disabled = true; } else { widget.hidden = original.hidden; widget.type = original.type; widget.computeSize = original.computeSize; widget.draw = original.draw; widget.disabled = original.disabled; } for (const item of new Set([widget.inputEl, widget.element])) { if (!item?.style) continue; if (managed) { item.style.setProperty("display", "none", "important"); item.style.setProperty("pointer-events", "none", "important"); item.setAttribute?.("aria-hidden", "true"); } else { item.style.removeProperty("display"); item.style.removeProperty("pointer-events"); item.removeAttribute?.("aria-hidden"); } } } function colorOverrides(node) { node.properties ??= {}; const current = node.properties[SCENE_COLOR_PROPERTY]; if (!current || typeof current !== "object" || Array.isArray(current)) { node.properties[SCENE_COLOR_PROPERTY] = {}; } return node.properties[SCENE_COLOR_PROPERTY]; } function planLayout(node) { node.properties ??= {}; const current = node.properties[LAYOUT_PROPERTY]; if (!current || typeof current !== "object" || Array.isArray(current)) { node.properties[LAYOUT_PROPERTY] = {}; } const layout = node.properties[LAYOUT_PROPERTY]; if (!layout.promptHeights || typeof layout.promptHeights !== "object" || Array.isArray(layout.promptHeights)) { layout.promptHeights = {}; } return layout; } function sceneColorKey(shot, index) { return safeShotId(shot?.id, `clip_${String(index + 1).padStart(4, "0")}`); } function sceneColor(node, shot, index) { const override = colorOverrides(node)[sceneColorKey(shot, index)]; return /^#[0-9a-f]{6}$/i.test(String(override || "")) ? String(override).toLowerCase() : automaticSceneColor(index); } 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}`; const offset = selected ? markup.length : 3; insertText(textarea, markup, offset); } function downloadJson(value, filename) { const blob = new Blob([value], {type: "application/json"}); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 0); } function replaceWithModernPlan(node) { const createNode = globalThis.LiteGraph?.createNode; const result = upgradeLegacyPlanNode(node, { createNode, confirmUpgrade: () => ( typeof globalThis.confirm !== "function" || globalThis.confirm( "Replace this Plan with MiniMax H3 Plan (Modern)?\n\n" + "Scenes, supported settings, position, and links are preserved. " + "Legacy fallback settings are intentionally not copied.", ) ), }); if (result.ok) { app.canvas?.selectNode?.(result.node); return; } if (result.reason === "cancelled") return; const messages = { policy_required: "Connect MiniMax H3 Generation Profile to this Plan before upgrading. " + "The Modern Plan has no legacy continuity or audio fallbacks, so the " + "original node was kept unchanged.", modern_unregistered: "MiniMax H3 Plan (Modern) is not registered. Restart ComfyUI after " + "updating the node pack.", create_unavailable: "ComfyUI could not create the Modern Plan node.", }; globalThis.alert?.(messages[result.reason] ?? "The Plan could not be upgraded."); } function mountEditor(node) { if (node._h3ChainEditor || typeof node.addDOMWidget !== "function") return; const planWidget = node.widgets?.find((widget) => widget.name === "plan_json"); if (!planWidget) return; const modern = (node.comfyClass ?? node.type) === MODERN_NODE_NAME; injectStyles(); const root = element("div", "h3c-editor"); root.title = "Build an ordered MiniMax H3 scene plan. Hover individual controls for wiring, timing, and formatting guidance."; for (const eventName of [ "pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick", ]) { root.addEventListener(eventName, (event) => event.stopPropagation()); } root.addEventListener("wheel", (event) => event.stopPropagation()); const savedLayout = planLayout(node); const state = { plan: null, advanced: Boolean(savedLayout.advanced), jsonOpen: Boolean(savedLayout.jsonOpen), settingsOpen: modern && savedLayout.settingsOpen !== false, lastWidgetValue: "", syncing: false, draggedIndex: null, resizeObservers: [], seedRefreshers: [], }; node._h3ChainEditor = state; collapseWidget(planWidget); collapseModernBackingWidgets(node); const domWidget = node.addDOMWidget("h3_chain_scene_editor", "h3-chain-editor", root, { serialize: false, hideOnZoom: false, getMinHeight: () => EDITOR_HEIGHT, }); domWidget.serialize = false; function graphDirty() { node.graph?.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } function savePanelState() { const layout = planLayout(node); layout.advanced = state.advanced; layout.jsonOpen = state.jsonOpen; layout.settingsOpen = state.settingsOpen; graphDirty(); } function disconnectResizeObservers() { for (const observer of state.resizeObservers) observer.disconnect?.(); state.resizeObservers = []; } function bindTextareaHeight(textarea, key, minimum) { const heights = planLayout(node).promptHeights; const saved = Number(heights[key]); if (Number.isFinite(saved) && saved >= minimum) { textarea.style.height = `${Math.round(saved)}px`; } if (typeof ResizeObserver !== "function") return; let initialized = false; const observer = new ResizeObserver(() => { if (!textarea.isConnected) return; const next = Math.round(textarea.offsetHeight); if (!Number.isFinite(next) || next < minimum) return; if (!initialized) { initialized = true; return; } const current = planLayout(node).promptHeights; if (Number(current[key]) === next) return; current[key] = next; node.properties[LAYOUT_PROPERTY] = { ...planLayout(node), promptHeights: {...current}, }; graphDirty(); }); observer.observe(textarea); state.resizeObservers.push(observer); } function saveSceneColor(key, value) { const colors = colorOverrides(node); if (value) colors[key] = value.toLowerCase(); else delete colors[key]; node.properties[SCENE_COLOR_PROPERTY] = {...colors}; graphDirty(); } function applyResponsiveSize() { collapseWidget(planWidget); collapseModernBackingWidgets(node); const width = Math.max(Number(node.size?.[0]) || MIN_WIDTH, MIN_WIDTH); // Preserve the workflow/user-selected height. The DOM widget receives // all free vertical space above its minimum, so taller nodes reveal // more scene cards and shorter nodes scroll internally. const height = Math.max(Number(node.size?.[1]) || 0, EDITOR_HEIGHT + 120); // Call even when dimensions are unchanged so ComfyUI recomputes the // DOM widget's free-space allocation after plan_json was collapsed. node.setSize?.([width, height]); graphDirty(); } function scheduleResponsiveSize() { // Workflow configuration restores serialized dimensions after // onNodeCreated. Reallocate the DOM viewport once layout has settled. requestAnimationFrame(() => requestAnimationFrame(applyResponsiveSize)); setTimeout(applyResponsiveSize, 150); } function syncProjectAssetManagedWidgets() { const managed = inputConnected(node, "project_assets"); setProjectAssetManagedWidget( node.widgets?.find((item) => item.name === "run_name"), managed, ); setProjectAssetManagedWidget( node.widgets?.find((item) => item.name === "generation_fingerprint"), managed, ); root.classList.toggle("h3c-project-assets-managed", managed); root.title = managed ? "Run name and reference fingerprint are managed by the connected Project Assets node." : "Build an ordered MiniMax H3 scene plan. Hover individual controls for wiring, timing, and formatting guidance."; } function currentSettings() { const transition = resolveTransitionPolicy(node); const audioPolicy = resolveAudioPolicy(node); return { contextLength: transition.known ? transition.contextLength : widgetValue(node, "context_length", 22), audioContextLength: resolveAudioContextLength(node), videoBlendFrames: widgetValue(node, "video_blend_frames", 0), encodeMode: widgetValue(node, "encode_mode", "video"), anchorMode: widgetValue(node, "anchor_mode", "head"), continuationMode: transition.known ? transition.continuationMode : widgetValue(node, "continuation_mode", "guide"), generatedContinuity: audioPolicy.known ? audioPolicy.generatedContinuity : "on", sourceAudioTarget: audioPolicy.known ? audioPolicy.sourceAudioTarget ?? "off" : "off", transitionPreset: transition.known ? transition.preset : "custom", audioPolicy, defaultDurationSeconds: widgetValue(node, "default_duration_seconds", 15), defaultSteps: widgetValue(node, "default_steps", 20), }; } function timing() { return calculatePlanTiming(state.plan, currentSettings()); } function syncPlan() { if (!state.plan) return; const value = planToJson(state.plan); state.syncing = true; planWidget.value = value; state.lastWidgetValue = value; state.syncing = false; const jsonArea = root.querySelector(".h3c-json"); if (jsonArea && document.activeElement !== jsonArea) jsonArea.value = value; updateTiming(); graphDirty(); } function updateTiming() { if (!state.plan) return; const result = timing(); const summary = root.querySelector(".h3c-summary"); if (summary) { summary.textContent = `${result.shots.length} scenes · ${result.totalFrames} delivered frames · ${formatClock(result.totalSeconds)}`; } for (const row of result.shots) { const card = root.querySelector(`.h3c-card[data-index="${row.index - 1}"]`); if (!card) continue; const label = card.querySelector(".h3c-timing"); if (label) { label.textContent = `${row.rawFrames || "—"} raw / ${row.deliveredFrames || "—"} delivered · ${formatClock(row.deliveredSeconds)}${row.loraRoute === "base" ? "" : ` · LoRA ${row.loraRoute.toUpperCase()}`}`; label.title = row.errors.join("\n") || `Generation starts at delivered frame ${row.generationStartFrame}. ` + `The incoming assembly boundary blends ${row.videoBlendFrames} frame(s).`; } card.classList.toggle("h3c-invalid", row.errors.length > 0); } const errors = root.querySelector(".h3c-errors"); if (errors) { errors.textContent = result.errors.join("\n"); errors.classList.toggle("h3c-open", result.errors.length > 0); } } function promptTools(textarea, scene = null) { const wrap = element("div", "h3c-prompt-tools"); const references = button("@ Reference", "Insert a MiniMax reference tag", () => { const opening = !menu.classList.contains("h3c-open"); if (opening) renderReferenceMenu(); menu.classList.toggle("h3c-open", opening); }); const dialogue = button("# Dialogue", "Wrap the selection in dialogue tags", () => { insertDialogue(textarea); }); const hint = element("span", "h3c-hint", "Shortcuts: @ reference · # dialogue"); const menu = element("div", "h3c-ref-menu"); function renderReferenceMenu() { menu.replaceChildren(); const requestedScene = scene ?? 1; const referenceData = availableReferenceRecords( node, requestedScene, { includeInactive: true, prompt: [ scene == null ? "" : sharedPrompt(state.plan).text.trim(), textarea.value.trim(), ].filter(Boolean).join("\n\n"), }, ); const {mode} = referenceData; const records = mode === "tagged" || scene == null ? referenceData.records : referenceData.records.filter((record) => record.active); if (!records.length) { menu.append(element( "span", "h3c-help", scene == null ? "No references connected to a downstream Ref2VA/I2V node." : `No connected references are active in scene ${scene}.`, )); return; } menu.append(element( "span", "h3c-help", mode === "tagged" ? "Connected prompt-driven references. Insert an @tag to activate that asset in this scene; it compiles to a native H3 label." : mode === "scheduled" ? "Connected scheduled references only. @aliases are optional authoring shortcuts that compile to native labels; the scheduler inserts no prompt text." : "Connected core references only. These use native labels; @aliases are not required.", )); for (const record of records) { const aliasMode = mode === "scheduled" || mode === "tagged"; const mapping = aliasMode && record.label ? ` → ${record.label}` : ""; menu.append(button( `${record.token}${mapping}`, aliasMode ? `Insert ${record.token}. It ${mode === "tagged" ? "activates this reference and " : ""}compiles to ${record.label ?? "a scene-local native label"}.` : `Insert ${record.token} for the connected core reference.`, () => { insertText(textarea, record.token); menu.classList.remove("h3c-open"); }, )); } } textarea.addEventListener("keydown", (event) => { if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.key === "@") { event.preventDefault(); renderReferenceMenu(); menu.classList.add("h3c-open"); references.focus(); } else if (event.key === "#") { event.preventDefault(); insertDialogue(textarea); } }); wrap.append(references, dialogue, hint); const group = element("div"); group.append(wrap, menu); return group; } function renderCard(shot, index) { const card = element("section", "h3c-card"); card.dataset.index = String(index); card.style.setProperty("--h3c-scene-color", sceneColor(node, shot, index)); card.addEventListener("dragover", (event) => { event.preventDefault(); card.classList.add("h3c-drag-over"); }); card.addEventListener("dragleave", () => card.classList.remove("h3c-drag-over")); card.addEventListener("drop", (event) => { event.preventDefault(); card.classList.remove("h3c-drag-over"); if (state.draggedIndex === null || state.draggedIndex === index) return; moveShot(state.plan.shots, state.draggedIndex, index); state.draggedIndex = null; syncPlan(); render(); }); const head = element("div", "h3c-card-head"); const drag = element("span", "h3c-drag", "⠿"); drag.title = "Drag to reorder"; drag.draggable = true; drag.addEventListener("dragstart", (event) => { state.draggedIndex = index; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", String(index)); }); drag.addEventListener("dragend", () => { state.draggedIndex = null; root.querySelectorAll(".h3c-drag-over").forEach((item) => item.classList.remove("h3c-drag-over")); }); const ordinal = element("span", "h3c-index", `Scene ${index + 1}`); const color = element("input", "h3c-color"); color.type = "color"; color.value = sceneColor(node, shot, index); color.title = "Scene border color. Double-click to restore the automatic color."; color.setAttribute("aria-label", `Scene ${index + 1} border color`); color.addEventListener("input", () => { saveSceneColor(sceneColorKey(shot, index), color.value); card.style.setProperty("--h3c-scene-color", color.value); }); color.addEventListener("dblclick", (event) => { event.preventDefault(); saveSceneColor(sceneColorKey(shot, index), null); color.value = automaticSceneColor(index); card.style.setProperty("--h3c-scene-color", color.value); }); const id = element("input", "h3c-id"); id.type = "text"; id.placeholder = `clip_${String(index + 1).padStart(4, "0")}`; id.value = shot.id ?? ""; id.title = "Stable scene ID used in checkpoint filenames and resume validation. Keep it unique and avoid changing it after rendering."; id.addEventListener("input", () => { const previousKey = sceneColorKey(shot, index); const previousId = safeShotId( shot.id, `clip_${String(index + 1).padStart(4, "0")}`, ); if (id.value) shot.id = id.value; else delete shot.id; const nextId = safeShotId( shot.id, `clip_${String(index + 1).padStart(4, "0")}`, ); for (const chapter of state.plan.chapters ?? []) { if (chapter.start_scene_id === previousId) { chapter.start_scene_id = nextId; } } const nextKey = sceneColorKey(shot, index); const colors = colorOverrides(node); if (previousKey !== nextKey && colors[previousKey]) { colors[nextKey] = colors[previousKey]; delete colors[previousKey]; node.properties[SCENE_COLOR_PROPERTY] = {...colors}; } const heights = planLayout(node).promptHeights; const previousPromptKey = `scene:${previousKey}`; const nextPromptKey = `scene:${nextKey}`; if (previousPromptKey !== nextPromptKey && heights[previousPromptKey]) { heights[nextPromptKey] = heights[previousPromptKey]; delete heights[previousPromptKey]; } syncPlan(); void refreshSeedStatus(); }); const timingLabel = element("span", "h3c-timing"); const up = button("↑", "Move scene up", () => { moveShot(state.plan.shots, index, index - 1); syncPlan(); render(); }); up.disabled = index === 0; const down = button("↓", "Move scene down", () => { moveShot(state.plan.shots, index, index + 1); syncPlan(); render(); }); down.disabled = index === state.plan.shots.length - 1; const copy = button("Duplicate", "Duplicate this scene", () => { if (state.plan.shots.length >= MAX_SHOTS) return; duplicateShot(state.plan.shots, index); syncPlan(); render(); }); const remove = button("Delete", "Delete this scene", () => { if (state.plan.shots.length <= 1) return; if (!window.confirm(`Delete scene ${index + 1}?`)) return; saveSceneColor(sceneColorKey(shot, index), null); removePlanShot(state.plan, index); syncPlan(); render(); }); remove.disabled = state.plan.shots.length <= 1; head.append(drag, color, ordinal, id, timingLabel, up, down, copy, remove); const lengthRow = element("div", "h3c-length-row"); const mode = element("select"); mode.title = "Choose whether this scene inherits the plan duration, requests seconds that are rounded up, or specifies an exact H3-valid frame count."; for (const [value, label] of [["default", "Plan default"], ["seconds", "Seconds"], ["frames", "Exact frames"]]) { const option = element("option", "", label); option.value = value; mode.append(option); } mode.value = shotLengthMode(shot); const value = numberInput("", {min: "0.01", step: "0.01"}); const lengthHelp = element("span", "h3c-help"); function refreshLengthControl() { const selected = shotLengthMode(shot); mode.value = selected; value.disabled = selected === "default"; if (selected === "seconds") { value.min = "0.01"; value.max = String(3592 / 24); value.step = "0.01"; value.value = shot.duration_seconds ?? ""; lengthHelp.textContent = "Rounded up to 17k+5."; value.title = "Requested seconds. The backend rounds up to the next H3-valid 17k+5 frame length at 24 fps."; } else if (selected === "frames") { value.min = "5"; value.max = "3592"; value.step = "17"; value.value = shot.length ?? shot.frames ?? ""; lengthHelp.textContent = "Must satisfy length % 17 = 5."; value.title = "Exact raw generation frames. Valid values satisfy frames % 17 = 5 and range from 5 to 3592."; } else { value.value = ""; lengthHelp.textContent = "Uses the Plan node default."; value.title = "Disabled because this scene inherits the Plan node default duration."; } } mode.addEventListener("change", () => { const fallback = widgetValue(node, "default_duration_seconds", 15); setShotLengthMode(shot, mode.value, fallback); refreshLengthControl(); syncPlan(); }); value.addEventListener("input", () => { if (!value.value) return; if (mode.value === "seconds") shot.duration_seconds = Number(value.value); if (mode.value === "frames") { shot.length = Number(value.value); delete shot.frames; } syncPlan(); }); refreshLengthControl(); lengthRow.append(mode, value, lengthHelp); const prompt = element("textarea", "h3c-prompt"); prompt.value = promptValueToText(shot.prompt, `Scene ${index + 1} prompt`); prompt.placeholder = "Optional with a shared prompt; otherwise describe this scene…"; prompt.title = "Scene-specific action, camera, performance, dialogue, and ending continuity. The shared prompt is automatically prepended; at least one of the two must contain text."; prompt.spellcheck = true; prompt.addEventListener("input", () => { shot.prompt = promptTextToLines(prompt.value); syncPlan(); }); bindTextareaHeight( prompt, `scene:${sceneColorKey(shot, index)}`, 112, ); const promptSeedControl = element("div", "h3c-seed-control"); const promptSeedMode = element("select"); for (const [value, label] of [ ["inherit", "Stable derived"], ["fixed", "Fixed scene seed"], ["randomize", "Randomize each queue"], ]) { const option = element("option", "", label); option.value = value; promptSeedMode.append(option); } const promptSeed = element("input"); promptSeed.type = "text"; promptSeed.inputMode = "numeric"; promptSeed.placeholder = "Scene prompt seed"; promptSeed.title = "Unsigned 64-bit seed used only to choose this scene's {one|two} prompt alternatives. It never changes the sampler seed."; const newPromptSeed = button( "New random", "Create and store a new fixed alternative-choice seed for only this scene", () => { shot.prompt_seed_mode = "fixed"; shot.prompt_seed = randomSceneSeed(); refreshPromptSeedControl(); syncPlan(); }, ); function refreshPromptSeedControl() { const selected = scenePromptSeedMode(shot); promptSeedMode.value = selected; promptSeed.disabled = selected !== "fixed"; newPromptSeed.disabled = selected !== "fixed"; promptSeed.value = selected === "fixed" ? (shot.prompt_seed ?? "") : ""; if (selected === "inherit") { promptSeed.title = "This scene derives a stable alternative-choice seed from its scene index and ID."; } else if (selected === "randomize") { promptSeed.title = "A fresh alternative-choice seed is generated whenever this Plan is queued. The exact resolved seed is still saved with the scene checkpoint."; } else { promptSeed.title = "Unsigned 64-bit seed used only to choose this scene's {one|two} prompt alternatives. It never changes the sampler seed."; } } promptSeedMode.addEventListener("change", () => { setScenePromptSeedMode(shot, promptSeedMode.value); refreshPromptSeedControl(); syncPlan(); }); promptSeed.addEventListener("change", () => { if (promptSeed.value.trim()) shot.prompt_seed = promptSeed.value.trim(); else shot.prompt_seed = randomSceneSeed(); setScenePromptSeedMode(shot, "fixed"); refreshPromptSeedControl(); syncPlan(); }); promptSeedControl.append(promptSeedMode, promptSeed, newPromptSeed); refreshPromptSeedControl(); const seedControl = element("div", "h3c-seed-control"); const seed = element("input"); seed.type = "text"; seed.inputMode = "numeric"; seed.placeholder = "Blank = stable derived seed"; seed.value = shot.seed ?? ""; seed.title = "Explicit unsigned 64-bit seed for this scene. Leave blank to use the displayed stable seed derived from base_seed, scene index, and scene ID."; const seedStatus = element("span", "h3c-seed-status"); async function refreshSeedStatus() { const explicit = seed.value.trim(); useDerivedSeed.disabled = !explicit; if (explicit) { seedStatus.textContent = `Explicit seed: ${explicit}`; seedStatus.title = "This exact seed is stored in plan_json."; return; } const baseSeed = widgetValue(node, "base_seed", 0); const shotId = safeShotId( shot.id, `clip_${String(index + 1).padStart(4, "0")}`, ); const request = `${baseSeed}:${index + 1}:${shotId}`; seedStatus.dataset.request = request; seedStatus.textContent = "Resolving stable derived seed…"; try { const resolved = await derivedSceneSeed(baseSeed, index + 1, shotId); if (seedStatus.isConnected && !seed.value.trim() && seedStatus.dataset.request === request) { seedStatus.textContent = `Derived seed: ${resolved}`; seedStatus.title = `Stable result of base_seed ${baseSeed}, scene ${index + 1}, and ID ${shotId}. It will repeat until one of those values changes.`; } } catch (error) { seedStatus.textContent = error.message; } } seed.addEventListener("input", () => { if (seed.value.trim()) shot.seed = seed.value.trim(); else delete shot.seed; syncPlan(); void refreshSeedStatus(); }); const randomizeSeed = button("New random", "Create and store a new explicit uint64 seed for only this scene", () => { seed.value = randomSceneSeed(); shot.seed = seed.value; syncPlan(); void refreshSeedStatus(); }); const useDerivedSeed = button("Use derived", "Remove this scene's explicit override and return to its stable base_seed-derived value", () => { seed.value = ""; delete shot.seed; syncPlan(); void refreshSeedStatus(); }); useDerivedSeed.disabled = !seed.value.trim(); seedControl.append(seed, randomizeSeed, useDerivedSeed, seedStatus); state.seedRefreshers.push(refreshSeedStatus); void refreshSeedStatus(); const loraRoute = element("select", "h3c-lora-route"); const selectedLoRARoute = sceneLoRARoute(shot); for (const route of availableLoRARoutes( node.graph ?? app.graph, [node], selectedLoRARoute, )) { const option = element("option", "", loraRouteLabel(route)); option.value = route; loraRoute.append(option); } loraRoute.value = selectedLoRARoute; loraRoute.title = "Select Base or a connected A-Z MODEL branch on MiniMax H3 Scene LoRA Scheduler. Connecting the scheduler's last empty route reveals the next one automatically; the scheduler routes already-patched models and does not load a LoRA itself."; loraRoute.addEventListener("change", () => { if (loraRoute.value === "base") delete shot.lora_route; else shot.lora_route = loraRoute.value; sceneLoRARoute(shot); syncPlan(); }); const advanced = element("div", "h3c-advanced-fields"); const steps = numberInput(shot.steps ?? "", {min: "1", max: "10000", step: "1"}); steps.placeholder = String(widgetValue(node, "default_steps", 20)); steps.title = "Optional sampler-step override for only this scene. Leave blank to inherit the Plan node default."; steps.addEventListener("input", () => { if (steps.value) shot.steps = Number(steps.value); else delete shot.steps; syncPlan(); }); const context = element("select", "h3c-context"); const resolvedPlanSettings = currentSettings(); const planContextLength = Number(resolvedPlanSettings.contextLength); function normalizeVisualLeadSpan() { if (!Object.hasOwn(shot, "visual_context_lead_source")) return; const resolved = sceneContextLength(shot, planContextLength); const allowed = visualContextCompositions() .filter((choice) => choice.total === resolved) .map((choice) => choice.lead); if (!allowed.length) { delete shot.visual_context_lead_source; delete shot.visual_context_lead_frames; delete shot.visual_context_lead_start_frame; return; } try { sceneVisualContextLeadFrames(shot, resolved); } catch (_error) { shot.visual_context_lead_frames = allowed[0]; } } const incomingTransition = element("select", "h3c-incoming-transition"); const inheritedPreset = resolvedPlanSettings.transitionPreset; const inheritOption = element( "option", "", `Inherit Chain Policy · ${transitionPresetLabel(inheritedPreset)}`, ); inheritOption.value = "inherit"; incomingTransition.append(inheritOption); for (const preset of primaryTransitionOptions()) { const option = element( "option", "", `${preset.label} · ${preset.description}`, ); option.value = preset.name; incomingTransition.append(option); } function refreshIncomingTransition() { const selected = sceneTransitionPreset( shot, resolvedPlanSettings.continuationMode, resolvedPlanSettings.contextLength, resolvedPlanSettings.audioContextLength, ); let custom = incomingTransition.querySelector( 'option[value="custom"]', ); if (selected === "custom" && !custom) { custom = element( "option", "", transitionPresetLabel("custom"), ); custom.value = "custom"; incomingTransition.append(custom); } incomingTransition.value = selected; } refreshIncomingTransition(); incomingTransition.title = "One semantic boundary choice. Inherit uses " + "the connected Chain Policy. Choosing a preset writes its tested " + "visual implementation/context pair and returns generated-audio " + "context to automatic behavior. Custom means this scene still " + "contains raw Advanced boundary overrides below."; incomingTransition.addEventListener("change", () => { if (incomingTransition.value === "custom") return; applySceneTransitionPreset(shot, incomingTransition.value); const nextContext = sceneContextLength(shot, planContextLength); if (Object.hasOwn(shot, "video_blend_frames") && Number(shot.video_blend_frames) > nextContext) { shot.video_blend_frames = nextContext; } normalizeVisualLeadSpan(); delete shot.visual_context_start_frame; delete shot.visual_context_lead_start_frame; syncPlan(); render(); }); for (const [value, label] of [ ["", `Plan default · ${planContextLength}`], ["0", "0 · new visual"], ...H3_CONTEXT_LENGTHS.map((value) => [String(value), `${value} frames`]), ]) { const option = element("option", "", label); option.value = value; context.append(option); } context.value = Object.hasOwn(shot, "context_length") && shot.context_length !== null ? String(shot.context_length) : ""; context.title = index === 0 ? "Video context entering this scene. Blank inherits the Plan default. Zero makes scene 1 visually independent even when Existing Video Context is connected; the original may still be prepended during assembly." : "Video frames carried from the preceding scene into this one. Blank inherits the Plan default; zero creates a visually new scene. Audio context is controlled separately."; context.addEventListener("change", () => { if (context.value === "") delete shot.context_length; else shot.context_length = Number(context.value); sceneContextLength(shot, planContextLength); normalizeVisualLeadSpan(); delete shot.visual_context_start_frame; delete shot.visual_context_lead_start_frame; refreshBlendControl(); refreshIncomingTransition(); syncPlan(); render(); }); const visualSource = element("select", "h3c-visual-source"); if (index === 0) { const option = element( "option", "", "Existing Video Context (if connected)", ); option.value = ""; visualSource.append(option); visualSource.disabled = true; } else { const previous = element( "option", "", `Previous scene · ${index} ${safeShotId( state.plan.shots[index - 1]?.id, `clip_${String(index).padStart(4, "0")}`, )}`, ); previous.value = ""; visualSource.append(previous); for (let sourceOffset = 0; sourceOffset < index - 1; sourceOffset += 1) { const sourceId = safeShotId( state.plan.shots[sourceOffset]?.id, `clip_${String(sourceOffset + 1).padStart(4, "0")}`, ); const option = element( "option", "", `Scene ${sourceOffset + 1} · ${sourceId}`, ); option.value = sourceId; visualSource.append(option); } const rawSource = shot.visual_context_source; if (rawSource === undefined || rawSource === null || ["", "previous", "immediate"].includes( String(rawSource).trim().toLowerCase())) { visualSource.value = ""; } else { try { const resolved = sceneVisualContextSource( state.plan, index + 1, ); visualSource.value = resolved === index ? "" : safeShotId( state.plan.shots[resolved - 1]?.id, `clip_${String(resolved).padStart(4, "0")}`, ); } catch (_error) { const invalid = element( "option", "", `Invalid · ${String(rawSource)}`, ); invalid.value = String(rawSource); visualSource.append(invalid); visualSource.value = String(rawSource); } } } visualSource.title = index === 0 ? "Scene 1 visual context comes from Existing Video Context." : "Choose which earlier saved scene supplies the video/RGB context. Generated-audio continuity always comes from the immediately previous timeline scene. A non-linear source is a hard cut in final assembly."; visualSource.addEventListener("change", () => { if (!visualSource.value) { delete shot.visual_context_source; } else { shot.visual_context_source = visualSource.value; shot.video_blend_frames = 0; } delete shot.visual_context_start_frame; refreshBlendControl(); syncPlan(); render(); }); const visualLeadSource = element("select", "h3c-visual-lead-source"); const noLead = element("option", "", "Off · one context block"); noLead.value = ""; visualLeadSource.append(noLead); const recentSourceIndex = index === 0 ? null : sceneVisualContextSource(state.plan, index + 1); if (recentSourceIndex !== null) { for (let sourceOffset = 0; sourceOffset < index; sourceOffset += 1) { const sourceId = safeShotId( state.plan.shots[sourceOffset]?.id, `clip_${String(sourceOffset + 1).padStart(4, "0")}`, ); const sameSource = sourceOffset + 1 === recentSourceIndex; const sourceLabel = `Scene ${sourceOffset + 1} · ${sourceId}` + (sameSource ? " · same scene, separate window" : ""); const option = element( "option", "", sourceLabel, ); option.value = sourceId; visualLeadSource.append(option); } } visualLeadSource.disabled = index === 0; try { const resolvedLead = sceneVisualContextLeadSource( state.plan, index + 1, ); visualLeadSource.value = resolvedLead === null ? "" : safeShotId( state.plan.shots[resolvedLead - 1]?.id, `clip_${String(resolvedLead).padStart(4, "0")}`, ); } catch (_error) { visualLeadSource.value = ""; } visualLeadSource.title = "Optional first block in one composed visual context. It may use a different scene or a second independently positioned window from the same scene. Visual context source supplies the block nearest generation; audio remains one continuous tail from the immediate timeline scene."; const visualLeadFrames = element("select", "h3c-visual-lead-frames"); const resolvedVisualContext = sceneContextLength( shot, planContextLength, ); const compositionChoices = visualContextCompositions(); const defaultComposition = compositionChoices.find( (choice) => choice.total === resolvedVisualContext, ) ?? compositionChoices[0]; visualLeadSource.disabled = visualLeadSource.disabled || !compositionChoices.length; for (const total of H3_CONTEXT_LENGTHS) { const groupChoices = compositionChoices.filter( (choice) => choice.total === total, ); if (!groupChoices.length) continue; const group = element("optgroup", ""); group.label = `${total} total frames`; for (const choice of groupChoices) { const option = element("option", "", choice.label); option.value = choice.value; group.append(option); } visualLeadFrames.append(group); } visualLeadFrames.disabled = !visualLeadSource.value || !compositionChoices.length; if (visualLeadSource.value) { try { const lead = sceneVisualContextLeadFrames( shot, resolvedVisualContext, ); visualLeadFrames.value = `${resolvedVisualContext}:${lead}`; } catch (_error) { visualLeadFrames.value = defaultComposition?.value ?? ""; } } else { visualLeadFrames.value = defaultComposition?.value ?? ""; } visualLeadFrames.title = "Select the total H3 context and its ordered two-block split. Both blocks may use the same scene with independent floating windows. For example, 39 total includes 5+34, 17+22, and their reverse orientations."; function applyVisualComposition() { const [totalRaw, leadRaw] = visualLeadFrames.value.split(":"); const total = Number(totalRaw); const lead = Number(leadRaw); if (!Number.isInteger(total) || !Number.isInteger(lead)) return; shot.context_length = total; shot.visual_context_lead_frames = lead; sceneVisualContextLeadFrames(shot, total); shot.video_blend_frames = 0; } visualLeadSource.addEventListener("change", () => { if (!visualLeadSource.value) { delete shot.visual_context_lead_source; delete shot.visual_context_lead_frames; delete shot.visual_context_lead_start_frame; } else { shot.visual_context_lead_source = visualLeadSource.value; applyVisualComposition(); } delete shot.visual_context_start_frame; delete shot.visual_context_lead_start_frame; refreshBlendControl(); syncPlan(); render(); }); visualLeadFrames.addEventListener("change", () => { if (visualLeadSource.value) { applyVisualComposition(); } delete shot.visual_context_start_frame; delete shot.visual_context_lead_start_frame; refreshBlendControl(); syncPlan(); render(); }); const audioContext = numberInput(shot.audio_context_length ?? "", { min: "0", max: "240", step: "1", }); const planAudioContextLength = Number( resolvedPlanSettings.audioContextLength, ); audioContext.placeholder = planAudioContextLength ? String(planAudioContextLength) : `Follow video · ${planContextLength}`; audioContext.title = "Generated-audio context entering this scene. Blank inherits the Plan audio default (whose 0 follows video context). An explicit 0 carries no prior generated sound. A positive value can continue audio when video context is 0. AV mask modes ignore this override and keep audio synchronized to the video prefix; source_track uses its exact timeline slice instead."; audioContext.addEventListener("input", () => { if (audioContext.value === "") delete shot.audio_context_length; else shot.audio_context_length = Number(audioContext.value); refreshIncomingTransition(); syncPlan(); }); const blendFrames = numberInput(shot.video_blend_frames ?? "", { min: "0", max: String(planContextLength), step: "1", }); const planBlendFrames = Number(resolvedPlanSettings.videoBlendFrames); function refreshBlendControl() { const resolvedContext = sceneContextLength(shot, planContextLength); blendFrames.max = String(resolvedContext); blendFrames.placeholder = String(Math.min( planBlendFrames, resolvedContext, )); } refreshBlendControl(); blendFrames.title = index === 0 ? "Visible assembly blend entering scene 1 when Existing Video Context supplies a predecessor. Blank inherits the Plan default, capped to this scene's video context. This is assembly-only and does not alter sampling." : "Visible assembly blend at the boundary from the previous scene into this scene. Blank inherits the Plan default, capped to this scene's video context. Zero keeps a hard cut. This is assembly-only and does not alter sampling."; blendFrames.addEventListener("input", () => { if (blendFrames.value === "") delete shot.video_blend_frames; else shot.video_blend_frames = Number(blendFrames.value); sceneVideoBlendFrames( shot, planBlendFrames, sceneContextLength(shot, planContextLength), ); syncPlan(); }); const continuation = element("select", "h3c-continuation"); const planContinuationMode = resolvedPlanSettings.continuationMode; for (const [value, label] of [ ["", `Plan default · ${planContinuationMode}`], ["guide", "Guide · new shot"], ["tone_carry_guide", "Tone Carry Guide · corrected RGB context"], ["latent_guide", "Latent Guide · direct generated latent"], ["tapered_guide", "Detail Guide · color injection"], ["tapered_av", "Detail AV · experimental latent taper"], ["drift_control_av", "Drift-Control AV · schedule-matched mask"], ["color_stable_drift_av", "Color-Stable Drift AV · tapered scene-one latent delta"], ["masked_av", "Masked AV · same shot"], ["feathered_av", "Feathered AV · experimental dual-stream feather"], ["audio_feathered_av", "Audio Feather AV · hard picture, soft sound"], ]) { const option = element("option", "", label); option.value = value; continuation.append(option); } continuation.value = Object.hasOwn(shot, "continuation_mode") ? sceneContinuationMode(shot, planContinuationMode) : ""; continuation.title = index === 0 ? "Continuation into this scene. Scene 1 uses it only when Existing Video Context supplies a predecessor. Guide re-encodes RGB; Latent Guide directly reuses a generated predecessor latent and falls back to RGB for imported context; Detail Guide adds a tapered chroma treatment; Detail AV applies a disposable video-only latent taper; Drift-Control AV applies a scheduler-matched video mask with a clean seam and needs the Chain Context MODEL path; Color-Stable Drift AV additionally carries a weak scene-one color correction as a tapered VAE delta on the copied video latent only; Masked AV protects an exact prefix; experimental Feathered AV softens both streams; Audio-Feathered AV keeps the picture exact and softens only the final audio ticks." : "Continuation from the preceding scene. Guide re-encodes RGB into persistent conditioning; Latent Guide supplies the saved sampled-latent tail as persistent conditioning; Detail Guide applies luma-preserving tapered chroma injection; Detail AV applies a disposable video-only latent taper; Drift-Control AV applies a scheduler-matched video mask with a clean seam and needs the Chain Context MODEL path; Color-Stable Drift AV additionally carries a weak scene-one color correction as a tapered VAE delta on the copied video latent only; Masked AV protects an exact prefix; experimental Feathered AV softens both streams; Audio-Feathered AV keeps the picture exact and softens only the final audio ticks."; continuation.addEventListener("change", () => { if (continuation.value) { shot.continuation_mode = continuation.value; } else { delete shot.continuation_mode; } // Resolve once here so malformed externally supplied values cannot // be introduced through the compact editor control. sceneContinuationMode(shot, planContinuationMode); refreshIncomingTransition(); syncPlan(); }); const spatialProxy = element("select", "h3c-spatial-proxy"); for (const [value, label] of [ ["", "Off · native context"], ["rgb_5_6", "Low-grid 5/6 proxy · Guide"], ["latent_5_6", "Latent 5/6 proxy · AV"], ]) { const option = element("option", "", label); option.value = value; spatialProxy.append(option); } spatialProxy.value = shot.context_spatial_proxy ?? ""; spatialProxy.title = "Scheduled only on the boundary entering scenes 2+. Low-grid 5/6 reduces and VAE-decodes the complete saved predecessor video latent at the proxy canvas, takes its delivered tail, then Motion Context restores those Guide frames to the target; 1376×768 uses 1152×640 (48×86 → 40×72 latent). This exact low-grid decode costs extra preparation time and peak memory. Latent 5/6 applies a cheaper latent down/up filter only to the copied AV video prefix. Audio, generated output, checkpoints, and assembly remain native size."; spatialProxy.addEventListener("change", () => { if (spatialProxy.value) { shot.context_spatial_proxy = spatialProxy.value; } else { delete shot.context_spatial_proxy; } syncPlan(); }); const boundary = element("div", "h3c-boundary-fields"); boundary.append( field("Incoming transition", incomingTransition), field("Final assembly crossfade frames", blendFrames), ); const planAudioPolicy = resolvedPlanSettings.audioPolicy; const effectiveAudioPolicy = sceneAudioPolicy(shot, planAudioPolicy); const lipSync = element("select"); for (const [value, label] of [ ["inherit", `Inherit · ${planAudioPolicy.sourceAudioTarget === "locked" ? "On" : "Off"}`], ["on", "On · vocals drive this scene"], ["off", "Off · no source-audio guidance"], ["custom", "Custom · advanced audio controls"], ]) { const option = element("option", "", label); option.value = value; option.disabled = value === "custom"; lipSync.append(option); } lipSync.value = sceneLipSyncMode(shot); lipSync.title = "On locks grouped vocals (or a legacy single track). Off " + "disables source locking, reference, and audio carry without changing " + "the final soundtrack. Inherit resets these three audio overrides."; lipSync.addEventListener("change", () => { applySceneLipSync(shot, lipSync.value); syncPlan(); render(); }); function audioOverrideSelect(key, inherited, choices, title) { const select = element("select", "h3c-audio-override"); const inheritedOption = element( "option", "", `Inherit Chain Policy · ${inherited}`, ); inheritedOption.value = "inherit"; select.append(inheritedOption); for (const [value, label] of choices) { const option = element("option", "", label); option.value = value; select.append(option); } select.value = sceneAudioOverride(shot, key); select.title = title; select.addEventListener("change", () => { applySceneAudioOverride(shot, key, select.value); lipSync.value = sceneLipSyncMode(shot); syncPlan(); }); return select; } const sourceReference = audioOverrideSelect( "source_reference", planAudioPolicy.sourceReference ?? effectiveAudioPolicy.sourceReference, [["on", "On · use this source-track window as Ref2VA audio"], ["off", "Off · no source audio reference"]], "Controls only this scene's loose source-audio Ref2VA reference. " + "It does not choose final soundtrack. Lock source audio wins " + "over this switch.", ); const generatedContinuity = audioOverrideSelect( "generated_continuity", planAudioPolicy.generatedContinuity ?? effectiveAudioPolicy.generatedContinuity, [["on", "On · continue prior generated audio"], ["off", "Off · generate an independent audio stream"]], "Controls whether this scene carries the predecessor's generated " + "audio latent. Lock source audio wins over this switch.", ); const inheritedLock = (planAudioPolicy.sourceAudioTarget ?? "off") === "locked" ? "on" : "off"; const lockSourceAudio = audioOverrideSelect( "source_audio_target", inheritedLock, [["locked", "On · protect this exact source window"], ["off", "Off · leave target audio denoisable"]], "Locks only this scene's exact source waveform into the H3 target " + "audio latent. While on, loose source reference and generated " + "continuity are effectively off. Final soundtrack remains " + "the global Chain Policy choice.", ); const audioFields = element("div", "h3c-audio-fields"); audioFields.append( field("Lip-sync", lipSync), field("Source reference", sourceReference), field("Generated continuity", generatedContinuity), field("Lock source audio", lockSourceAudio), ); advanced.append( field("Steps (blank = default)", steps), field("Advanced visual context", context), field("Visual context source", visualSource), field("Context block 1 source", visualLeadSource), field("Composed total / split", visualLeadFrames), field("Advanced audio context", audioContext), field("Advanced implementation", continuation), field("Boundary spatial proxy", spatialProxy), ); card.append( head, lengthRow, field("Scene prompt (optional with shared prompt)", prompt), promptTools(prompt, index + 1), field("Prompt alternatives", promptSeedControl), field("Scene seed", seedControl), field("Scene LoRA route", loraRoute), boundary, audioFields, advanced, ); return card; } function renderModernSettings() { if (!modern) return null; const managed = inputConnected(node, "project_assets"); const panel = element( "section", `h3c-section h3c-settings${state.settingsOpen ? " h3c-open" : ""}`, ); const heading = element("div", "h3c-settings-head"); const profileConnected = inputConnected(node, "chain_policy"); const policyStatus = element( "span", `h3c-policy-status${profileConnected ? "" : " h3c-missing"}`, profileConnected ? "Generation Profile connected" : "Generation Profile required", ); heading.append( element("strong", "", "Plan settings"), element( "span", "h3c-help", "Continuity and audio live in Generation Profile.", ), policyStatus, ); function group(title, ...fields) { const wrap = element("div", "h3c-settings-group"); wrap.append(element("div", "h3c-settings-group-title", title)); const grid = element("div", "h3c-settings-fields"); grid.append(...fields); wrap.append(grid); return wrap; } function textSetting(name, label, options = {}) { const control = element("input"); control.type = "text"; if (options.numeric) control.inputMode = "numeric"; control.value = String(widgetValue(node, name, options.fallback ?? "")); control.disabled = Boolean(options.disabled); control.title = options.title ?? ""; control.addEventListener("change", () => { let value = control.value; if (options.numeric) { value = value.trim(); if (!/^\d+$/.test(value)) { control.value = String(widgetValue( node, name, options.fallback ?? "0")); return; } } setWidgetValue(node, name, value); updateTiming(); }); const wrapped = field(label, control); if (options.wide) wrapped.classList.add("h3c-wide"); return wrapped; } function numberSetting(name, label, options) { const control = numberInput(widgetValue(node, name, options.fallback), { min: options.min, max: options.max, step: options.step, }); control.title = options.title ?? ""; control.addEventListener("change", () => { if (!control.validity.valid || control.value === "") { control.value = String(widgetValue( node, name, options.fallback)); return; } setWidgetValue(node, name, Number(control.value)); updateTiming(); }); return field(label, control); } function choiceSetting(name, label, choices, title) { const control = selectInput(widgetValue(node, name, choices[0][0]), choices); control.title = title; control.addEventListener("change", () => { setWidgetValue(node, name, control.value); updateTiming(); }); return field(label, control); } const run = textSetting("run_name", "Run name", { fallback: "h3_chain", disabled: managed, title: managed ? "Managed by the connected Project Asset Carousel." : "Folder and checkpoint identity for this production.", }); const fingerprint = textSetting( "generation_fingerprint", "Generation fingerprint", { fallback: "", disabled: managed, wide: true, title: managed ? "Project Assets contributes the active reference lineage." : "Change this when model, VAE, LoRA, CFG, sampler, scheduler, or global references change.", }, ); const project = group("Project", run, fingerprint); if (managed) { project.append(element( "div", "h3c-managed-note", "Run identity and reference lineage are managed by Project Assets.", )); } const canvas = group( "Canvas & context fitting", numberSetting("width", "Width", { fallback:960, min:32, max:4096, step:32, title:"Generation width; must be a multiple of 32.", }), numberSetting("height", "Height", { fallback:544, min:32, max:4096, step:32, title:"Generation height; must be a multiple of 32.", }), choiceSetting("encode_mode", "Context encoding", [ ["video", "Video · preserve motion"], ["frames", "Frames · still anchors"], ], "How carried picture context is encoded."), choiceSetting("crop", "Context fit", [ ["disabled", "Resize to canvas"], ["center", "Preserve ratio + center crop"], ], "How saved picture context is fitted to the Plan canvas."), ); const defaults = group( "Generation defaults", numberSetting("default_duration_seconds", "Default seconds", { fallback:15, min:0.1, max:15.0833333333, step:0.01, title:"Used only when a scene has no explicit duration or frame length.", }), numberSetting("default_steps", "Default steps", { fallback:20, min:1, max:10000, step:1, title:"Used only when a scene has no sampler-step override.", }), textSetting("base_seed", "Base seed", { fallback:"0", numeric:true, wide:true, title:"Stable uint64 base used to derive one seed per scene.", }), ); const delivery = group( "Delivery", numberSetting("segment_crf", "Scene MP4 quality (CRF)", { fallback:18, min:0, max:51, step:1, title:"Lower is higher quality; 18 is visually high quality.", }), numberSetting("video_blend_frames", "Default crossfade frames", { fallback:0, min:0, max:243, step:1, title:"Default final-assembly picture blend entering a scene; 0 is a hard cut.", }), ); const grid = element("div", "h3c-settings-grid"); grid.append(project, canvas, defaults, delivery); panel.append(heading, grid); return panel; } function render() { if (!state.plan) return; disconnectResizeObservers(); state.seedRefreshers = []; const scrollTop = root.scrollTop; root.replaceChildren(); root.classList.toggle("h3c-show-advanced", state.advanced); const externalPlanConnected = inputConnected(node, "plan_json_input"); const header = element("div", "h3c-header"); const openOutput = button( "Output", "Open this Plan's output/h3_chains/ folder on the ComfyUI host. " + "If the host has no desktop session, its path is copied instead.", async () => { const runName = String(widgetValue(node, "run_name", "")).trim(); openOutput.disabled = true; setOutputButtonLabel(openOutput, "Opening…"); try { const response = await api.fetchApi( "/minimax_h3_context_loop/open-run-folder", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({run_name: runName}), }, ); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); openOutput.title = payload.path; if (payload.opened) { setOutputButtonLabel(openOutput, "Opened ✓"); } else { try { await navigator.clipboard.writeText(payload.path); setOutputButtonLabel(openOutput, "Path copied"); } catch (_error) { setOutputButtonLabel(openOutput, "See tooltip"); } if (payload.error) openOutput.title += `\n${payload.error}`; } } catch (error) { setOutputButtonLabel(openOutput, "Open failed"); openOutput.title = String(error?.message || error); } finally { setTimeout(() => { setOutputButtonLabel(openOutput, "Output", true); openOutput.disabled = false; }, 2200); } }, ); openOutput.classList.add("h3c-open-output"); openOutput.setAttribute("aria-label", "Open project output folder"); setOutputButtonLabel(openOutput, "Output", true); const headerActions = element("div", "h3c-header-actions"); if (modern) { headerActions.append(button( state.settingsOpen ? "Hide settings" : "Settings", "Show or hide the organized Plan settings", () => { state.settingsOpen = !state.settingsOpen; savePanelState(); render(); }, )); } headerActions.append(openOutput, element("div", "h3c-summary")); header.append(element( "div", "h3c-title", modern ? "MiniMax H3 Modern Plan" : "MiniMax H3 Scene Plan", ), headerActions); const settingsPanel = renderModernSettings(); const externalNotice = element("div", "h3c-external-plan"); externalNotice.append( element("strong", "", "External plan input connected. "), document.createTextNode( "A non-empty upstream string controls execution. The editor below " + "shows and edits the local fallback used only when that string is empty " + "or disconnected.", ), ); const prefix = element("textarea", "h3c-prefix"); prefix.value = sharedPrompt(state.plan).text; prefix.placeholder = "Identity, wardrobe, style and continuity rules shared by every scene…"; prefix.title = "Text automatically prepended to every scene prompt. Put identity, wardrobe, reference definitions, audio rules, style, and global continuity here instead of repeating them."; prefix.spellcheck = true; prefix.addEventListener("input", () => { setSharedPrompt(state.plan, prefix.value); syncPlan(); }); bindTextareaHeight(prefix, "shared", 88); const prefixSection = element("section", "h3c-section"); prefixSection.append( field("Shared prompt — automatically prepended to every scene", prefix), promptTools(prefix, null), ); const toolbar = element("div", "h3c-toolbar"); const add = button("+ Add scene", "Append a new scene", () => { if (state.plan.shots.length >= MAX_SHOTS) return; state.plan.shots.push(makeShot(state.plan.shots)); syncPlan(); render(); }); add.disabled = state.plan.shots.length >= MAX_SHOTS; const advanced = button(state.advanced ? "Hide advanced" : "Show advanced", "Show or hide per-scene sampler-step and continuation overrides", () => { state.advanced = !state.advanced; savePanelState(); render(); }); const json = button(state.jsonOpen ? "Hide raw JSON" : "Raw JSON", "Expand the raw plan JSON for direct editing, import, or export", () => { state.jsonOpen = !state.jsonOpen; savePanelState(); render(); }); toolbar.append(add, advanced, element("span", "h3c-spacer"), json); const errors = element("div", "h3c-errors"); const cards = element("div", "h3c-cards"); state.plan.shots.forEach((shot, index) => cards.append(renderCard(shot, index))); const jsonPanel = element("section", `h3c-section h3c-json-panel${state.jsonOpen ? " h3c-open" : ""}`); const jsonArea = element("textarea", "h3c-json"); jsonArea.value = planToJson(state.plan); jsonArea.spellcheck = false; jsonArea.title = "Canonical plan JSON. Apply JSON replaces the visual editor contents after validation; saving the workflow also serializes this value."; bindTextareaHeight(jsonArea, "json", 260); const jsonStatus = element("span", "h3c-json-status", "Raw JSON escape hatch"); const apply = button("Apply JSON", "Validate and load this JSON into the scene editor", () => { try { state.plan = parsePlanJson(jsonArea.value); jsonStatus.textContent = "JSON applied"; syncPlan(); render(); } catch (error) { jsonStatus.textContent = error.message; } }); const copyJson = button("Copy", "Copy plan JSON", async () => { try { await navigator.clipboard.writeText(jsonArea.value); jsonStatus.textContent = "Copied"; } catch (_error) { jsonArea.select(); document.execCommand("copy"); jsonStatus.textContent = "Copied"; } }); const saveJson = button("Save .json", "Download this plan as JSON", () => { const runName = String(widgetValue(node, "run_name", "h3_chain")); downloadJson(jsonArea.value, `${runName || "h3_chain"}_plan.json`); }); const loadInput = element("input"); loadInput.type = "file"; loadInput.accept = ".json,application/json"; loadInput.hidden = true; loadInput.addEventListener("change", async () => { const file = loadInput.files?.[0]; if (!file) return; jsonArea.value = await file.text(); apply.click(); loadInput.value = ""; }); const loadJson = button("Load .json", "Load a plan JSON file", () => loadInput.click()); const jsonActions = element("div", "h3c-json-actions"); jsonActions.append(apply, copyJson, saveJson, loadJson, loadInput, jsonStatus); jsonPanel.append(jsonArea, jsonActions); const footer = element("div", "h3c-footer"); footer.append( element( "span", "", externalPlanConnected ? "Edits are serialized into fallback plan_json; non-empty external JSON takes precedence." : "Edits are serialized into plan_json; connect plan_json_input for an external override.", ), Object.assign(element("a", "", "UI inspiration: nkxx188"), { href: "https://github.com/nkxx188/ComfyUI-MiniMaxH3-Easy", target: "_blank", rel: "noreferrer", }), ); root.append( header, ...(settingsPanel ? [settingsPanel] : []), ...(externalPlanConnected ? [externalNotice] : []), prefixSection, toolbar, errors, cards, jsonPanel, footer, ); updateTiming(); requestAnimationFrame(() => { root.scrollTop = scrollTop; }); graphDirty(); } function loadFromWidget(force = false) { const value = String(planWidget.value ?? ""); if (!force && value === state.lastWidgetValue) return; try { state.plan = parsePlanJson(value); state.lastWidgetValue = value; render(); } catch (error) { root.replaceChildren(); const failure = element("div", "h3c-errors h3c-open", error.message); const raw = element("textarea", "h3c-json"); raw.value = value; const retry = button("Apply repaired JSON", "Parse this JSON and open the scene editor", () => { planWidget.value = raw.value; state.lastWidgetValue = ""; loadFromWidget(true); }); root.append(failure, raw, retry); } } const originalCallback = planWidget.callback; planWidget.callback = function (...args) { const result = originalCallback?.apply(this, args); if (!state.syncing) setTimeout(() => loadFromWidget(), 0); return result; }; for (const name of [ "context_length", "audio_context_length", "encode_mode", "anchor_mode", "continuation_mode", "default_duration_seconds", "default_steps", "base_seed", "video_blend_frames", ]) { const widget = node.widgets?.find((item) => item.name === name); if (!widget || widget._h3TimingWrapped) continue; const callback = widget.callback; widget.callback = function (...args) { const result = callback?.apply(this, args); setTimeout(() => updateTiming(), 0); if (name === "base_seed") { setTimeout(() => { for (const refresh of state.seedRefreshers) void refresh(); }, 0); } if (name === "continuation_mode") { setTimeout(() => render(), 0); } return result; }; widget._h3TimingWrapped = true; } node._h3ChainEditorRefresh = () => { collapseWidget(planWidget); collapseModernBackingWidgets(node); syncProjectAssetManagedWidgets(); const layout = planLayout(node); state.advanced = Boolean(layout.advanced); state.jsonOpen = Boolean(layout.jsonOpen); state.settingsOpen = modern && layout.settingsOpen !== false; loadFromWidget(true); scheduleResponsiveSize(); }; node._h3ChainEditorConnectionRefresh = () => { syncProjectAssetManagedWidgets(); scheduleResponsiveSize(); render(); }; node._h3ChainEditorFit = applyResponsiveSize; syncProjectAssetManagedWidgets(); loadFromWidget(true); scheduleResponsiveSize(); const onLoRARoutesChanged = () => render(); document.addEventListener("h3-lora-routes-changed", onLoRARoutesChanged); const removed = node.onRemoved; node.onRemoved = function () { disconnectResizeObservers(); document.removeEventListener( "h3-lora-routes-changed", onLoRARoutesChanged); return removed?.apply(this, arguments); }; } app.registerExtension({ name: EXTENSION, async beforeRegisterNodeDef(nodeType, nodeData) { if (!NODE_NAMES.has(nodeData.name)) return; const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = onNodeCreated?.apply(this, arguments); setTimeout(() => mountEditor(this), 0); return result; }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = onConfigure?.apply(this, arguments); setTimeout(() => this._h3ChainEditorRefresh?.(), 0); return result; }; const onGraphConfigured = nodeType.prototype.onGraphConfigured; nodeType.prototype.onGraphConfigured = function () { const result = onGraphConfigured?.apply(this, arguments); setTimeout(() => this._h3ChainEditorRefresh?.(), 0); return result; }; const onAdded = nodeType.prototype.onAdded; nodeType.prototype.onAdded = function () { const result = onAdded?.apply(this, arguments); // ComfyUI registers DOM widgets again when an existing node is // removed and re-added. Retire the hidden plan_json surface again. setTimeout(() => this._h3ChainEditorRefresh?.(), 0); return result; }; const onConnectionsChange = nodeType.prototype.onConnectionsChange; nodeType.prototype.onConnectionsChange = function () { const result = onConnectionsChange?.apply(this, arguments); setTimeout(() => this._h3ChainEditorConnectionRefresh?.(), 0); return result; }; if (nodeData.name === NODE_NAME) { const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function (_, options) { const result = getExtraMenuOptions?.apply(this, arguments); options.push({ content: "Upgrade to Modern Plan…", callback: () => replaceWithModernPlan(this), }); return result; }; } }, });