import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; import { openReferenceImageEditor } from "./o1keyReferenceImageEditor.js"; import "./o1keyImageGenerator.js"; const GENERATOR = "O1keyVideoGenerator"; const RESULT = "O1keyVideoResult"; const SAVE_VIDEO = "SaveVideo"; const SAVE_IMAGE = "SaveImage"; const TERMINAL = new Set(["completed", "failed", "cancelled"]); const STAGE_LABELS = { starting: "正在启动", preparing: "准备任务", submitting: "提交视频任务", polling: "生成视频", downloading: "下载视频", saving: "保存视频", completed: "生成完成", failed: "生成失败", cancelled: "已取消" }; const SEEDANCE_REFERENCE_LIMITS = Object.freeze({ minDimension: 300, maxDimension: 6000, minAspectRatio: 0.4, maxAspectRatio: 2.5, minVideoPixels: 407696, maxVideoPixels: 8295044, }); const jobs = new Map(); let openDropdown = null; const MODEL_OPTIONS = [ { value: "seedance-2.0", label: "Seedance 2.0", description: "高质量" }, { value: "seedance-2.0-fast", label: "Seedance 2.0 Fast", description: "快速" }, { value: "seedance-2.0-mini", label: "Seedance 2.0 Mini", description: "轻量" }, { value: "seedance-2.5", label: "Seedance 2.5", description: "增强" }, ]; const ROUTE_OPTIONS = [ { value: "domestic", label: "国内", description: "Doubao 素材" }, { value: "overseas_hc", label: "海外", description: "HC 素材" }, ]; const MODE_OPTIONS = [ { value: "text", label: "文生视频", description: "仅提示词" }, { value: "first_frame", label: "首帧图生视频", description: "1 张图" }, { value: "first_last_frame", label: "首尾帧生视频", description: "2 张图" }, { value: "multimodal", label: "多模态参考", description: "图/视频/音频" }, ]; const ASSET_CREATION_OPTIONS = [ { value: "auto", label: "自动创建", description: "直接上传" }, { value: "manual", label: "手动", description: "填写素材 ID" }, ]; const RATIO_OPTIONS = [ { value: "auto", label: "智能" }, "16:9", "9:16", "4:3", "3:4", "1:1", "21:9", ]; const TOGGLE_OPTIONS = [ { value: "off", label: "关闭" }, { value: "on", label: "开启" }, ]; const MODEL_CAPS = { "seedance-2.0": { maxDuration: 15, resolutions: ["480p", "720p", "1080p", "4k"] }, "seedance-2.0-fast": { maxDuration: 15, resolutions: ["480p", "720p"] }, "seedance-2.0-mini": { maxDuration: 15, resolutions: ["480p", "720p"] }, "seedance-2.5": { maxDuration: 30, resolutions: ["480p", "720p", "1080p", "4k"] }, }; const CSS = ` .o1vg-panel{ width:100%;height:100%;min-height:292px;box-sizing:border-box;padding:8px;color:#e9e9e9; font-family:Inter,"Microsoft YaHei UI","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif; font-size:12px;line-height:1.5;display:flex;flex-direction:column;gap:8px;overflow:visible;user-select:none; } .o1vg-prompt-wrap{position:relative;width:100%;height:auto;min-height:142px;flex:1 1 142px;min-width:0;overflow:hidden;} .o1vg-prompt{ width:100%;height:100%;min-height:142px;max-height:none;resize:none;box-sizing:border-box; padding:10px 45px 40px 11px;border:1px solid rgba(255,255,255,.1);border-radius:9px;background:#191a1c;color:#f2f2f2; outline:none;font:inherit;font-size:12px;line-height:1.55;user-select:text; box-shadow:inset 0 1px 0 rgba(255,255,255,.025),0 7px 18px rgba(0,0,0,.12); transition:border-color .15s ease,background .15s ease; } .o1vg-prompt::placeholder{color:#777}.o1vg-prompt:focus{background:#1c1d1f;border-color:rgba(255,255,255,.24)} .o1vg-prompt-write{ position:absolute;right:8px;bottom:8px;min-width:96px;height:28px;padding:0 9px;display:flex;align-items:center;justify-content:center;gap:5px; border:1px solid rgba(255,255,255,.11);border-radius:7px;background:rgba(35,36,39,.92);color:#aaa; cursor:pointer;font:inherit;font-size:11px;font-weight:700;line-height:1;white-space:nowrap; transition:color .15s ease,background .15s ease,border-color .15s ease,transform .15s ease; } .o1vg-prompt-write-icon{display:block;font-size:15px;line-height:1;transform-origin:center} .o1vg-prompt-write:hover,.o1vg-prompt-write:focus-visible{color:#fff;background:#303136;border-color:rgba(255,255,255,.26);outline:none;transform:translateY(-1px)} .o1vg-prompt-write:disabled{cursor:wait;color:#d8c278;opacity:.9;transform:none} .o1vg-prompt-write.busy .o1vg-prompt-write-icon{animation:o1vg-magic-pulse .9s ease-in-out infinite alternate} @keyframes o1vg-magic-pulse{from{transform:rotate(-5deg) scale(.92);opacity:.58}to{transform:rotate(5deg) scale(1.08);opacity:1}} .o1vg-prompt-write-status{ position:absolute;right:112px;bottom:10px;z-index:1;display:none;align-items:center;gap:6px; max-width:calc(100% - 128px);height:24px;box-sizing:border-box;padding:0 7px;border-radius:6px; overflow:hidden;white-space:nowrap;text-overflow:ellipsis;pointer-events:none; background:rgba(25,26,28,.9);color:#aeb0b5;font-size:10px;line-height:24px; } .o1vg-prompt-write-status.visible{display:flex}.o1vg-prompt-write-status.busy{color:#d8c278}.o1vg-prompt-write-status.ok{color:#a8c89a}.o1vg-prompt-write-status.error{color:#d3a0a0} .o1vg-prompt-write-status.busy::before{content:"";flex:0 0 auto;width:6px;height:6px;border-radius:50%;background:currentColor;animation:o1vg-prompt-status-pulse .8s ease-in-out infinite alternate} @keyframes o1vg-prompt-status-pulse{from{opacity:.35;transform:scale(.8)}to{opacity:1;transform:scale(1.12)}} .o1vg-toolbar{width:100%;min-width:0;display:grid;flex:0 0 auto;grid-template-columns:minmax(0,1fr);gap:7px;position:relative;overflow:visible;} .o1vg-field{display:grid;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:4px 10px;min-width:0;} .o1vg-field>label{min-width:0;padding-left:2px;font-size:11px;line-height:1.4;letter-spacing:.04em;color:#999;text-align:left;white-space:nowrap;} .o1vg-control{ width:100%;height:34px;min-width:0;box-sizing:border-box;padding:0 9px;border:1px solid rgba(255,255,255,.1); border-radius:7px;background:#1a1b1d;color:#ddd;outline:none;font:inherit;font-size:12px;font-weight:500; line-height:1.4;letter-spacing:.035em;font-variant-numeric:tabular-nums;box-shadow:inset 0 1px 0 rgba(255,255,255,.025); } .o1vg-control:hover,.o1vg-control:focus{border-color:rgba(255,255,255,.22);background:#202123;} .o1vg-select{position:relative;min-width:0;height:34px;z-index:1}.o1vg-select.open{z-index:50} .o1vg-select-trigger{ width:100%;height:34px;min-width:0;display:flex;align-items:center;gap:6px;box-sizing:border-box;padding:0 7px 0 9px; border:1px solid rgba(255,255,255,.1);border-radius:7px;background:#1a1b1d;color:#ddd;cursor:pointer; font:inherit;font-size:12px;font-weight:500;line-height:1.4;letter-spacing:.035em;font-variant-numeric:tabular-nums; text-align:left;box-shadow:inset 0 1px 0 rgba(255,255,255,.025);transition:.14s ease; } .o1vg-select-trigger:hover{border-color:rgba(255,255,255,.28);background:#242527;color:#fff} .o1vg-select-trigger:disabled{cursor:not-allowed;opacity:.48;background:#191a1c;color:#888} .o1vg-select-trigger:focus-visible{outline:2px solid rgba(255,255,255,.7);outline-offset:1px} .o1vg-select.open .o1vg-select-trigger{border-color:rgba(255,255,255,.48);background:#2a2b2e;color:#fff;box-shadow:0 7px 18px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.08)} .o1vg-select-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.4} .o1vg-select-description{flex:0 0 auto;max-width:42%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:2px 6px;border:1px solid rgba(255,255,255,.1);border-radius:999px;background:rgba(255,255,255,.07);color:#aeb0b5;font-size:10px;font-weight:600;line-height:1.35;letter-spacing:.04em} .o1vg-select-description:empty{display:none} .o1vg-select-caret{width:14px;height:14px;flex:0 0 14px;display:flex;align-items:center;justify-content:center;color:#aaa;line-height:0;transform-origin:50% 50%;transition:transform .14s ease,color .14s ease} .o1vg-select-caret svg{display:block;width:12px;height:12px;overflow:visible}.o1vg-select.open .o1vg-select-caret{transform:rotate(180deg);color:#fff} .o1vg-select-menu{position:absolute;top:40px;left:0;min-width:100%;width:max-content;max-width:310px;display:none;box-sizing:border-box;padding:5px;border:1px solid rgba(255,255,255,.18);border-radius:9px;background:#202123;box-shadow:0 15px 32px rgba(0,0,0,.48),inset 0 1px 0 rgba(255,255,255,.055)} .o1vg-select.open .o1vg-select-menu{display:grid;gap:2px} .o1vg-select-option{min-width:100%;min-height:30px;box-sizing:border-box;padding:7px 9px;border:0;border-radius:5px;background:transparent;color:#bfc0c3;display:flex;align-items:center;gap:14px;justify-content:space-between;cursor:pointer;font:inherit;font-size:12px;font-weight:500;line-height:1.4;letter-spacing:.035em;white-space:nowrap;transition:background .12s ease,color .12s ease} .o1vg-select-option:hover,.o1vg-select-option:focus-visible{outline:none;background:#343538;color:#fff}.o1vg-select-option.selected{background:#e7e7e7;color:#151515;font-weight:700;box-shadow:inset 0 1px 0 #fff} .o1vg-select-option small{padding:2px 6px;border:1px solid rgba(255,255,255,.1);border-radius:999px;background:rgba(255,255,255,.07);color:#aeb0b5;font-size:10px;font-weight:600}.o1vg-select-option.selected small{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.07);color:#343434} .o1vg-seed{width:100%;height:34px;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) 34px;gap:0;box-sizing:border-box;overflow:hidden;border:1px solid rgba(255,255,255,.1);border-radius:7px;background:#1a1b1d;box-shadow:inset 0 1px 0 rgba(255,255,255,.025)} .o1vg-seed:hover,.o1vg-seed:focus-within{border-color:rgba(255,255,255,.22);background:#202123}.o1vg-seed .o1vg-control{height:32px;border:0;border-radius:0;background:transparent;box-shadow:none}.o1vg-seed button{padding:0;border:0;border-left:1px solid rgba(255,255,255,.1);background:transparent;color:#ddd;font-size:14px;cursor:pointer}.o1vg-seed button:hover{background:rgba(255,255,255,.08)} .o1vg-assets{display:flex;flex-direction:column;gap:6px;min-width:0}.o1vg-asset-header{display:flex;align-items:center;gap:8px;min-height:30px;padding:0 2px;color:#aeb0b5;font-size:10px;line-height:16px}.o1vg-asset-header strong{color:#dedfe1;font-size:11px}.o1vg-asset-meta{display:flex;min-width:0;align-items:baseline;gap:7px}.o1vg-asset-hint{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#82858a}.o1vg-asset-actions{display:flex;flex:0 0 auto;align-items:center;gap:5px;margin-left:auto} .o1vg-multimodal-assets .o1vg-asset-actions{gap:4px}.o1vg-multimodal-assets .o1vg-add{min-width:58px;height:29px;gap:4px;padding:0 10px;border-radius:7px;font-size:11px;font-weight:600;line-height:1;letter-spacing:.02em}.o1vg-multimodal-assets .o1vg-add b{display:block;font-size:13px;line-height:1;transform:translateY(-.5px)}.o1vg-multimodal-assets .o1vg-asset-hint{font-variant-numeric:tabular-nums} .o1vg-add{position:relative;display:flex;flex:0 0 auto;height:30px;box-sizing:border-box;align-items:center;justify-content:center;gap:5px;padding:0 9px;border:1px solid rgba(255,255,255,.11);border-radius:7px;background:#1b1c1e;color:#aaa;cursor:pointer;transition:.15s ease;font:inherit;font-size:10px}.o1vg-add:hover{background:#232426;border-color:rgba(255,255,255,.28);color:#fff}.o1vg-add b{font-size:15px;line-height:1;font-weight:400}.o1vg-canvas-pick b{font-size:13px} .o1vg-refs{display:flex;flex:0 0 109px;box-sizing:border-box;gap:7px;min-height:109px;overflow-x:auto;overflow-y:hidden;padding:1px 1px 4px;border-radius:9px;scrollbar-width:thin;scrollbar-color:#555 transparent;transition:background .12s ease,box-shadow .12s ease} .o1vg-refs.o1vg-reordering{background:rgba(150,184,136,.08);box-shadow:inset 0 0 0 1px rgba(173,205,158,.16)} .o1vg-thumb,.o1vg-empty{position:relative;flex:0 0 102px;height:102px;box-sizing:border-box;border-radius:9px;border:1px solid rgba(255,255,255,.11);background:#1b1c1e;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.035),0 5px 14px rgba(0,0,0,.13);transition:transform .12s ease,opacity .12s ease,filter .12s ease,box-shadow .12s ease,border-color .12s ease} .o1vg-thumb img,.o1vg-thumb-video{width:100%;height:100%;display:block;object-fit:contain;background:#111}.o1vg-thumb-video{position:absolute;inset:0;opacity:0;pointer-events:none;transition:opacity .12s ease}.o1vg-thumb-video.ready{opacity:1}.o1vg-thumb-media{width:100%;height:100%;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:9px;color:#999;text-align:center;transition:opacity .12s ease}.o1vg-thumb-video.ready+.o1vg-thumb-media{opacity:0}.o1vg-thumb-media b{color:#ddd;font-size:24px;font-weight:400}.o1vg-thumb-media span{width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px} .o1vg-thumb-edit{position:absolute;top:5px;left:5px;z-index:2;width:25px;height:25px;padding:0;display:grid;place-items:center;border:1px solid rgba(255,255,255,.18);border-radius:6px;background:rgba(10,10,10,.78);color:#e7e7e7;cursor:pointer;font:700 15px/1 system-ui,sans-serif;opacity:.84;transition:opacity .12s ease,background .12s ease}.o1vg-thumb:hover .o1vg-thumb-edit,.o1vg-thumb-edit:focus-visible{opacity:1}.o1vg-thumb-edit:hover{background:#eef0ed;color:#151715;border-color:#fff} .o1vg-thumb-remove{position:absolute;top:5px;right:5px;width:23px;height:23px;padding:0;border:0;border-radius:6px;background:rgba(10,10,10,.76);color:#ddd;cursor:pointer;line-height:23px;font-size:14px;opacity:0;transition:opacity .12s ease}.o1vg-thumb:hover .o1vg-thumb-remove{opacity:1}.o1vg-thumb-remove:hover{color:#fff;background:#050505} .o1vg-thumb-index{position:absolute;left:5px;bottom:5px;z-index:1;min-width:23px;height:22px;padding:0 6px;box-sizing:border-box;display:grid;place-items:center;border:1px solid rgba(255,255,255,.46);border-radius:6px;background:rgba(10,10,10,.78);color:#fff;font-size:11px;font-weight:700;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.75);box-shadow:0 2px 5px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.1);pointer-events:none} .o1vg-thumb.o1vg-sortable{cursor:grab}.o1vg-thumb.o1vg-sortable:active{cursor:grabbing}.o1vg-thumb.o1vg-dragging{opacity:.3;filter:brightness(.52) saturate(.45);transform:scale(.91);border-color:rgba(194,220,184,.72);box-shadow:inset 0 0 0 2px rgba(194,220,184,.5),0 2px 7px rgba(0,0,0,.24)}.o1vg-thumb.o1vg-dragging .o1vg-thumb-remove{display:none}.o1vg-thumb.o1vg-drop-before{transform:translateX(7px);box-shadow:inset 5px 0 0 #dcebd6,0 8px 20px rgba(0,0,0,.2)}.o1vg-thumb.o1vg-drop-after{transform:translateX(-7px);box-shadow:inset -5px 0 0 #dcebd6,0 8px 20px rgba(0,0,0,.2)} .o1vg-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border-style:dashed;color:#888;cursor:pointer;font:inherit}.o1vg-empty:hover{border-color:rgba(255,255,255,.3);background:#212225;color:#ccc}.o1vg-empty b{font-size:22px;line-height:1;font-weight:300}.o1vg-empty span{font-size:10px} .o1vg-generate{width:100%;height:39px;flex:0 0 39px;border:1px solid rgba(255,255,255,.82);border-radius:8px;background:#e7e7e7;color:#151515;font-size:12px;font-weight:700;cursor:pointer;box-shadow:0 8px 20px rgba(0,0,0,.24),inset 0 1px 0 #fff;transition:transform .12s ease,background .12s ease,box-shadow .12s ease}.o1vg-generate:hover{background:#fff;box-shadow:0 10px 24px rgba(0,0,0,.3),inset 0 1px 0 #fff}.o1vg-generate:active{transform:translateY(1px)}.o1vg-generate:disabled{cursor:wait;opacity:.72;transform:none} .o1vg-status{display:none;flex:0 0 14px;min-height:14px;text-align:center;color:#aaa;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.o1vg-status.visible{display:block}.o1vg-status.error{color:#d3a0a0} .o1vg-result-panel{padding:6px;gap:7px}.o1vg-result-head{display:flex;align-items:center;justify-content:space-between;gap:8px;color:#aaa;font-size:10px}.o1vg-result-head strong{color:#ddd;font-size:11px}.o1vg-progress{height:2px;border-radius:2px;overflow:hidden;background:rgba(255,255,255,.13)}.o1vg-progress i{display:block;width:0;height:100%;background:#eee;transition:width .2s ease}.o1vg-video-shell{display:none;min-height:168px;overflow:hidden;border:1px solid rgba(255,255,255,.1);border-radius:8px;background:#111}.o1vg-video-shell.visible{display:grid;place-items:center}.o1vg-video-shell video{display:block;width:100%;max-height:430px;background:#111}.o1vg-error{display:none;padding:7px 8px;border-radius:6px;background:rgba(120,54,54,.18);color:#d3a0a0;font-size:10px;line-height:1.45;white-space:pre-wrap}.o1vg-error.visible{display:block}.o1vg-actions{display:flex;gap:6px}.o1vg-actions button{height:30px;flex:1;padding:0 8px;border:1px solid rgba(255,255,255,.11);border-radius:7px;background:#1b1c1e;color:#aaa;cursor:pointer;font:inherit;font-size:10px}.o1vg-actions button:hover{border-color:rgba(255,255,255,.28);background:#232426;color:#fff} `; function installStyles() { if (document.getElementById("o1key-video-generator-styles")) return; const style = document.createElement("style"); style.id = "o1key-video-generator-styles"; style.textContent = CSS; document.head.appendChild(style); document.addEventListener("pointerdown", (event) => { if (openDropdown && !openDropdown.contains(event.target)) closeDropdown(); }, true); } function widget(node, name) { return node.widgets?.find((item) => item.name === name); } function setWidget(node, name, value) { const target = widget(node, name); if (target) { target.value = value; target.callback?.(value); } } function hideWidget(target) { if (!target || target._o1vgHidden) return; target._o1vgHidden = true; target.hidden = true; target.options ||= {}; target.options.hidden = true; target.computeSize = () => [0, -4]; target.serializeValue = target.serializeValue || function () { return this.value; }; } function hideBackendWidgets(node) { for (const target of node.widgets || []) hideWidget(target); } function el(tag, className = "", text = "") { const value = document.createElement(tag); if (className) value.className = className; if (text) value.textContent = text; return value; } function makeField(labelText, control) { const root = el("div", "o1vg-field"); root.append(el("label", "", labelText), control); return root; } function normalizeOptions(options) { return options.map((option) => typeof option === "string" ? { value: option, label: option } : option); } function closeDropdown(dropdown = openDropdown) { if (!dropdown) return; dropdown.classList.remove("open"); dropdown._trigger?.setAttribute("aria-expanded", "false"); if (openDropdown === dropdown) openDropdown = null; } function setDropdownValue(dropdown, value) { const selected = dropdown._options.find((option) => option.value === value) || dropdown._options[0]; dropdown._value = selected?.value || ""; dropdown._valueLabel.textContent = selected?.label || ""; dropdown._valueDescription.textContent = selected?.description || ""; for (const button of dropdown._menu.children) button.classList.toggle("selected", button.dataset.value === dropdown._value); } function setDropdownOptions(dropdown, options, preferred) { dropdown._options = normalizeOptions(options); dropdown._menu.replaceChildren(); for (const option of dropdown._options) { const button = el("button", "o1vg-select-option"); button.type = "button"; button.dataset.value = option.value; button.append(el("span", "", option.label)); if (option.description) button.append(el("small", "", option.description)); button.addEventListener("click", () => { setDropdownValue(dropdown, option.value); closeDropdown(dropdown); dropdown.dispatchEvent(new Event("change", { bubbles: true })); }); dropdown._menu.append(button); } setDropdownValue(dropdown, preferred); } function makeSelect(options, current, title) { const root = el("div", "o1vg-select"); root.setAttribute("role", "combobox"); root.setAttribute("aria-label", title); const trigger = el("button", "o1vg-select-trigger"); trigger.type = "button"; trigger.setAttribute("aria-expanded", "false"); const valueLabel = el("span", "o1vg-select-value"); const description = el("span", "o1vg-select-description"); const caret = el("span", "o1vg-select-caret"); caret.innerHTML = ''; trigger.append(valueLabel, description, caret); const menu = el("div", "o1vg-select-menu"); root.append(trigger, menu); Object.assign(root, { _trigger: trigger, _menu: menu, _valueLabel: valueLabel, _valueDescription: description }); Object.defineProperty(root, "value", { get: () => root._value, set: (value) => setDropdownValue(root, value) }); trigger.addEventListener("click", () => { const opening = !root.classList.contains("open"); if (openDropdown && openDropdown !== root) closeDropdown(); root.classList.toggle("open", opening); trigger.setAttribute("aria-expanded", String(opening)); openDropdown = opening ? root : null; }); setDropdownOptions(root, options, current); return root; } function toast(severity, summary, detail) { app.extensionManager?.toast?.add?.({ severity, summary, detail, life: 3500 }); } function updateNode(node) { node?.setDirtyCanvas?.(true, true); app.graph?.setDirtyCanvas?.(true, true); } function safeDescriptor(value) { return { name: String(value?.name || "").split(/[\\/]/).pop(), subfolder: String(value?.subfolder || "").replace(/\\/g, "/"), type: "input" }; } let uploadTail = Promise.resolve(); async function uploadFile(file) { const body = new FormData(); body.append("image", file, file.name); body.append("type", "input"); body.append("overwrite", "false"); const response = await api.fetchApi("/upload/image", { method: "POST", body }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || `上传失败:${file.name}`); return safeDescriptor({ ...data, name: data.name || file.name }); } function enqueueUpload(file) { const request = uploadTail.then(() => uploadFile(file)); uploadTail = request.catch(() => {}); return request; } async function validateReferenceImageFile(file) { let width = 0, height = 0; if (typeof createImageBitmap === "function") { const bitmap = await createImageBitmap(file); try { width = bitmap.width; height = bitmap.height; } finally { bitmap.close?.(); } } else { const url = URL.createObjectURL(file); try { const dimensions = await new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve([image.naturalWidth, image.naturalHeight]); image.onerror = () => reject(new Error(`无法读取图片:${file.name}`)); image.src = url; }); [width, height] = dimensions; } finally { URL.revokeObjectURL(url); } } validateReferenceDimensions(width, height, file.name); } function validateReferenceDimensions(width, height, label, { video = false } = {}) { const limits = SEEDANCE_REFERENCE_LIMITS; if (width < limits.minDimension || width > limits.maxDimension || height < limits.minDimension || height > limits.maxDimension) { throw new Error(`${label} 宽高必须分别在 ${limits.minDimension}~${limits.maxDimension}px,当前为 ${width}×${height}`); } const ratio = width / height; if (ratio < limits.minAspectRatio || ratio > limits.maxAspectRatio) { throw new Error(`${label} 宽高比必须在 ${limits.minAspectRatio}~${limits.maxAspectRatio},当前为 ${ratio.toFixed(3)}(${width}:${height})`); } if (video) { const pixels = width * height; if (pixels < limits.minVideoPixels || pixels > limits.maxVideoPixels) { throw new Error(`${label} 总像素必须在 ${limits.minVideoPixels.toLocaleString()}~${limits.maxVideoPixels.toLocaleString()} 之间,当前为 ${width}×${height}(${pixels.toLocaleString()} 像素)`); } } } async function validateReferenceVideoFile(file) { const url = URL.createObjectURL(file); try { const dimensions = await new Promise((resolve) => { const video = document.createElement("video"); let settled = false; const timeout = window.setTimeout(() => finish(null), 8000); const finish = (value) => { if (settled) return; settled = true; window.clearTimeout(timeout); video.pause(); video.onloadedmetadata = null; video.onerror = null; video.removeAttribute("src"); video.load(); resolve(value); }; video.preload = "metadata"; video.muted = true; video.onloadedmetadata = () => finish([video.videoWidth, video.videoHeight]); // MOV/H.265 may be valid for Seedance while the browser cannot decode it. // Let the authoritative PyAV backend check those files after upload. video.onerror = () => finish(null); video.src = url; video.load(); }); if (dimensions) validateReferenceDimensions(dimensions[0], dimensions[1], file.name, { video: true }); } finally { URL.revokeObjectURL(url); } } function descriptorUrl(descriptor, result = false) { const filename = result ? descriptor?.filename : descriptor?.name; if (!filename) return ""; const params = new URLSearchParams({ filename, subfolder: descriptor.subfolder || "", type: descriptor.type || (result ? "output" : "input") }); return api.apiURL(`/view?${params.toString()}`); } function makeBatchId() { return `video_${crypto.randomUUID().replaceAll("-", "")}`; } function parseIds(value) { return String(value || "").split(/[,,\n]/).map((item) => item.trim()).filter(Boolean); } function requestJson(path, options = {}) { return api.fetchApi(path, options).then(async (response) => { const data = await response.json().catch(() => ({})); if (!response.ok) { const error = new Error(data.error || `请求失败 (${response.status})`); error.status = response.status; throw error; } return data; }); } function ensureMediaState(node) { node.properties ||= {}; node.properties.o1keyVideoMedia ||= { first_frame: null, last_frame: null, reference_images: [], reference_videos: [], reference_audios: [] }; return node.properties.o1keyVideoMedia; } function releaseVideoPreviews(root) { for (const video of root?.querySelectorAll?.("video.o1vg-thumb-video") || []) { video.pause(); video.removeAttribute("src"); video.load(); } } function makeVideoPreview(item, title) { const video = document.createElement("video"); video.className = "o1vg-thumb-video"; video.preload = "metadata"; video.muted = true; video.defaultMuted = true; video.playsInline = true; video.controls = false; video.draggable = false; video.tabIndex = -1; video.setAttribute("aria-label", `${title}首帧预览:${item.name}`); const markReady = () => video.classList.add("ready"); video.addEventListener("loadedmetadata", () => { const duration = Number(video.duration); const previewTime = Number.isFinite(duration) && duration > 0 ? Math.min(0.01, duration / 2) : 0.001; try { video.currentTime = previewTime; } catch { /* The loadeddata fallback still exposes the first decodable frame. */ } }, { once: true }); video.addEventListener("loadeddata", markReady, { once: true }); video.addEventListener("seeked", markReady, { once: true }); video.src = descriptorUrl(item); return video; } function mediaDropDestination(fromIndex, targetIndex, afterTarget) { let insertionIndex = targetIndex + (afterTarget ? 1 : 0); if (fromIndex < insertionIndex) insertionIndex -= 1; return Math.max(0, insertionIndex); } function moveMediaItem(node, key, fromIndex, toIndex) { const items = ensureMediaState(node)[key]; if (!Array.isArray(items) || items.length < 2) return false; const from = Math.floor(Number(fromIndex)); const target = Math.max(0, Math.min(items.length - 1, Math.floor(Number(toIndex)))); if (!Number.isInteger(from) || !Number.isInteger(target) || from < 0 || from >= items.length || from === target) return false; const [item] = items.splice(from, 1); items.splice(target, 0, item); updateNode(node); return true; } async function editMediaImage(node, key, item, multiple, render) { try { await openReferenceImageEditor({ sourceUrl: descriptorUrl(item), filename: item.name, onConfirm: async ({ blob, filename }) => { const file = new File([blob], filename, { type: "image/png" }); await validateReferenceImageFile(file); const uploaded = await enqueueUpload(file); const media = ensureMediaState(node); if (multiple) { const currentIndex = (media[key] || []).indexOf(item); if (currentIndex < 0) throw new Error("图片已被移除,编辑结果未替换"); media[key].splice(currentIndex, 1, uploaded); } else { if (media[key] !== item) throw new Error("图片已被替换,编辑结果未覆盖新图片"); media[key] = uploaded; } render(); updateNode(node); toast("success", "图片编辑完成", `${uploaded.name} · 已替换当前图片`); }, }); } catch (error) { toast("error", "无法打开图片编辑器", String(error?.message || error)); } } function makeMediaSection(node, key, title, hint, { multiple = false, accept = "*/*", kind = "image" } = {}) { const section = el("section", "o1vg-assets"); const header = el("div", "o1vg-asset-header"); const meta = el("div", "o1vg-asset-meta"); const hintElement = el("span", "o1vg-asset-hint", hint); meta.append(el("strong", "", title), hintElement); const actions = el("div", "o1vg-asset-actions"); const upload = el("button", "o1vg-add"); upload.type = "button"; upload.append(el("b", "", "+"), el("span", "", "上传")); const input = document.createElement("input"); input.type = "file"; input.accept = accept; input.multiple = multiple; input.hidden = true; upload.append(input); actions.append(upload); const canvasPick = kind === "image" ? el("button", "o1vg-add o1vg-canvas-pick") : null; if (canvasPick) { canvasPick.type = "button"; canvasPick.title = `从画布中选择已生成的${title}`; canvasPick.append(el("b", "", "▣"), el("span", "", "画布取图")); actions.append(canvasPick); } header.append(meta, actions); const track = el("div", "o1vg-refs"); track.addEventListener("wheel", (event) => event.stopPropagation()); const choose = () => input.click(); upload.addEventListener("click", (event) => { if (event.target !== input) choose(); }); let dragIndex = -1; const clearDrag = () => { dragIndex = -1; track.classList.remove("o1vg-reordering"); for (const child of track.children) child.classList?.remove("o1vg-dragging", "o1vg-drop-before", "o1vg-drop-after"); }; const render = () => { clearDrag(); releaseVideoPreviews(track); track.replaceChildren(); const media = ensureMediaState(node); const values = multiple ? (media[key] || []) : (media[key] ? [media[key]] : []); hintElement.textContent = `${hint} · ${values.length}${multiple ? " 个" : values.length ? " 已选择" : " 未选择"}`; values.forEach((item, index) => { const sortable = multiple && values.length > 1; const tile = el("div", `o1vg-thumb${sortable ? " o1vg-sortable" : ""}`); tile.title = sortable ? `${item.name} · 第 ${index + 1} 项 · 拖动可排序` : item.name; tile.draggable = sortable; if (sortable) { tile.tabIndex = 0; tile.setAttribute("aria-label", `${title}第 ${index + 1} 项,按住拖动可排序;Alt 加方向键也可移动`); tile.addEventListener("keydown", (event) => { if (!event.altKey) return; const targets = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: values.length - 1 }; if (!(event.key in targets)) return; event.preventDefault(); event.stopPropagation(); const target = Math.max(0, Math.min(values.length - 1, targets[event.key])); if (moveMediaItem(node, key, index, target)) { render(); requestAnimationFrame(() => track.children[target]?.focus?.()); } }); tile.addEventListener("dragstart", (event) => { dragIndex = index; tile.classList.add("o1vg-dragging"); track.classList.add("o1vg-reordering"); if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", `${key}:${index}`); } event.stopPropagation(); }); tile.addEventListener("dragend", clearDrag); tile.addEventListener("dragover", (event) => { if (dragIndex < 0) return; event.preventDefault(); event.stopPropagation(); for (const child of track.children) child.classList?.remove("o1vg-drop-before", "o1vg-drop-after"); if (index === dragIndex) return; const bounds = tile.getBoundingClientRect(); const after = event.clientX >= bounds.left + bounds.width / 2; tile.classList.add(after ? "o1vg-drop-after" : "o1vg-drop-before"); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; }); tile.addEventListener("drop", (event) => { if (dragIndex < 0) return; event.preventDefault(); event.stopPropagation(); const from = dragIndex; const bounds = tile.getBoundingClientRect(); const destination = mediaDropDestination(from, index, event.clientX >= bounds.left + bounds.width / 2); clearDrag(); if (moveMediaItem(node, key, from, destination)) render(); }); } if (kind === "image") { const image = document.createElement("img"); image.src = descriptorUrl(item); image.alt = item.name; image.draggable = false; tile.append(image); } else { const content = el("div", "o1vg-thumb-media"); content.append(el("b", "", kind === "video" ? "▶" : "♫"), el("span", "", item.name)); if (kind === "video") tile.append(makeVideoPreview(item, title)); tile.append(content); } if (kind === "image") { const edit = el("button", "o1vg-thumb-edit", "✎"); edit.type = "button"; edit.title = "编辑图片"; edit.setAttribute("aria-label", `编辑${title}${index + 1}`); edit.draggable = false; edit.addEventListener("pointerdown", (event) => event.stopPropagation()); edit.addEventListener("dragstart", (event) => event.preventDefault()); edit.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); editMediaImage(node, key, item, multiple, render); }); tile.append(edit); } if (multiple) tile.append(el("span", "o1vg-thumb-index", String(index + 1))); const remove = el("button", "o1vg-thumb-remove", "×"); remove.type = "button"; remove.title = "移除"; remove.draggable = false; remove.addEventListener("pointerdown", (event) => event.stopPropagation()); remove.addEventListener("dragstart", (event) => event.preventDefault()); remove.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (multiple) media[key].splice(index, 1); else media[key] = null; render(); updateNode(node); }); tile.append(remove); track.append(tile); }); const empty = el("button", "o1vg-empty"); empty.type = "button"; empty.append(el("b", "", "+"), el("span", "", values.length && !multiple ? "替换" : "上传")); empty.addEventListener("click", choose); track.append(empty); }; input.addEventListener("change", async () => { const files = [...(input.files || [])]; input.value = ""; if (!files.length) return; upload.disabled = true; node._o1vgUploading = (node._o1vgUploading || 0) + 1; try { const uploaded = []; for (const file of files) { if (kind === "image") await validateReferenceImageFile(file); else if (kind === "video") await validateReferenceVideoFile(file); uploaded.push(await enqueueUpload(file)); } const media = ensureMediaState(node); media[key] = multiple ? [...(media[key] || []), ...uploaded] : uploaded[0]; render(); updateNode(node); } catch (error) { toast("error", "素材上传失败", String(error.message || error)); } finally { upload.disabled = false; node._o1vgUploading = Math.max(0, (node._o1vgUploading || 1) - 1); } }); canvasPick?.addEventListener("click", () => { const picker = window.o1keyCanvasImagePicker; if (!picker?.open || !picker?.readFile) { toast("error", "画布取图不可用", "请刷新 ComfyUI 后重试"); return; } picker.open(node, { label: title, limit: 1, remaining: 1, onSelect: async (descriptor) => { node._o1vgUploading = (node._o1vgUploading || 0) + 1; canvasPick.disabled = true; try { const file = await picker.readFile(descriptor); await validateReferenceImageFile(file); const uploaded = await enqueueUpload(file); const media = ensureMediaState(node); media[key] = multiple ? [...(media[key] || []), uploaded] : uploaded; render(); updateNode(node); } finally { canvasPick.disabled = false; node._o1vgUploading = Math.max(0, (node._o1vgUploading || 1) - 1); } }, }); }); section.append(header, track); section._render = render; render(); return section; } function makeMultimodalMediaSection(node) { const sources = [ { key: "reference_images", title: "参考图片", shortTitle: "图片", hint: "宽高 300~6000px · 比例 0.4~2.5", accept: "image/png,image/jpeg,image/webp,image/bmp", kind: "image", icon: "▧" }, { key: "reference_videos", title: "参考视频", shortTitle: "视频", hint: "40.77万~829.50万像素", accept: "video/mp4,video/quicktime,.mp4,.mov", kind: "video", icon: "▶" }, { key: "reference_audios", title: "参考音频", shortTitle: "音频", hint: "节奏与声音参考", accept: "audio/*,.wav,.mp3,.m4a,.aac,.flac,.ogg", kind: "audio", icon: "♫" }, ]; const section = el("section", "o1vg-assets o1vg-multimodal-assets"); const header = el("div", "o1vg-asset-header"); const meta = el("div", "o1vg-asset-meta"); const hintElement = el("span", "o1vg-asset-hint"); meta.append(el("strong", "", "参考素材"), hintElement); const actions = el("div", "o1vg-asset-actions"); const track = el("div", "o1vg-refs"); track.addEventListener("wheel", (event) => event.stopPropagation()); let dragState = null; const sourceForFile = (file) => { const type = String(file?.type || "").toLowerCase(); if (type.startsWith("image/")) return sources[0]; if (type.startsWith("video/")) return sources[1]; if (type.startsWith("audio/")) return sources[2]; const extension = String(file?.name || "").toLowerCase().match(/\.[^.]+$/)?.[0] || ""; if ([".png", ".jpg", ".jpeg", ".webp", ".bmp"].includes(extension)) return sources[0]; if ([".mp4", ".mov"].includes(extension)) return sources[1]; if ([".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg"].includes(extension)) return sources[2]; return null; }; const addFiles = async (files, fixedSource = null) => { const selected = files.map((file) => ({ file, source: fixedSource || sourceForFile(file) })); const unsupported = selected.find(({ source }) => !source); if (unsupported) throw new Error(`不支持的素材类型:${unsupported.file.name}`); for (const { file, source } of selected) { if (source.kind === "image") await validateReferenceImageFile(file); else if (source.kind === "video") await validateReferenceVideoFile(file); } const additions = new Map(sources.map((source) => [source.key, []])); for (const { file, source } of selected) additions.get(source.key).push(await enqueueUpload(file)); const media = ensureMediaState(node); for (const source of sources) media[source.key] = [...(media[source.key] || []), ...additions.get(source.key)]; render(); updateNode(node); }; const upload = el("button", "o1vg-add"); upload.type = "button"; upload.title = "上传图片、视频或音频素材"; upload.append(el("b", "", "+"), el("span", "", "上传")); const input = document.createElement("input"); input.type = "file"; input.accept = sources.map((source) => source.accept).join(","); input.multiple = true; input.hidden = true; upload.append(input); actions.append(upload); upload.addEventListener("click", (event) => { if (event.target !== input) input.click(); }); input.addEventListener("change", async () => { const files = [...(input.files || [])]; input.value = ""; if (!files.length) return; upload.disabled = true; node._o1vgUploading = (node._o1vgUploading || 0) + 1; try { await addFiles(files); } catch (error) { toast("error", "素材上传失败", String(error.message || error)); } finally { upload.disabled = false; node._o1vgUploading = Math.max(0, (node._o1vgUploading || 1) - 1); } }); const clearDrag = () => { dragState = null; track.classList.remove("o1vg-reordering"); for (const child of track.children) child.classList?.remove("o1vg-dragging", "o1vg-drop-before", "o1vg-drop-after"); }; const focusTile = (key, index) => requestAnimationFrame(() => track.querySelector(`[data-o1vg-key="${key}"][data-o1vg-index="${index}"]`)?.focus?.()); const render = () => { clearDrag(); releaseVideoPreviews(track); track.replaceChildren(); const media = ensureMediaState(node); hintElement.textContent = sources.map((source) => `${source.shortTitle} ${(media[source.key] || []).length}`).join(" · "); for (const source of sources) { const values = media[source.key] || []; values.forEach((item, index) => { const sortable = values.length > 1; const tile = el("div", `o1vg-thumb${sortable ? " o1vg-sortable" : ""}`); tile.dataset.o1vgKey = source.key; tile.dataset.o1vgIndex = String(index); tile.title = sortable ? `${item.name} · ${source.title}第 ${index + 1} 项 · 拖动可排序` : `${item.name} · ${source.title}`; tile.draggable = sortable; if (sortable) { tile.tabIndex = 0; tile.setAttribute("aria-label", `${source.title}第 ${index + 1} 项,按住拖动可排序;Alt 加方向键也可移动`); tile.addEventListener("keydown", (event) => { if (!event.altKey) return; const targets = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: values.length - 1 }; if (!(event.key in targets)) return; event.preventDefault(); event.stopPropagation(); const target = Math.max(0, Math.min(values.length - 1, targets[event.key])); if (moveMediaItem(node, source.key, index, target)) { render(); focusTile(source.key, target); } }); tile.addEventListener("dragstart", (event) => { dragState = { key: source.key, index }; tile.classList.add("o1vg-dragging"); track.classList.add("o1vg-reordering"); if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", `${source.key}:${index}`); } event.stopPropagation(); }); tile.addEventListener("dragend", clearDrag); tile.addEventListener("dragover", (event) => { if (!dragState || dragState.key !== source.key) return; event.preventDefault(); event.stopPropagation(); for (const child of track.children) child.classList?.remove("o1vg-drop-before", "o1vg-drop-after"); if (index === dragState.index) return; const bounds = tile.getBoundingClientRect(); const after = event.clientX >= bounds.left + bounds.width / 2; tile.classList.add(after ? "o1vg-drop-after" : "o1vg-drop-before"); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; }); tile.addEventListener("drop", (event) => { if (!dragState || dragState.key !== source.key) return; event.preventDefault(); event.stopPropagation(); const from = dragState.index; const bounds = tile.getBoundingClientRect(); const destination = mediaDropDestination(from, index, event.clientX >= bounds.left + bounds.width / 2); clearDrag(); if (moveMediaItem(node, source.key, from, destination)) render(); }); } if (source.kind === "image") { const image = document.createElement("img"); image.src = descriptorUrl(item); image.alt = item.name; image.draggable = false; tile.append(image); const edit = el("button", "o1vg-thumb-edit", "✎"); edit.type = "button"; edit.title = "编辑图片"; edit.setAttribute("aria-label", `编辑${source.title}${index + 1}`); edit.draggable = false; edit.addEventListener("pointerdown", (event) => event.stopPropagation()); edit.addEventListener("dragstart", (event) => event.preventDefault()); edit.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); editMediaImage(node, source.key, item, true, render); }); tile.append(edit); } else { const content = el("div", "o1vg-thumb-media"); content.append(el("b", "", source.kind === "video" ? "▶" : "♫"), el("span", "", item.name)); if (source.kind === "video") tile.append(makeVideoPreview(item, source.title)); tile.append(content); } tile.append(el("span", "o1vg-thumb-index", `${source.shortTitle}${index + 1}`)); const remove = el("button", "o1vg-thumb-remove", "×"); remove.type = "button"; remove.title = "移除"; remove.draggable = false; remove.addEventListener("pointerdown", (event) => event.stopPropagation()); remove.addEventListener("dragstart", (event) => event.preventDefault()); remove.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); media[source.key].splice(index, 1); render(); updateNode(node); }); tile.append(remove); track.append(tile); }); } }; header.append(meta, actions); section.append(header, track); section._render = render; render(); return section; } function nativeSavePrefix(request, suffix = "") { const prefix = String(request.filename_prefix || "o1key_video").replace(/[\\/]+/g, "_"); const location = String(request.save_location || "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); const relativeLocation = /^(?:[A-Za-z]:|\/\/)/.test(String(request.save_location || "").trim()) ? "video" : location; return [relativeLocation, `${prefix}${suffix}`].filter(Boolean).join("/"); } function createNativeOutputNodes(source, request, batchId) { const graph = source.graph || app.graph; const saveVideo = LiteGraph.createNode(SAVE_VIDEO); if (!graph || !saveVideo) throw new Error("无法创建原生保存视频节点,请重启 ComfyUI 后重试"); const saveImage = request.return_last_frame ? LiteGraph.createNode(SAVE_IMAGE) : null; if (request.return_last_frame && !saveImage) throw new Error("无法创建原生保存图像节点,请重启 ComfyUI 后重试"); source.properties ||= {}; const ordinal = Number(source.properties.o1keyVideoOutputCount || 0); source.properties.o1keyVideoOutputCount = ordinal + 1; const baseX = source.pos[0] + source.size[0] + 70; const baseY = source.pos[1] + 30 + ordinal * 330; const created = []; graph.beforeChange?.(); try { saveVideo.pos = [baseX, baseY]; saveVideo.properties ||= {}; Object.assign(saveVideo.properties, { o1keyAutoCreated: true, o1keyVideoBatchId: batchId, o1keyVideoGeneratorNodeId: source.id, o1keyVideoRequest: request, o1keyVideoState: "queued", o1keyVideoSaveImageNodeId: saveImage?.id ?? null }); graph.add(saveVideo); created.push(saveVideo); const videoPrefix = widget(saveVideo, "filename_prefix"); if (videoPrefix) videoPrefix.value = nativeSavePrefix(request); source.connect(0, saveVideo, 0); if (saveImage) { saveImage.pos = [baseX + 340, baseY]; saveImage.properties ||= {}; Object.assign(saveImage.properties, { o1keyAutoCreated: true, o1keyVideoBatchId: batchId, o1keyVideoGeneratorNodeId: source.id, o1keyVideoSaveVideoNodeId: saveVideo.id, o1keyVideoState: "queued" }); graph.add(saveImage); created.push(saveImage); saveVideo.properties.o1keyVideoSaveImageNodeId = saveImage.id; const imagePrefix = widget(saveImage, "filename_prefix"); if (imagePrefix) imagePrefix.value = nativeSavePrefix(request, "_last_frame"); source.connect(1, saveImage, 0); } graph.setDirtyCanvas?.(true, true); return { saveVideo, saveImage }; } catch (error) { for (const node of created.reverse()) graph.remove?.(node); throw error; } finally { graph.afterChange?.(); } } function resultForId(id) { return app.graph?._nodes_by_id?.[id] || app.graph?.getNodeById?.(id) || null; } function setResultDetail(node, detail) { if (!node) return; node.properties ||= {}; node.properties.o1keyVideoState = detail.state || "queued"; node.properties.o1keyVideoDetail = { batch_id: detail.batch_id, state: detail.state, stage: detail.stage, progress: detail.progress, error: detail.error || "", video: detail.video || null, last_frame: detail.last_frame || null, resolved_assets: detail.resolved_assets || null }; setWidget(node, "batch_id", detail.batch_id || ""); if (detail.video) setWidget(node, "video_manifest", JSON.stringify(detail.video)); if (detail.last_frame) setWidget(node, "last_frame_manifest", JSON.stringify(detail.last_frame)); node._o1vgRender?.(); updateNode(node); } function dispatchNativePreview(node, descriptor, animated = false) { if (!node || !descriptor) return; const output = { images: [descriptor] }; if (animated) output.animated = [true]; if (typeof api.dispatchCustomEvent === "function") api.dispatchCustomEvent("executed", { node: node.id, display_node: node.id, output, prompt_id: node.properties?.o1keyVideoBatchId || "" }); else node.onExecuted?.(output); updateNode(node); } function setNativeOutputDetail(source, saveVideo, saveImage, detail) { if (!saveVideo) return; const state = String(detail.state || "queued"); const safeDetail = { batch_id: detail.batch_id, state, stage: detail.stage, progress: detail.progress, error: detail.error || "", video: detail.video || null, last_frame: detail.last_frame || null, resolved_assets: detail.resolved_assets || null }; for (const target of [saveVideo, saveImage].filter(Boolean)) { target.properties ||= {}; target.properties.o1keyVideoState = state; target.properties.o1keyVideoDetail = safeDetail; } if (detail.video) { saveVideo.properties.o1keyVideoDescriptor = detail.video; dispatchNativePreview(saveVideo, detail.video, true); if (source) setWidget(source, "video_manifest", JSON.stringify(detail.video)); } if (detail.last_frame && saveImage) { saveImage.properties.o1keyVideoDescriptor = detail.last_frame; dispatchNativePreview(saveImage, detail.last_frame); if (source) setWidget(source, "last_frame_manifest", JSON.stringify(detail.last_frame)); } if (source) { const stage = STAGE_LABELS[detail.stage] || detail.stage || "生成视频"; if (state === "failed") setPanelStatus(source, detail.error || "视频生成失败", "error"); else if (state === "cancelled") setPanelStatus(source, detail.error || "任务已取消", "error"); else if (state === "completed") { setPanelStatus(source, "生成完成,结果已显示在保存节点"); setTimeout(() => setPanelStatus(source), 2600); } else setPanelStatus(source, `${stage} · ${Math.round(Math.max(0, Math.min(1, Number(detail.progress) || 0)) * 100)}%`); } } function requestForRetry(node) { const request = node.properties?.o1keyVideoRequest; const resolved = node.properties?.o1keyVideoDetail?.resolved_assets; if (!request || request.asset_creation_mode !== "auto" || !resolved) return request; const assets = { images: Array.isArray(resolved.images) ? [...resolved.images] : [], videos: Array.isArray(resolved.videos) ? [...resolved.videos] : [], audios: Array.isArray(resolved.audios) ? [...resolved.audios] : [], }; if (!assets.images.length && !assets.videos.length && !assets.audios.length) return request; return { ...request, asset_creation_mode: "manual", media: {}, assets }; } function handleJob(detail) { const batchId = String(detail?.batch_id || ""); if (!batchId) return; const tracked = jobs.get(batchId); const resultNodeId = detail.result_node_id ?? tracked?.resultNodeId ?? tracked?.saveVideoNodeId; const result = resultForId(resultNodeId); const nativeOutput = Boolean( result && (result.type === SAVE_VIDEO || result.comfyClass === SAVE_VIDEO) && String(result.properties?.o1keyVideoBatchId || "") === batchId ); if (tracked?.kind === "native" && !nativeOutput) { if (TERMINAL.has(String(detail.state || ""))) jobs.delete(batchId); return; } if (nativeOutput) { const source = resultForId(detail.generator_node_id ?? tracked?.generatorNodeId ?? result?.properties?.o1keyVideoGeneratorNodeId); const saveImage = resultForId(tracked?.saveImageNodeId ?? result?.properties?.o1keyVideoSaveImageNodeId); setNativeOutputDetail(source, result, saveImage, detail); } else setResultDetail(result, detail); if (TERMINAL.has(String(detail.state || ""))) jobs.delete(batchId); } async function monitor(batchId) { const tracked = jobs.get(batchId); if (!tracked || tracked.monitoring) return; tracked.monitoring = true; try { while (jobs.has(batchId)) { await new Promise((resolve) => setTimeout(resolve, 2500)); if (!jobs.has(batchId)) break; try { const detail = await requestJson(`/o1key/video/jobs/${encodeURIComponent(batchId)}`, { cache: "no-store" }); handleJob(detail); if (TERMINAL.has(String(detail.state || ""))) break; } catch (error) { if (error?.status === 404) { const current = jobs.get(batchId); handleJob({ batch_id: batchId, result_node_id: current?.resultNodeId ?? current?.saveVideoNodeId, generator_node_id: current?.generatorNodeId, state: "failed", stage: "failed", progress: 0, error: "任务不存在;ComfyUI 重启前已提交的远端任务无法自动恢复" }); break; } } } } finally { if (jobs.get(batchId) === tracked) tracked.monitoring = false; } } async function submitLegacyJob(source, result, request) { const batchId = makeBatchId(); const payload = { ...request, batch_id: batchId, generator_node_id: Number(source.id), result_node_id: Number(result.id) }; result.properties.o1keyVideoRequest = request; setWidget(result, "batch_id", batchId); setWidget(result, "video_manifest", "{}"); setWidget(result, "last_frame_manifest", "{}"); jobs.set(batchId, { kind: "legacy", resultNodeId: result.id, generatorNodeId: source.id, monitoring: false }); setResultDetail(result, { ...payload, state: "queued", stage: "submitting", progress: 0 }); try { const detail = await requestJson("/o1key/video/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); handleJob(detail); monitor(batchId); return detail; } catch (error) { jobs.delete(batchId); setResultDetail(result, { ...payload, state: "failed", stage: "failed", error: String(error.message || error), progress: 0 }); throw error; } } async function submitNativeJob(source, targets, request, batchId) { const payload = { ...request, batch_id: batchId, generator_node_id: Number(source.id), result_node_id: Number(targets.saveVideo.id) }; setWidget(source, "video_manifest", "{}"); setWidget(source, "last_frame_manifest", "{}"); jobs.set(batchId, { kind: "native", saveVideoNodeId: targets.saveVideo.id, saveImageNodeId: targets.saveImage?.id, generatorNodeId: source.id, monitoring: false }); setNativeOutputDetail(source, targets.saveVideo, targets.saveImage, { ...payload, state: "queued", stage: "submitting", progress: 0 }); try { const detail = await requestJson("/o1key/video/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); handleJob(detail); monitor(batchId); return detail; } catch (error) { jobs.delete(batchId); setNativeOutputDetail(source, targets.saveVideo, targets.saveImage, { ...payload, state: "failed", stage: "failed", error: String(error.message || error), progress: 0 }); throw error; } } function setPanelStatus(node, text = "", kind = "") { if (!node._o1vgStatus) return; node._o1vgStatus.textContent = text; node._o1vgStatus.className = `o1vg-status${text ? " visible" : ""}${kind === "error" ? " error" : ""}`; } function setPromptWriteStatus(node, text = "", kind = "") { if (!node._o1vgPromptWriteStatus) return; node._o1vgPromptWriteStatus.textContent = text; node._o1vgPromptWriteStatus.className = `o1vg-prompt-write-status${text ? " visible" : ""}${kind ? ` ${kind}` : ""}`; } function fitGenerator(node, mode, manualAssets = false) { const automaticHeights = { text: 679, first_frame: 824, first_last_frame: 964, multimodal: 824 }; const manualHeights = { text: 679, first_frame: 739, first_last_frame: 739, multimodal: 824 }; const heights = manualAssets ? manualHeights : automaticHeights; node.setSize([Math.max(560, Number(node.size?.[0]) || 560), heights[mode] || heights.text]); } function applyGeneratorDefaultSize(node) { if (node._o1vgDefaultSizeApplied) return; node._o1vgDefaultSizeApplied = true; node.setSize?.([560, 679]); node.setDirtyCanvas?.(true, true); } function installNativeMinimumSize(nodeType) { const originalComputeSize = nodeType.prototype.computeSize; nodeType.prototype.computeSize = function (size) { const computed = originalComputeSize?.call(this, size) || size || [0, 0]; computed[0] = Math.max(Number(computed[0]) || 0, 560); computed[1] = Math.max(Number(computed[1]) || 0, 679); return computed; }; } function setupGenerator(node) { if (node._o1vgPanel || typeof node.addDOMWidget !== "function") return; installStyles(); hideBackendWidgets(node); node.properties ||= {}; ensureMediaState(node); const panel = el("div", "o1vg-panel"); panel.addEventListener("pointerdown", (event) => { if (openDropdown && !openDropdown.contains(event.target)) closeDropdown(); event.stopPropagation(); }); const mediaHost = el("div"); mediaHost.style.display = "contents"; const firstSection = makeMediaSection(node, "first_frame", "首帧图片", "宽高 300~6000px · 比例 0.4~2.5", { accept: "image/png,image/jpeg,image/webp,image/bmp" }); const lastSection = makeMediaSection(node, "last_frame", "尾帧图片", "宽高 300~6000px · 比例 0.4~2.5", { accept: "image/png,image/jpeg,image/webp,image/bmp" }); const multimodalSection = makeMultimodalMediaSection(node); mediaHost.append(firstSection, lastSection, multimodalSection); const prompt = document.createElement("textarea"); prompt.className = "o1vg-prompt"; prompt.placeholder = "描述你想生成的视频、动作、镜头和节奏…"; prompt.value = widget(node, "prompt")?.value || ""; prompt.addEventListener("wheel", (event) => event.stopPropagation()); const promptWrite = el("button", "o1vg-prompt-write"); promptWrite.type = "button"; promptWrite.title = "AI帮写"; promptWrite.setAttribute("aria-label", "AI帮写"); const promptWriteIcon = el("span", "o1vg-prompt-write-icon", "✨"); promptWriteIcon.setAttribute("aria-hidden", "true"); promptWrite.append(promptWriteIcon, el("span", "", "AI帮写")); const promptWriteStatus = el("div", "o1vg-prompt-write-status"); promptWriteStatus.setAttribute("role", "status"); promptWriteStatus.setAttribute("aria-live", "polite"); const promptWrap = el("div", "o1vg-prompt-wrap"); promptWrap.append(prompt, promptWriteStatus, promptWrite); const toolbar = el("div", "o1vg-toolbar"); const model = makeSelect(MODEL_OPTIONS, widget(node, "model")?.value || "seedance-2.0", "模型"); const route = makeSelect(ROUTE_OPTIONS, widget(node, "route")?.value || "domestic", "模型线路"); const mode = makeSelect(MODE_OPTIONS, widget(node, "generation_mode")?.value || "multimodal", "生成模式"); const assetCreation = makeSelect(ASSET_CREATION_OPTIONS, widget(node, "asset_creation_mode")?.value || "auto", "素材创建"); const resolution = makeSelect(["480p", "720p", "1080p", "4k"], widget(node, "resolution")?.value || "720p", "分辨率"); const ratio = makeSelect(RATIO_OPTIONS, widget(node, "aspect_ratio")?.value || "auto", "宽高比"); const duration = makeSelect(["auto", ...Array.from({ length: 27 }, (_, index) => ({ value: String(index + 4), label: `${index + 4}秒` }))], widget(node, "duration")?.value || "5", "时长"); const generateAudio = makeSelect(TOGGLE_OPTIONS, widget(node, "generate_audio")?.value ? "on" : "off", "生成音频"); const returnLast = makeSelect(TOGGLE_OPTIONS, widget(node, "return_last_frame")?.value ? "on" : "off", "返回尾帧"); const prefix = document.createElement("input"); prefix.className = "o1vg-control"; prefix.type = "text"; prefix.value = widget(node, "filename_prefix")?.value || "o1key_video"; const location = document.createElement("input"); location.className = "o1vg-control"; location.type = "text"; location.value = widget(node, "save_location")?.value || "video"; location.placeholder = "留空为 output;或填写 D:/视频"; const seed = document.createElement("input"); seed.className = "o1vg-control"; seed.type = "number"; seed.min = "0"; seed.value = String(widget(node, "seed")?.value || 0); const dice = el("button", "", "↻"); dice.type = "button"; dice.title = "随机种子"; const seedWrap = el("div", "o1vg-seed"); seedWrap.append(seed, dice); const imageIds = document.createElement("input"); imageIds.className = "o1vg-control"; imageIds.placeholder = "多个 ID 用逗号分隔"; const videoIds = imageIds.cloneNode(); const audioIds = imageIds.cloneNode(); const savedAssets = node.properties.o1keyVideoAssets || {}; imageIds.value = savedAssets.images || savedAssets.persons || ""; videoIds.value = savedAssets.videos || ""; audioIds.value = savedAssets.audios || ""; const assetCreationField = makeField("素材创建", assetCreation); const imageIdField = makeField("图片素材 ID", imageIds); const videoIdField = makeField("视频素材 ID", videoIds); const audioIdField = makeField("音频素材 ID", audioIds); toolbar.append(makeField("模型", model), makeField("模型线路", route), makeField("生成模式", mode), assetCreationField, makeField("分辨率", resolution), makeField("宽高比", ratio), makeField("时长", duration), makeField("生成音频", generateAudio), makeField("返回尾帧", returnLast), imageIdField, videoIdField, audioIdField, makeField("文件名前缀", prefix), makeField("保存位置", location), makeField("种子", seedWrap)); const status = el("div", "o1vg-status"); const run = el("button", "o1vg-generate", "开始生成"); run.type = "button"; panel.append(mediaHost, promptWrap, toolbar, status, run); Object.assign(node, { _o1vgPanel: panel, _o1vgStatus: status, _o1vgPromptWrite: promptWrite, _o1vgPromptWriteStatus: promptWriteStatus, _o1vgWriting: false }); const sync = () => { const caps = MODEL_CAPS[model.value] || MODEL_CAPS["seedance-2.0"]; const selectedResolution = caps.resolutions.includes(resolution.value) ? resolution.value : "720p"; if (resolution._options.map((item) => item.value).join("|") !== caps.resolutions.join("|")) setDropdownOptions(resolution, caps.resolutions, selectedResolution); const durationOptions = ["auto", ...Array.from({ length: caps.maxDuration - 3 }, (_, index) => ({ value: String(index + 4), label: `${index + 4}秒` }))]; const selectedDuration = duration.value === "auto" || Number(duration.value) <= caps.maxDuration ? duration.value : "15"; if (duration._options.length !== durationOptions.length) setDropdownOptions(duration, durationOptions, selectedDuration); const activeMode = mode.value; const isSeedance = model.value.startsWith("seedance-"); const assetModeAvailable = isSeedance && activeMode !== "text"; if (!assetModeAvailable && assetCreation.value !== "auto") setDropdownValue(assetCreation, "auto"); const manualAssets = assetModeAvailable && assetCreation.value === "manual"; assetCreationField.style.display = isSeedance ? "grid" : "none"; assetCreation._trigger.disabled = !assetModeAvailable; firstSection.style.display = !manualAssets && ["first_frame", "first_last_frame"].includes(activeMode) ? "flex" : "none"; lastSection.style.display = !manualAssets && activeMode === "first_last_frame" ? "flex" : "none"; multimodalSection.style.display = !manualAssets && activeMode === "multimodal" ? "flex" : "none"; imageIdField.style.display = manualAssets ? "grid" : "none"; videoIdField.style.display = manualAssets && activeMode === "multimodal" ? "grid" : "none"; audioIdField.style.display = manualAssets && activeMode === "multimodal" ? "grid" : "none"; setWidget(node, "prompt", prompt.value); setWidget(node, "model", model.value); setWidget(node, "route", route.value); setWidget(node, "generation_mode", activeMode); setWidget(node, "asset_creation_mode", manualAssets ? "manual" : "auto"); setWidget(node, "resolution", resolution.value); setWidget(node, "aspect_ratio", ratio.value); setWidget(node, "duration", duration.value); setWidget(node, "generate_audio", generateAudio.value === "on"); setWidget(node, "return_last_frame", returnLast.value === "on"); setWidget(node, "seed", Math.max(0, Math.floor(Number(seed.value) || 0))); setWidget(node, "filename_prefix", prefix.value); setWidget(node, "save_location", location.value); node.properties.o1keyVideoAssets = { images: imageIds.value, videos: videoIds.value, audios: audioIds.value }; fitGenerator(node, activeMode, manualAssets); updateNode(node); }; const restore = () => { const value = (name, fallback) => widget(node, name)?.value ?? fallback; prompt.value = String(value("prompt", "")); model.value = value("model", "seedance-2.0"); route.value = value("route", "domestic"); mode.value = value("generation_mode", "multimodal"); assetCreation.value = value("asset_creation_mode", "auto"); resolution.value = value("resolution", "720p"); ratio.value = value("aspect_ratio", "auto"); duration.value = value("duration", "5"); generateAudio.value = value("generate_audio", false) ? "on" : "off"; returnLast.value = value("return_last_frame", false) ? "on" : "off"; seed.value = String(value("seed", 0)); prefix.value = String(value("filename_prefix", "o1key_video")); location.value = String(value("save_location", "video")); const restoredAssets = node.properties?.o1keyVideoAssets || {}; imageIds.value = restoredAssets.images || restoredAssets.persons || ""; videoIds.value = restoredAssets.videos || ""; audioIds.value = restoredAssets.audios || ""; firstSection._render?.(); lastSection._render?.(); multimodalSection._render?.(); sync(); }; node._o1vgRestore = restore; for (const control of [prompt, model, route, mode, assetCreation, resolution, ratio, duration, generateAudio, returnLast, seed, prefix, location, imageIds, videoIds, audioIds]) control.addEventListener("change", sync); for (const control of [prompt, prefix, location, imageIds, videoIds, audioIds]) control.addEventListener("input", sync); dice.addEventListener("click", () => { seed.value = String(Math.floor(Math.random() * 0x7fffffff)); sync(); }); promptWrite.addEventListener("click", async () => { if (node._o1vgWriting) return; const currentPrompt = prompt.value.trim(); if (!currentPrompt) { setPromptWriteStatus(node, "请先输入视频创意", "error"); prompt.focus(); return; } if (node._o1vgUploading) { setPromptWriteStatus(node, "请等待参考素材上传完成", "error"); return; } if (run.disabled) { setPromptWriteStatus(node, "请等待当前任务提交完成", "error"); return; } const media = ensureMediaState(node); const activeMode = mode.value; const manualAssets = activeMode !== "text" && assetCreation.value === "manual"; const references = manualAssets ? [] : activeMode === "first_frame" ? [media.first_frame].filter(Boolean).map((item) => ({ ...safeDescriptor(item), role: "首帧" })) : activeMode === "first_last_frame" ? [media.first_frame, media.last_frame].filter(Boolean).map((item, index) => ({ ...safeDescriptor(item), role: index === 0 ? "首帧" : "尾帧" })) : activeMode === "multimodal" ? (media.reference_images || []).map((item, index) => ({ ...safeDescriptor(item), role: `参考图 ${index + 1}` })) : []; node._o1vgWriting = true; prompt.readOnly = true; promptWrite.disabled = true; promptWrite.classList.add("busy"); run.disabled = true; setPromptWriteStatus(node, "AI帮写中…", "busy"); try { const result = await requestJson("/o1key/video/prompt-write", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: currentPrompt, generation_mode: activeMode, duration: duration.value, aspect_ratio: ratio.value, generate_audio: generateAudio.value === "on", references, reference_video_count: manualAssets && activeMode === "multimodal" ? parseIds(videoIds.value).length : (manualAssets ? 0 : (media.reference_videos || []).length), reference_audio_count: manualAssets && activeMode === "multimodal" ? parseIds(audioIds.value).length : (manualAssets ? 0 : (media.reference_audios || []).length), }), }); const written = String(result.prompt || "").trim(); if (!written) throw new Error("AI帮写结果为空"); prompt.value = written; sync(); setPromptWriteStatus(node, "AI帮写完成", "ok"); toast("success", "AI帮写完成", "已按视频模式、素材顺序和生成参数整理提示词"); } catch (error) { const message = String(error?.message || error); setPromptWriteStatus(node, message, "error"); toast("error", "AI帮写失败", message); } finally { node._o1vgWriting = false; prompt.readOnly = false; promptWrite.disabled = false; promptWrite.classList.remove("busy"); run.disabled = false; } }); run.addEventListener("click", async () => { if (node._o1vgWriting) { setPromptWriteStatus(node, "请等待 AI帮写完成", "error"); return; } if (node._o1vgUploading) { setPanelStatus(node, "请等待参考素材上传完成", "error"); return; } sync(); const media = ensureMediaState(node); const manualAssets = mode.value !== "text" && assetCreation.value === "manual"; const selectedAssets = { images: parseIds(imageIds.value), videos: parseIds(videoIds.value), audios: parseIds(audioIds.value), }; if (manualAssets && mode.value === "first_frame" && selectedAssets.images.length !== 1) return toast("error", "素材 ID 数量不正确", "首帧图生视频需要填写一个图片素材 ID"); if (manualAssets && mode.value === "first_last_frame" && selectedAssets.images.length !== 2) return toast("error", "素材 ID 数量不正确", "首尾帧生视频需要填写两个图片素材 ID"); if (manualAssets && mode.value === "multimodal" && !Object.values(selectedAssets).some((items) => items.length)) return toast("error", "缺少素材 ID", "请至少填写一个图片、视频或音频素材 ID"); if (!manualAssets && mode.value === "first_frame" && !media.first_frame) return toast("error", "缺少首帧", "请选择一张首帧图片"); if (!manualAssets && mode.value === "first_last_frame" && (!media.first_frame || !media.last_frame)) return toast("error", "缺少图片", "请选择首帧和尾帧图片"); const automaticMedia = mode.value === "text" ? {} : mode.value === "first_frame" ? { first_frame: media.first_frame } : mode.value === "first_last_frame" ? { first_frame: media.first_frame, last_frame: media.last_frame } : { reference_images: media.reference_images || [], reference_videos: media.reference_videos || [], reference_audios: media.reference_audios || [] }; const manualRequestAssets = mode.value === "multimodal" ? selectedAssets : { images: selectedAssets.images, videos: [], audios: [] }; const request = { provider: "seedance", model: model.value, route: route.value, generation_mode: mode.value, asset_creation_mode: manualAssets ? "manual" : "auto", prompt: prompt.value, resolution: resolution.value, aspect_ratio: ratio.value, duration: duration.value, generate_audio: generateAudio.value === "on", return_last_frame: returnLast.value === "on", seed: Math.max(0, Math.floor(Number(seed.value) || 0)), filename_prefix: prefix.value, save_location: location.value, media: manualAssets ? {} : automaticMedia, assets: manualAssets ? manualRequestAssets : {}, }; setWidget(node, "media_manifest", JSON.stringify(request.media)); setWidget(node, "asset_manifest", JSON.stringify(request.assets)); run.disabled = true; promptWrite.disabled = true; run.textContent = "正在提交…"; setPanelStatus(node, "正在创建保存节点…"); try { const batchId = makeBatchId(); const targets = createNativeOutputNodes(node, request, batchId); await submitNativeJob(node, targets, request, batchId); setPanelStatus(node, "任务已提交,可以继续生成"); setTimeout(() => setPanelStatus(node), 2200); } catch (error) { setPanelStatus(node, String(error.message || error), "error"); toast("error", "提交失败", String(error.message || error)); } finally { run.disabled = false; promptWrite.disabled = false; run.textContent = "开始生成"; } }); node.addDOMWidget("o1key_video_generator_panel", "div", panel, { serialize: false, hideOnZoom: false, getMinHeight: () => 631 }); restore(); } function setupResult(node) { if (node._o1vgPanel || typeof node.addDOMWidget !== "function") return; installStyles(); hideBackendWidgets(node); const panel = el("div", "o1vg-panel o1vg-result-panel"); const head = el("div", "o1vg-result-head"); const label = el("strong", "", "等待任务"); const percent = el("span", "", "0%"); head.append(label, percent); const progress = el("div", "o1vg-progress"); const bar = el("i"); progress.append(bar); const shell = el("div", "o1vg-video-shell"); const video = document.createElement("video"); video.controls = true; video.preload = "metadata"; shell.append(video); const error = el("div", "o1vg-error"); const actions = el("div", "o1vg-actions"); const retry = el("button", "", "重新生成"); const cancel = el("button", "", "取消任务"); retry.type = cancel.type = "button"; actions.append(retry, cancel); panel.append(head, progress, shell, error, actions); node._o1vgPanel = panel; node._o1vgRender = () => { const detail = node.properties?.o1keyVideoDetail || {}; const state = String(detail.state || node.properties?.o1keyVideoState || "queued"); const stage = STAGE_LABELS[detail.stage] || detail.stage || "生成中"; const names = { queued: "正在启动", running: stage, completed: "生成完成", failed: "生成失败", cancelled: "已取消" }; const value = state === "completed" ? 1 : Math.max(0, Math.min(1, Number(detail.progress) || 0)); label.textContent = names[state] || state; percent.textContent = `${Math.round(value * 100)}%`; bar.style.width = `${value * 100}%`; error.textContent = detail.error || ""; error.classList.toggle("visible", Boolean(detail.error)); const url = descriptorUrl(detail.video, true); if (url && video.dataset.src !== url) { video.dataset.src = url; video.src = url; shell.classList.add("visible"); } else if (!url) { video.pause(); video.removeAttribute("src"); video.dataset.src = ""; shell.classList.remove("visible"); } cancel.style.display = ["queued", "running"].includes(state) ? "block" : "none"; retry.style.display = TERMINAL.has(state) ? "block" : "none"; node.setSize([Math.max(320, Number(node.size?.[0]) || 320), url ? 350 : detail.error ? 190 : 145]); }; retry.addEventListener("click", async () => { const request = requestForRetry(node); const source = resultForId(node.properties?.o1keyVideoGeneratorNodeId); if (!request || !source) return toast("error", "无法重试", "原生成节点或任务参数已不存在"); retry.disabled = true; try { await submitLegacyJob(source, node, request); } catch (reason) { toast("error", "重试提交失败", String(reason.message || reason)); } finally { retry.disabled = false; } }); cancel.addEventListener("click", async () => { const batchId = String(widget(node, "batch_id")?.value || ""); if (!batchId) return; cancel.disabled = true; try { handleJob(await requestJson(`/o1key/video/jobs/${encodeURIComponent(batchId)}/cancel`, { method: "POST" })); } catch (reason) { toast("error", "取消失败", String(reason.message || reason)); } finally { cancel.disabled = false; } }); node.addDOMWidget("o1key_video_result_panel", "div", panel, { serialize: false, hideOnZoom: false, getMinHeight: () => 95 }); node._o1vgRender(); const batchId = String(widget(node, "batch_id")?.value || node.properties?.o1keyVideoDetail?.batch_id || ""); const state = String(node.properties?.o1keyVideoState || ""); if (batchId && !TERMINAL.has(state)) { jobs.set(batchId, { kind: "legacy", resultNodeId: node.id, generatorNodeId: node.properties?.o1keyVideoGeneratorNodeId, monitoring: false }); monitor(batchId); } } function recoverNativeSaveVideo(node) { if (!node?.properties?.o1keyAutoCreated || !node.properties.o1keyVideoBatchId) return; const batchId = String(node.properties.o1keyVideoBatchId); const source = resultForId(node.properties.o1keyVideoGeneratorNodeId); const saveImage = resultForId(node.properties.o1keyVideoSaveImageNodeId); if (node.properties.o1keyVideoDescriptor) dispatchNativePreview(node, node.properties.o1keyVideoDescriptor, true); if (saveImage?.properties?.o1keyVideoDescriptor) dispatchNativePreview(saveImage, saveImage.properties.o1keyVideoDescriptor); const state = String(node.properties.o1keyVideoState || ""); if (TERMINAL.has(state)) return; jobs.set(batchId, { kind: "native", saveVideoNodeId: node.id, saveImageNodeId: saveImage?.id, generatorNodeId: source?.id ?? node.properties.o1keyVideoGeneratorNodeId, monitoring: false }); monitor(batchId); } app.registerExtension({ name: "o1key.video.generator.parallel", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name === GENERATOR) { installNativeMinimumSize(nodeType); const created = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = created?.apply(this, arguments); applyGeneratorDefaultSize(this); setupGenerator(this); return result; }; const configured = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = configured?.apply(this, arguments); setupGenerator(this); this._o1vgRestore?.(); return result; }; } if (nodeData.name === RESULT) { const created = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = created?.apply(this, arguments); requestAnimationFrame(() => setupResult(this)); return result; }; } }, loadedGraphNode(node) { if (node.comfyClass === GENERATOR || node.type === GENERATOR) requestAnimationFrame(() => { setupGenerator(node); node._o1vgRestore?.(); }); if (node.comfyClass === RESULT || node.type === RESULT) requestAnimationFrame(() => setupResult(node)); if (node.comfyClass === SAVE_VIDEO || node.type === SAVE_VIDEO) requestAnimationFrame(() => recoverNativeSaveVideo(node)); }, setup() { api.addEventListener("o1key.video_job", ({ detail }) => handleJob(detail)); }, });