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×512(262144)~6000×6000(36000000)之间,当前为 ${width}×${height}(${pixels})`); } if (!layerDecomposition && pixels > limits.maxPixels) { throw new Error(`${label}总像素不能超过 6000×6000(36000000),当前为 ${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:16~16:1,总像素 512×512~6000×6000" : "Seedream 参考图:单图不超过 30MB,宽和高均 >14px,宽高比 1:16~16: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, "生成已取消"); } } }); }, });