Files
comfyui_o1key/web/js/o1keyImageGenerator.js
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

5097 lines
220 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { openReferenceImageEditor } from "./o1keyReferenceImageEditor.js";
const NODE_TYPE = "O1keyImageGenerator";
const SAVE_NODE_TYPE = "O1keyImageSave";
const MAX_REFERENCES = 50;
const MAX_REQUEST_REFERENCES = 10;
const GENERATOR_MIN_SIZE = [500, 1025];
const GENERATOR_DENSE_MIN_HEIGHT = 1110;
const GENERATOR_BATCH_MIN_HEIGHT = 1215;
const GENERATOR_DENSE_BATCH_MIN_HEIGHT = 1300;
const GENERATOR_DEFAULT_SIZE = [560, 1035];
const PROMPT_PLACEHOLDER = "描述你想生成的图片;多个提示词用独占一行的 --- 分隔;Seedream 图层拆分时可留空…";
const GENERATOR_OUTPUT_COUNT = 4;
const GENERATOR_LAYER_OUTPUT_COUNT = 3;
const SAVE_NODE_LAYOUT_SIZE = [300, 260];
const SAVE_SLOT_GRID_NODE_INSET = 24;
const SAVE_SLOT_GRID_PADDING_BORDER = 14;
const SAVE_SLOT_GRID_GAP = 7;
const SAVE_PREVIEW_MIN_HEIGHT = 220;
const SAVE_PREVIEW_MAX_HEIGHT = 720;
const SAVE_PREVIEW_SIZE_LABEL_HEIGHT = 15;
const GENERATOR_CHROME_HEIGHT = 48;
const GENERATOR_PANEL_MIN_HEIGHT = GENERATOR_MIN_SIZE[1] - GENERATOR_CHROME_HEIGHT;
const REFERENCE_LIGHTBOX_ID = "o1key-image-reference-lightbox";
const CANVAS_IMAGE_PICKER_ID = "o1key-canvas-image-picker";
const MODEL_OPTIONS = [
{ value: "Nano Banana 2", label: "Nano Banana 2", description: "快速,批量" },
{ value: "Nano Banana Pro", label: "Nano Banana Pro", description: "高质量资产" },
{ value: "gpt-image-2", label: "GPT Image 2", description: "高质量,编辑" },
{ value: "gpt-image-2.5-sunburst", label: "GPT Image 2.5 Sunburst", description: "最新,高质量" },
{ value: "gpt-image-2.5-flare", label: "GPT Image 2.5 Flare", description: "快速,日常" },
{ value: "Seedream 5.0 Pro", label: "Seedream 5.0 Pro", description: "高质量,参考图,分层" },
{ value: "Nano Banana 2 Lite", label: "Nano Banana 2 Lite", description: "快速,草稿" },
{ value: "Nano Banana", label: "Nano Banana", description: "快速,草稿" },
];
const ROUTES = [
{ value: "畅速", label: "特价", description: "便宜" },
{ value: "直连", label: "优质", description: "小贵" },
{ value: "专线", label: "企业", description: "贵" },
];
const THINKING_LEVEL_OPTIONS = [
{ value: "低", label: "低", description: "耗时低,智力低" },
{ value: "高", label: "高", description: "耗时高,智力高" },
];
const ONLINE_SEARCH_OPTIONS = ["关闭", "打开"];
const RESIZE_OPTIONS = [
{ value: "不缩放", label: "不缩放", description: "无大图" },
{ value: "智能缩放", label: "智能缩放", description: "有大图" },
];
const ALL_RESOLUTIONS = ["智能", "512", "1K", "2K", "4K"];
const GPT_RESOLUTIONS = ["智能", "1K", "2K", "4K"];
const GPT_RATIOS = ["智能", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"];
const GPT_QUALITIES = ["高", "中", "低", "自动"];
const GPT_25_QUALITIES = [...GPT_QUALITIES, "超高", "最高"];
const GPT_IMAGE_COUNTS = Array.from({ length: 8 }, (_, index) => String(index + 1));
const GPT_OUTPUT_FORMATS = [
{ value: "jpeg", label: "JPEG", description: "不支持透明背景" },
{ value: "png", label: "PNG", description: "支持透明背景" },
{ value: "webp", label: "WebP", description: "支持透明背景" },
];
const SEEDREAM_RESOLUTIONS = ["智能", "1K", "2K"];
const SEEDREAM_LAYER_RESOLUTIONS = ["智能", "1K", "1.5K", "2K"];
const SEEDREAM_RATIOS = ["智能", "1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
const SEEDREAM_OUTPUT_FORMATS = [
{ value: "png", label: "PNG", description: "无损输出" },
{ value: "jpeg", label: "JPEG", description: "较小文件" },
];
const SEEDREAM_REFERENCE_LIMITS = Object.freeze({
maxBytes: 30 * 1024 * 1024,
minSideExclusive: 14,
minLayerPixels: 512 * 512,
maxPixels: 6000 * 6000,
minAspectRatio: 1 / 16,
maxAspectRatio: 16,
});
const SAVE_NAMING_RULES = [
{ value: "自定义前缀", label: "自定义", description: "使用文件名前缀" },
{ value: "和主图一致", label: "和主图一致", description: "沿用第1张参考图名称" },
{ value: "自然数字", label: "自然数字", description: "按 1、2、3… 命名" },
];
const SAVE_FORMATS = [
{ value: "原始", label: "原始", description: "保留模型返回格式" },
{ value: "png", label: "PNG", description: "无损转换" },
{ value: "jpg", label: "JPEG", description: "高质量有损转换" },
{ value: "webp", label: "WebP", description: "无损转换" },
];
const GPT_BACKGROUNDS = [
{ value: "auto", label: "自动", description: "由模型选择" },
{ value: "transparent", label: "透明", description: "仅 PNG / WebP" },
{ value: "opaque", label: "不透明", description: "生成实色背景" },
];
const IMAGE_COUNTS = ["1", "2", "4", "9"];
const BATCH_MODE_GROUP_TO_MODELS = "一组搭配+多模特";
const BATCH_MODE_CARTESIAN = "全部搭配×全部模特";
const BATCH_MODE_SINGLE_REFERENCES = "单图素材批量";
const BATCH_MODE_OPTIONS = [
{ value: BATCH_MODE_GROUP_TO_MODELS, label: "整组素材 → 多个目标", description: "整组应用" },
{ value: BATCH_MODE_CARTESIAN, label: "全匹配(素材 × 目标)", description: "逐一组合" },
{ value: BATCH_MODE_SINGLE_REFERENCES, label: "单图批量(每张素材独立)", description: "无需目标图" },
];
const ALL_RATIOS = [
"智能", "1:1", "1:4", "1:8", "2:3", "3:2", "3:4",
"4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9",
];
const NANO2_ONLY_RATIOS = new Set(["1:4", "1:8", "4:1", "8:1"]);
const GPT_IMAGE_MODELS = new Set([
"gpt-image-2",
"gpt-image-2.5-sunburst",
"gpt-image-2.5-flare",
]);
const isGptImageModel = (model) => GPT_IMAGE_MODELS.has(model);
const MODEL_CAPABILITIES = Object.freeze({
"Nano Banana 2": Object.freeze({ thinking: true, search: true, quality: false, output: false, mask: false, resize: true, saveFormat: true }),
"Nano Banana Pro": Object.freeze({ thinking: false, search: false, quality: false, output: false, mask: false, resize: true, saveFormat: true }),
"gpt-image-2": Object.freeze({ thinking: false, search: false, quality: true, output: true, mask: true, resize: true, saveFormat: false }),
"gpt-image-2.5-sunburst": Object.freeze({ thinking: false, search: false, quality: true, output: true, mask: true, resize: true, saveFormat: false }),
"gpt-image-2.5-flare": Object.freeze({ thinking: false, search: false, quality: true, output: true, mask: true, resize: true, saveFormat: false }),
"Seedream 5.0 Pro": Object.freeze({ thinking: false, search: false, quality: false, output: true, mask: false, resize: false, saveFormat: false, layers: true }),
"Nano Banana 2 Lite": Object.freeze({ thinking: false, search: false, quality: false, output: false, mask: false, resize: true, saveFormat: true }),
"Nano Banana": Object.freeze({ thinking: false, search: false, quality: false, output: false, mask: false, resize: true, saveFormat: true }),
});
// The retired moderation name only hides a stale server widget until ComfyUI restarts.
const BACKEND_WIDGETS = [
"prompt", "模型", "模型线路", "思考等级", "分辨率", "宽高比",
"生图数量", "seed", "参考图清单", "质量", "输出格式", "蒙版清单", "缩放图片", "背景", "内容审查强度",
"批量出图", "批量模式", "模特图清单", "命名规则", "filename_prefix", "格式", "保存位置", "在线搜索", "图层拆分",
];
const CSS = `
.o1ig-panel{
width:100%;height:100%;min-height:292px;box-sizing:border-box;padding:10px 12px 12px;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:10px;overflow:visible;user-select:none;
}
.o1ig-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;
}
.o1ig-refs.o1ig-reordering{background:rgba(150,184,136,.08);box-shadow:inset 0 0 0 1px rgba(173,205,158,.16);}
.o1ig-batch-bar{
display:flex;gap:8px;flex:0 0 auto;align-items:stretch;
}
.o1ig-batch-toggle{
flex:1;min-width:0;height:36px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 11px;
box-sizing:border-box;border:1px solid rgba(255,255,255,.12);border-radius:8px;background:#202124;
color:#c5c7cc;cursor:pointer;font:inherit;font-size:11px;font-weight:650;letter-spacing:.035em;
}
.o1ig-batch-toggle:hover{border-color:rgba(255,255,255,.24);background:#202123;color:#fff;}
.o1ig-batch-switch{width:28px;height:16px;padding:2px;box-sizing:border-box;border-radius:999px;background:#45474b;transition:.15s ease;}
.o1ig-batch-switch::after{content:"";display:block;width:12px;height:12px;border-radius:50%;background:#c8c9cb;transition:.15s ease;}
.o1ig-batch-toggle.enabled{border-color:rgba(173,205,158,.35);background:#202720;color:#dce8d7;}
.o1ig-batch-toggle.enabled .o1ig-batch-switch{background:#729466;}
.o1ig-batch-toggle.enabled .o1ig-batch-switch::after{transform:translateX(12px);background:#fff;}
.o1ig-batch-mode{display:none;flex:1;min-width:0;}.o1ig-batch-mode.visible{display:block;}
.o1ig-assets{display:flex;flex-direction:column;gap:6px;min-width:0;}
.o1ig-asset-header{display:flex;align-items:center;gap:8px;min-height:30px;padding:0 2px;color:#aeb0b5;font-size:10px;line-height:16px;}
.o1ig-asset-header strong{display:flex;align-items:center;gap:7px;color:#e5e6ea;font-size:11px;}
.o1ig-asset-header strong::before{content:"";width:3px;height:12px;border-radius:2px;background:#99aaff;}
.o1ig-asset-meta{display:flex;min-width:0;align-items:baseline;gap:7px;}
.o1ig-asset-actions{display:flex;flex:0 0 auto;align-items:center;gap:5px;margin-left:auto;}
.o1ig-asset-hint{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#82858a;}
.o1ig-model-assets{display:none;}.o1ig-model-assets.visible{display:flex;}
.o1ig-batch-summary{display:none;min-height:18px;padding:5px 8px;box-sizing:border-box;border-radius:6px;
background:rgba(125,151,114,.1);color:#b8c9b0;font-size:10px;line-height:1.45;}
.o1ig-batch-summary.visible{display:block;}
.o1ig-thumb{
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;
}
.o1ig-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;
}
.o1ig-add:hover,.o1ig-add.drag{background:#232426;border-color:rgba(255,255,255,.28);color:#fff;}
.o1ig-add b{font-size:15px;line-height:1;font-weight:400;}.o1ig-add span{font-size:10px;}
.o1ig-canvas-pick b{font-size:13px;line-height:1;}
.o1ig-thumb img{width:100%;height:100%;display:block;object-fit:contain;background:#111;}
.o1ig-thumb .o1ig-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;
}
.o1ig-thumb:hover .o1ig-edit,.o1ig-thumb .o1ig-edit:focus-visible{opacity:1;}
.o1ig-thumb .o1ig-edit:hover{background:#eef0ed;color:#151715;border-color:#fff;}
.o1ig-thumb .o1ig-replace{
position:absolute;right:5px;bottom:5px;z-index:2;height:22px;padding:0 7px;
border:1px solid rgba(255,255,255,.46);border-radius:6px;background:rgba(10,10,10,.78);
color:#fff;cursor:pointer;font:600 10px/20px system-ui,sans-serif;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);
}
.o1ig-thumb .o1ig-replace:hover,.o1ig-thumb .o1ig-replace:focus-visible{
border-color:rgba(255,255,255,.75);background:rgba(10,10,10,.92);color:#fff;
}
.o1ig-thumb .o1ig-replace:disabled{cursor:wait;opacity:.6;}
.o1ig-thumb .o1ig-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;
}
.o1ig-thumb:hover .o1ig-remove{opacity:1;}.o1ig-thumb .o1ig-remove:hover{color:#fff;background:#050505;}
.o1ig-thumb.previewable{cursor:grab;}
.o1ig-thumb.previewable:active{cursor:grabbing;}
.o1ig-thumb.sort-locked{cursor:zoom-in;}
.o1ig-thumb.o1ig-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);
}
.o1ig-thumb.o1ig-dragging::before{
content:"移动中";position:absolute;inset:0;z-index:4;display:grid;place-items:center;
background:rgba(12,15,12,.38);color:#f2f6f0;font-size:11px;font-weight:700;letter-spacing:.08em;pointer-events:none;
}
.o1ig-thumb.o1ig-dragging .o1ig-remove,.o1ig-thumb.o1ig-dragging .o1ig-replace{display:none;}
.o1ig-thumb.replacing::after{
content:"替换中…";position:absolute;inset:0;z-index:3;display:grid;place-items:center;
background:rgba(10,13,23,.65);color:#fff;font-size:11px;font-weight:700;pointer-events:none;
}
.o1ig-thumb.o1ig-drop-before{transform:translateX(7px);box-shadow:inset 5px 0 0 #dcebd6,0 8px 20px rgba(0,0,0,.2);}
.o1ig-thumb.o1ig-drop-after{transform:translateX(-7px);box-shadow:inset -5px 0 0 #dcebd6,0 8px 20px rgba(0,0,0,.2);}
.o1ig-thumb img.o1ig-previewable{cursor:inherit;}
.o1ig-thumb img.o1ig-previewable:focus-visible{outline:2px solid rgba(255,255,255,.9);outline-offset:-3px;}
.o1ig-reference-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;
}
.o1ig-thumb.pending::after{
content:'···';position:absolute;inset:0;display:grid;place-items:center;
background:rgba(10,10,10,.52);color:#fff;letter-spacing:2px;
}
.o1ig-empty-reference{
flex:0 0 102px;height:102px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;
border:1px dashed rgba(164,179,255,.28);border-radius:9px;background:#202228;color:#aeb7d6;cursor:pointer;font:inherit;
}
.o1ig-empty-reference:hover,.o1ig-empty-reference.drag{border-color:#aab9ff;background:#292e3d;color:#fff;}
.o1ig-empty-reference b{font-size:22px;line-height:1;font-weight:300;}.o1ig-empty-reference span{font-size:10px;}
.o1ig-lightbox{
position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.9);outline:none;
}
.o1ig-lightbox[aria-hidden="true"]{display:none;}
.o1ig-lightbox-image{display:block;width:auto;height:auto;max-width:90vw;max-height:90vh;border-radius:4px;object-fit:contain;}
.o1ig-lightbox-button{
position:fixed;z-index:1;width:44px;height:44px;padding:0;display:grid;place-items:center;
border:1px solid rgba(255,255,255,.14);border-radius:50%;background:#2b2c2f;color:#f3f3f3;
box-shadow:0 8px 22px rgba(0,0,0,.32);cursor:pointer;font:400 30px/1 system-ui,sans-serif;
}
.o1ig-lightbox-button:hover,.o1ig-lightbox-button:focus-visible{background:#3a3b3f;color:#fff;outline:2px solid rgba(255,255,255,.8);outline-offset:2px;}
.o1ig-lightbox-close{top:16px;right:16px;font-size:27px;}
.o1ig-lightbox-previous{top:50%;left:16px;transform:translateY(-50%);}
.o1ig-lightbox-next{top:50%;right:16px;transform:translateY(-50%);}
.o1ig-canvas-picker{
position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;
box-sizing:border-box;background:rgba(0,0,0,.76);font-family:Inter,"Microsoft YaHei UI","Microsoft YaHei",system-ui,sans-serif;
}
.o1ig-canvas-picker-dialog{
width:min(840px,92vw);max-height:min(720px,88vh);display:flex;flex-direction:column;overflow:hidden;
border:1px solid rgba(255,255,255,.16);border-radius:12px;background:#202124;color:#f1f1f1;
box-shadow:0 24px 70px rgba(0,0,0,.56);
}
.o1ig-canvas-picker-header{display:flex;align-items:center;gap:12px;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.1);}
.o1ig-canvas-picker-header strong{flex:1;font-size:15px;}.o1ig-canvas-picker-header span{color:#999;font-size:11px;}
.o1ig-canvas-picker-close{width:30px;height:30px;padding:0;border:0;border-radius:7px;background:#303136;color:#ddd;cursor:pointer;font-size:20px;}
.o1ig-canvas-picker-close:hover{background:#3a3b3e;color:#fff;}
.o1ig-canvas-picker-grid{
min-height:140px;padding:14px;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:10px;
}
.o1ig-canvas-picker-card{
min-width:0;padding:0;overflow:hidden;border:1px solid rgba(255,255,255,.11);border-radius:9px;background:#18191b;
color:#ddd;cursor:pointer;text-align:left;font:inherit;
}
.o1ig-canvas-picker-card:hover,.o1ig-canvas-picker-card:focus-visible{border-color:rgba(255,255,255,.4);background:#242527;outline:none;}
.o1ig-canvas-picker-card:disabled{cursor:wait;opacity:.65;}
.o1ig-canvas-picker-card img{display:block;width:100%;height:118px;object-fit:contain;background:#111;}
.o1ig-canvas-picker-card strong,.o1ig-canvas-picker-card span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 8px;}
.o1ig-canvas-picker-card strong{padding-top:7px;color:#eee;font-size:11px;}.o1ig-canvas-picker-card span{padding-top:2px;padding-bottom:8px;color:#8f9297;font-size:10px;}
.o1ig-canvas-picker-empty{grid-column:1/-1;display:grid;place-items:center;min-height:150px;color:#999;text-align:center;font-size:12px;}
.o1ig-prompt-wrap{position:relative;width:100%;height:auto;min-height:142px;flex:1 1 142px;min-width:0;overflow:visible;}
.o1ig-prompt{
width:100%;height:100%;min-height:142px;max-height:none;resize:none;
box-sizing:border-box;padding:13px 45px 40px 13px;border:1px solid rgba(255,255,255,.12);
border-radius:10px;background:#202124;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);
transition:border-color .15s ease,background .15s ease;
}
.o1ig-prompt::placeholder{color:#8f929b;}.o1ig-prompt:focus{background:#22242a;border-color:#91a3ef;box-shadow:0 0 0 2px rgba(145,163,239,.13);}
.o1ig-prompt-optimize{
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;
}
.o1ig-prompt-optimize-icon{display:block;font-size:15px;line-height:1;transform-origin:center;}
.o1ig-prompt-optimize:hover,.o1ig-prompt-optimize:focus-visible{color:#fff;background:#303136;border-color:rgba(255,255,255,.26);outline:none;transform:translateY(-1px);}
.o1ig-prompt-optimize:disabled{cursor:wait;color:#d8c278;opacity:.9;transform:none;}
.o1ig-prompt-optimize.busy .o1ig-prompt-optimize-icon{animation:o1ig-magic-pulse .9s ease-in-out infinite alternate;}
@keyframes o1ig-magic-pulse{from{transform:rotate(-5deg) scale(.92);opacity:.58}to{transform:rotate(5deg) scale(1.08);opacity:1}}
.o1ig-prompt-optimize-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;
}
.o1ig-prompt-optimize-status.visible{display:flex;}
.o1ig-prompt-optimize-status.busy{color:#d8c278;}
.o1ig-prompt-optimize-status.ok{color:#a8c89a;}
.o1ig-prompt-optimize-status.error{color:#d3a0a0;}
.o1ig-prompt-optimize-status.busy::before{
content:"";flex:0 0 auto;width:6px;height:6px;border-radius:50%;background:currentColor;
animation:o1ig-prompt-status-pulse .8s ease-in-out infinite alternate;
}
@keyframes o1ig-prompt-status-pulse{from{opacity:.35;transform:scale(.8)}to{opacity:1;transform:scale(1.12)}}
.o1ig-toolbar{width:100%;min-width:0;display:grid;flex:0 0 auto;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px 10px;position:relative;overflow:visible;}
.o1ig-section-title{grid-column:1/-1;display:flex;align-items:center;gap:9px;min-height:18px;margin-top:2px;color:#cfd2db;font-size:11px;font-weight:700;letter-spacing:.04em;}
.o1ig-section-title::after{content:"";height:1px;flex:1;background:rgba(255,255,255,.1);}
.o1ig-field-wide{grid-column:1/-1;}
.o1ig-control{
width:100%;height:34px;min-width:0;box-sizing:border-box;padding:0 9px;
border:1px solid rgba(255,255,255,.12);border-radius:8px;background:#202124;color:#ececf0;
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);
}
.o1ig-control:hover{border-color:rgba(255,255,255,.28);background:#25262a;}
.o1ig-control:focus{border-color:#91a3ef;background:#25262a;box-shadow:0 0 0 2px rgba(145,163,239,.13);}
.o1ig-select{position:relative;min-width:0;height:34px;z-index:1;}
.o1ig-select.open{z-index:50;}
.o1ig-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,.12);border-radius:8px;background:#202124;color:#ececf0;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;
}
.o1ig-select-trigger:hover{border-color:rgba(255,255,255,.28);background:#25262a;color:#fff;}
.o1ig-select-trigger:focus-visible{outline:2px solid #91a3ef;outline-offset:1px;}
.o1ig-select.open .o1ig-select-trigger{
border-color:#91a3ef;background:#292b32;color:#fff;
box-shadow:0 7px 18px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.08);
}
.o1ig-select-value{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.4;}
.o1ig-select-selected-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;
}
.o1ig-select-selected-description:empty{display:none;}
.o1ig-select-caret{
width:14px;height:14px;flex:0 0 14px;display:flex;align-items:center;justify-content:center;align-self:center;
color:#aaa;line-height:0;transform-origin:50% 50%;transition:transform .14s ease,color .14s ease;
}
.o1ig-select-caret svg{display:block;width:12px;height:12px;overflow:visible;}
.o1ig-select-trigger:hover .o1ig-select-caret,.o1ig-select.open .o1ig-select-caret{color:#fff;}
.o1ig-select.open .o1ig-select-caret{transform:rotate(180deg);}
.o1ig-select-menu{
position:absolute;top:40px;left:0;min-width:100%;width:max-content;max-width:300px;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);
}
.o1ig-select.open .o1ig-select-menu{display:grid;gap:2px;}
.o1ig-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;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;white-space:nowrap;transition:background .12s ease,color .12s ease;
}
.o1ig-select-option.has-description{gap:14px;justify-content:space-between;}
.o1ig-select-option-label{min-width:0;overflow:hidden;text-overflow:ellipsis;}
.o1ig-select-option-description{
flex:0 0 auto;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;
}
.o1ig-select-option:hover,.o1ig-select-option:focus-visible{outline:none;background:#343538;color:#fff;}
.o1ig-select-option.selected{background:#e7e7e7;color:#151515;font-weight:700;box-shadow:inset 0 1px 0 #fff;}
.o1ig-select-option.selected:hover,.o1ig-select-option.selected:focus-visible{background:#fff;color:#151515;}
.o1ig-select-option.selected .o1ig-select-option-description{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.07);color:#343434;}
.o1ig-field{display:flex;flex-direction:column;align-items:stretch;gap:4px;min-width:0;}
.o1ig-field label{min-width:0;padding-left:2px;font-size:10px;font-weight:600;line-height:1.3;letter-spacing:.03em;color:#b8bbc5;text-align:left;white-space:nowrap;}
.o1ig-resize-warning{display:none;color:#d5aa62;font-size:10px;line-height:1.4;}
.o1ig-resize-warning.visible{display:block;}
.o1ig-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);
}
.o1ig-seed:hover,.o1ig-seed:focus-within{border-color:rgba(255,255,255,.22);background:#202123;}
.o1ig-seed .o1ig-control{
height:32px;border:0;border-radius:0;background:transparent;box-shadow:none;
}
.o1ig-seed .o1ig-control:hover,.o1ig-seed .o1ig-control:focus{border-color:transparent;background:transparent;}
.o1ig-seed button{
padding:0;border-left:1px solid rgba(255,255,255,.1);font-size:14px;cursor:pointer;
}
.o1ig-seed button:hover,.o1ig-seed button:focus{background:rgba(255,255,255,.08);}
.o1ig-mask-wrap{display:grid;grid-template-columns:minmax(0,1fr) 32px;gap:5px;}
.o1ig-mask-button{padding:0 9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left;cursor:pointer;}
.o1ig-mask-clear{padding:0;font-size:15px;cursor:pointer;}
.o1ig-generate{
width:100%;height:42px;flex:0 0 42px;border:1px solid #b9c5ff;border-radius:9px;
background:#aab9ff;color:#171c31;font-size:12px;font-weight:700;cursor:pointer;
box-shadow:0 4px 14px rgba(78,96,178,.18),inset 0 1px 0 rgba(255,255,255,.35);
transition:transform .12s ease,background .12s ease,box-shadow .12s ease;
}
.o1ig-generate:hover{background:#c3ceff;box-shadow:0 6px 18px rgba(78,96,178,.28),inset 0 1px 0 rgba(255,255,255,.45);}
.o1ig-generate:focus-visible{outline:2px solid #e2e7ff;outline-offset:2px;}
.o1ig-generate:active{transform:translateY(1px);}
.o1ig-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;}
.o1ig-status.visible{display:block;}.o1ig-status.error{color:#cfcfcf;}
.o1igs-progress-host{position:relative;}
.o1igs-progress-track{
position:absolute;z-index:2;top:1px;left:8px;right:8px;height:2px;border-radius:2px;
opacity:0;transition:opacity .14s ease;pointer-events:none;
background:linear-gradient(90deg,#eee 0 var(--o1igs-progress,0%),rgba(255,255,255,.13) var(--o1igs-progress,0%) 100%);
}
.o1igs-progress-track.busy{opacity:1;}
.o1igs-slots{
width:100%;box-sizing:border-box;padding:6px;display:grid;gap:7px;overflow:auto;
border:1px solid rgba(255,255,255,.08);border-radius:8px;background:#202123;
}
.o1igs-slot{position:relative;min-width:0;aspect-ratio:1/1;display:grid;place-items:center;overflow:hidden;
border:1px solid rgba(255,255,255,.1);border-radius:7px;background:#191a1c;color:#989a9f;font-size:11px;}
.o1igs-slot.pending::after,.o1igs-slot.running::after{content:"";position:absolute;inset:0;transform:translateX(-100%);
background:linear-gradient(90deg,transparent,rgba(255,255,255,.07),transparent);animation:o1igs-slot-loading 1.3s infinite;}
@keyframes o1igs-slot-loading{to{transform:translateX(100%)}}
.o1igs-slot img{width:100%;height:100%;display:block;object-fit:cover;}
.o1igs-slot-index{position:absolute;top:4px;left:4px;z-index:2;min-width:18px;height:18px;padding:0 4px;box-sizing:border-box;
display:grid;place-items:center;border-radius:5px;background:rgba(0,0,0,.68);color:#fff;font-size:10px;font-weight:700;}
.o1igs-slot-retry{width:100%;height:100%;padding:8px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
border:0;background:rgba(87,36,36,.34);color:#efcaca;cursor:pointer;font:inherit;font-size:11px;}
.o1igs-slot-retry:hover{background:rgba(116,42,42,.48);color:#fff}.o1igs-slot-retry:disabled{cursor:wait;opacity:.6}
.o1igs-slot-retry b{font-size:18px;line-height:1}.o1igs-slot.cancelled{color:#8f9196;background:#1b1c1e;}
.o1key-slots-visible .image-preview{display:none!important;}
`;
function injectStyles() {
if (document.getElementById("o1ig-styles")) return;
const style = document.createElement("style");
style.id = "o1ig-styles";
style.textContent = CSS;
document.head.appendChild(style);
document.addEventListener("pointerdown", (event) => {
if (openDropdown && !openDropdown.contains(event.target)) closeDropdown();
}, true);
}
function findWidget(node, name) {
return node?.widgets?.find((widget) => widget.name === name);
}
function setWidgetValue(node, name, value) {
const widget = findWidget(node, name);
if (!widget) return;
widget.value = value;
widget.callback?.(value);
node.setDirtyCanvas?.(true, true);
}
function isGeneratedSeedControl(widget) {
const name = String(widget?.name || "");
return name === "control_after_generate" || name.endsWith(" control_after_generate");
}
function hideBackendWidget(widget) {
if (!widget || widget.__o1igHidden) return;
widget.__o1igHidden = true;
widget.hidden = true;
widget.options ??= {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
}
function hideBackendWidgets(node) {
for (const name of BACKEND_WIDGETS) {
hideBackendWidget(findWidget(node, name));
}
for (const widget of node?.widgets || []) {
if (isGeneratedSeedControl(widget)) hideBackendWidget(widget);
}
}
function parseReferences(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value || "[]") : value;
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_REFERENCES).filter((item) => item?.name).map((item) => ({
name: String(item.name),
subfolder: String(item.subfolder || ""),
type: "input",
}));
} catch {
return [];
}
}
function parseMask(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value || "{}") : value;
if (!parsed || typeof parsed !== "object" || !parsed.name) return null;
return {
name: String(parsed.name),
subfolder: String(parsed.subfolder || ""),
type: "input",
};
} catch {
return null;
}
}
function syncMaskControl(node) {
if (!node._o1igMaskButton || !node._o1igMaskClear) return;
node._o1igMaskButton.textContent = node._o1igMask?.name || "添加蒙版";
node._o1igMaskButton.title = node._o1igMask?.name || "上传 GPT Image 编辑蒙版";
node._o1igMaskClear.disabled = !node._o1igMask;
}
function viewPath(item) {
const filename = item?.filename || item?.name;
if (!filename) return "";
const params = new URLSearchParams({ filename: String(filename), type: item.type || "input" });
if (item.subfolder) params.set("subfolder", item.subfolder);
return `/view?${params.toString()}`;
}
function viewUrl(item) {
const path = viewPath(item);
return path ? api.apiURL(path) : "";
}
function thumbnailUrl(item) {
const filename = item?.filename || item?.name;
if (!filename) return "";
const params = new URLSearchParams({
filename: String(filename),
type: item.type || "input",
});
if (item.subfolder) params.set("subfolder", item.subfolder);
return api.apiURL(`/o1key/image/thumbnail?${params.toString()}`);
}
function canvasImageDescriptor(item) {
const filename = String(item?.filename || item?.name || "").trim();
const type = String(item?.type || "").trim();
if (!filename || !["input", "output", "temp"].includes(type)) return null;
return {
filename,
subfolder: String(item?.subfolder || ""),
type,
};
}
function canvasImageDescriptorFromUrl(value) {
if (!value) return null;
try {
const url = new URL(String(value), document.baseURI || "http://localhost/");
return canvasImageDescriptor({
filename: url.searchParams.get("filename"),
subfolder: url.searchParams.get("subfolder"),
type: url.searchParams.get("type"),
});
} catch {
return null;
}
}
function graphNodeList(graph) {
if (Array.isArray(graph?._nodes)) return graph._nodes;
if (graph?.nodes instanceof Map) return [...graph.nodes.values()];
if (Array.isArray(graph?.nodes)) return graph.nodes;
return [];
}
function canvasImageCandidates(targetNode) {
const graph = targetNode?.graph || app.graph;
const candidates = [];
const seen = new Set();
for (const sourceNode of graphNodeList(graph)) {
if (!sourceNode || sourceNode === targetNode) continue;
const nodeId = String(sourceNode.id);
const output = app.nodeOutputs?.[nodeId] || {};
const descriptors = [
...(Array.isArray(output.images) ? output.images : []),
...(Array.isArray(output.gifs) ? output.gifs : []),
...(Array.isArray(sourceNode._o1igsLastResults) ? sourceNode._o1igsLastResults : []),
...(Array.isArray(sourceNode.images) ? sourceNode.images : []),
...(Array.isArray(sourceNode.imgs)
? sourceNode.imgs.map((image) => canvasImageDescriptorFromUrl(image?.currentSrc || image?.src))
: []),
];
const sourceLabel = String(
sourceNode.title || sourceNode.comfyClass || sourceNode.type || `节点 ${nodeId}`,
);
for (const rawDescriptor of descriptors) {
const descriptor = canvasImageDescriptor(rawDescriptor);
if (!descriptor) continue;
const identity = [descriptor.filename, descriptor.subfolder, descriptor.type].join("\n");
if (seen.has(identity)) continue;
seen.add(identity);
candidates.push({ nodeId, sourceLabel, descriptor });
}
}
return candidates;
}
function closeReferenceLightbox(lightbox = document.getElementById(REFERENCE_LIGHTBOX_ID)) {
if (!lightbox) return;
lightbox.setAttribute("aria-hidden", "true");
lightbox._o1igItems = [];
lightbox._o1igActiveIndex = -1;
lightbox._o1igPreviouslyFocusedElement?.focus?.();
lightbox._o1igPreviouslyFocusedElement = null;
}
function updateReferenceLightbox(lightbox) {
const items = lightbox?._o1igItems || [];
const activeIndex = Number(lightbox?._o1igActiveIndex);
const entry = items[activeIndex];
if (!entry) {
closeReferenceLightbox(lightbox);
return;
}
lightbox._o1igImage.src = viewUrl(entry.item);
lightbox._o1igImage.alt = entry.item?.name || `图片 ${activeIndex + 1}`;
const hasMultiple = items.length > 1;
lightbox._o1igPrevious.hidden = !hasMultiple;
lightbox._o1igNext.hidden = !hasMultiple;
}
function navigateReferenceLightbox(lightbox, direction) {
const items = lightbox?._o1igItems || [];
if (!items.length) return;
lightbox._o1igActiveIndex = (
lightbox._o1igActiveIndex + direction + items.length
) % items.length;
updateReferenceLightbox(lightbox);
}
function ensureReferenceLightbox() {
let lightbox = document.getElementById(REFERENCE_LIGHTBOX_ID);
if (lightbox || !document.body) return lightbox;
lightbox = document.createElement("div");
lightbox.id = REFERENCE_LIGHTBOX_ID;
lightbox.className = "o1ig-lightbox";
lightbox.tabIndex = -1;
lightbox.setAttribute("role", "dialog");
lightbox.setAttribute("aria-modal", "true");
lightbox.setAttribute("aria-label", "图片预览");
lightbox.setAttribute("aria-hidden", "true");
const image = document.createElement("img");
image.className = "o1ig-lightbox-image";
const makeButton = (className, label, text) => {
const button = document.createElement("button");
button.type = "button";
button.className = `o1ig-lightbox-button ${className}`;
button.setAttribute("aria-label", label);
button.textContent = text;
return button;
};
const close = makeButton("o1ig-lightbox-close", "关闭", "×");
const previous = makeButton("o1ig-lightbox-previous", "上一张", "");
const next = makeButton("o1ig-lightbox-next", "下一张", "");
Object.assign(lightbox, {
_o1igImage: image,
_o1igClose: close,
_o1igPrevious: previous,
_o1igNext: next,
_o1igItems: [],
_o1igActiveIndex: -1,
_o1igMaskMouseDownTarget: null,
_o1igPreviouslyFocusedElement: null,
});
close.addEventListener("click", () => closeReferenceLightbox(lightbox));
previous.addEventListener("click", () => navigateReferenceLightbox(lightbox, -1));
next.addEventListener("click", () => navigateReferenceLightbox(lightbox, 1));
lightbox.addEventListener("mousedown", (event) => {
lightbox._o1igMaskMouseDownTarget = event.target;
});
lightbox.addEventListener("mouseup", (event) => {
if (lightbox._o1igMaskMouseDownTarget === event.target && event.target === lightbox) {
closeReferenceLightbox(lightbox);
}
});
lightbox.addEventListener("keydown", (event) => {
const actions = {
ArrowLeft: () => navigateReferenceLightbox(lightbox, -1),
ArrowRight: () => navigateReferenceLightbox(lightbox, 1),
Escape: () => closeReferenceLightbox(lightbox),
};
const action = actions[event.key];
if (!action) return;
event.preventDefault();
event.stopPropagation();
action();
});
lightbox.append(image, close, previous, next);
document.body.append(lightbox);
return lightbox;
}
function referenceLightboxEntries(node) {
const entries = (node?._o1igReferences || []).map((item, index) => ({
item, role: "references", index,
}));
if (
node?._o1igBatchEnabled
&& node?._o1igBatchMode?.value !== BATCH_MODE_SINGLE_REFERENCES
) {
entries.push(...(node._o1igModelReferences || []).map((item, index) => ({
item, role: "models", index,
})));
}
return entries.filter((entry) => viewUrl(entry.item));
}
function showReferenceLightbox(node, role, listIndex) {
const lightbox = ensureReferenceLightbox();
if (!lightbox) return;
const items = referenceLightboxEntries(node);
const activeIndex = items.findIndex((entry) => entry.role === role && entry.index === listIndex);
if (activeIndex < 0) return;
lightbox._o1igItems = items;
lightbox._o1igActiveIndex = activeIndex;
lightbox._o1igPreviouslyFocusedElement = document.activeElement;
updateReferenceLightbox(lightbox);
lightbox.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => lightbox.focus());
}
async function uploadImage(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 });
if (!response.ok) throw new Error(`上传失败:${file.name}`);
const data = await response.json();
return {
name: String(data.name || file.name),
subfolder: String(data.subfolder || ""),
type: "input",
};
}
let imageUploadQueue = Promise.resolve();
function enqueueImageUpload(file) {
// ComfyUI appends a natural-number suffix when a name already exists.
// Keep every o1key upload sequential so two requests cannot race while the
// native endpoint checks and allocates that filename in the input root.
const request = imageUploadQueue.then(() => uploadImage(file));
imageUploadQueue = request.catch(() => {});
return request;
}
function validateSeedreamReferenceDimensions(width, height, label, { layerDecomposition = false } = {}) {
const limits = SEEDREAM_REFERENCE_LIMITS;
width = Number(width);
height = Number(height);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new Error(`${label}尺寸无效:${width}×${height}`);
}
const pixels = width * height;
if (!layerDecomposition && (width <= limits.minSideExclusive || height <= limits.minSideExclusive)) {
throw new Error(`${label}宽和高都必须大于 ${limits.minSideExclusive}px,当前为 ${width}×${height}`);
}
const ratio = width / height;
if (ratio < limits.minAspectRatio || ratio > limits.maxAspectRatio) {
throw new Error(`${label}宽高比必须在 1:16~16:1,当前为 ${width}:${height}`);
}
if (layerDecomposition && (pixels < limits.minLayerPixels || pixels > limits.maxPixels)) {
throw new Error(`${label}总像素必须在 512×512262144)~6000×600036000000)之间,当前为 ${width}×${height}${pixels}`);
}
if (!layerDecomposition && pixels > limits.maxPixels) {
throw new Error(`${label}总像素不能超过 6000×600036000000),当前为 ${width}×${height}${pixels}`);
}
}
async function readImageFileDimensions(file) {
if (typeof createImageBitmap !== "function") return null;
const bitmap = await createImageBitmap(file);
try {
return [bitmap.width, bitmap.height];
} finally {
bitmap.close?.();
}
}
async function validateSeedreamReferenceFile(node, file) {
if (node?._o1igModel?.value !== "Seedream 5.0 Pro") return;
const label = file?.name || "Seedream 参考图";
if (Number(file?.size) > SEEDREAM_REFERENCE_LIMITS.maxBytes) {
throw new Error(`${label}文件不能超过 30MB,当前为 ${(file.size / 1024 / 1024).toFixed(2)}MB`);
}
const dimensions = await readImageFileDimensions(file);
if (!dimensions) return;
const [width, height] = dimensions;
validateSeedreamReferenceDimensions(width, height, label, {
layerDecomposition: node?._o1igLayerDecomposition?.value === "开启",
});
}
function toast(severity, summary, detail) {
app.extensionManager?.toast?.add?.({ severity, summary, detail, life: 3000 });
}
const UNSAFE_IMAGE_ERROR_MESSAGE = "内容被拒绝:该图像被内容安全系统标记为不安全。";
const SAFETY_REJECTION_ERROR_MESSAGE = "您的请求已被安全系统拒绝";
const INSUFFICIENT_BALANCE_ERROR_MESSAGE = "上游额度不足!";
const EMPTY_IMAGE_RESPONSE_ERROR_MESSAGE = "图片生成过程中被内容审查机制拒绝!";
const UNSAFE_PROMPT_ERROR_MESSAGE = "提供的提示被认为是不安全的,不能用于生成内容。";
const SEEDANCE_ERROR_NODE_TYPES = new Set(["SeedanceAutoPass", "SeedanceMultiModal"]);
const SEEDANCE_COPYRIGHT_ERROR_MESSAGE = "输出视频触发版权审查被拒绝生成!";
function formatImageGenerationError(value) {
const message = String(value || "生成失败");
if (/content rejected:\s*the image was flagged as unsafe by the content safety system/i.test(message)) {
return UNSAFE_IMAGE_ERROR_MESSAGE;
}
if (/your request was rejected by the safety system/i.test(message)) {
return SAFETY_REJECTION_ERROR_MESSAGE;
}
if (/insufficient balance/i.test(message)) {
return INSUFFICIENT_BALANCE_ERROR_MESSAGE;
}
if (/image generation returned empty response/i.test(message)) {
return EMPTY_IMAGE_RESPONSE_ERROR_MESSAGE;
}
if (/the provided prompt is considered unsafe and it cannot be used to generate content/i.test(message)) {
return UNSAFE_PROMPT_ERROR_MESSAGE;
}
return message;
}
function formatSeedanceGenerationError(value) {
const message = String(value || "视频生成失败");
if (/the request failed because the output video may be related to copyright restrictions?/i.test(message)) {
return SEEDANCE_COPYRIGHT_ERROR_MESSAGE;
}
return message;
}
function updateExecutionErrorOverlay(message, root = document) {
const overlay = root?.querySelector?.('[data-testid="error-overlay"]');
const messageContainer = overlay?.querySelector?.('[data-testid="error-overlay-messages"]');
const messageElement = messageContainer?.querySelector?.("p");
if (!messageElement) return false;
const nextMessage = String(message || "生成失败");
if (messageElement.textContent !== nextMessage) {
messageElement.textContent = nextMessage;
}
return true;
}
let executionErrorOverlayObserver = null;
let executionErrorOverlayTimer = null;
function replaceExecutionErrorOverlay(message) {
const formattedMessage = String(message || "生成失败");
executionErrorOverlayObserver?.disconnect?.();
if (executionErrorOverlayTimer) clearTimeout(executionErrorOverlayTimer);
const applyMessage = () => updateExecutionErrorOverlay(formattedMessage);
const root = document.body || document.documentElement;
if (root && typeof MutationObserver === "function") {
executionErrorOverlayObserver = new MutationObserver(() => {
applyMessage();
});
executionErrorOverlayObserver.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
}
// Vue updates the core error overlay after the execution_error listeners
// finish. Apply on the next frame and keep observing briefly in case the
// existing overlay is re-rendered with ComfyUI's generic fallback copy.
requestAnimationFrame(applyMessage);
executionErrorOverlayTimer = setTimeout(() => {
applyMessage();
executionErrorOverlayObserver?.disconnect?.();
executionErrorOverlayObserver = null;
executionErrorOverlayTimer = null;
}, 1500);
}
let openDropdown = null;
const parallelBatches = new Map();
const NATIVE_QUEUE_BRIDGE_KEY = "__o1keyImageJobQueueBridge";
const O1KEY_QUEUE_JOB_PREFIX = "o1key:";
const O1KEY_QUEUE_WORKFLOW_LABEL = "o1key 图片生成";
const MAX_NATIVE_QUEUE_HISTORY = 64;
const nativeQueueBridge = api[NATIVE_QUEUE_BRIDGE_KEY] || {
installed: false,
jobs: new Map(),
sequence: 0,
lastNativeQueueRemaining: 0,
historyLoaded: false,
historyLoadPromise: null,
originals: {},
};
nativeQueueBridge.historyLoaded ??= false;
nativeQueueBridge.historyLoadPromise ??= null;
api[NATIVE_QUEUE_BRIDGE_KEY] = nativeQueueBridge;
function activeGraphNodeById(nodeId) {
if (nodeId == null) return null;
const graph = app.graph;
return graph?.getNodeById?.(nodeId)
|| graph?.getNodeById?.(Number(nodeId))
|| graphNodes(graph).find((node) => String(node?.id) === String(nodeId))
|| null;
}
function isActiveGraphNode(node) {
return Boolean(node && activeGraphNodeById(node.id) === node);
}
function detachParallelBatchNode(node) {
if (!node) return;
for (const registered of parallelBatches.values()) {
if (registered.source === node) {
registered.generatorNodeId ??= node.id;
registered.source = null;
}
if (registered.saveNode === node) {
registered.saveNodeId ??= node.id;
registered.saveNode = null;
}
}
}
function nativeQueueJobId(batchId) {
return `${O1KEY_QUEUE_JOB_PREFIX}${batchId}`;
}
function isTerminalNativeQueueStatus(status) {
return status === "completed" || status === "failed" || status === "cancelled";
}
function nativeQueueStatus(state) {
if (state === "queued") return "pending";
if (state === "running" || state === "saving") return "in_progress";
if (state === "completed") return "completed";
if (state === "cancelled") return "cancelled";
return "failed";
}
function nativeQueueImages(detail) {
return normalizeSaveResults(detail?.images).filter(
(item) => item.type === "output" || item.external_saved === true,
);
}
function trimNativeQueueHistory() {
const terminal = [...nativeQueueBridge.jobs.values()]
.filter((job) => isTerminalNativeQueueStatus(job.status))
.sort((left, right) => (right.execution_end_time || 0) - (left.execution_end_time || 0));
for (const job of terminal.slice(MAX_NATIVE_QUEUE_HISTORY)) {
nativeQueueBridge.jobs.delete(job.id);
}
}
function notifyNativeTaskQueue() {
if (typeof api.dispatchCustomEvent !== "function") return;
const activeCount = [...nativeQueueBridge.jobs.values()].filter(
(job) => !isTerminalNativeQueueStatus(job.status),
).length;
api.dispatchCustomEvent("status", {
exec_info: {
queue_remaining: Math.max(0, Number(nativeQueueBridge.lastNativeQueueRemaining) || 0) + activeCount,
},
o1key_virtual_queue_update: true,
});
}
function updateNativeQueueJob(detail, { forceState = "", images = null, error = "" } = {}) {
const batchId = String(detail?.batch_id || "");
if (!batchId) return null;
const id = nativeQueueJobId(batchId);
const now = Date.now();
let job = nativeQueueBridge.jobs.get(id);
const previousStatus = job?.status || "";
if (!job) {
nativeQueueBridge.sequence += 1;
job = {
id,
batch_id: batchId,
status: "pending",
create_time: now,
execution_start_time: null,
execution_end_time: null,
preview_output: null,
outputs_count: 0,
execution_error: null,
workflow_id: O1KEY_QUEUE_WORKFLOW_LABEL,
priority: 1_100_000 + nativeQueueBridge.sequence,
generator_node_id: Number(detail?.generator_node_id),
save_node_id: Number(detail?.save_node_id),
images: [],
o1key_total_count: 0,
};
nativeQueueBridge.jobs.set(id, job);
}
const state = forceState || String(detail?.state || "queued");
const status = nativeQueueStatus(state);
job.status = status;
if (Number.isFinite(Number(detail?.generator_node_id))) {
job.generator_node_id = Number(detail.generator_node_id);
}
if (Number.isFinite(Number(detail?.save_node_id))) {
job.save_node_id = Number(detail.save_node_id);
}
const totalCount = Math.max(0, Math.floor(Number(detail?.total_count) || 0));
if (totalCount) job.o1key_total_count = totalCount;
const createTime = Number(detail?.create_time);
const startTime = Number(detail?.execution_start_time);
const endTime = Number(detail?.execution_end_time);
if (Number.isFinite(createTime) && createTime > 0) job.create_time = createTime;
if (Number.isFinite(startTime) && startTime > 0) job.execution_start_time = startTime;
if (status === "in_progress" && !job.execution_start_time) job.execution_start_time = now;
if (Number.isFinite(endTime) && endTime > 0) job.execution_end_time = endTime;
if (isTerminalNativeQueueStatus(status)) job.execution_end_time ||= now;
const savedImages = images === null ? nativeQueueImages(detail) : normalizeSaveResults(images);
if (savedImages.length) {
job.images = savedImages;
job.outputs_count = savedImages.length;
const preview = savedImages[0];
job.preview_output = {
...preview,
nodeId: String(job.save_node_id),
mediaType: "images",
display_name: preview.filename,
};
}
if (!job.o1key_total_count && savedImages.length) {
job.o1key_total_count = savedImages.length;
}
const errorMessage = String(error || detail?.error || "");
job.execution_error = status === "failed" ? {
prompt_id: id,
timestamp: now,
node_id: String(job.save_node_id),
node_type: SAVE_NODE_TYPE,
executed: [],
exception_message: errorMessage || "o1key 图片生成失败",
exception_type: "O1keyImageGenerationError",
traceback: [],
current_inputs: {},
current_outputs: {},
} : null;
trimNativeQueueHistory();
if (!previousStatus || previousStatus !== status || isTerminalNativeQueueStatus(status)) {
notifyNativeTaskQueue();
}
scheduleNativePreviewActionSync();
return job;
}
async function loadPersistedNativeQueueHistory() {
if (nativeQueueBridge.historyLoaded) return;
if (!nativeQueueBridge.historyLoadPromise) {
nativeQueueBridge.historyLoadPromise = (async () => {
let loaded = false;
try {
const response = await api.fetchApi(
`/o1key/image/jobs/history?limit=${MAX_NATIVE_QUEUE_HISTORY}`,
{ cache: "no-store" },
);
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result?.error || `HTTP ${response.status}`);
for (const detail of result?.items || []) updateNativeQueueJob(detail);
loaded = true;
} catch {
// Native queue/history must remain usable during a brief reconnect.
} finally {
nativeQueueBridge.historyLoaded = loaded;
nativeQueueBridge.historyLoadPromise = null;
}
})();
}
return nativeQueueBridge.historyLoadPromise;
}
async function updatePersistedNativeQueueHistory(payload) {
const response = await api.fetchApi("/o1key/image/jobs/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result?.error || `HTTP ${response.status}`);
}
async function cancelNativeO1keyJob(job) {
if (!job || isTerminalNativeQueueStatus(job.status)) return;
const response = await api.fetchApi(`/o1key/image/jobs/${encodeURIComponent(job.batch_id)}/cancel`, {
method: "POST",
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result?.error || `取消任务失败(HTTP ${response.status}`);
if (!findBatchNodes(result).batchId) {
updateNativeQueueJob(result, {
forceState: String(result?.state || "cancelled"),
error: result?.error || "任务已取消",
});
}
handleParallelImageJob(result);
}
function installNativeTaskQueueBridge() {
if (nativeQueueBridge.installed || typeof api.getQueue !== "function") return;
nativeQueueBridge.installed = true;
const originals = nativeQueueBridge.originals;
originals.getQueue = api.getQueue.bind(api);
originals.getHistory = typeof api.getHistory === "function" ? api.getHistory.bind(api) : async () => [];
originals.getJobDetail = typeof api.getJobDetail === "function" ? api.getJobDetail.bind(api) : async () => undefined;
originals.cancelJob = typeof api.cancelJob === "function" ? api.cancelJob.bind(api) : async () => {};
originals.cancelJobs = typeof api.cancelJobs === "function" ? api.cancelJobs.bind(api) : async () => {};
originals.deleteItem = typeof api.deleteItem === "function" ? api.deleteItem.bind(api) : async () => {};
originals.clearItems = typeof api.clearItems === "function" ? api.clearItems.bind(api) : async () => {};
api.getQueue = async (...args) => {
const result = await originals.getQueue(...args);
const jobs = [...nativeQueueBridge.jobs.values()];
return {
Running: [...(result?.Running || []), ...jobs.filter((job) => job.status === "in_progress")],
Pending: [...(result?.Pending || []), ...jobs.filter((job) => job.status === "pending")],
};
};
api.getHistory = async (maxItems = 200, options = {}) => {
const history = await originals.getHistory(maxItems, options);
if ((Number(options?.offset) || 0) > 0) return history;
await loadPersistedNativeQueueHistory();
const jobs = [...nativeQueueBridge.jobs.values()]
.filter((job) => isTerminalNativeQueueStatus(job.status));
return [...history, ...jobs]
.sort((left, right) => (
(right.execution_end_time || right.create_time || 0)
- (left.execution_end_time || left.create_time || 0)
))
.slice(0, Math.max(0, Number(maxItems) || 200));
};
api.getJobDetail = async (jobId) => {
const job = nativeQueueBridge.jobs.get(String(jobId));
if (!job) return originals.getJobDetail(jobId);
return {
...job,
outputs: job.images?.length ? { [String(job.save_node_id)]: { images: job.images } } : {},
update_time: job.execution_end_time || Date.now(),
};
};
api.cancelJob = async (jobId) => {
const job = nativeQueueBridge.jobs.get(String(jobId));
if (job) return cancelNativeO1keyJob(job);
return originals.cancelJob(jobId);
};
api.cancelJobs = async (jobIds) => {
const nativeIds = [];
const o1keyJobs = [];
for (const jobId of jobIds || []) {
const job = nativeQueueBridge.jobs.get(String(jobId));
if (job) o1keyJobs.push(job);
else nativeIds.push(jobId);
}
await Promise.all([
...o1keyJobs.map((job) => cancelNativeO1keyJob(job)),
...(nativeIds.length ? [originals.cancelJobs(nativeIds)] : []),
]);
};
api.deleteItem = async (type, jobId) => {
const id = String(jobId);
const job = nativeQueueBridge.jobs.get(id);
if (!job) return originals.deleteItem(type, jobId);
if (!isTerminalNativeQueueStatus(job.status)) await cancelNativeO1keyJob(job);
if (type === "history" || isTerminalNativeQueueStatus(job.status)) {
await updatePersistedNativeQueueHistory({ batch_id: job.batch_id });
nativeQueueBridge.jobs.delete(id);
notifyNativeTaskQueue();
}
};
api.clearItems = async (type) => {
await originals.clearItems(type);
if (type === "queue") {
const pending = [...nativeQueueBridge.jobs.values()].filter(
(job) => job.status === "pending",
);
await Promise.all(pending.map((job) => cancelNativeO1keyJob(job)));
} else if (type === "history") {
await updatePersistedNativeQueueHistory({ clear: true });
for (const [id, job] of nativeQueueBridge.jobs) {
if (isTerminalNativeQueueStatus(job.status)) nativeQueueBridge.jobs.delete(id);
}
notifyNativeTaskQueue();
}
};
api.addEventListener?.("status", ({ detail }) => {
if (detail?.o1key_virtual_queue_update) return;
nativeQueueBridge.lastNativeQueueRemaining = Math.max(
0,
Number(detail?.exec_info?.queue_remaining) || 0,
);
});
}
function createBatchId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
function closeDropdown(dropdown = openDropdown) {
if (!dropdown) return;
dropdown.classList.remove("open");
dropdown._o1igTrigger.setAttribute("aria-expanded", "false");
if (openDropdown === dropdown) openDropdown = null;
}
function setDropdownValue(dropdown, value) {
const fallback = dropdown._o1igOptions[0]?.value ?? "";
const selected = dropdown._o1igOptions.find((option) => option.value === value);
const nextValue = selected ? selected.value : fallback;
dropdown._o1igValue = nextValue;
const selectedOption = selected || dropdown._o1igOptions[0];
dropdown._o1igValueLabel.textContent = selectedOption?.label || "";
dropdown._o1igValueDescription.textContent = selectedOption?.description || "";
dropdown._o1igTrigger.title = selectedOption?.description
? `${selectedOption.label} · ${selectedOption.description}`
: selectedOption?.label || "";
for (const button of dropdown._o1igMenu.children) {
const active = button.dataset.value === nextValue;
button.classList.toggle("selected", active);
button.setAttribute("aria-selected", String(active));
}
}
function setDropdownOptions(dropdown, options, preferred) {
dropdown._o1igOptions = options.map((option) => (
typeof option === "string" ? { value: option, label: option } : option
));
dropdown._o1igMenu.replaceChildren();
for (const option of dropdown._o1igOptions) {
const button = document.createElement("button");
button.type = "button";
button.className = "o1ig-select-option";
button.dataset.value = option.value;
const optionLabel = document.createElement("span");
optionLabel.className = "o1ig-select-option-label";
optionLabel.textContent = option.label;
button.append(optionLabel);
if (option.description) {
button.classList.add("has-description");
const description = document.createElement("span");
description.className = "o1ig-select-option-description";
description.textContent = option.description;
button.append(description);
button.title = `${option.label} · ${option.description}`;
}
button.setAttribute("role", "option");
button.addEventListener("click", () => {
setDropdownValue(dropdown, option.value);
closeDropdown(dropdown);
dropdown.dispatchEvent(new Event("change", { bubbles: true }));
});
dropdown._o1igMenu.append(button);
}
setDropdownValue(dropdown, preferred);
}
function makeDropdown(options, current, title = "") {
const dropdown = document.createElement("div");
dropdown.className = "o1ig-select";
dropdown.title = title;
dropdown.setAttribute("role", "combobox");
dropdown.setAttribute("aria-label", title);
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "o1ig-select-trigger";
trigger.setAttribute("aria-haspopup", "listbox");
trigger.setAttribute("aria-expanded", "false");
const valueLabel = document.createElement("span");
valueLabel.className = "o1ig-select-value";
const valueDescription = document.createElement("span");
valueDescription.className = "o1ig-select-selected-description";
const caret = document.createElement("span");
caret.className = "o1ig-select-caret";
caret.setAttribute("aria-hidden", "true");
const caretIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
caretIcon.setAttribute("viewBox", "0 0 12 12");
caretIcon.setAttribute("focusable", "false");
const caretPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
caretPath.setAttribute("d", "M2.25 4.25 6 8l3.75-3.75");
caretPath.setAttribute("fill", "none");
caretPath.setAttribute("stroke", "currentColor");
caretPath.setAttribute("stroke-width", "1.5");
caretPath.setAttribute("stroke-linecap", "round");
caretPath.setAttribute("stroke-linejoin", "round");
caretIcon.append(caretPath);
caret.append(caretIcon);
trigger.append(valueLabel, valueDescription, caret);
const menu = document.createElement("div");
menu.className = "o1ig-select-menu";
menu.setAttribute("role", "listbox");
dropdown.append(trigger, menu);
Object.assign(dropdown, {
_o1igMenu: menu,
_o1igTrigger: trigger,
_o1igValueLabel: valueLabel,
_o1igValueDescription: valueDescription,
});
Object.defineProperty(dropdown, "value", {
get: () => dropdown._o1igValue,
set: (value) => setDropdownValue(dropdown, value),
});
trigger.addEventListener("click", () => {
const willOpen = !dropdown.classList.contains("open");
if (openDropdown && openDropdown !== dropdown) closeDropdown();
dropdown.classList.toggle("open", willOpen);
trigger.setAttribute("aria-expanded", String(willOpen));
openDropdown = willOpen ? dropdown : null;
});
trigger.addEventListener("keydown", (event) => {
if (event.key !== "ArrowDown" && event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
if (!dropdown.classList.contains("open")) trigger.click();
dropdown._o1igMenu.querySelector(".selected")?.focus();
});
setDropdownOptions(dropdown, options, current);
return dropdown;
}
function makeSelect(values, current, title = "") {
return makeDropdown(values, current, title);
}
function imageCountOptions(model, current) {
const values = isGptImageModel(model) ? [...GPT_IMAGE_COUNTS] : [...IMAGE_COUNTS];
if (isGptImageModel(model) && String(current) === "9") values.push("9");
return values.map((value) => ({ value, label: `${value}张` }));
}
function makeImageCountSelect(current, model) {
return makeDropdown(imageCountOptions(model, current), String(current ?? "1"), "生图数量");
}
function normalizeImageCount(value, model = "") {
const normalized = String(value ?? "1");
return imageCountOptions(model, normalized).some((option) => option.value === normalized) ? normalized : "1";
}
function replaceOptions(select, values, preferred) {
const optionValues = values.map((option) => typeof option === "string" ? option : option.value);
setDropdownOptions(
select,
values,
optionValues.includes(preferred) ? preferred : optionValues[0],
);
}
function makeAdvancedField(labelText, control) {
const field = document.createElement("div");
field.className = "o1ig-field";
const label = document.createElement("label");
label.textContent = labelText;
field.append(label, control);
return field;
}
function makePanelSectionTitle(text) {
const heading = document.createElement("div");
heading.className = "o1ig-section-title";
heading.textContent = text;
return heading;
}
function setFieldVisible(field, visible) {
if (field) field.style.display = visible ? "" : "none";
}
function syncGptOutputOptions(node) {
if (!node._o1igOutputFormat || !node._o1igBackground) return;
const formats = node._o1igModel?.value === "Seedream 5.0 Pro"
? SEEDREAM_OUTPUT_FORMATS
: node._o1igBackground.value === "transparent"
? GPT_OUTPUT_FORMATS.filter((option) => option.value !== "jpeg")
: GPT_OUTPUT_FORMATS;
replaceOptions(node._o1igOutputFormat, formats, node._o1igOutputFormat.value);
setWidgetValue(node, "输出格式", node._o1igOutputFormat.value);
setWidgetValue(node, "背景", node._o1igBackground.value);
}
function generatorSaveSettings(node) {
const model = node?._o1igModel?.value || findWidget(node, "模型")?.value || "Nano Banana 2";
const capabilities = MODEL_CAPABILITIES[model] || MODEL_CAPABILITIES["Nano Banana 2"];
return {
filename_prefix: String(
node?._o1igFilenamePrefix?.value
?? findWidget(node, "filename_prefix")?.value
?? "o1key"
).trim() || "o1key",
format: capabilities.saveFormat
? node?._o1igSaveFormat?.value || findWidget(node, "格式")?.value || "原始"
: "原始",
save_location: String(
node?._o1igSaveLocation?.value
?? findWidget(node, "保存位置")?.value
?? ""
).trim(),
naming_rule: node?._o1igNamingRule?.value
|| findWidget(node, "命名规则")?.value
|| "自定义前缀",
};
}
function syncGeneratorSaveControls(node) {
if (!node?._o1igNamingRule) return;
const capabilities = MODEL_CAPABILITIES[node._o1igModel?.value]
|| MODEL_CAPABILITIES["Nano Banana 2"];
setFieldVisible(node._o1igFilenamePrefixField, node._o1igNamingRule.value === "自定义前缀");
setFieldVisible(node._o1igSaveFormatField, capabilities.saveFormat);
const settings = generatorSaveSettings(node);
setWidgetValue(node, "命名规则", settings.naming_rule);
setWidgetValue(node, "filename_prefix", settings.filename_prefix);
setWidgetValue(node, "格式", node._o1igSaveFormat?.value || "原始");
setWidgetValue(node, "保存位置", settings.save_location);
}
function syncResizeWarning(node) {
if (!node._o1igResizeWarning) return;
const resizeAvailable = node._o1igResizeField?.style.display !== "none";
node._o1igResizeWarning.classList.toggle(
"visible",
resizeAvailable && node._o1igResize?.value === "智能缩放",
);
}
function setStatus(node, text = "", kind = "") {
if (!node._o1igGenerate || !node._o1igStatus) return;
clearTimeout(node._o1igStatusTimer);
node._o1igStatus.className = `o1ig-status${text ? " visible" : ""}${kind === "error" ? " error" : ""}`;
node._o1igStatus.textContent = text;
node._o1igGenerate.disabled = false;
node._o1igGenerate.textContent = "开始生成";
node._o1igGenerate.classList?.remove?.("cancel");
node._o1igPanel?.classList.toggle("busy", kind === "busy");
if (kind === "ok" && text) {
node._o1igStatusTimer = setTimeout(() => {
if (!node._o1igRunning) setStatus(node, "", "");
}, 2200);
}
}
function setPromptOptimizeStatus(node, text = "", kind = "") {
const status = node._o1igPromptOptimizeStatus;
if (!status) return;
clearTimeout(node._o1igPromptOptimizeStatusTimer);
status.className = `o1ig-prompt-optimize-status${text ? " visible" : ""}${kind ? ` ${kind}` : ""}`;
status.textContent = text;
status.title = text;
if (text && kind !== "busy") {
node._o1igPromptOptimizeStatusTimer = setTimeout(() => {
if (!node._o1igOptimizing) setPromptOptimizeStatus(node);
}, kind === "error" ? 3600 : 2200);
}
}
function fitNode(node) {
if (!node._o1igPanelWidget) return;
const enforce = () => {
const width = Math.max(Number(node.size?.[0]) || 0, GENERATOR_MIN_SIZE[0]);
const denseLayout = isGptImageModel(node._o1igModel?.value);
const minimumHeight = node._o1igBatchEnabled
? denseLayout ? GENERATOR_DENSE_BATCH_MIN_HEIGHT : GENERATOR_BATCH_MIN_HEIGHT
: denseLayout ? GENERATOR_DENSE_MIN_HEIGHT : GENERATOR_MIN_SIZE[1];
const height = Math.max(Number(node.size?.[1]) || 0, minimumHeight);
node.setSize?.([width, height]);
node.setDirtyCanvas?.(true, true);
};
enforce();
requestAnimationFrame(enforce);
}
function applyGeneratorDefaultSize(node) {
if (node._o1igDefaultSizeApplied) return;
node._o1igDefaultSizeApplied = true;
node.setSize?.([...GENERATOR_DEFAULT_SIZE]);
node.setDirtyCanvas?.(true, true);
}
function generatorOutputs(node) {
if (!Array.isArray(node?.outputs)) return [];
if (node.outputs.length >= GENERATOR_OUTPUT_COUNT) {
node._o1igAllOutputs = node.outputs.slice(0, GENERATOR_OUTPUT_COUNT);
} else if (Array.isArray(node._o1igAllOutputs)) {
node.outputs.forEach((output, index) => {
node._o1igAllOutputs[index] = output;
});
}
return Array.isArray(node._o1igAllOutputs) ? node._o1igAllOutputs : node.outputs;
}
function syncGeneratorOutputVisibility(node) {
const outputs = generatorOutputs(node);
if (outputs.length < GENERATOR_OUTPUT_COUNT) return;
const layerDecomposition = node._o1igModel?.value === "Seedream 5.0 Pro"
&& node._o1igLayerDecomposition?.value === "开启";
const connectedOutputCount = outputs.reduce((count, output, index) => (
output?.links?.length ? Math.max(count, index + 1) : count
), 1);
const visibleOutputCount = Math.max(
layerDecomposition ? GENERATOR_LAYER_OUTPUT_COUNT : 1,
connectedOutputCount,
);
const visibleOutputs = outputs.slice(0, visibleOutputCount);
if (
node.outputs.length === visibleOutputs.length
&& node.outputs.every((output, index) => output === visibleOutputs[index])
) return;
node.outputs = visibleOutputs;
node.setDirtyCanvas?.(true, true);
}
function referenceRole(node, role = "references") {
if (role === "models") {
return {
items: node._o1igModelReferences || [],
pending: node._o1igModelPending || [],
container: node._o1igModelRefs,
add: node._o1igModelAdd,
canvasPicker: node._o1igModelCanvasPick,
widget: "模特图清单",
label: "目标图",
commit: "_o1igModelUploadCommit",
};
}
return {
items: node._o1igReferences || [],
pending: node._o1igPending || [],
container: node._o1igRefs,
add: node._o1igAdd,
canvasPicker: node._o1igCanvasPick,
widget: "参考图清单",
label: node._o1igBatchEnabled ? "素材图" : "参考图",
commit: "_o1igUploadCommit",
};
}
function hasPendingReferenceUploads(node) {
return Boolean(
node?._o1igPending?.length
|| node?._o1igModelPending?.length
|| node?._o1igReplacing?.size
|| node?._o1igMaskPending
);
}
function referenceLimit(node, role = "references") {
if (node?._o1igLayerDecomposition?.value === "开启") return role === "references" ? 1 : 0;
if (!node?._o1igBatchEnabled) return MAX_REQUEST_REFERENCES;
if (role === "models") return MAX_REFERENCES;
return node._o1igBatchMode?.value === BATCH_MODE_GROUP_TO_MODELS
? MAX_REQUEST_REFERENCES - 1
: MAX_REFERENCES;
}
function referenceBadgeDetails(node, role, listIndex) {
const ordinal = listIndex + 1;
if (!node?._o1igBatchEnabled) {
const primary = ordinal === 1 ? " · 主图/色彩基准" : "";
return {
label: `图${ordinal}`,
title: `图${ordinal}${primary}(按当前顺序)`,
};
}
if (node._o1igBatchMode?.value === BATCH_MODE_SINGLE_REFERENCES) {
return {
label: "图1",
title: `素材图${ordinal} · 作为单张参考图独立生成`,
};
}
const cartesian = node._o1igBatchMode?.value === BATCH_MODE_CARTESIAN;
if (role === "models") {
const referenceCount = node._o1igReferences?.length || 0;
const requestIndex = referenceCount ? (cartesian ? 2 : referenceCount + 1) : 1;
return {
label: `图${requestIndex}`,
title: `目标图${ordinal} · 在各自请求中是图${requestIndex}`,
};
}
const requestIndex = cartesian ? 1 : ordinal;
const primary = requestIndex === 1 ? " · 主图/色彩基准" : "";
return {
label: `图${requestIndex}`,
title: `素材图${ordinal} · 在各自请求中是图${requestIndex}${primary}`,
};
}
function referenceDropDestination(fromIndex, targetIndex, afterTarget) {
let insertionIndex = targetIndex + (afterTarget ? 1 : 0);
if (fromIndex < insertionIndex) insertionIndex -= 1;
return Math.max(0, insertionIndex);
}
function clearReferenceDrag(node) {
const drag = node?._o1igReferenceDrag;
if (drag?.tile) drag.tile.classList.remove("o1ig-dragging");
for (const role of ["references", "models"]) {
const container = referenceRole(node, role).container;
container?.classList?.remove("o1ig-reordering");
for (const child of container?.children || []) {
child.classList?.remove("o1ig-drop-before");
child.classList?.remove("o1ig-drop-after");
}
}
if (node) node._o1igReferenceDrag = null;
}
function moveReference(node, role, fromIndex, toIndex, options = {}) {
const state = referenceRole(node, role);
if (state.pending.length || state.items.length < 2) return false;
const from = Math.floor(Number(fromIndex));
const requestedTarget = Math.floor(Number(toIndex));
if (!Number.isInteger(from) || !Number.isInteger(requestedTarget)) return false;
const target = Math.max(0, Math.min(state.items.length - 1, requestedTarget));
if (from < 0 || from >= state.items.length || from === target) return false;
const [item] = state.items.splice(from, 1);
state.items.splice(target, 0, item);
setWidgetValue(node, state.widget, JSON.stringify(state.items));
renderReferences(node);
if (options.focus) {
requestAnimationFrame(() => node?._o1igReferenceHandles?.[role]?.[target]?.focus?.());
}
return true;
}
function handleReferenceOrderKey(event, node, role, index) {
if (!event.altKey) return false;
const state = referenceRole(node, role);
const targets = {
ArrowLeft: index - 1,
ArrowRight: index + 1,
Home: 0,
End: state.items.length - 1,
};
if (!(event.key in targets)) return false;
event.preventDefault();
event.stopPropagation();
moveReference(node, role, index, targets[event.key], { focus: true });
return true;
}
function beginReferenceDrag(event, node, role, index, tile) {
const state = referenceRole(node, role);
if (state.pending.length || state.items.length < 2) {
event.preventDefault();
return;
}
clearReferenceDrag(node);
node._o1igReferenceDrag = { role, index, tile };
node._o1igSuppressReferenceClickUntil = Date.now() + 350;
tile.classList.add("o1ig-dragging");
state.container?.classList?.add("o1ig-reordering");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData?.("text/plain", `${role}:${index}`);
}
event.stopPropagation();
}
function markReferenceDrop(event, node, role, targetIndex, tile) {
const drag = node?._o1igReferenceDrag;
if (!drag || drag.role !== role) return null;
event.preventDefault();
event.stopPropagation();
const bounds = tile.getBoundingClientRect?.() || { left: 0, width: 0 };
const afterTarget = Number(event.clientX) >= bounds.left + (bounds.width / 2);
for (const child of referenceRole(node, role).container?.children || []) {
child.classList?.remove("o1ig-drop-before");
child.classList?.remove("o1ig-drop-after");
}
if (targetIndex === drag.index) return null;
tile.classList.add(afterTarget ? "o1ig-drop-after" : "o1ig-drop-before");
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
return afterTarget;
}
function completeReferenceDrop(event, node, role, targetIndex, tile) {
const drag = node?._o1igReferenceDrag;
if (!drag || drag.role !== role) return;
const afterTarget = markReferenceDrop(event, node, role, targetIndex, tile);
const destination = referenceDropDestination(drag.index, targetIndex, afterTarget);
clearReferenceDrag(node);
moveReference(node, role, drag.index, destination);
}
async function editReference(node, role, listIndex) {
const state = referenceRole(node, role);
const original = state.items[listIndex];
if (!original) return;
closeReferenceLightbox();
try {
await openReferenceImageEditor({
sourceUrl: viewUrl(original),
filename: original.name,
onConfirm: async ({ blob, filename }) => {
const file = new File([blob], filename, { type: "image/png" });
await validateSeedreamReferenceFile(node, file);
const uploaded = await enqueueImageUpload(file);
const currentIndex = state.items.indexOf(original);
if (currentIndex < 0) throw new Error(`${state.label}已被移除,编辑结果未替换`);
state.items.splice(currentIndex, 1, uploaded);
setWidgetValue(node, state.widget, JSON.stringify(state.items));
renderReferences(node);
toast("success", "图片编辑完成", `${uploaded.name} · 已替换当前${state.label}`);
},
});
} catch (error) {
toast("error", "无法打开图片编辑器", error?.message || String(error));
}
}
function requestReferenceReplacement(node, role, original) {
const input = node?._o1igReplaceInput;
if (!input || !original || node?._o1igReplacing?.has(original)) return;
node._o1igReplaceTarget = { role, original };
input.value = "";
input.click();
}
async function replaceReference(node, role, original, file) {
const state = referenceRole(node, role);
if (!file || !file.type?.startsWith("image/") || !state.items.includes(original)) return false;
node._o1igReplacing ??= new Map();
if (node._o1igReplacing.has(original)) return false;
try {
await validateSeedreamReferenceFile(node, file);
} catch (error) {
toast("error", `无法替换:${file.name}`, error?.message || String(error));
return false;
}
node._o1igReplacing.set(original, file.name);
renderReferences(node);
setStatus(node, "替换图片中…", "busy");
try {
const uploaded = await enqueueImageUpload(file);
const currentIndex = state.items.indexOf(original);
if (currentIndex < 0) throw new Error(`${state.label}已被移除,替换结果未使用`);
state.items.splice(currentIndex, 1, uploaded);
setWidgetValue(node, state.widget, JSON.stringify(state.items));
toast("success", "图片替换完成", `${uploaded.name} · 已替换当前${state.label}`);
return true;
} catch (error) {
toast("error", `替换失败:${file.name}`, error?.message || String(error));
return false;
} finally {
node._o1igReplacing.delete(original);
renderReferences(node);
if (hasPendingReferenceUploads(node)) setStatus(node, "上传中…", "busy");
else if (pendingBatchIds(node).size) updateGeneratorActivity(node);
else setStatus(node, "", "");
}
}
function renderReferenceRole(node, role = "references") {
const state = referenceRole(node, role);
if (!state.container || !state.add) return;
node._o1igReferenceHandles ??= {};
node._o1igReferenceHandles[role] = [];
state.container.replaceChildren();
const entries = [
...state.items.map((item, index) => ({ item, index, pending: false })),
...state.pending.map((item) => ({ item, index: -1, pending: true })),
];
for (const entry of entries) {
const tile = document.createElement("div");
const replacing = !entry.pending && node?._o1igReplacing?.has(entry.item);
tile.className = `o1ig-thumb${entry.pending ? " pending" : replacing ? " replacing" : " previewable"}`;
const image = document.createElement("img");
if (!entry.pending) {
image.src = thumbnailUrl(entry.item);
image.loading = "lazy";
image.decoding = "async";
}
image.alt = entry.item.name;
image.draggable = false;
if (!entry.pending) {
const sortingLocked = replacing || state.pending.length > 0 || state.items.length < 2;
tile.draggable = !sortingLocked;
tile.classList.toggle("sort-locked", sortingLocked);
image.className = "o1ig-previewable";
image.tabIndex = 0;
image.setAttribute("role", "button");
image.setAttribute("aria-label", sortingLocked ? "查看大图" : "查看大图;按住拖动可排序");
image.addEventListener("click", (event) => {
if (Date.now() < Number(node?._o1igSuppressReferenceClickUntil || 0)) {
event.preventDefault?.();
event.stopPropagation();
return;
}
event.stopPropagation();
showReferenceLightbox(node, role, entry.index);
});
image.addEventListener("keydown", (event) => {
if (handleReferenceOrderKey(event, node, role, entry.index)) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
showReferenceLightbox(node, role, entry.index);
});
tile.addEventListener("dragstart", (event) => beginReferenceDrag(event, node, role, entry.index, tile));
tile.addEventListener("dragend", () => clearReferenceDrag(node));
tile.addEventListener("dragover", (event) => markReferenceDrop(event, node, role, entry.index, tile));
tile.addEventListener("drop", (event) => completeReferenceDrop(event, node, role, entry.index, tile));
node._o1igReferenceHandles[role][entry.index] = image;
}
tile.append(image);
if (!entry.pending) {
const edit = document.createElement("button");
edit.type = "button";
edit.className = "o1ig-edit";
edit.textContent = "✎";
edit.title = "编辑图片";
edit.setAttribute("aria-label", `编辑${state.label}${entry.index + 1}`);
edit.draggable = false;
edit.disabled = replacing;
edit.addEventListener("pointerdown", (event) => event.stopPropagation());
edit.addEventListener("dragstart", (event) => event.preventDefault());
edit.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
editReference(node, role, entry.index);
});
tile.append(edit);
const replace = document.createElement("button");
replace.type = "button";
replace.className = "o1ig-replace";
replace.textContent = "替换";
replace.title = `替换这张${state.label}`;
replace.setAttribute("aria-label", `替换${state.label}${entry.index + 1}`);
replace.draggable = false;
replace.disabled = replacing;
replace.addEventListener("pointerdown", (event) => event.stopPropagation());
replace.addEventListener("dragstart", (event) => event.preventDefault());
replace.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
requestReferenceReplacement(node, role, entry.item);
});
tile.append(replace);
const badges = referenceBadgeDetails(node, role, entry.index);
const index = document.createElement("span");
index.className = "o1ig-reference-index";
index.textContent = badges.label;
index.title = badges.title;
tile.append(index);
tile.title = badges.title;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "o1ig-remove";
remove.textContent = "×";
remove.title = "移除";
remove.disabled = replacing;
remove.addEventListener("click", (event) => {
event.stopPropagation();
state.items.splice(entry.index, 1);
setWidgetValue(node, state.widget, JSON.stringify(state.items));
renderReferences(node);
});
tile.append(remove);
}
state.container.append(tile);
}
const upload = document.createElement("button");
upload.type = "button";
upload.className = "o1ig-empty-reference";
const icon = document.createElement("b");
icon.textContent = "+";
const label = document.createElement("span");
label.textContent = `添加${state.label}`;
upload.append(icon, label);
upload.addEventListener("click", () => state.add.click?.());
bindImageFileDropTarget(node, upload, role);
state.container.append(upload);
}
function renderReferences(node) {
if (node?._o1igReferenceDrag) clearReferenceDrag(node);
renderReferenceRole(node, "references");
renderReferenceRole(node, "models");
updateBatchSummary(node);
}
async function acceptFiles(node, files, role = "references") {
const state = referenceRole(node, role);
const limit = referenceLimit(node, role);
const imageFiles = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
if (!imageFiles.length) return;
const remaining = limit - state.items.length - state.pending.length;
if (remaining <= 0) {
toast("warn", `${state.label}已满`, `最多 ${limit} 张`);
return;
}
const selected = imageFiles.slice(0, remaining);
if (selected.length < imageFiles.length) toast("warn", `部分${state.label}未上传`, `最多 ${limit} 张`);
const validated = [];
for (const file of selected) {
try {
await validateSeedreamReferenceFile(node, file);
validated.push(file);
} catch (error) {
toast("error", `未上传:${file.name}`, error?.message || String(error));
}
}
if (!validated.length) return;
const pendingEntries = validated.map((file) => ({
file,
pending: { name: file.name },
}));
state.pending.push(...pendingEntries.map((entry) => entry.pending));
renderReferences(node);
setStatus(node, "上传中…", "busy");
const uploadResults = Promise.allSettled(
pendingEntries.map((entry) => enqueueImageUpload(entry.file)),
);
// Upload groups can overlap, but their files share the serialized upload
// queue and each group still commits in its original selection order.
const previousCommit = node[state.commit] || Promise.resolve();
const commit = previousCommit.then(async () => {
const results = await uploadResults;
for (let index = 0; index < results.length; index += 1) {
const result = results[index];
const entry = pendingEntries[index];
if (result.status === "fulfilled") {
state.items.push(result.value);
} else {
toast("error", `上传失败:${entry.file.name}`, result.reason?.message || String(result.reason));
}
const pendingIndex = state.pending.indexOf(entry.pending);
if (pendingIndex >= 0) state.pending.splice(pendingIndex, 1);
}
setWidgetValue(node, state.widget, JSON.stringify(state.items));
renderReferences(node);
});
node[state.commit] = commit.catch(() => {});
await commit;
if (hasPendingReferenceUploads(node)) {
setStatus(node, "上传中…", "busy");
}
else if (pendingBatchIds(node).size) updateGeneratorActivity(node);
else setStatus(node, "", "");
}
function isExternalFileDrag(event) {
const transfer = event?.dataTransfer;
if (!transfer) return false;
if (Array.from(transfer.files || []).length) return true;
if (Array.from(transfer.items || []).some((item) => item?.kind === "file")) return true;
return Array.from(transfer.types || []).includes("Files");
}
function clearImageFileDropHighlight(node, target, role = "references") {
target?.classList?.remove("drag");
const state = referenceRole(node, role);
state.container?.classList?.remove("drag");
state.add?.classList?.remove("drag");
}
function bindImageFileDropTarget(node, target, role = "references") {
if (!target || target._o1igFileDropBound) return;
target._o1igFileDropBound = true;
for (const eventName of ["dragenter", "dragover"]) {
target.addEventListener(eventName, (event) => {
if (!isExternalFileDrag(event)) return;
event.preventDefault();
event.stopPropagation?.();
target.classList.add("drag");
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
});
}
target.addEventListener("dragleave", (event) => {
if (!isExternalFileDrag(event)) return;
if (event.relatedTarget && target.contains?.(event.relatedTarget)) return;
target.classList.remove("drag");
});
target.addEventListener("drop", (event) => {
if (!isExternalFileDrag(event)) return;
event.preventDefault();
event.stopPropagation?.();
clearImageFileDropHighlight(node, target, role);
void acceptFiles(node, event.dataTransfer?.files, role);
});
}
function imageMimeForFilename(filename) {
const extension = String(filename || "").split(".").pop()?.toLowerCase();
return {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
}[extension] || "image/png";
}
async function readCanvasImageFile(descriptor) {
const normalized = canvasImageDescriptor(descriptor);
const path = viewPath(normalized);
if (!normalized || !path) throw new Error("画布图片描述无效");
const response = await api.fetchApi(path, { method: "GET", cache: "no-store" });
if (!response.ok) throw new Error(`无法读取画布图片:${normalized.filename}`);
const blob = await response.blob();
const type = blob.type?.startsWith("image/")
? blob.type
: imageMimeForFilename(normalized.filename);
return new File([blob], normalized.filename, { type });
}
async function importCanvasImage(node, descriptor, role = "references") {
const file = await readCanvasImageFile(descriptor);
await acceptFiles(node, [file], role);
}
function closeCanvasImagePicker() {
const picker = document.getElementById(CANVAS_IMAGE_PICKER_ID);
if (!picker) return;
document.removeEventListener("keydown", picker._o1igKeydown);
picker.remove();
}
function openCanvasImagePicker(node, role = "references", options = null) {
injectStyles();
closeCanvasImagePicker();
const customTarget = options && typeof options.onSelect === "function";
const state = customTarget
? { label: String(options.label || "图片") }
: referenceRole(node, role);
const limit = customTarget ? Math.max(1, Number(options.limit) || 1) : referenceLimit(node, role);
const remaining = customTarget
? Math.max(0, Number(options.remaining ?? limit) || 0)
: limit - state.items.length - state.pending.length;
if (remaining <= 0) {
toast("warn", `${state.label}已满`, `最多 ${limit} 张`);
return null;
}
const candidates = canvasImageCandidates(node);
const overlay = document.createElement("div");
overlay.id = CANVAS_IMAGE_PICKER_ID;
overlay.className = "o1ig-canvas-picker";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "从画布取图");
const dialog = document.createElement("div");
dialog.className = "o1ig-canvas-picker-dialog";
const header = document.createElement("div");
header.className = "o1ig-canvas-picker-header";
const title = document.createElement("strong");
title.textContent = `从画布添加${state.label}`;
const hint = document.createElement("span");
hint.textContent = "选择已执行节点的图片结果";
const close = document.createElement("button");
close.type = "button";
close.className = "o1ig-canvas-picker-close";
close.textContent = "×";
close.title = "关闭";
close.setAttribute("aria-label", "关闭");
close.addEventListener("click", closeCanvasImagePicker);
header.append(title, hint, close);
const grid = document.createElement("div");
grid.className = "o1ig-canvas-picker-grid";
if (!candidates.length) {
const empty = document.createElement("div");
empty.className = "o1ig-canvas-picker-empty";
empty.textContent = "画布中暂无可用图片,请先执行裁剪、预览或保存节点。";
grid.append(empty);
} else {
for (const candidate of candidates) {
const card = document.createElement("button");
card.type = "button";
card.className = "o1ig-canvas-picker-card";
card.title = `${candidate.sourceLabel} · ${candidate.descriptor.filename}`;
const image = document.createElement("img");
image.src = viewUrl(candidate.descriptor);
image.alt = candidate.descriptor.filename;
image.loading = "lazy";
const source = document.createElement("strong");
source.textContent = candidate.sourceLabel;
const filename = document.createElement("span");
filename.textContent = candidate.descriptor.filename;
card.append(image, source, filename);
card.addEventListener("click", async () => {
card.disabled = true;
try {
if (customTarget) await options.onSelect(candidate.descriptor);
else await importCanvasImage(node, candidate.descriptor, role);
closeCanvasImagePicker();
toast("success", `${state.label}已添加`, candidate.descriptor.filename);
} catch (error) {
card.disabled = false;
toast("error", "画布取图失败", error?.message || String(error));
}
});
grid.append(card);
}
}
dialog.append(header, grid);
overlay.append(dialog);
overlay.addEventListener("mousedown", (event) => {
if (event.target === overlay) closeCanvasImagePicker();
});
overlay._o1igKeydown = (event) => {
if (event.key === "Escape") closeCanvasImagePicker();
};
document.addEventListener("keydown", overlay._o1igKeydown);
document.body.append(overlay);
close.focus();
return overlay;
}
// Other o1key media panels reuse the exact same candidate discovery, dialog,
// and /view reader through this namespaced frontend capability.
window.o1keyCanvasImagePicker = Object.freeze({
open(node, options) {
return openCanvasImagePicker(node, "references", options);
},
readFile: readCanvasImageFile,
});
function getGraphLink(graph, linkId) {
return graph?.links?.get?.(linkId) ?? graph?.links?.[linkId] ?? null;
}
function findConnectedSaveNodesAtOutput(node, outputIndex = 0) {
const graph = node.graph || app.graph;
const results = [];
const output = generatorOutputs(node)[outputIndex];
for (const linkId of output?.links || []) {
const link = getGraphLink(graph, linkId);
const target = link ? graph.getNodeById?.(link.target_id) : null;
if (target?.type === SAVE_NODE_TYPE || target?.comfyClass === SAVE_NODE_TYPE) results.push(target);
}
return results;
}
function findConnectedSaveNode(node) {
return findConnectedSaveNodesAtOutput(node, 0)[0] || null;
}
function findConnectedSaveNodes(node) {
return findConnectedSaveNodesAtOutput(node, 0);
}
function findAllConnectedSaveNodes(node) {
const unique = new Map();
generatorOutputs(node).forEach((_output, outputIndex) => {
for (const saveNode of findConnectedSaveNodesAtOutput(node, outputIndex)) {
unique.set(String(saveNode.id), saveNode);
}
});
return [...unique.values()];
}
function isBlankSaveNode(node) {
if (
!node
|| node._o1igsBusy
|| savingSaveBatchIds(node).size
|| node._o1igsStandardQueueReserved
|| node._o1igsStandardExecutionPending
) return false;
if (Array.isArray(node.imgs) && node.imgs.length) return false;
if (normalizeSaveResults(node._o1igsLastResults).length) return false;
if (storedSaveResults(node).length) return false;
return !normalizeSaveResults(app.nodeOutputs?.[String(node.id)]?.images).length;
}
function findBlankConnectedSaveNode(node) {
return findConnectedSaveNodes(node).find(isBlankSaveNode) || null;
}
function bindSaveNodeToBatch(source, saveNode, batchId) {
saveNode.properties ??= {};
saveNode.properties.o1keyBatchId = batchId;
saveNode.properties.o1keyGeneratorNodeId = source.id;
delete saveNode.properties.o1keyBatchTerminal;
return saveNode;
}
function findLegacyAutoSaveNodes(node) {
const graph = node.graph || app.graph;
const legacyNodes = [];
for (const linkId of [...(node.outputs?.[0]?.links || [])]) {
const link = getGraphLink(graph, linkId);
const target = link ? graph.getNodeById?.(link.target_id) : null;
const isNativeSave = target?.type === "SaveImage" || target?.comfyClass === "SaveImage";
const prefix = findWidget(target, "filename_prefix")?.value;
const isLegacyO1keyNode = target?.properties?.o1keyAutoCreated === true || prefix === "o1key";
if (isNativeSave && isLegacyO1keyNode) legacyNodes.push(target);
}
return legacyNodes;
}
function removeLegacyAutoSaveNodes(node) {
const graph = node.graph || app.graph;
if (!graph?.remove) return;
const legacyNodes = findLegacyAutoSaveNodes(node);
legacyNodes.forEach((legacyNode) => graph.remove(legacyNode));
}
function ensureSaveNode(node) {
const existing = findConnectedSaveNode(node);
const graph = node.graph || app.graph;
const LiteGraph = window.LiteGraph;
if (!graph || !LiteGraph?.createNode) throw new Error("无法访问 ComfyUI 画布");
const legacyNodes = findLegacyAutoSaveNodes(node);
if (existing) {
if (legacyNodes.length) {
graph.beforeChange?.();
try {
removeLegacyAutoSaveNodes(node);
} finally {
graph.afterChange?.();
}
}
return existing;
}
const saveNode = LiteGraph.createNode(SAVE_NODE_TYPE);
if (!saveNode) throw new Error("无法创建 o1key 保存图像节点,请重启 ComfyUI");
const legacyPosition = legacyNodes[0]?.pos;
graph.beforeChange?.();
try {
removeLegacyAutoSaveNodes(node);
saveNode.pos = legacyPosition
? [legacyPosition[0], legacyPosition[1]]
: [node.pos[0] + node.size[0] + 70, node.pos[1] + 30];
saveNode.properties ??= {};
saveNode.properties.o1keyAutoCreated = true;
graph.add(saveNode);
const prefix = saveNode.widgets?.find((widget) => widget.name === "filename_prefix");
if (prefix) prefix.value = "o1key";
node.connect(0, saveNode, 0);
graph.setDirtyCanvas?.(true, true);
return saveNode;
} finally {
graph.afterChange?.();
}
}
function createSaveNodeForBatch(node, batchId = createBatchId(), outputIndex = 0) {
const graph = node.graph || app.graph;
const LiteGraph = window.LiteGraph;
if (!graph || !LiteGraph?.createNode) throw new Error("无法访问 ComfyUI 画布");
const saveNode = LiteGraph.createNode(SAVE_NODE_TYPE);
if (!saveNode) throw new Error("无法创建 o1key 保存图像节点,请重启 ComfyUI");
const connectedSaveNodes = findAllConnectedSaveNodes(node);
const batchIndex = connectedSaveNodes.reduce((maximum, connectedNode, index) => {
const savedIndex = Number(connectedNode.properties?.o1keyBatchIndex) || index + 1;
return Math.max(maximum, savedIndex);
}, 0);
const column = batchIndex % 3;
const row = Math.floor(batchIndex / 3);
graph.beforeChange?.();
try {
saveNode.pos = [
node.pos[0] + node.size[0] + 70 + column * (SAVE_NODE_LAYOUT_SIZE[0] + 40),
node.pos[1] + 30 + row * (SAVE_NODE_LAYOUT_SIZE[1] + 50),
];
saveNode.properties ??= {};
saveNode.properties.o1keyAutoCreated = true;
saveNode.properties.o1keyBatchIndex = batchIndex + 1;
if (batchId) bindSaveNodeToBatch(node, saveNode, batchId);
else saveNode.properties.o1keyGeneratorNodeId = node.id;
graph.add(saveNode);
node.connect(outputIndex, saveNode, 0);
graph.setDirtyCanvas?.(true, true);
return saveNode;
} finally {
graph.afterChange?.();
}
}
function acquireSaveNodeForBatch(node, batchId) {
const blank = findBlankConnectedSaveNode(node);
if (blank) return bindSaveNodeToBatch(node, blank, batchId);
return createSaveNodeForBatch(node, batchId);
}
function findLayerSaveNode(node, imageSaveNode) {
const imageSaveNodeId = String(imageSaveNode?.id ?? "");
const candidates = findConnectedSaveNodesAtOutput(node, 1);
return candidates.find((candidate) => (
String(candidate.properties?.o1keyImageSaveNodeId ?? "") === imageSaveNodeId
)) || null;
}
function acquireLayerSaveNodeForBatch(node, imageSaveNode) {
let layerSaveNode = findLayerSaveNode(node, imageSaveNode);
if (!layerSaveNode) {
layerSaveNode = findConnectedSaveNodesAtOutput(node, 1).find((candidate) => (
isBlankSaveNode(candidate)
&& candidate.properties?.o1keySaveRole === "layers"
&& candidate.properties?.o1keyImageSaveNodeId == null
)) || createSaveNodeForBatch(node, "", 1);
}
layerSaveNode.properties ??= {};
layerSaveNode.properties.o1keySaveRole = "layers";
layerSaveNode.properties.o1keyImageSaveNodeId = imageSaveNode.id;
layerSaveNode.properties.o1keyGeneratorNodeId = node.id;
delete layerSaveNode.properties.o1keyBatchId;
layerSaveNode.title = "o1key 保存图层";
return layerSaveNode;
}
function isLayerDecompositionEnabled(node) {
return node?._o1igModel?.value === "Seedream 5.0 Pro"
&& node?._o1igLayerDecomposition?.value === "开启";
}
function splitLayerSaveResults(images) {
const normalized = normalizeSaveResults(images);
return {
imageResults: normalized.slice(0, 1),
layerResults: normalized.slice(1),
};
}
function findConnectedGenerator(saveNode) {
const graph = saveNode?.graph || app.graph;
const link = getGraphLink(graph, saveNode?.inputs?.[0]?.link);
const source = link ? graph?.getNodeById?.(link.origin_id) : null;
return source?.type === NODE_TYPE || source?.comfyClass === NODE_TYPE ? source : null;
}
function chooseStandardSaveNode(generator) {
return findBlankConnectedSaveNode(generator) || createSaveNodeForBatch(generator, "");
}
function reserveStandardExecution(generator, saveNode) {
generator._o1igsQueuedStandardSaveNodeIds ??= [];
if (!generator._o1igsQueuedStandardSaveNodeIds.includes(saveNode.id)) {
generator._o1igsQueuedStandardSaveNodeIds.push(saveNode.id);
}
saveNode._o1igsStandardQueueReserved = true;
saveNode._o1igsSourceGeneratorId = generator.id;
}
function startNextStandardExecution(generator) {
const queued = generator._o1igsQueuedStandardSaveNodeIds || [];
while (queued.length) {
const saveNodeId = queued.shift();
const saveNode = (generator.graph || app.graph)?.getNodeById?.(saveNodeId);
if (!saveNode) continue;
delete saveNode._o1igsStandardQueueReserved;
saveNode._o1igsStandardExecutionPending = true;
generator._o1igsActiveStandardSaveNodeId = saveNode.id;
setSaveBusy(saveNode, true, "running", 0);
return saveNode;
}
return null;
}
function activeStandardSaveNode(generator) {
const graph = generator?.graph || app.graph;
const activeId = generator?._o1igsActiveStandardSaveNodeId;
if (activeId != null) return graph?.getNodeById?.(activeId) || null;
const queuedId = generator?._o1igsQueuedStandardSaveNodeIds?.[0];
return queuedId != null ? graph?.getNodeById?.(queuedId) || null : null;
}
function clearStandardExecution(saveNode) {
const source = findConnectedGenerator(saveNode)
|| saveNode?.graph?.getNodeById?.(saveNode?._o1igsSourceGeneratorId);
delete saveNode._o1igsStandardQueueReserved;
delete saveNode._o1igsStandardExecutionPending;
if (!source) return null;
if (String(source._o1igsActiveStandardSaveNodeId) === String(saveNode.id)) {
delete source._o1igsActiveStandardSaveNodeId;
}
source._o1igsQueuedStandardSaveNodeIds = (
source._o1igsQueuedStandardSaveNodeIds || []
).filter((nodeId) => String(nodeId) !== String(saveNode.id));
return source;
}
function prepareStandardQueue(generator, { isPartialExecution = false } = {}) {
let saveNode = null;
if (isPartialExecution) {
const requestedId = generator._o1igsRequestedStandardSaveNodeId;
if (requestedId == null) return null;
saveNode = (generator.graph || app.graph)?.getNodeById?.(requestedId) || null;
if (!findConnectedSaveNodes(generator).includes(saveNode)) saveNode = null;
}
saveNode ||= chooseStandardSaveNode(generator);
const layerDecomposition = isLayerDecompositionEnabled(generator);
const layerSaveNode = layerDecomposition
? acquireLayerSaveNodeForBatch(generator, saveNode)
: null;
reserveStandardExecution(generator, saveNode);
generator._o1igsStandardQueueSaveNodeId = saveNode.id;
generator._o1igsStandardQueueLayerSaveNodeId = layerSaveNode?.id;
return saveNode;
}
function finishStandardQueue(generator) {
delete generator._o1igsRequestedStandardSaveNodeId;
delete generator._o1igsStandardQueueSaveNodeId;
delete generator._o1igsStandardQueueLayerSaveNodeId;
}
function graphNodes(graph) {
if (Array.isArray(graph?._nodes)) return graph._nodes;
const values = graph?.nodes?.values?.();
return values ? Array.from(values) : [];
}
function takeStandardQueueRoutes() {
const routes = [];
for (const generator of graphNodes(app.graph)) {
if (generator?.type !== NODE_TYPE && generator?.comfyClass !== NODE_TYPE) continue;
const saveNodeId = generator._o1igsStandardQueueSaveNodeId;
if (saveNodeId == null) continue;
routes.push({
generator,
saveNodeId,
layerSaveNodeId: generator._o1igsStandardQueueLayerSaveNodeId,
});
delete generator._o1igsStandardQueueSaveNodeId;
delete generator._o1igsStandardQueueLayerSaveNodeId;
}
return routes;
}
function filterStandardQueueOutputs(promptResult, routes) {
const output = promptResult?.output;
if (!output || !routes.length) return promptResult;
const removed = new Set();
for (const { generator, saveNodeId, layerSaveNodeId } of routes) {
for (const saveNode of findConnectedSaveNodes(generator)) {
if (String(saveNode.id) !== String(saveNodeId)) removed.add(String(saveNode.id));
}
for (const saveNode of findConnectedSaveNodesAtOutput(generator, 1)) {
if (String(saveNode.id) !== String(layerSaveNodeId)) removed.add(String(saveNode.id));
}
}
let changed = true;
while (changed) {
changed = false;
for (const [nodeId, promptNode] of Object.entries(output)) {
if (removed.has(String(nodeId))) continue;
const dependsOnRemoved = Object.values(promptNode?.inputs || {}).some((value) => (
Array.isArray(value)
&& value.length === 2
&& removed.has(String(value[0]))
));
if (dependsOnRemoved) {
removed.add(String(nodeId));
changed = true;
}
}
}
for (const nodeId of removed) delete output[nodeId];
return promptResult;
}
function findUpstreamSaveNodes(node) {
const graph = node?.graph || app.graph;
const pending = [node];
const visited = new Set();
const results = [];
while (pending.length) {
const current = pending.pop();
if (!current || visited.has(current.id)) continue;
visited.add(current.id);
if (current.type === SAVE_NODE_TYPE || current.comfyClass === SAVE_NODE_TYPE) {
results.push(current);
continue;
}
for (const input of current.inputs || []) {
const link = getGraphLink(graph, input?.link);
const source = link ? graph?.getNodeById?.(link.origin_id) : null;
if (source) pending.push(source);
}
}
return results;
}
function reroutePartialExecutionTargets(queueNodeIds) {
if (!Array.isArray(queueNodeIds) || !queueNodeIds.length) return queueNodeIds;
const routed = [];
for (const rawId of queueNodeIds) {
const node = app.graph?.getNodeById?.(rawId)
|| app.graph?.getNodeById?.(Number(rawId));
if (node?.type !== SAVE_NODE_TYPE && node?.comfyClass !== SAVE_NODE_TYPE) {
for (const upstreamSave of findUpstreamSaveNodes(node)) {
const generator = findConnectedGenerator(upstreamSave);
if (generator) generator._o1igsRequestedStandardSaveNodeId = upstreamSave.id;
}
routed.push(rawId);
continue;
}
const generator = findConnectedGenerator(node);
if (!generator) {
routed.push(rawId);
continue;
}
const saveNode = chooseStandardSaveNode(generator);
generator._o1igsRequestedStandardSaveNodeId = saveNode.id;
routed.push(saveNode.id);
}
return [...new Set(routed)];
}
function installStandardQueueRouting() {
if (app.__o1keyImageQueueRoutingInstalled) return;
if (typeof app.queuePrompt !== "function" || typeof app.graphToPrompt !== "function") return;
app.__o1keyImageQueueRoutingInstalled = true;
const originalQueuePrompt = app.queuePrompt;
app.queuePrompt = function (number, batchCount = 1, queueNodeIds) {
return originalQueuePrompt.call(
this,
number,
batchCount,
reroutePartialExecutionTargets(queueNodeIds),
);
};
const originalGraphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const routes = takeStandardQueueRoutes();
const result = await originalGraphToPrompt.apply(this, arguments);
return filterStandardQueueOutputs(result, routes);
};
}
function installStandardQueueWidget(node) {
const widget = findWidget(node, "prompt") || node.widgets?.[0];
if (!widget || widget.__o1keyStandardQueueInstalled) return;
widget.__o1keyStandardQueueInstalled = true;
const originalBeforeQueued = widget.beforeQueued;
widget.beforeQueued = function (options = {}) {
const result = originalBeforeQueued?.apply(this, arguments);
prepareStandardQueue(node, options);
return result;
};
const originalAfterQueued = widget.afterQueued;
widget.afterQueued = function () {
const result = originalAfterQueued?.apply(this, arguments);
finishStandardQueue(node);
return result;
};
}
function clearNativePreview(node) {
node.imgs = null;
node.imageIndex = null;
if ("previewMediaType" in node) node.previewMediaType = null;
}
function mediaIdentity(url) {
try {
const parsed = new URL(String(url || ""), document.baseURI || "http://localhost/");
return ["filename", "subfolder", "type"]
.map((key) => parsed.searchParams.get(key) || "")
.join("\n");
} catch {
return "";
}
}
function getNativePreviewImageIndex(node, preview) {
const fallback = Math.max(0, Number(node.imageIndex) || 0);
const mainImage = preview?.querySelector?.('[data-testid="main-image"]');
const currentIdentity = mediaIdentity(mainImage?.currentSrc || mainImage?.src);
if (!currentIdentity) return fallback;
const index = (node._o1igsLastResults || [])
.findIndex((item) => mediaIdentity(viewUrl(item)) === currentIdentity);
return index >= 0 ? index : fallback;
}
function syncNativePreviewAction(node) {
const nodeId = Number(node?.id);
if (!Number.isFinite(nodeId)) return;
const preview = document.querySelector?.(`[data-node-id="${nodeId}"] .image-preview`);
if (!preview) return;
for (const button of preview.querySelectorAll?.("button") || []) {
if (button.dataset?.o1keyRegenerate === String(nodeId)) {
button.disabled = Boolean(node._o1igsBusy);
continue;
}
const icon = button.querySelector?.('i[class*="lucide--download"]');
if (!icon) continue;
button.dataset.o1keyRegenerate = String(nodeId);
const label = "重新生成";
button.title = label;
button.setAttribute("aria-label", label);
icon.className = String(icon.className).replace("lucide--download", "lucide--refresh-cw");
button.disabled = Boolean(node._o1igsBusy);
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
regenerateFromSaveNode(node, getNativePreviewImageIndex(node, preview));
}, true);
}
}
function syncSaveProgressOverlay(node) {
const nodeId = Number(node?.id);
if (!Number.isFinite(nodeId)) return;
const container = document.querySelector?.(`[data-node-id="${nodeId}"]`);
container?.classList?.add?.("o1key-image-save-node");
const body = container?.querySelector?.(`[data-testid="node-body-${nodeId}"]`);
if (!body) return;
body.classList.add("o1igs-progress-host");
let track = body.querySelector?.(`[data-o1key-save-progress="${nodeId}"]`);
if (!track) {
track = document.createElement("div");
track.className = "o1igs-progress-track";
track.dataset.o1keySaveProgress = String(nodeId);
track.setAttribute("aria-hidden", "true");
body.append(track);
}
node._o1igsProgressTrack = track;
track.classList.toggle("busy", Boolean(node._o1igsBusy));
const percent = Math.max(0, Math.min(100, Math.round(Number(node._o1igsProgress) * 100 || 0)));
track.style.setProperty("--o1igs-progress", `${node._o1igsBusy ? percent : 0}%`);
}
let nativePreviewSyncScheduled = false;
function syncAllNativePreviewActions() {
for (const node of app.graph?._nodes || []) {
if (node?.comfyClass === SAVE_NODE_TYPE || node?.type === SAVE_NODE_TYPE) {
syncNativePreviewAction(node);
syncSaveProgressOverlay(node);
}
}
syncNativeQueueCounts();
}
function syncNativeQueueCounts() {
if (typeof document.querySelectorAll !== "function") return;
for (const job of nativeQueueBridge.jobs.values()) {
const count = Math.max(0, Math.floor(Number(job.o1key_total_count) || 0));
if (!count) continue;
const selector = `[data-job-id="${job.id}"]`;
for (const row of document.querySelectorAll(selector)) {
const item = row.firstElementChild || row.children?.[0] || row;
let badge = item.querySelector?.(`[data-o1key-queue-count="${job.id}"]`);
if (!badge) {
badge = document.createElement("button");
badge.type = "button";
badge.className = [
"o1key-queue-count",
"relative", "z-1", "inline-flex", "shrink-0", "items-center", "justify-center",
"gap-2", "cursor-pointer", "touch-manipulation", "whitespace-nowrap", "appearance-none",
"border-none", "rounded-md", "text-sm", "font-medium", "font-inter",
"transition-colors", "hover:bg-secondary-background-hover",
"focus-visible:outline-none", "focus-visible:ring-1", "focus-visible:ring-ring",
"text-secondary-foreground", "bg-secondary-background",
"h-8", "rounded-lg", "p-2", "text-xs",
].join(" ");
badge.dataset.o1keyQueueCount = job.id;
const icon = document.createElement("i");
icon.className = "icon-[lucide--layers] size-4";
icon.setAttribute("aria-hidden", "true");
const value = document.createElement("span");
badge.append(icon, value);
badge.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
item.dispatchEvent?.(new MouseEvent("dblclick", { bubbles: true }));
});
item.append?.(badge);
}
badge.children?.[1] && (badge.children[1].textContent = String(count));
badge.title = `批次共 ${count} 张`;
badge.setAttribute?.("aria-label", badge.title);
}
}
}
function scheduleNativePreviewActionSync() {
if (nativePreviewSyncScheduled) return;
nativePreviewSyncScheduled = true;
requestAnimationFrame(() => {
nativePreviewSyncScheduled = false;
syncAllNativePreviewActions();
});
}
function installNativePreviewActionSync() {
document.__o1keyNativePreviewObserver?.disconnect?.();
if (!document.body || typeof MutationObserver === "undefined") return;
const observer = new MutationObserver(scheduleNativePreviewActionSync);
observer.observe(document.body, { childList: true, subtree: true });
document.__o1keyNativePreviewObserver = observer;
scheduleNativePreviewActionSync();
}
function normalizeLayerMetadata(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const zIndex = Math.max(0, Math.min(16, Math.floor(Number(value.z_index) || 0)));
const normalized = { z_index: zIndex };
for (const [key, limit] of [["name", 200], ["description", 1000], ["size", 64], ["output_format", 16]]) {
const text = typeof value[key] === "string" ? value[key].trim().slice(0, limit) : "";
if (text) normalized[key] = text;
}
const box = value.bounding_box;
if (box && typeof box === "object" && !Array.isArray(box)) {
const safeBox = {};
for (const key of ["absolute", "normalized"]) {
if (Array.isArray(box[key]) && box[key].length === 4) {
const values = box[key].map((item) => Math.floor(Number(item)));
if (values.every(Number.isFinite)) safeBox[key] = values;
}
}
if (Object.keys(safeBox).length) normalized.bounding_box = safeBox;
}
return normalized;
}
function normalizeSaveResults(images) {
if (!Array.isArray(images)) return [];
return images.map((item) => {
const filename = String(item?.filename || item?.name || "");
if (!filename) return null;
const normalized = {
filename,
subfolder: String(item?.subfolder || ""),
type: String(item?.type || "output"),
};
if (item?.external_saved === true) normalized.external_saved = true;
if (item?.batch_id) normalized.batch_id = String(item.batch_id);
const requestIndex = Math.max(0, Math.floor(Number(item?.request_index) || 0));
const resultIndex = Math.max(0, Math.floor(Number(item?.result_index) || 0));
if (requestIndex) normalized.request_index = requestIndex;
if (resultIndex) normalized.result_index = resultIndex;
const layer = normalizeLayerMetadata(item?.layer);
if (layer) normalized.layer = layer;
return normalized;
}).filter((item) => item && viewUrl(item));
}
function storedSaveResults(node, data = null) {
const candidates = [
data?.o1key_image_save_results,
data?.properties?.o1keyImageSaveResults,
node?.properties?.o1keyImageSaveResults,
];
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.length) return normalizeSaveResults(candidate);
}
return [];
}
function persistSaveResults(node, images) {
const previous = normalizeSaveResults(node?.properties?.o1keyImageSaveResults);
if (JSON.stringify(previous) === JSON.stringify(images)) return;
const graph = node.graph || app.graph;
graph?.beforeChange?.();
try {
node.properties ??= {};
node.properties.o1keyImageSaveResults = images;
} finally {
graph?.afterChange?.();
}
}
function expandPanelPromptTasks(prompt, imageCount) {
const lines = String(prompt || "").split(/\r?\n/);
const hasSeparator = lines.some((line) => line.trim() === "---");
const prompts = [];
if (!hasSeparator) {
if (String(prompt || "").trim()) prompts.push(String(prompt).trim());
} else {
let current = [];
for (const line of lines) {
if (line.trim() === "---") {
const value = current.join("\n").trim();
if (value) prompts.push(value);
current = [];
} else {
current.push(line);
}
}
const value = current.join("\n").trim();
if (value) prompts.push(value);
}
const count = Math.max(1, Math.floor(Number(imageCount) || 1));
return prompts.flatMap((value) => Array.from({ length: count }, () => value));
}
function expandPanelGenerationTasks({
prompt,
imageCount = 1,
batchEnabled = false,
batchMode = BATCH_MODE_GROUP_TO_MODELS,
references = [],
modelReferences = [],
} = {}) {
const prompts = expandPanelPromptTasks(prompt, 1);
const count = Math.max(1, Math.floor(Number(imageCount) || 1));
let pairings = [{ references: [...references], outfitIndex: null, modelIndex: null }];
if (batchEnabled) {
if (batchMode === BATCH_MODE_SINGLE_REFERENCES) {
pairings = references.map((reference, outfitIndex) => ({
references: [reference],
outfitIndex,
modelIndex: null,
}));
} else {
if (!references.length || !modelReferences.length) return [];
pairings = batchMode === BATCH_MODE_CARTESIAN
? references.flatMap((outfit, outfitIndex) => modelReferences.map((model, modelIndex) => ({
references: [outfit, model],
outfitIndex,
modelIndex,
})))
: modelReferences.map((model, modelIndex) => ({
references: [...references, model],
outfitIndex: null,
modelIndex,
}));
}
}
return prompts.flatMap((taskPrompt) => pairings.flatMap((pairing) => (
Array.from({ length: count }, () => ({ prompt: taskPrompt, ...pairing }))
)));
}
function panelGenerationTasks(node, prompt, imageCount) {
return expandPanelGenerationTasks({
prompt,
imageCount,
batchEnabled: Boolean(node?._o1igBatchEnabled),
batchMode: node?._o1igBatchMode?.value || BATCH_MODE_GROUP_TO_MODELS,
references: node?._o1igReferences || [],
modelReferences: node?._o1igModelReferences || [],
});
}
function updateBatchReferenceHint(node) {
const hint = node?._o1igReferenceHint;
if (!hint) return;
const enabled = Boolean(node._o1igBatchEnabled);
const referenceCount = node._o1igReferences?.length || 0;
const modelCount = node._o1igModelReferences?.length || 0;
if (node._o1igReferenceTitle) {
node._o1igReferenceTitle.textContent = enabled ? "素材图" : "参考图";
}
hint.textContent = !enabled
? `${referenceCount} 张 · 最多 ${MAX_REQUEST_REFERENCES} 张`
: node._o1igBatchMode?.value === BATCH_MODE_SINGLE_REFERENCES
? `${referenceCount} 张 · 每张独立生成`
: node._o1igBatchMode?.value === BATCH_MODE_CARTESIAN
? `${referenceCount} 张 · 匹配时每张独立`
: `${referenceCount} 张 · 整组参与`;
if (node?._o1igModel?.value === "Seedream 5.0 Pro") {
const layerDecomposition = node?._o1igLayerDecomposition?.value === "开启";
hint.title = layerDecomposition
? "Seedream 图层拆分:单图不超过 30MB,宽高比 1:1616:1,总像素 512×5126000×6000"
: "Seedream 参考图:单图不超过 30MB,宽和高均 >14px,宽高比 1:1616:1,总像素不超过 6000×6000";
} else {
hint.title = "";
}
if (node._o1igModelHint) {
node._o1igModelHint.textContent = `${modelCount} 张 · 每张目标图单独参与组合`;
}
}
function updateBatchSummary(node) {
const summary = node?._o1igBatchSummary;
if (!summary) return;
const enabled = Boolean(node._o1igBatchEnabled);
updateBatchReferenceHint(node);
if (!enabled) {
summary.classList.remove("visible");
summary.textContent = "";
return;
}
const outfits = node._o1igReferences?.length || 0;
const models = node._o1igModelReferences?.length || 0;
const perPair = Math.max(1, Math.floor(Number(node._o1igCount?.value) || 1));
const promptCount = Math.max(1, expandPanelPromptTasks(node._o1igPrompt?.value || "", 1).length);
const singleReferences = node._o1igBatchMode?.value === BATCH_MODE_SINGLE_REFERENCES;
const pairCount = singleReferences
? outfits
: node._o1igBatchMode?.value === BATCH_MODE_CARTESIAN
? outfits * models
: models;
if (singleReferences ? !outfits : (!outfits || !models)) {
summary.classList.remove("visible");
summary.textContent = "";
return;
}
summary.classList.add("visible");
const total = pairCount * perPair * promptCount;
const pairingText = singleReferences
? `${outfits} 张素材(每张独立)`
: node._o1igBatchMode?.value === BATCH_MODE_CARTESIAN
? `${outfits} 个素材 × ${models} 个目标 = ${pairCount} 个组合`
: `1 组素材 × ${models} 个目标 = ${pairCount} 个组合`;
const promptText = promptCount > 1 ? ` × ${promptCount} 条提示词` : "";
summary.textContent = `${pairingText} × 每组 ${perPair}${promptText},共 ${total} 张`;
}
function normalizeSaveSlots(slots) {
if (!Array.isArray(slots)) return [];
const allowedStates = new Set(["pending", "running", "success", "failed", "cancelled"]);
return slots.map((slot, offset) => {
const index = Math.max(1, Math.floor(Number(slot?.index) || offset + 1));
const image = normalizeSaveResults(slot?.image ? [slot.image] : [])[0] || null;
const state = allowedStates.has(slot?.state) ? slot.state : image ? "success" : "pending";
return {
index,
state: image ? "success" : state,
prompt: String(slot?.prompt || ""),
references: parseReferences(slot?.references || []),
error: String(slot?.error || ""),
image,
};
}).sort((left, right) => left.index - right.index);
}
function reconcileSaveSlotsWithResults(slots, images) {
const normalizedSlots = normalizeSaveSlots(slots);
const normalizedImages = normalizeSaveResults(images);
if (!normalizedSlots.length || !normalizedImages.length) return normalizedSlots;
const imageByIndex = new Map();
for (const [offset, image] of normalizedImages.entries()) {
const index = Math.max(1, Number(image.request_index) || offset + 1);
if (!imageByIndex.has(index)) imageByIndex.set(index, image);
}
return normalizedSlots.map((slot) => {
const image = imageByIndex.get(slot.index);
return image
? { ...slot, state: "success", image: { ...image, request_index: slot.index }, error: "" }
: slot;
});
}
function applyPartialSaveSlots(node, images, retrySlotIndex = 0) {
const normalizedImages = normalizeSaveResults(images);
if (!normalizedImages.length) return;
const slots = normalizeSaveSlots(node?._o1igsSlots);
if (retrySlotIndex) {
const image = normalizedImages[0];
node._o1igsSlots = slots.map((slot) => slot.index === retrySlotIndex
? {
...slot,
state: "success",
image: { ...image, request_index: retrySlotIndex },
error: "",
}
: slot);
} else {
node._o1igsSlots = reconcileSaveSlotsWithResults(slots, normalizedImages);
}
syncSaveSlotsWidget(node);
}
function storedSaveSlots(node, data = null) {
const candidates = [
data?.o1key_image_slots,
data?.properties?.o1keyImageSlots,
node?.properties?.o1keyImageSlots,
];
for (const candidate of candidates) {
if (Array.isArray(candidate) && candidate.length) return normalizeSaveSlots(candidate);
}
return [];
}
function persistSaveSlots(node) {
const slots = normalizeSaveSlots(node?._o1igsSlots);
const previous = normalizeSaveSlots(node?.properties?.o1keyImageSlots);
if (JSON.stringify(previous) === JSON.stringify(slots)) return;
const graph = node.graph || app.graph;
graph?.beforeChange?.();
try {
node.properties ??= {};
node.properties.o1keyImageSlots = slots;
} finally {
graph?.afterChange?.();
}
}
function saveSlotsVisible(node) {
const slots = normalizeSaveSlots(node?._o1igsSlots);
return Boolean(slots.length && (node?._o1igsBusy || slots.some((slot) => slot.state !== "success")));
}
function saveSlotsColumnCount(total) {
if (total <= 1) return 1;
if (total <= 4) return 2;
if (total <= 9) return 3;
return 4;
}
function saveSlotsWidgetHeight(node, availableWidth = 0) {
const total = normalizeSaveSlots(node?._o1igsSlots).length;
const columns = saveSlotsColumnCount(total);
const rows = Math.ceil(total / columns);
const measuredWidth = Number(node?._o1igsSlotsElement?.offsetWidth) || 0;
const fallbackWidth = Math.max(
220,
Number(availableWidth) || Number(node?.size?.[0]) || 300,
) - SAVE_SLOT_GRID_NODE_INSET;
const gridWidth = measuredWidth > 0 ? measuredWidth : fallbackWidth;
const contentWidth = Math.max(
columns * 54,
gridWidth - SAVE_SLOT_GRID_PADDING_BORDER,
);
const cell = Math.max(
54,
(contentWidth - (columns - 1) * SAVE_SLOT_GRID_GAP) / columns,
);
const contentHeight = rows * cell + Math.max(0, rows - 1) * SAVE_SLOT_GRID_GAP;
return Math.min(420, Math.ceil(contentHeight + SAVE_SLOT_GRID_PADDING_BORDER));
}
function ensureSaveSlotsWidget(node) {
if (node._o1igsSlotsWidget || typeof node.addDOMWidget !== "function") return;
const grid = document.createElement("div");
grid.className = "o1igs-slots";
const widget = node.addDOMWidget("o1key_image_slots", "div", grid, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => saveSlotsVisible(node) ? saveSlotsWidgetHeight(node) : 0,
});
widget.computeSize = (width) => saveSlotsVisible(node)
? [Math.max(0, Number(width) || Number(node?.size?.[0]) || 0), saveSlotsWidgetHeight(node, width)]
: [0, -4];
node._o1igsSlotsElement = grid;
node._o1igsSlotsWidget = widget;
}
function removeSaveSlotsWidget(node) {
const widget = node?._o1igsSlotsWidget;
const grid = node?._o1igsSlotsElement;
if (!widget && !grid) return false;
const widgets = Array.isArray(node?.widgets) ? node.widgets : null;
const widgetIndex = widget && widgets ? widgets.indexOf(widget) : -1;
let removedByNode = false;
if (widgetIndex >= 0 && typeof node.removeWidget === "function") {
try {
node.removeWidget(widget);
removedByNode = true;
} catch {
// Fall back to the array path for older LiteGraph builds.
}
}
if (widgetIndex >= 0 && !removedByNode) {
widget.onRemove?.();
widgets.splice(widgetIndex, 1);
} else if (widget && widgetIndex < 0) {
widget.onRemove?.();
}
grid?.remove?.();
node._o1igsSlotsWidget = null;
node._o1igsSlotsElement = null;
node.setDirtyCanvas?.(true, true);
return true;
}
function syncSaveSlotsWidget(node) {
const slots = normalizeSaveSlots(node?._o1igsSlots);
node._o1igsSlots = slots;
const visible = saveSlotsVisible(node);
const container = Number.isFinite(Number(node?.id))
? document.querySelector?.(`[data-node-id="${Number(node.id)}"]`)
: null;
container?.classList?.toggle?.("o1key-slots-visible", visible);
if (!visible) {
removeSaveSlotsWidget(node);
return;
}
ensureSaveSlotsWidget(node);
const grid = node._o1igsSlotsElement;
if (!grid) return;
grid.style.display = "grid";
grid.style.gridTemplateColumns = `repeat(${saveSlotsColumnCount(slots.length)},minmax(0,1fr))`;
const widgetHeight = saveSlotsWidgetHeight(node);
grid.style.maxHeight = `${widgetHeight}px`;
grid.replaceChildren();
for (const slot of slots) {
const card = document.createElement("div");
card.className = `o1igs-slot ${slot.state}`;
card.dataset.o1keySlotIndex = String(slot.index);
const index = document.createElement("span");
index.className = "o1igs-slot-index";
index.textContent = String(slot.index);
card.append(index);
if (slot.state === "success" && slot.image) {
const image = document.createElement("img");
image.src = viewUrl(slot.image);
image.alt = `第 ${slot.index} 张`;
card.append(image);
} else if (slot.state === "failed") {
const retry = document.createElement("button");
retry.type = "button";
retry.className = "o1igs-slot-retry";
retry.disabled = false;
retry.title = slot.error || `重新生成第 ${slot.index} 张`;
const icon = document.createElement("b");
icon.textContent = "↻";
const label = document.createElement("span");
label.textContent = `失败 · 点击重试`;
retry.append(icon, label);
retry.addEventListener("click", () => retryFailedSaveSlot(node, slot.index));
card.append(retry);
} else {
const label = document.createElement("span");
label.textContent = slot.state === "cancelled"
? "已取消"
: slot.state === "running" ? "生成中" : "等待中";
card.append(label);
}
grid.append(card);
}
const desiredHeight = widgetHeight + 140;
if (Array.isArray(node.size) && Number(node.size[1]) < desiredHeight) {
node.setSize?.([node.size[0], desiredHeight]);
}
node.setDirtyCanvas?.(true, true);
}
function initializeSaveSlots(node, tasks) {
clearTimeout(node?._o1igsPreviewFitTimer);
node._o1igsSlots = (tasks || []).map((task, offset) => {
const structured = task && typeof task === "object";
return {
index: offset + 1,
state: "pending",
prompt: String(structured ? task.prompt || "" : task || ""),
references: structured ? parseReferences(task.references || []) : [],
error: "",
image: null,
};
});
persistSaveSlots(node);
syncSaveSlotsWidget(node);
}
function updateSaveSlotsForState(node, detail, retrySlotIndex = 0) {
const state = String(detail?.state || "");
const targetState = state === "cancelled" ? "cancelled" : state === "failed" ? "failed" : state;
const error = String(detail?.error || "");
node._o1igsSlots = normalizeSaveSlots(node._o1igsSlots).map((slot) => {
if (retrySlotIndex && slot.index !== retrySlotIndex) return slot;
if (slot.state === "success") return slot;
return {
...slot,
state: targetState === "queued" ? "pending" : targetState,
error: targetState === "failed" ? error : slot.error,
};
});
if (state === "failed" || state === "cancelled") persistSaveSlots(node);
syncSaveSlotsWidget(node);
}
function applyCompletedSaveSlots(
node,
detail,
savedImages,
retrySlotIndex = 0,
layerDecomposition = false,
) {
const normalizedImages = normalizeSaveResults(savedImages);
let slots = normalizeSaveSlots(node._o1igsSlots);
const multiResultRequest = layerDecomposition
&& Number(detail?.result_count || normalizedImages.length)
> Number(detail?.request_count || detail?.total_count || slots.length || 1);
if (multiResultRequest && normalizedImages.length) {
if (slots.length) {
slots[0] = { ...slots[0], state: "success", image: normalizedImages[0], error: "" };
node._o1igsSlots = slots;
persistSaveSlots(node);
syncSaveSlotsWidget(node);
}
return normalizedImages;
}
if (!slots.length) {
const total = Math.max(
1,
Math.floor(Number(detail?.total_count) || 0),
retrySlotIndex,
...normalizedImages.map((image, offset) => Number(image.request_index) || offset + 1),
);
slots = Array.from({ length: total }, (_, offset) => ({
index: offset + 1,
state: "pending",
prompt: "",
error: "",
image: null,
}));
}
const retryImage = retrySlotIndex ? normalizedImages[0] || null : null;
const imageByIndex = new Map();
for (const image of normalizedImages) {
const index = Math.max(1, Number(image.request_index) || retrySlotIndex || imageByIndex.size + 1);
if (!imageByIndex.has(index)) imageByIndex.set(index, image);
}
const failedByIndex = new Map((detail?.failed_items || []).map((item) => [
Math.max(1, Math.floor(Number(item?.request_index) || 1)),
String(item?.error || "图片生成失败"),
]));
node._o1igsSlots = slots.map((slot) => {
if (retrySlotIndex && slot.index !== retrySlotIndex) return slot;
const image = retryImage || imageByIndex.get(slot.index);
if (image) return {
...slot,
state: "success",
image: { ...image, request_index: slot.index },
error: "",
};
const failure = failedByIndex.get(slot.index) || "本次没有返回图片";
return { ...slot, state: "failed", image: null, error: failure };
});
persistSaveSlots(node);
syncSaveSlotsWidget(node);
const completedSlotImages = normalizeSaveSlots(node._o1igsSlots)
.filter((slot) => slot.state === "success" && slot.image)
.map((slot) => slot.image);
return retrySlotIndex ? completedSlotImages : normalizedImages;
}
function renderSaveResults(node, images, { persist = true } = {}) {
node._o1igsLastResults = normalizeSaveResults(images);
if (persist) persistSaveResults(node, node._o1igsLastResults);
syncSaveSlotsWidget(node);
scheduleNativePreviewActionSync();
}
function nativeSavePreviewWidget(node) {
return (node?.widgets || []).find((widget) => (
widget?.constructor?.name === "ImagePreviewWidget"
|| (widget?.type === "custom" && widget?.serialize === false && Number(widget?.computedHeight) > 0)
));
}
function preferredNativeSavePreviewHeight(node, images = node?.imgs || []) {
const sourceImages = Array.isArray(images) ? images : [];
const loaded = sourceImages.filter((image) => (
Number(image?.naturalWidth || image?.width) > 0
&& Number(image?.naturalHeight || image?.height) > 0
));
if (!sourceImages.length || loaded.length !== sourceImages.length) return 0;
const width = Math.max(1, Number(node?.size?.[0]) || SAVE_NODE_LAYOUT_SIZE[0]);
const selectedIndex = Number.isInteger(node?.imageIndex)
&& node.imageIndex >= 0
&& node.imageIndex < loaded.length
? node.imageIndex
: null;
let imageHeight = 0;
if (selectedIndex != null || loaded.length === 1) {
const image = loaded[selectedIndex ?? 0];
imageHeight = width * Number(image.naturalHeight || image.height)
/ Number(image.naturalWidth || image.width);
} else {
const columns = saveSlotsColumnCount(loaded.length);
const cellWidth = width / columns;
for (let offset = 0; offset < loaded.length; offset += columns) {
const row = loaded.slice(offset, offset + columns);
const tallestRatio = Math.max(...row.map((image) => (
Number(image.naturalHeight || image.height)
/ Number(image.naturalWidth || image.width)
)));
imageHeight += cellWidth * tallestRatio;
}
}
return Math.max(
SAVE_PREVIEW_MIN_HEIGHT,
Math.min(SAVE_PREVIEW_MAX_HEIGHT, Math.ceil(imageHeight + SAVE_PREVIEW_SIZE_LABEL_HEIGHT)),
);
}
function fitSaveNodeToNativeImages(node) {
if (!node || saveSlotsVisible(node)) return false;
const preview = nativeSavePreviewWidget(node);
const currentPreviewHeight = Number(preview?.computedHeight) || 0;
const preferredPreviewHeight = preferredNativeSavePreviewHeight(node);
const currentNodeHeight = Number(node?.size?.[1]) || 0;
if (!currentPreviewHeight || !preferredPreviewHeight || !currentNodeHeight) return false;
const fixedHeight = Math.max(0, currentNodeHeight - currentPreviewHeight);
const targetHeight = Math.ceil(fixedHeight + preferredPreviewHeight);
if (Math.abs(targetHeight - currentNodeHeight) > 1) {
node.setSize?.([node.size[0], targetHeight]);
node.setDirtyCanvas?.(true, true);
}
return true;
}
function scheduleSaveNodeImageFit(node, attempt = 0) {
clearTimeout(node?._o1igsPreviewFitTimer);
if (!node || saveSlotsVisible(node)) return;
const run = () => {
if (fitSaveNodeToNativeImages(node) || attempt >= 20 || saveSlotsVisible(node)) return;
node._o1igsPreviewFitTimer = setTimeout(() => scheduleSaveNodeImageFit(node, attempt + 1), 50);
};
requestAnimationFrame(run);
}
function restoreNativeSavePreview(node) {
const images = normalizeSaveResults(node?._o1igsLastResults);
if (!images.length) return;
const signature = JSON.stringify(images);
requestAnimationFrame(() => {
if (node._o1igsNativeRestoreSignature === signature) return;
const current = normalizeSaveResults(app.nodeOutputs?.[String(node.id)]?.images);
if (JSON.stringify(current) === signature) {
node._o1igsNativeRestoreSignature = signature;
scheduleNativePreviewActionSync();
return;
}
const output = { images };
node._o1igsRestoringPreview = true;
try {
if (typeof api.dispatchCustomEvent === "function") {
api.dispatchCustomEvent("executed", {
node: node.id,
display_node: node.id,
output,
prompt_id: `o1key-restored-${node.id}`,
});
} else {
node.onExecuted?.(output);
}
node._o1igsNativeRestoreSignature = signature;
} finally {
node._o1igsRestoringPreview = false;
}
scheduleNativePreviewActionSync();
});
}
function setSaveBusy(node, busy, state = "running", progress = 0) {
node._o1igsBusy = busy;
node._o1igsState = busy ? state : "";
node._o1igsProgress = busy ? Math.max(0, Math.min(1, Number(progress) || 0)) : 0;
if (node._o1igsProgressTrack) node._o1igsProgressTrack.title = busy ? state : "";
syncSaveProgressOverlay(node);
syncSaveSlotsWidget(node);
syncNativePreviewAction(node);
}
function activeSaveBatchIds(node) {
node._o1igsActiveBatchIds ??= new Set();
return node._o1igsActiveBatchIds;
}
function terminalSaveBatchIds(node) {
node._o1igsTerminalBatchIds ??= new Set();
return node._o1igsTerminalBatchIds;
}
function savingSaveBatchIds(node) {
node._o1igsSavingBatchIds ??= new Set();
return node._o1igsSavingBatchIds;
}
function pendingBatchIds(node) {
node._o1igPendingBatchIds ??= new Set();
return node._o1igPendingBatchIds;
}
function updateGeneratorActivity(node, completedText = "") {
void completedText;
const remaining = pendingBatchIds(node).size;
node._o1igRunning = remaining > 0;
if (!node._o1igPending?.length && !node._o1igMaskPending) {
setStatus(node, "", "");
}
}
function beginGenerationBatch(source, saveNode, batchId = "") {
pendingBatchIds(source).add(saveNode.id);
const effectiveBatchId = String(batchId || saveNode.properties?.o1keyBatchId || "");
if (effectiveBatchId) {
activeSaveBatchIds(saveNode).add(effectiveBatchId);
terminalSaveBatchIds(saveNode).delete(effectiveBatchId);
}
saveNode._o1igsSourceGeneratorId = source.id;
setSaveBusy(saveNode, true, "queued", 0);
updateGeneratorActivity(source);
}
function beginLayerGenerationBatch(source, layerSaveNode, batchId = "") {
if (!layerSaveNode) return;
const effectiveBatchId = String(batchId || "");
if (effectiveBatchId) {
activeSaveBatchIds(layerSaveNode).add(effectiveBatchId);
terminalSaveBatchIds(layerSaveNode).delete(effectiveBatchId);
}
layerSaveNode._o1igsSourceGeneratorId = source.id;
layerSaveNode._o1igsLayerBatchId = effectiveBatchId;
setSaveBusy(layerSaveNode, true, "queued", 0);
}
function finishLayerGenerationBatch(layerSaveNode, batchId = "") {
if (!layerSaveNode) return;
const effectiveBatchId = String(batchId || layerSaveNode._o1igsLayerBatchId || "");
if (effectiveBatchId) {
activeSaveBatchIds(layerSaveNode).delete(effectiveBatchId);
savingSaveBatchIds(layerSaveNode).delete(effectiveBatchId);
}
if (String(layerSaveNode._o1igsLayerBatchId || "") === effectiveBatchId) {
delete layerSaveNode._o1igsLayerBatchId;
}
const stillBusy = activeSaveBatchIds(layerSaveNode).size > 0;
setSaveBusy(layerSaveNode, stillBusy, stillBusy ? "running" : "", layerSaveNode._o1igsProgress);
}
function finishGenerationBatch(saveNode, completedText = "生成完成", batchId = "") {
const source = findUpstreamGenerator(saveNode)
|| saveNode.graph?.getNodeById?.(saveNode._o1igsSourceGeneratorId);
const standardExecution = Boolean(
saveNode._o1igsStandardQueueReserved || saveNode._o1igsStandardExecutionPending,
);
if (standardExecution) {
setSaveBusy(saveNode, false);
const standardSource = clearStandardExecution(saveNode) || source;
if (standardSource) {
if (pendingBatchIds(standardSource).size) updateGeneratorActivity(standardSource);
else {
standardSource._o1igRunning = false;
setStatus(standardSource, "", "");
}
}
return;
}
const effectiveBatchId = String(batchId || saveNode.properties?.o1keyBatchId || "");
if (effectiveBatchId) {
parallelBatches.delete(effectiveBatchId);
activeSaveBatchIds(saveNode).delete(effectiveBatchId);
savingSaveBatchIds(saveNode).delete(effectiveBatchId);
}
const stillBusy = activeSaveBatchIds(saveNode).size > 0;
setSaveBusy(saveNode, stillBusy, stillBusy ? "running" : "", saveNode._o1igsProgress);
if (!stillBusy) delete saveNode.properties?.o1keyRetrySlotIndex;
if (!source) return;
if (!stillBusy) pendingBatchIds(source).delete(saveNode.id);
updateGeneratorActivity(source, completedText);
}
function findBatchNodes(detail) {
const batchId = String(detail?.batch_id || "");
if (!batchId) return {};
const registered = parallelBatches.get(batchId);
const saveNodeId = detail?.save_node_id ?? registered?.saveNodeId ?? registered?.saveNode?.id;
const generatorNodeId = detail?.generator_node_id
?? registered?.generatorNodeId
?? registered?.source?.id;
if (saveNodeId == null || generatorNodeId == null) return {};
const registeredSaveNode = isActiveGraphNode(registered?.saveNode)
&& String(registered.saveNode.id) === String(saveNodeId)
? registered.saveNode
: null;
const saveNode = registeredSaveNode || activeGraphNodeById(saveNodeId);
const registeredSource = isActiveGraphNode(registered?.source)
&& String(registered.source.id) === String(generatorNodeId)
? registered.source
: null;
const source = (saveNode ? findUpstreamGenerator(saveNode) : null) || registeredSource;
if (!saveNode || !source) return {};
if (!registered && saveNode.properties?.o1keyBatchId !== batchId) return {};
if (String(saveNode.id) !== String(saveNodeId)) return {};
if (String(source.id) !== String(generatorNodeId)) return {};
const registeredLayerSaveNode = isActiveGraphNode(registered?.layerSaveNode)
&& String(registered.layerSaveNode.id) === String(registered?.layerSaveNodeId)
? registered.layerSaveNode
: null;
const layerSaveNode = registeredLayerSaveNode || findLayerSaveNode(source, saveNode);
if (registered) Object.assign(registered, {
source,
saveNode,
generatorNodeId: source.id,
saveNodeId: saveNode.id,
layerSaveNode,
layerSaveNodeId: layerSaveNode?.id,
});
return { batchId, saveNode, source, layerSaveNode };
}
async function saveWorkflowMetadata() {
let prompt = null;
let workflow = null;
try {
const promptData = await app.graphToPrompt?.();
prompt = promptData?.output || null;
workflow = promptData?.workflow || null;
} catch {
// Saving the generated pixels must not fail because prompt serialization did.
}
if (!workflow) {
try {
workflow = app.graph?.serialize?.() || null;
} catch {
// Keep the save path available even in older frontend builds.
}
}
return {
prompt,
extra_pnginfo: workflow ? { workflow } : null,
};
}
async function promoteTempResults(batchId, saveNode, images) {
const metadata = await saveWorkflowMetadata();
const saveSettings = generatorSaveSettings(findConnectedGenerator(saveNode));
const response = await api.fetchApi("/o1key/image/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
batch_id: batchId,
save_node_id: saveNode.id,
images,
filename_prefix: saveSettings.filename_prefix,
format: saveSettings.format,
save_location: saveSettings.save_location,
naming_rule: saveSettings.naming_rule,
prompt: metadata.prompt,
extra_pnginfo: metadata.extra_pnginfo,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result?.error || `保存图片失败(HTTP ${response.status}`);
const saved = normalizeSaveResults(result?.images)
.filter((item) => item.type === "output" || item.external_saved === true)
.map((item) => ({ ...item, batch_id: item.batch_id || batchId }));
if (!saved.length) throw new Error("保存节点没有返回图片结果");
return saved;
}
function dispatchSaveResults(saveNode, batchId, images) {
const output = { images };
saveNode._o1igsDispatchBatchId = batchId;
if (typeof api.dispatchCustomEvent === "function") {
api.dispatchCustomEvent("executed", {
node: saveNode.id,
display_node: saveNode.id,
output,
prompt_id: batchId,
});
} else if (typeof saveNode.onExecuted === "function") {
saveNode.onExecuted(output);
} else {
renderSaveResults(saveNode, images);
finishGenerationBatch(saveNode, "生成完成", batchId);
}
if (saveNode._o1igsDispatchBatchId === batchId) {
delete saveNode._o1igsDispatchBatchId;
}
}
async function fetchParallelBatchStatus(batchId) {
const registered = parallelBatches.get(batchId);
const params = new URLSearchParams();
const generatorNodeId = registered?.generatorNodeId ?? registered?.source?.id;
const saveNodeId = registered?.saveNodeId ?? registered?.saveNode?.id;
if (generatorNodeId != null) params.set("generator_node_id", String(generatorNodeId));
if (saveNodeId != null) params.set("save_node_id", String(saveNodeId));
const query = params.toString();
const response = await api.fetchApi(`/o1key/image/jobs/${encodeURIComponent(batchId)}${query ? `?${query}` : ""}`, {
cache: "no-store",
});
if (response.status === 404) return { notFound: true };
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result?.error || `HTTP ${response.status}`);
return { detail: result };
}
async function recoverParallelBatch(saveNode) {
const batchId = String(saveNode?.properties?.o1keyBatchId || "");
if (!batchId) return;
const existing = parallelBatches.get(batchId);
if (terminalSaveBatchIds(saveNode).has(batchId)) {
setSaveBusy(saveNode, false);
return;
}
if (saveNode._o1igsLastResults?.length) {
if (
existing
&& String(existing.saveNodeId ?? existing.saveNode?.id) === String(saveNode.id)
) parallelBatches.delete(batchId);
setSaveBusy(saveNode, false);
return;
}
const source = findUpstreamGenerator(saveNode);
if (!source) return;
if (Number(saveNode.properties?.o1keyGeneratorNodeId) !== Number(source.id)) return;
if (existing?.source === source && existing?.saveNode === saveNode) return;
const retrySlotIndex = Math.max(
0,
Math.floor(Number(saveNode.properties?.o1keyRetrySlotIndex) || 0),
);
const layerDecomposition = isLayerDecompositionEnabled(source);
const layerSaveNode = layerDecomposition
? findLayerSaveNode(source, saveNode) || acquireLayerSaveNodeForBatch(source, saveNode)
: null;
const registered = existing || {};
Object.assign(registered, {
source,
saveNode,
generatorNodeId: source.id,
saveNodeId: saveNode.id,
layerSaveNode,
layerSaveNodeId: layerSaveNode?.id,
layerDecomposition,
progress: Number(existing?.progress) || 0,
recovering: true,
retrySlotIndex,
});
parallelBatches.set(batchId, registered);
updateNativeQueueJob({
batch_id: batchId,
generator_node_id: source.id,
save_node_id: saveNode.id,
state: "queued",
});
beginGenerationBatch(source, saveNode, batchId);
beginLayerGenerationBatch(source, layerSaveNode, batchId);
if (registered.lastDetail) {
handleParallelImageJob(registered.lastDetail);
if (["completed", "failed", "cancelled"].includes(String(registered.lastDetail.state || ""))) return;
if (registered.monitoring) return;
}
try {
const status = await fetchParallelBatchStatus(batchId);
if (status.notFound) {
handleParallelImageJob({
batch_id: batchId,
generator_node_id: source.id,
save_node_id: saveNode.id,
state: "failed",
error: "任务不存在或未成功提交",
});
return;
}
handleParallelImageJob(status.detail);
if (status.detail?.state === "queued" || status.detail?.state === "running") {
monitorParallelBatch(batchId);
}
} catch {
monitorParallelBatch(batchId);
}
}
async function monitorParallelBatch(batchId) {
const registered = parallelBatches.get(batchId);
if (!registered || registered.monitoring) return;
registered.monitoring = true;
try {
while (parallelBatches.has(batchId)) {
await new Promise((resolve) => setTimeout(resolve, 2500));
if (!parallelBatches.has(batchId)) break;
try {
const status = await fetchParallelBatchStatus(batchId);
if (status.notFound) {
const current = parallelBatches.get(batchId);
handleParallelImageJob({
batch_id: batchId,
generator_node_id: current?.generatorNodeId ?? current?.source?.id,
save_node_id: current?.saveNodeId ?? current?.saveNode?.id,
state: "failed",
error: "任务不存在或未成功提交",
});
break;
}
handleParallelImageJob(status.detail);
if (["completed", "failed", "cancelled"].includes(String(status.detail?.state || ""))) {
break;
}
} catch {
// A local ComfyUI reconnect is normally brief. Keep the paid task
// registered and retry instead of detaching its result node.
}
}
} finally {
if (parallelBatches.get(batchId) === registered) registered.monitoring = false;
}
}
function handleParallelImageJob(detail) {
const rawBatchId = String(detail?.batch_id || "");
if (!rawBatchId) return;
const registered = parallelBatches.get(rawBatchId);
const expectedGeneratorNodeId = registered?.generatorNodeId ?? registered?.source?.id;
const expectedSaveNodeId = registered?.saveNodeId ?? registered?.saveNode?.id;
if (
expectedGeneratorNodeId != null
&& detail?.generator_node_id != null
&& String(expectedGeneratorNodeId) !== String(detail.generator_node_id)
) return;
if (
expectedSaveNodeId != null
&& detail?.save_node_id != null
&& String(expectedSaveNodeId) !== String(detail.save_node_id)
) return;
const state = String(detail?.state || "");
if (state === "queued" && registered?.state === "running") return;
if (registered) Object.assign(registered, {
lastDetail: detail,
state,
progress: Math.max(0, Math.min(1, Number(detail?.progress) || 0)),
queuePosition: Number(detail?.queue_position) || 0,
});
const { batchId, saveNode, source, layerSaveNode } = findBatchNodes(detail);
if (!batchId) {
updateNativeQueueJob(detail);
return;
}
const routeLayerResults = Boolean(registered?.layerDecomposition && layerSaveNode);
if (terminalSaveBatchIds(saveNode).has(batchId)) return;
if (state === "queued" || state === "running") {
const progress = Math.max(0, Math.min(1, Number(detail?.progress) || 0));
updateNativeQueueJob(detail);
const retrySlotIndex = Number(registered?.retrySlotIndex) || 0;
updateSaveSlotsForState(saveNode, detail, retrySlotIndex);
if (state === "running") {
applyPartialSaveSlots(saveNode, detail?.images, retrySlotIndex);
}
setSaveBusy(saveNode, true, state, progress);
if (routeLayerResults) setSaveBusy(layerSaveNode, true, state, progress);
updateGeneratorActivity(source);
return;
}
if (state === "completed") {
const retrySlotIndex = Number(registered?.retrySlotIndex) || 0;
const images = Array.isArray(detail?.images) ? detail.images.filter((item) => (
item?.batch_id === batchId
)) : [];
if (!images.length) {
updateNativeQueueJob(detail, { forceState: "failed", error: "生成完成但没有图片结果" });
updateSaveSlotsForState(saveNode, {
...detail,
state: "failed",
error: "生成完成但没有图片结果",
}, retrySlotIndex);
terminalSaveBatchIds(saveNode).add(batchId);
finishLayerGenerationBatch(layerSaveNode, batchId);
finishGenerationBatch(saveNode, "生成完成但没有图片结果", batchId);
return;
}
const tempImages = images.filter((item) => (
item?.type === "temp" && item?.external_saved !== true
));
if (tempImages.length) {
if (savingSaveBatchIds(saveNode).has(batchId)) return;
savingSaveBatchIds(saveNode).add(batchId);
updateNativeQueueJob(detail, { forceState: "saving" });
setSaveBusy(saveNode, true, "saving", 1);
if (routeLayerResults) setSaveBusy(layerSaveNode, true, "saving", 1);
updateGeneratorActivity(source);
void promoteTempResults(batchId, saveNode, tempImages).then((savedImages) => {
savingSaveBatchIds(saveNode).delete(batchId);
handleParallelImageJob({ ...detail, images: savedImages });
}).catch((error) => {
savingSaveBatchIds(saveNode).delete(batchId);
const message = error?.message || String(error);
handleParallelImageJob({
...detail,
state: "failed",
error: message,
});
});
return;
}
terminalSaveBatchIds(saveNode).add(batchId);
updateNativeQueueJob(detail, { forceState: "completed", images });
const completeImages = applyCompletedSaveSlots(
saveNode,
detail,
images,
retrySlotIndex,
Boolean(registered?.layerDecomposition),
);
if (routeLayerResults) {
const { imageResults, layerResults } = splitLayerSaveResults(completeImages);
terminalSaveBatchIds(layerSaveNode).add(batchId);
if (layerResults.length) dispatchSaveResults(layerSaveNode, batchId, layerResults);
else finishLayerGenerationBatch(layerSaveNode, batchId);
dispatchSaveResults(saveNode, batchId, imageResults);
} else {
dispatchSaveResults(saveNode, batchId, completeImages);
}
if (detail?.warnings?.length) {
toast("warn", "部分图片生成失败", formatImageGenerationError(detail.warnings[0]));
}
return;
}
if (state === "failed" || state === "cancelled") {
terminalSaveBatchIds(saveNode).add(batchId);
const message = state === "cancelled" ? "任务已取消" : formatImageGenerationError(detail?.error);
updateNativeQueueJob(detail, { forceState: state, error: message });
updateSaveSlotsForState(saveNode, { ...detail, error: message }, Number(registered?.retrySlotIndex) || 0);
finishLayerGenerationBatch(layerSaveNode, batchId);
finishGenerationBatch(saveNode, message, batchId);
toast(state === "cancelled" ? "info" : "error", state === "cancelled" ? "已取消" : "生成失败", message);
}
}
function failOldestGenerationBatch(source, message) {
const standardSaveNode = activeStandardSaveNode(source);
if (standardSaveNode) {
setSaveBusy(standardSaveNode, false);
clearStandardExecution(standardSaveNode);
source._o1igRunning = false;
setStatus(source, "", "");
return true;
}
const oldestId = pendingBatchIds(source).values().next().value;
if (oldestId == null) {
source._o1igRunning = false;
setStatus(source, message, "error");
return false;
}
const saveNode = (source.graph || app.graph)?.getNodeById?.(oldestId);
if (saveNode) finishGenerationBatch(saveNode, message);
else {
pendingBatchIds(source).delete(oldestId);
updateGeneratorActivity(source, message);
}
return false;
}
function findUpstreamGenerator(node) {
const graph = node.graph || app.graph;
const link = getGraphLink(graph, node.inputs?.[0]?.link);
const source = link ? graph?.getNodeById?.(link.origin_id) : null;
return source?.comfyClass === NODE_TYPE || source?.type === NODE_TYPE ? source : null;
}
async function regenerateFromSaveNode(node, imageIndex = 0) {
const source = findUpstreamGenerator(node);
if (!source) {
toast("warn", "无法重新生成", "请连接 o1key 图片生成节点");
return;
}
const nextSeed = Math.floor(Math.random() * 0x7fffffff);
setWidgetValue(source, "seed", nextSeed);
if (source._o1igSeed) source._o1igSeed.value = String(nextSeed);
await queueGeneration(source, 1);
}
async function retryFailedSaveSlot(node, slotIndex) {
const slot = normalizeSaveSlots(node?._o1igsSlots).find(
(item) => item.index === Number(slotIndex),
);
if (!slot || slot.state !== "failed") return;
const source = findUpstreamGenerator(node);
if (!source) {
toast("warn", "无法重新生成", "请连接 o1key 图片生成节点");
return;
}
const nextSeed = Math.floor(Math.random() * 0x7fffffff);
setWidgetValue(source, "seed", nextSeed);
if (source._o1igSeed) source._o1igSeed.value = String(nextSeed);
await queueGeneration(source, 1, {
targetSaveNode: node,
retrySlotIndex: slot.index,
promptOverride: slot.prompt || source._o1igPrompt?.value || "",
referenceOverride: slot.references || [],
});
}
function syncBatchLayout(node) {
if (!node?._o1igBatchToggle) return;
const enabled = Boolean(node._o1igBatchEnabled);
const singleReferences = enabled
&& node._o1igBatchMode?.value === BATCH_MODE_SINGLE_REFERENCES;
node._o1igBatchToggle.classList.toggle("enabled", enabled);
node._o1igBatchToggle.setAttribute("aria-pressed", enabled ? "true" : "false");
node._o1igBatchModeWrap?.classList.toggle("visible", enabled);
node._o1igModelAssets?.classList.toggle("visible", enabled && !singleReferences);
if (node._o1igAddLabel) {
node._o1igAddLabel.textContent = "上传";
}
if (node._o1igCountLabel) {
node._o1igCountLabel.textContent = singleReferences ? "每图生成数" : enabled ? "每组生图数" : "生图数量";
}
const capabilities = MODEL_CAPABILITIES[node._o1igModel?.value] || MODEL_CAPABILITIES["Nano Banana 2"];
setFieldVisible(node._o1igMaskField, Boolean(capabilities.mask && !enabled));
setWidgetValue(node, "批量出图", enabled);
setWidgetValue(
node,
"批量模式",
node._o1igBatchMode?.value || BATCH_MODE_GROUP_TO_MODELS,
);
setWidgetValue(node, "模特图清单", JSON.stringify(node._o1igModelReferences || []));
renderReferences(node);
updateBatchSummary(node);
fitNode(node);
}
function syncModelOptions(node) {
const model = node._o1igModel.value;
const capabilities = MODEL_CAPABILITIES[model] || MODEL_CAPABILITIES["Nano Banana 2"];
const isGptImage = isGptImageModel(model);
if (isGptImage && node._o1igApplyGptDefaults) {
if (!node._o1igOutputTouched) node._o1igOutputFormat.value = "png";
if (!node._o1igResizeTouched) node._o1igResize.value = "智能缩放";
node._o1igApplyGptDefaults = false;
}
const isSeedream = model === "Seedream 5.0 Pro";
if (!isSeedream && node._o1igLayerDecomposition) {
node._o1igLayerDecomposition.value = "关闭";
}
const layerDecomposition = isSeedream
&& node._o1igLayerDecomposition?.value === "开启";
const isNano2 = model === "Nano Banana 2";
const isNano2Lite = model === "Nano Banana 2 Lite";
const resolutions = isSeedream
? layerDecomposition ? SEEDREAM_LAYER_RESOLUTIONS : SEEDREAM_RESOLUTIONS
: isGptImage
? GPT_RESOLUTIONS
: model === "Nano Banana"
? ["智能", "1K"]
: isNano2Lite
? ALL_RESOLUTIONS
: ["智能", "1K", "2K", "4K"];
const ratios = isSeedream
? SEEDREAM_RATIOS
: isGptImage
? GPT_RATIOS
: isNano2 || isNano2Lite
? ALL_RATIOS
: ALL_RATIOS.filter((ratio) => !NANO2_ONLY_RATIOS.has(ratio));
replaceOptions(node._o1igResolution, resolutions, findWidget(node, "分辨率")?.value || "智能");
replaceOptions(node._o1igRatio, ratios, findWidget(node, "宽高比")?.value || "智能");
replaceOptions(node._o1igCount, imageCountOptions(model, node._o1igCount.value), node._o1igCount.value);
if (isGptImage) {
const qualities = model.startsWith("gpt-image-2.5-") ? GPT_25_QUALITIES : GPT_QUALITIES;
replaceOptions(
node._o1igQuality,
qualities,
qualities.includes(node._o1igQuality.value) ? node._o1igQuality.value : "自动",
);
}
if (layerDecomposition) {
node._o1igBatchEnabled = false;
node._o1igCount.value = "1";
node._o1igOutputFormat.value = "png";
}
if (node._o1igLayerDecompositionField) {
setFieldVisible(node._o1igLayerDecompositionField, isSeedream);
setFieldVisible(node._o1igRatioField, !layerDecomposition);
setFieldVisible(node._o1igCountField, !layerDecomposition);
}
setFieldVisible(node._o1igThinkingField, capabilities.thinking);
setFieldVisible(node._o1igOnlineSearchField, capabilities.search);
setFieldVisible(node._o1igQualityField, capabilities.quality);
setFieldVisible(node._o1igOutputFormatField, capabilities.output && !layerDecomposition);
setFieldVisible(node._o1igBackgroundField, isGptImage);
setFieldVisible(node._o1igResizeField, capabilities.resize);
setFieldVisible(node._o1igMaskField, capabilities.mask && !node._o1igBatchEnabled);
syncGptOutputOptions(node);
syncGeneratorSaveControls(node);
syncResizeWarning(node);
setWidgetValue(node, "模型", model);
setWidgetValue(node, "分辨率", node._o1igResolution.value);
setWidgetValue(node, "宽高比", node._o1igRatio.value);
setWidgetValue(node, "质量", node._o1igQuality.value);
setWidgetValue(node, "在线搜索", node._o1igOnlineSearch.value);
setWidgetValue(node, "缩放图片", node._o1igResize.value);
setWidgetValue(
node,
"生图数量",
layerDecomposition ? "1" : node._o1igCount?.value || findWidget(node, "生图数量")?.value || "1",
);
setWidgetValue(node, "输出格式", node._o1igOutputFormat.value);
setWidgetValue(node, "图层拆分", layerDecomposition);
if (node._o1igOutputsReady) syncGeneratorOutputVisibility(node);
if (node._o1igBatchBar) node._o1igBatchBar.style.display = layerDecomposition ? "none" : "";
syncBatchLayout(node);
}
async function optimizePrompt(node) {
if (node._o1igOptimizing) return;
const prompt = node._o1igPrompt?.value.trim() || "";
if (!prompt) {
setPromptOptimizeStatus(node, "请先输入提示词", "error");
node._o1igPrompt?.focus();
return;
}
if (hasPendingReferenceUploads(node)) {
setPromptOptimizeStatus(node, "请等待参考图上传完成", "error");
return;
}
if (node._o1igRunning) {
setPromptOptimizeStatus(node, "请等待当前生成任务提交完成", "error");
return;
}
node._o1igOptimizing = true;
node._o1igPrompt.readOnly = true;
node._o1igPromptOptimize.disabled = true;
node._o1igPromptOptimize.classList.add("busy");
setPromptOptimizeStatus(node, "AI帮写中…", "busy");
node._o1igGenerate.disabled = true;
try {
const response = await api.fetchApi("/o1key/image/prompt-optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
references: node._o1igReferences.map((item) => ({
name: String(item.name),
subfolder: String(item.subfolder || ""),
type: "input",
})),
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `AI帮写失败 (${response.status})`);
const optimized = String(payload.prompt || "").trim();
if (!optimized) throw new Error("AI帮写结果为空");
node._o1igPrompt.value = optimized;
setWidgetValue(node, "prompt", optimized);
setPromptOptimizeStatus(node, "AI帮写完成", "ok");
toast("success", "AI帮写完成", "已结合当前参考图重新整理视觉需求");
} catch (error) {
const message = formatImageGenerationError(error?.message || error);
setPromptOptimizeStatus(node, message, "error");
toast("error", "AI帮写失败", message);
} finally {
node._o1igOptimizing = false;
node._o1igPrompt.readOnly = false;
node._o1igPromptOptimize.disabled = false;
node._o1igPromptOptimize.classList.remove("busy");
node._o1igGenerate.disabled = false;
}
}
async function queueGeneration(node, imageCountOverride = null, options = {}) {
let saveNode = null;
let layerSaveNode = null;
let batchId = "";
const retrySlotIndex = Math.max(0, Math.floor(Number(options?.retrySlotIndex) || 0));
const storedReferenceOverride = parseReferences(options?.referenceOverride || []);
const layerDecomposition = isLayerDecompositionEnabled(node);
const requestedImageCount = layerDecomposition
? "1"
: normalizeImageCount(imageCountOverride ?? node._o1igCount.value, node._o1igModel.value);
const prompt = String(options?.promptOverride ?? node._o1igPrompt.value).trim();
const saveSettings = generatorSaveSettings(node);
if (!prompt && !layerDecomposition) {
setStatus(node, "请输入提示词", "error");
node._o1igPrompt.focus();
return;
}
if (node._o1igOptimizing) {
setPromptOptimizeStatus(node, "请等待 AI帮写完成", "error");
return;
}
if (hasPendingReferenceUploads(node)) {
setStatus(node, "请等待图片上传完成", "error");
return;
}
if (layerDecomposition && node._o1igReferences.length !== 1) {
setStatus(node, "图层拆分必须且只能上传1张参考图", "error");
return;
}
if (node._o1igBatchEnabled && !retrySlotIndex) {
const singleReferences = node._o1igBatchMode.value === BATCH_MODE_SINGLE_REFERENCES;
if (singleReferences && !node._o1igReferences.length) {
setStatus(node, "单图批量至少需要上传1张素材图", "error");
return;
}
if (!singleReferences && !node._o1igReferences.length) {
setStatus(node, "批量出图至少需要上传1张素材图", "error");
return;
}
if (!singleReferences && !node._o1igModelReferences.length) {
setStatus(node, "批量出图至少需要上传1张目标图", "error");
return;
}
if (
node._o1igBatchMode.value === BATCH_MODE_GROUP_TO_MODELS
&& node._o1igReferences.length >= MAX_REQUEST_REFERENCES
) {
setStatus(node, "整组素材模式最多上传9张素材图(另加1张目标图)", "error");
return;
}
if (node._o1igMask) {
setStatus(node, "批量出图暂不支持蒙版,请先移除蒙版", "error");
return;
}
}
if (!node._o1igBatchEnabled && node._o1igReferences.length > MAX_REQUEST_REFERENCES) {
setStatus(node, `普通模式最多支持 ${MAX_REQUEST_REFERENCES} 张参考图`, "error");
return;
}
if (!retrySlotIndex) setWidgetValue(node, "prompt", prompt);
setWidgetValue(node, "模型", node._o1igModel.value);
setWidgetValue(node, "模型线路", node._o1igRoute.value);
setWidgetValue(node, "思考等级", node._o1igThinking.value);
setWidgetValue(node, "分辨率", node._o1igResolution.value);
setWidgetValue(node, "宽高比", node._o1igRatio.value);
if (!retrySlotIndex) setWidgetValue(node, "生图数量", normalizeImageCount(node._o1igCount.value, node._o1igModel.value));
setWidgetValue(node, "seed", Math.max(0, Math.floor(Number(node._o1igSeed.value) || 0)));
setWidgetValue(node, "参考图清单", JSON.stringify(node._o1igReferences));
setWidgetValue(node, "质量", node._o1igQuality.value);
setWidgetValue(node, "输出格式", node._o1igOutputFormat.value);
setWidgetValue(node, "蒙版清单", JSON.stringify(node._o1igMask || {}));
setWidgetValue(node, "缩放图片", node._o1igResize.value);
setWidgetValue(node, "背景", node._o1igBackground.value);
setWidgetValue(node, "批量出图", Boolean(node._o1igBatchEnabled));
setWidgetValue(node, "批量模式", node._o1igBatchMode.value);
setWidgetValue(node, "模特图清单", JSON.stringify(node._o1igModelReferences));
setWidgetValue(node, "命名规则", saveSettings.naming_rule);
setWidgetValue(node, "filename_prefix", saveSettings.filename_prefix);
setWidgetValue(node, "格式", node._o1igSaveFormat.value);
setWidgetValue(node, "保存位置", saveSettings.save_location);
setWidgetValue(node, "图层拆分", layerDecomposition);
try {
setStatus(node, "", "busy");
batchId = createBatchId();
saveNode = options?.targetSaveNode
? bindSaveNodeToBatch(node, options.targetSaveNode, batchId)
: acquireSaveNodeForBatch(node, batchId);
if (layerDecomposition) {
layerSaveNode = acquireLayerSaveNodeForBatch(node, saveNode);
}
const fullTaskPlan = panelGenerationTasks(
node,
retrySlotIndex ? node._o1igPrompt.value : prompt,
retrySlotIndex ? normalizeImageCount(node._o1igCount.value, node._o1igModel.value) : requestedImageCount,
);
if (!fullTaskPlan.length && !storedReferenceOverride.length) {
throw new Error("批量组合为空,请检查目标图");
}
if (fullTaskPlan.length > 1000) throw new Error("单次生成任务最多支持 1000 个");
const retryTask = retrySlotIndex
? storedReferenceOverride.length
? { prompt, references: storedReferenceOverride }
: fullTaskPlan[retrySlotIndex - 1]
: null;
if (retrySlotIndex && !retryTask) throw new Error("无法恢复该失败任务的原始组合");
const submittedTasks = retryTask ? [retryTask] : fullTaskPlan;
const taskPrompts = submittedTasks.map((task) => task.prompt);
if (retrySlotIndex) {
saveNode.properties.o1keyRetrySlotIndex = retrySlotIndex;
saveNode._o1igsSlots = normalizeSaveSlots(saveNode._o1igsSlots).map((slot) => (
slot.index === retrySlotIndex
? { ...slot, state: "pending", error: "", image: null }
: slot
));
persistSaveSlots(saveNode);
syncSaveSlotsWidget(saveNode);
} else {
delete saveNode.properties.o1keyRetrySlotIndex;
initializeSaveSlots(saveNode, submittedTasks);
}
beginGenerationBatch(node, saveNode, batchId);
beginLayerGenerationBatch(node, layerSaveNode, batchId);
parallelBatches.set(batchId, {
source: node,
saveNode,
generatorNodeId: node.id,
saveNodeId: saveNode.id,
layerSaveNode,
layerSaveNodeId: layerSaveNode?.id,
layerDecomposition,
retrySlotIndex,
});
updateNativeQueueJob({
batch_id: batchId,
generator_node_id: node.id,
save_node_id: saveNode.id,
state: "queued",
total_count: taskPrompts.length,
});
const jobPayload = {
batch_id: batchId,
generator_node_id: node.id,
save_node_id: saveNode.id,
prompt: retryTask?.prompt || prompt,
model: node._o1igModel.value,
model_route: node._o1igRoute.value,
thinking_level: node._o1igThinking.value,
resolution: node._o1igResolution.value,
aspect_ratio: node._o1igRatio.value,
image_count: retryTask ? 1 : Number(requestedImageCount),
seed: Math.max(0, Math.floor(Number(node._o1igSeed.value) || 0)),
quality: node._o1igQuality.value,
resize_mode: node._o1igResize.value,
filename_prefix: saveSettings.filename_prefix,
save_format: saveSettings.format,
save_location: saveSettings.save_location,
naming_rule: saveSettings.naming_rule,
references: (retryTask?.references || node._o1igReferences).map((item) => ({
name: String(item.name),
subfolder: String(item.subfolder || ""),
type: "input",
})),
mask: isGptImageModel(node._o1igModel.value) && node._o1igMask
? {
name: String(node._o1igMask.name),
subfolder: String(node._o1igMask.subfolder || ""),
type: "input",
}
: null,
batch_enabled: retryTask ? false : Boolean(node._o1igBatchEnabled),
batch_mode: node._o1igBatchMode.value,
model_references: retryTask || node._o1igBatchMode.value === BATCH_MODE_SINGLE_REFERENCES
? []
: node._o1igModelReferences.map((item) => ({
name: String(item.name),
subfolder: String(item.subfolder || ""),
type: "input",
})),
};
if (isGptImageModel(node._o1igModel.value)) {
jobPayload.output_format = node._o1igOutputFormat.value;
jobPayload.background = node._o1igBackground.value;
} else if (node._o1igModel.value === "Seedream 5.0 Pro") {
jobPayload.output_format = node._o1igOutputFormat.value;
if (layerDecomposition) jobPayload.layer_decomposition = true;
} else if (
node._o1igModel.value === "Nano Banana 2"
&& node._o1igOnlineSearch.value === "打开"
) {
jobPayload.google_search = true;
}
const response = await api.fetchApi("/o1key/image/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(jobPayload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result?.error || `提交任务失败(HTTP ${response.status}`);
handleParallelImageJob({ ...result, generator_node_id: node.id, save_node_id: saveNode.id });
monitorParallelBatch(batchId);
updateGeneratorActivity(node);
} catch (error) {
const message = formatImageGenerationError(error?.message || error);
let recovered = false;
if (saveNode && batchId) {
try {
const status = await fetchParallelBatchStatus(batchId);
if (status.detail) {
recovered = true;
handleParallelImageJob(status.detail);
monitorParallelBatch(batchId);
}
} catch {
// The POST response may have been lost after the server accepted
// the job. Retain its exact node binding and confirm by polling.
recovered = true;
setSaveBusy(saveNode, true, "queued");
monitorParallelBatch(batchId);
}
}
if (recovered) {
toast("warn", "正在确认任务状态", "连接短暂中断,结果节点会继续自动接收该批次。");
return;
}
if (saveNode) {
updateNativeQueueJob({
batch_id: batchId,
generator_node_id: node.id,
save_node_id: saveNode.id,
state: "failed",
error: message,
}, { forceState: "failed", error: message });
updateSaveSlotsForState(saveNode, {
state: "failed",
error: message,
}, retrySlotIndex);
finishLayerGenerationBatch(layerSaveNode, batchId);
finishGenerationBatch(saveNode, message, batchId);
}
else setStatus(node, message, "error");
toast("error", "生成失败", message);
}
}
function buildPanel(node) {
if (node._o1igPanel) {
installStandardQueueWidget(node);
return;
}
injectStyles();
hideBackendWidgets(node);
installStandardQueueWidget(node);
node._o1igReferences = parseReferences(findWidget(node, "参考图清单")?.value);
node._o1igModelReferences = parseReferences(findWidget(node, "模特图清单")?.value);
node._o1igMask = parseMask(findWidget(node, "蒙版清单")?.value);
node._o1igPending = [];
node._o1igModelPending = [];
node._o1igMaskPending = false;
const rawBatchEnabled = findWidget(node, "批量出图")?.value;
node._o1igBatchEnabled = rawBatchEnabled === true
|| String(rawBatchEnabled || "").toLowerCase() === "true"
|| rawBatchEnabled === "开启";
const panel = document.createElement("div");
panel.className = "o1ig-panel";
panel.addEventListener("pointerdown", (event) => {
if (openDropdown && !openDropdown.contains(event.target)) closeDropdown();
event.stopPropagation();
});
const refs = document.createElement("div");
refs.className = "o1ig-refs";
refs.addEventListener("wheel", (event) => event.stopPropagation());
bindImageFileDropTarget(node, refs);
const add = document.createElement("button");
add.type = "button";
add.className = "o1ig-add";
add.title = "添加参考图";
const addIcon = document.createElement("b");
addIcon.textContent = "";
const addLabel = document.createElement("span");
addLabel.textContent = "参考图";
add.append(addIcon, addLabel);
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
fileInput.multiple = true;
fileInput.hidden = true;
add.append(fileInput);
add.addEventListener("click", (event) => {
if (event.target !== fileInput) fileInput.click();
});
fileInput.addEventListener("change", async () => {
await acceptFiles(node, fileInput.files);
fileInput.value = "";
});
bindImageFileDropTarget(node, add);
const canvasPick = document.createElement("button");
canvasPick.type = "button";
canvasPick.className = "o1ig-add o1ig-canvas-pick";
canvasPick.title = "从画布中选择已处理的图片";
const canvasPickIcon = document.createElement("b");
canvasPickIcon.textContent = "▣";
const canvasPickLabel = document.createElement("span");
canvasPickLabel.textContent = "画布取图";
canvasPick.append(canvasPickIcon, canvasPickLabel);
canvasPick.addEventListener("click", () => openCanvasImagePicker(node));
const referenceHeader = document.createElement("div");
referenceHeader.className = "o1ig-asset-header";
const referenceTitle = document.createElement("strong");
referenceTitle.textContent = "素材图";
const referenceHint = document.createElement("span");
referenceHint.className = "o1ig-asset-hint";
referenceHint.textContent = "整组参与";
const referenceMeta = document.createElement("div");
referenceMeta.className = "o1ig-asset-meta";
referenceMeta.append(referenceTitle, referenceHint);
const referenceActions = document.createElement("div");
referenceActions.className = "o1ig-asset-actions";
referenceActions.append(add, canvasPick);
referenceHeader.append(referenceMeta, referenceActions);
const referenceAssets = document.createElement("div");
referenceAssets.className = "o1ig-assets";
const replaceInput = document.createElement("input");
replaceInput.type = "file";
replaceInput.accept = fileInput.accept;
replaceInput.hidden = true;
replaceInput.addEventListener("change", async () => {
const target = node._o1igReplaceTarget;
const file = replaceInput.files?.[0];
node._o1igReplaceTarget = null;
replaceInput.value = "";
if (target && file) await replaceReference(node, target.role, target.original, file);
});
referenceAssets.append(referenceHeader, refs, replaceInput);
const modelRefs = document.createElement("div");
modelRefs.className = "o1ig-refs";
modelRefs.addEventListener("wheel", (event) => event.stopPropagation());
bindImageFileDropTarget(node, modelRefs, "models");
const modelAdd = document.createElement("button");
modelAdd.type = "button";
modelAdd.className = "o1ig-add";
modelAdd.title = "添加目标图";
const modelAddIcon = document.createElement("b");
modelAddIcon.textContent = "";
const modelAddLabel = document.createElement("span");
modelAddLabel.textContent = "上传";
const modelFileInput = document.createElement("input");
modelFileInput.type = "file";
modelFileInput.accept = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
modelFileInput.multiple = true;
modelFileInput.hidden = true;
modelAdd.append(modelAddIcon, modelAddLabel, modelFileInput);
modelAdd.addEventListener("click", (event) => {
if (event.target !== modelFileInput) modelFileInput.click();
});
modelFileInput.addEventListener("change", async () => {
await acceptFiles(node, modelFileInput.files, "models");
modelFileInput.value = "";
});
bindImageFileDropTarget(node, modelAdd, "models");
const modelCanvasPick = document.createElement("button");
modelCanvasPick.type = "button";
modelCanvasPick.className = "o1ig-add o1ig-canvas-pick";
modelCanvasPick.title = "从画布中选择已处理的目标图";
const modelCanvasPickIcon = document.createElement("b");
modelCanvasPickIcon.textContent = "▣";
const modelCanvasPickLabel = document.createElement("span");
modelCanvasPickLabel.textContent = "画布取图";
modelCanvasPick.append(modelCanvasPickIcon, modelCanvasPickLabel);
modelCanvasPick.addEventListener("click", () => openCanvasImagePicker(node, "models"));
const modelHeader = document.createElement("div");
modelHeader.className = "o1ig-asset-header visible";
const modelTitle = document.createElement("strong");
modelTitle.textContent = "目标图";
const modelHint = document.createElement("span");
modelHint.className = "o1ig-asset-hint";
modelHint.textContent = "每张目标图单独参与组合";
const modelMeta = document.createElement("div");
modelMeta.className = "o1ig-asset-meta";
modelMeta.append(modelTitle, modelHint);
const modelActions = document.createElement("div");
modelActions.className = "o1ig-asset-actions";
modelActions.append(modelAdd, modelCanvasPick);
modelHeader.append(modelMeta, modelActions);
const modelAssets = document.createElement("div");
modelAssets.className = "o1ig-assets o1ig-model-assets";
modelAssets.append(modelHeader, modelRefs);
const batchToggle = document.createElement("button");
batchToggle.type = "button";
batchToggle.className = "o1ig-batch-toggle";
const batchToggleLabel = document.createElement("span");
batchToggleLabel.textContent = "批量出图";
const batchSwitch = document.createElement("span");
batchSwitch.className = "o1ig-batch-switch";
batchToggle.append(batchToggleLabel, batchSwitch);
const batchMode = makeSelect(
BATCH_MODE_OPTIONS,
findWidget(node, "批量模式")?.value || BATCH_MODE_GROUP_TO_MODELS,
"批量模式",
);
const batchModeWrap = document.createElement("div");
batchModeWrap.className = "o1ig-batch-mode";
batchModeWrap.append(batchMode);
const batchBar = document.createElement("div");
batchBar.className = "o1ig-batch-bar";
batchBar.append(batchToggle, batchModeWrap);
const batchSummary = document.createElement("div");
batchSummary.className = "o1ig-batch-summary";
const prompt = document.createElement("textarea");
prompt.className = "o1ig-prompt";
prompt.placeholder = PROMPT_PLACEHOLDER;
prompt.value = findWidget(node, "prompt")?.value || "";
prompt.addEventListener("wheel", (event) => event.stopPropagation());
const promptOptimize = document.createElement("button");
promptOptimize.type = "button";
promptOptimize.className = "o1ig-prompt-optimize";
promptOptimize.title = "AI帮写";
promptOptimize.setAttribute("aria-label", "AI帮写");
const promptOptimizeIcon = document.createElement("span");
promptOptimizeIcon.className = "o1ig-prompt-optimize-icon";
promptOptimizeIcon.textContent = "✨";
promptOptimizeIcon.setAttribute("aria-hidden", "true");
const promptOptimizeLabel = document.createElement("span");
promptOptimizeLabel.textContent = "AI帮写";
promptOptimize.append(promptOptimizeIcon, promptOptimizeLabel);
const promptOptimizeStatus = document.createElement("div");
promptOptimizeStatus.className = "o1ig-prompt-optimize-status";
promptOptimizeStatus.setAttribute("role", "status");
promptOptimizeStatus.setAttribute("aria-live", "polite");
const promptWrap = document.createElement("div");
promptWrap.className = "o1ig-prompt-wrap";
promptWrap.append(prompt, promptOptimizeStatus, promptOptimize);
const toolbar = document.createElement("div");
toolbar.className = "o1ig-toolbar";
const model = makeSelect(MODEL_OPTIONS, findWidget(node, "模型")?.value || "Nano Banana 2", "模型");
const resolution = makeSelect(ALL_RESOLUTIONS, findWidget(node, "分辨率")?.value || "智能", "分辨率");
const ratio = makeSelect(ALL_RATIOS, findWidget(node, "宽高比")?.value || "智能", "宽高比");
const ratioField = makeAdvancedField("宽高比", ratio);
const count = makeImageCountSelect(findWidget(node, "生图数量")?.value, model.value);
const countField = makeAdvancedField("生图数量", count);
const countLabel = countField.children?.[0] || null;
const route = makeSelect(ROUTES, findWidget(node, "模型线路")?.value || "畅速");
const thinking = makeSelect(THINKING_LEVEL_OPTIONS, findWidget(node, "思考等级")?.value || "低");
const thinkingField = makeAdvancedField("思考等级", thinking);
const onlineSearch = makeSelect(
ONLINE_SEARCH_OPTIONS,
findWidget(node, "在线搜索")?.value || "关闭",
"在线搜索",
);
const onlineSearchField = makeAdvancedField("在线搜索", onlineSearch);
const layerDecomposition = makeSelect(
["关闭", "开启"],
findWidget(node, "图层拆分")?.value === true ? "开启" : "关闭",
"图层拆分",
);
const layerDecompositionField = makeAdvancedField("图层拆分", layerDecomposition);
const quality = makeSelect(
model.value.startsWith("gpt-image-2.5-") ? GPT_25_QUALITIES : GPT_QUALITIES,
findWidget(node, "质量")?.value || "自动",
"质量",
);
const qualityField = makeAdvancedField("质量", quality);
const background = makeSelect(
GPT_BACKGROUNDS,
findWidget(node, "背景")?.value || "auto",
"背景",
);
const backgroundField = makeAdvancedField("背景", background);
const outputFormat = makeSelect(
GPT_OUTPUT_FORMATS,
findWidget(node, "输出格式")?.value || "jpeg",
"输出格式",
);
const outputFormatField = makeAdvancedField("输出格式", outputFormat);
const resize = makeSelect(
RESIZE_OPTIONS,
findWidget(node, "缩放图片")?.value || "不缩放",
"缩放图片",
);
const resizeWarning = document.createElement("div");
resizeWarning.className = "o1ig-resize-warning";
resizeWarning.textContent = "可能发生像素偏移";
const resizeField = makeAdvancedField("缩放图片", resize);
resizeField.append(resizeWarning);
const namingRule = makeSelect(
SAVE_NAMING_RULES,
findWidget(node, "命名规则")?.value || "自定义前缀",
"命名规则",
);
const namingRuleField = makeAdvancedField("命名规则", namingRule);
const filenamePrefix = document.createElement("input");
filenamePrefix.className = "o1ig-control";
filenamePrefix.type = "text";
filenamePrefix.maxLength = 512;
filenamePrefix.value = findWidget(node, "filename_prefix")?.value || "o1key";
filenamePrefix.placeholder = "o1key";
const filenamePrefixField = makeAdvancedField("文件名前缀", filenamePrefix);
filenamePrefixField.classList.add("o1ig-field-wide");
const saveFormat = makeSelect(
SAVE_FORMATS,
findWidget(node, "格式")?.value || "原始",
"格式",
);
const saveFormatField = makeAdvancedField("格式", saveFormat);
const saveLocation = document.createElement("input");
saveLocation.className = "o1ig-control";
saveLocation.type = "text";
saveLocation.value = findWidget(node, "保存位置")?.value || "";
saveLocation.placeholder = "留空为 output;或填写 D:/图片";
saveLocation.title = "相对路径保存到 output 内;绝对路径可保存到任意磁盘目录";
const saveLocationField = makeAdvancedField("保存位置", saveLocation);
saveLocationField.classList.add("o1ig-field-wide");
const maskFileInput = document.createElement("input");
maskFileInput.type = "file";
maskFileInput.accept = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
maskFileInput.hidden = true;
const maskButton = document.createElement("button");
maskButton.type = "button";
maskButton.className = "o1ig-control o1ig-mask-button";
const maskClear = document.createElement("button");
maskClear.type = "button";
maskClear.className = "o1ig-control o1ig-mask-clear";
maskClear.textContent = "×";
maskClear.title = "移除蒙版";
const maskWrap = document.createElement("div");
maskWrap.className = "o1ig-mask-wrap";
maskWrap.append(maskButton, maskClear, maskFileInput);
const maskField = makeAdvancedField("蒙版", maskWrap);
const seed = document.createElement("input");
seed.className = "o1ig-control";
seed.type = "number";
seed.min = "0";
seed.value = String(findWidget(node, "seed")?.value || 0);
const dice = document.createElement("button");
dice.type = "button";
dice.className = "o1ig-control";
dice.textContent = "↻";
dice.title = "随机种子";
const seedWrap = document.createElement("div");
seedWrap.className = "o1ig-seed";
seedWrap.append(seed, dice);
toolbar.append(
makePanelSectionTitle("生成参数"),
makeAdvancedField("模型", model),
makeAdvancedField("模型线路", route),
makeAdvancedField("分辨率", resolution),
ratioField,
countField,
thinkingField,
onlineSearchField,
layerDecompositionField,
qualityField,
backgroundField,
outputFormatField,
resizeField,
makeAdvancedField("种子", seedWrap),
maskField,
makePanelSectionTitle("保存设置"),
namingRuleField,
saveFormatField,
filenamePrefixField,
saveLocationField,
);
const generate = document.createElement("button");
generate.type = "button";
generate.className = "o1ig-generate";
generate.textContent = "开始生成";
const status = document.createElement("div");
status.className = "o1ig-status";
panel.append(
batchBar,
referenceAssets,
modelAssets,
batchSummary,
makePanelSectionTitle("画面描述"),
promptWrap,
toolbar,
status,
generate,
);
const panelWidget = node.addDOMWidget("o1key_image_generator_panel", "div", panel, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => GENERATOR_PANEL_MIN_HEIGHT,
});
Object.assign(node, {
_o1igPanel: panel,
_o1igPanelWidget: panelWidget,
_o1igToolbar: toolbar,
_o1igRefs: refs,
_o1igReplaceInput: replaceInput,
_o1igAdd: add,
_o1igCanvasPick: canvasPick,
_o1igAddLabel: addLabel,
_o1igReferenceHeader: referenceHeader,
_o1igReferenceTitle: referenceTitle,
_o1igReferenceHint: referenceHint,
_o1igModelRefs: modelRefs,
_o1igModelAdd: modelAdd,
_o1igModelCanvasPick: modelCanvasPick,
_o1igModelAssets: modelAssets,
_o1igModelHint: modelHint,
_o1igBatchToggle: batchToggle,
_o1igBatchMode: batchMode,
_o1igBatchModeWrap: batchModeWrap,
_o1igBatchSummary: batchSummary,
_o1igBatchBar: batchBar,
_o1igModel: model,
_o1igResolution: resolution,
_o1igRatio: ratio,
_o1igRatioField: ratioField,
_o1igCount: count,
_o1igCountField: countField,
_o1igCountLabel: countLabel,
_o1igRoute: route,
_o1igThinking: thinking,
_o1igThinkingField: thinkingField,
_o1igOnlineSearch: onlineSearch,
_o1igOnlineSearchField: onlineSearchField,
_o1igLayerDecomposition: layerDecomposition,
_o1igLayerDecompositionField: layerDecompositionField,
_o1igQuality: quality,
_o1igQualityField: qualityField,
_o1igBackground: background,
_o1igBackgroundField: backgroundField,
_o1igOutputFormat: outputFormat,
_o1igOutputFormatField: outputFormatField,
_o1igResize: resize,
_o1igResizeField: resizeField,
_o1igResizeWarning: resizeWarning,
_o1igNamingRule: namingRule,
_o1igNamingRuleField: namingRuleField,
_o1igFilenamePrefix: filenamePrefix,
_o1igFilenamePrefixField: filenamePrefixField,
_o1igSaveFormat: saveFormat,
_o1igSaveFormatField: saveFormatField,
_o1igSaveLocation: saveLocation,
_o1igSaveLocationField: saveLocationField,
_o1igMaskButton: maskButton,
_o1igMaskClear: maskClear,
_o1igMaskField: maskField,
_o1igSeed: seed,
_o1igPrompt: prompt,
_o1igPromptOptimize: promptOptimize,
_o1igPromptOptimizeStatus: promptOptimizeStatus,
_o1igGenerate: generate,
_o1igStatus: status,
});
model.addEventListener("change", () => syncModelOptions(node));
layerDecomposition.addEventListener("change", () => syncModelOptions(node));
batchToggle.addEventListener("click", () => {
node._o1igBatchEnabled = !node._o1igBatchEnabled;
syncBatchLayout(node);
});
batchMode.addEventListener("change", () => {
setWidgetValue(node, "批量模式", batchMode.value);
syncBatchLayout(node);
});
resolution.addEventListener("change", () => setWidgetValue(node, "分辨率", resolution.value));
ratio.addEventListener("change", () => setWidgetValue(node, "宽高比", ratio.value));
count.addEventListener("change", () => {
setWidgetValue(node, "生图数量", normalizeImageCount(count.value, model.value));
updateBatchSummary(node);
});
route.addEventListener("change", () => setWidgetValue(node, "模型线路", route.value));
thinking.addEventListener("change", () => setWidgetValue(node, "思考等级", thinking.value));
onlineSearch.addEventListener("change", () => setWidgetValue(node, "在线搜索", onlineSearch.value));
quality.addEventListener("change", () => setWidgetValue(node, "质量", quality.value));
background.addEventListener("change", () => syncGptOutputOptions(node));
outputFormat.addEventListener("change", () => {
node._o1igOutputTouched = true;
setWidgetValue(node, "输出格式", outputFormat.value);
});
resize.addEventListener("change", () => {
node._o1igResizeTouched = true;
setWidgetValue(node, "缩放图片", resize.value);
syncResizeWarning(node);
});
namingRule.addEventListener("change", () => {
setWidgetValue(node, "命名规则", namingRule.value);
syncGeneratorSaveControls(node);
fitNode(node);
});
filenamePrefix.addEventListener("change", () => {
filenamePrefix.value = filenamePrefix.value.trim() || "o1key";
setWidgetValue(node, "filename_prefix", filenamePrefix.value);
});
saveFormat.addEventListener("change", () => setWidgetValue(node, "格式", saveFormat.value));
saveLocation.addEventListener("change", () => {
saveLocation.value = saveLocation.value.trim();
setWidgetValue(node, "保存位置", saveLocation.value);
});
maskButton.addEventListener("click", () => maskFileInput.click());
maskFileInput.addEventListener("change", async () => {
const file = maskFileInput.files?.[0];
maskFileInput.value = "";
if (!file) return;
try {
node._o1igMaskPending = true;
setStatus(node, "上传中…", "busy");
node._o1igMask = await enqueueImageUpload(file);
setWidgetValue(node, "蒙版清单", JSON.stringify(node._o1igMask));
syncMaskControl(node);
setStatus(node, "", "");
} catch (error) {
const message = error?.message || String(error);
setStatus(node, message, "error");
toast("error", "蒙版上传失败", message);
} finally {
node._o1igMaskPending = false;
}
});
maskClear.addEventListener("click", () => {
node._o1igMask = null;
setWidgetValue(node, "蒙版清单", "{}");
syncMaskControl(node);
});
seed.addEventListener("change", () => setWidgetValue(node, "seed", Math.max(0, Math.floor(Number(seed.value) || 0))));
dice.addEventListener("click", () => {
seed.value = String(Math.floor(Math.random() * 0x7fffffff));
setWidgetValue(node, "seed", Number(seed.value));
});
prompt.addEventListener("input", () => {
setWidgetValue(node, "prompt", prompt.value);
updateBatchSummary(node);
if (!node._o1igOptimizing) setPromptOptimizeStatus(node);
});
promptOptimize.addEventListener("click", () => optimizePrompt(node));
generate.addEventListener("click", () => queueGeneration(node));
renderReferences(node);
syncMaskControl(node);
syncModelOptions(node);
syncResizeWarning(node);
syncBatchLayout(node);
fitNode(node);
requestAnimationFrame(() => {
node._o1igOutputsReady = true;
syncGeneratorOutputVisibility(node);
});
}
function buildSavePanel(node) {
if (node._o1igsPanelReady) return;
injectStyles();
Object.assign(node, {
_o1igsPanelReady: true,
_o1igsLastResults: node._o1igsLastResults || [],
_o1igsSlots: node._o1igsSlots || storedSaveSlots(node),
_o1igsBusy: false,
_o1igsProgress: 0,
});
syncSaveProgressOverlay(node);
syncSaveSlotsWidget(node);
renderSaveResults(node, node._o1igsLastResults, { persist: false });
}
function installNativeMinimumSize(nodeType, minimum) {
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, minimum[0]);
computed[1] = Math.max(Number(computed[1]) || 0, minimum[1]);
return computed;
};
}
function restorePanelFromWidgets(node) {
if (!node._o1igPanel) return;
hideBackendWidgets(node);
node._o1igReferences = parseReferences(findWidget(node, "参考图清单")?.value);
node._o1igModelReferences = parseReferences(findWidget(node, "模特图清单")?.value);
node._o1igMask = parseMask(findWidget(node, "蒙版清单")?.value);
const rawBatchEnabled = findWidget(node, "批量出图")?.value;
node._o1igBatchEnabled = rawBatchEnabled === true
|| String(rawBatchEnabled || "").toLowerCase() === "true"
|| rawBatchEnabled === "开启";
node._o1igBatchMode.value = findWidget(node, "批量模式")?.value || BATCH_MODE_GROUP_TO_MODELS;
node._o1igModel.value = findWidget(node, "模型")?.value || "Nano Banana 2";
node._o1igRoute.value = findWidget(node, "模型线路")?.value || "畅速";
node._o1igThinking.value = findWidget(node, "思考等级")?.value || "低";
node._o1igOnlineSearch.value = findWidget(node, "在线搜索")?.value || "关闭";
node._o1igQuality.value = findWidget(node, "质量")?.value || "自动";
node._o1igOutputFormat.value = findWidget(node, "输出格式")?.value || "jpeg";
node._o1igBackground.value = findWidget(node, "背景")?.value || "auto";
node._o1igResize.value = findWidget(node, "缩放图片")?.value || "不缩放";
node._o1igNamingRule.value = findWidget(node, "命名规则")?.value || "自定义前缀";
node._o1igFilenamePrefix.value = findWidget(node, "filename_prefix")?.value || "o1key";
node._o1igSaveFormat.value = findWidget(node, "格式")?.value || "原始";
node._o1igSaveLocation.value = findWidget(node, "保存位置")?.value || "";
node._o1igPrompt.value = findWidget(node, "prompt")?.value || "";
node._o1igCount.value = normalizeImageCount(findWidget(node, "生图数量")?.value, node._o1igModel.value);
node._o1igSeed.value = String(findWidget(node, "seed")?.value || 0);
syncModelOptions(node);
syncResizeWarning(node);
syncBatchLayout(node);
renderReferences(node);
syncMaskControl(node);
}
app.registerExtension({
name: "o1key.imageGeneratorPanel",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === SAVE_NODE_TYPE) {
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalCreated?.apply(this, arguments);
buildSavePanel(this);
};
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (data) {
const result = originalConfigure?.apply(this, arguments);
buildSavePanel(this);
const storedResults = storedSaveResults(this, data);
this._o1igsSlots = reconcileSaveSlotsWithResults(
storedSaveSlots(this, data),
storedResults,
);
syncSaveSlotsWidget(this);
renderSaveResults(this, storedResults, { persist: false });
return result;
};
const originalSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (data) {
originalSerialize?.apply(this, arguments);
const images = normalizeSaveResults(this._o1igsLastResults);
const slots = normalizeSaveSlots(this._o1igsSlots);
this.properties ??= {};
this.properties.o1keyImageSaveResults = images;
this.properties.o1keyImageSlots = slots;
data.properties ??= {};
data.properties.o1keyImageSaveResults = images;
data.properties.o1keyImageSlots = slots;
data.o1key_image_save_results = images;
data.o1key_image_slots = slots;
};
const originalExecutionStart = nodeType.prototype.onExecutionStart;
nodeType.prototype.onExecutionStart = function () {
originalExecutionStart?.apply(this, arguments);
buildSavePanel(this);
if (
this._o1igsStandardQueueReserved
|| this._o1igsStandardExecutionPending
|| this._o1igsBusy
) setSaveBusy(this, true);
};
const originalExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
const images = Array.isArray(message?.images) ? message.images : [];
const restoringPreview = Boolean(this._o1igsRestoringPreview);
originalExecuted?.apply(this, arguments);
buildSavePanel(this);
renderSaveResults(this, images, { persist: !restoringPreview });
if (images.length) scheduleSaveNodeImageFit(this);
if (restoringPreview) setSaveBusy(this, false);
else finishGenerationBatch(
this,
"生成完成",
String(this._o1igsDispatchBatchId || ""),
);
this.setDirtyCanvas?.(true, true);
};
const originalRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
clearTimeout(this._o1igsPreviewFitTimer);
detachParallelBatchNode(this);
originalRemoved?.apply(this, arguments);
};
return;
}
if (nodeData.name !== NODE_TYPE) return;
// A running ComfyUI server can still serve the previous schema until restart.
if (nodeData.input?.optional) delete nodeData.input.optional.external_prompt;
installNativeMinimumSize(nodeType, GENERATOR_MIN_SIZE);
const originalCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalCreated?.apply(this, arguments);
this._o1igApplyGptDefaults = true;
applyGeneratorDefaultSize(this);
buildPanel(this);
};
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (data) {
this._o1igApplyGptDefaults = false;
const result = originalConfigure?.apply(this, arguments);
buildPanel(this);
restorePanelFromWidgets(this);
fitNode(this);
return result;
};
const originalExecutionStart = nodeType.prototype.onExecutionStart;
nodeType.prototype.onExecutionStart = function () {
originalExecutionStart?.apply(this, arguments);
buildPanel(this);
const standardSaveNode = startNextStandardExecution(this);
if (standardSaveNode) {
this._o1igRunning = false;
setStatus(this, "", "");
return;
}
if (pendingBatchIds(this).size) updateGeneratorActivity(this);
else {
this._o1igRunning = false;
setStatus(this, "", "");
}
};
const originalExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
originalExecuted?.apply(this, arguments);
buildPanel(this);
clearNativePreview(this);
if (this._o1igsActiveStandardSaveNodeId != null) {
this._o1igRunning = false;
setStatus(this, "", "");
} else if (pendingBatchIds(this).size) updateGeneratorActivity(this);
else {
this._o1igRunning = false;
setStatus(this, "", "");
}
this.setDirtyCanvas?.(true, true);
};
const originalRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
if (this._o1igOutsideHandler) document.removeEventListener("pointerdown", this._o1igOutsideHandler);
clearTimeout(this._o1igStatusTimer);
clearTimeout(this._o1igPromptOptimizeStatusTimer);
detachParallelBatchNode(this);
originalRemoved?.apply(this, arguments);
};
},
loadedGraphNode(node) {
if (node?.comfyClass === NODE_TYPE) {
buildPanel(node);
fitNode(node);
} else if (node?.comfyClass === SAVE_NODE_TYPE) {
buildSavePanel(node);
restoreNativeSavePreview(node);
}
},
async afterConfigureGraph() {
requestAnimationFrame(() => {
for (const node of app.graph?._nodes || []) {
if (node?.comfyClass === NODE_TYPE) {
node._o1igOutputsReady = true;
syncGeneratorOutputVisibility(node);
fitNode(node);
if (!findLegacyAutoSaveNodes(node).length) continue;
try {
ensureSaveNode(node);
} catch {
// Keep the legacy node intact until the new backend node is available.
}
} else if (node?.comfyClass === SAVE_NODE_TYPE) {
restoreNativeSavePreview(node);
recoverParallelBatch(node);
}
}
});
},
setup() {
installNativeTaskQueueBridge();
installStandardQueueRouting();
installNativePreviewActionSync();
api.addEventListener("o1key.image_job", ({ detail }) => {
handleParallelImageJob(detail);
});
api.addEventListener("execution_error", ({ detail }) => {
const rawId = detail?.node_id ?? detail?.node;
const nodeId = typeof rawId === "string" ? Number(rawId) : rawId;
const node = app.graph?.getNodeById?.(nodeId);
const rawMessage = detail?.exception_message || detail?.error;
const message = formatImageGenerationError(rawMessage);
if (node?.comfyClass === NODE_TYPE) {
failOldestGenerationBatch(node, message);
replaceExecutionErrorOverlay(message);
} else if (node?.comfyClass === SAVE_NODE_TYPE) {
finishGenerationBatch(node, message);
replaceExecutionErrorOverlay(message);
} else if (SEEDANCE_ERROR_NODE_TYPES.has(node?.comfyClass)) {
replaceExecutionErrorOverlay(formatSeedanceGenerationError(rawMessage));
}
});
api.addEventListener("execution_interrupted", () => {
for (const node of app.graph?._nodes || []) {
if (
node?.comfyClass === NODE_TYPE
&& (node._o1igRunning || pendingBatchIds(node).size || activeStandardSaveNode(node))
) {
failOldestGenerationBatch(node, "生成已取消");
}
}
});
},
});