Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
1041 lines
82 KiB
JavaScript
1041 lines
82 KiB
JavaScript
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 = '<svg viewBox="0 0 12 12" focusable="false"><path d="M2.25 4.25 6 8l3.75-3.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||
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)); },
|
||
});
|