import assert from "node:assert/strict"; import { webcrypto } from "node:crypto"; import { File } from "node:buffer"; import fs from "node:fs"; import vm from "node:vm"; const sourcePath = new URL("../web/js/o1keyImageGenerator.js", import.meta.url); const rawSource = fs.readFileSync(sourcePath, "utf8"); const source = rawSource.replace(/^import .*;\s*$/gm, ""); const migrationPath = new URL("../web/js/migrateWorkflow.js", import.meta.url); const migrationSource = fs.readFileSync(migrationPath, "utf8"); const nanoPromptOptimizerPath = new URL("../web/js/nanoBananaPromptOptimizer.js", import.meta.url); assert.equal(fs.existsSync(nanoPromptOptimizerPath), false); const batchReferenceLabelsPath = new URL("../web/js/batchNanoBananaReferenceLabels.js", import.meta.url); const batchReferenceLabelsSource = fs.readFileSync(batchReferenceLabelsPath, "utf8"); const nanoRouteLabelsPath = new URL("../web/js/nanoBananaRouteLabels.js", import.meta.url); const nanoRouteLabelsSource = fs.readFileSync(nanoRouteLabelsPath, "utf8"); const batchNanoQualityPath = new URL("../web/js/batchNanoBananaImageQuality.js", import.meta.url); const batchNanoQualitySource = fs.readFileSync(batchNanoQualityPath, "utf8"); const seedanceMultiModalDynamicPath = new URL("../web/js/seedanceMultiModalDynamic.js", import.meta.url); const seedanceMultiModalDynamicSource = fs.readFileSync(seedanceMultiModalDynamicPath, "utf8"); const seedanceAutoPassDynamicPath = new URL("../web/js/seedanceAutoPassDynamic.js", import.meta.url); const seedanceAutoPassDynamicSource = fs.readFileSync(seedanceAutoPassDynamicPath, "utf8"); const seedanceResolutionGuardPath = new URL("../web/js/seedanceResolutionGuard.js", import.meta.url); const seedanceResolutionGuardSource = fs.readFileSync(seedanceResolutionGuardPath, "utf8"); const minimaxH3ParameterGuardPath = new URL("../web/js/minimaxH3ParameterGuard.js", import.meta.url); const minimaxH3ParameterGuardSource = fs.readFileSync(minimaxH3ParameterGuardPath, "utf8"); let batchReferenceLabelsExtension; vm.runInNewContext(batchReferenceLabelsSource.replace(/^import .*;\s*$/gm, ""), { app: { registerExtension(extension) { batchReferenceLabelsExtension = extension; } }, requestAnimationFrame(callback) { callback(); }, }); for (const comfyClass of ["BatchNanoBananaPro", "O1keyGPTImageBatch"]) { const pathCountWidget = { name: "图片路径数量", value: "1个路径", callback() { return "path-count-callback"; }, }; const inputs = [1, 2, 3].map(index => ({ name: `参考图组.参考图${index}` })); const node = { comfyClass, widgets: [pathCountWidget], inputs, setDirtyCanvas() {}, }; batchReferenceLabelsExtension.nodeCreated(node); assert.deepEqual(inputs.map(input => input.label), ["参考图2", "参考图3", "参考图4"]); pathCountWidget.value = "3个路径"; assert.equal(pathCountWidget.callback(), "path-count-callback"); assert.deepEqual(inputs.map(input => input.label), ["参考图4", "参考图5", "参考图6"]); assert.deepEqual(inputs.map(input => input.name), [ "参考图组.参考图1", "参考图组.参考图2", "参考图组.参考图3", ]); } class ClassList { constructor(element) { this.element = element; this.values = new Set(); } add(value) { this.values.add(value); } remove(value) { this.values.delete(value); } contains(value) { return this.values.has(value); } toggle(value, force) { const enabled = force ?? !this.values.has(value); if (enabled) this.values.add(value); else this.values.delete(value); return enabled; } } class Element { constructor(tagName) { this.tagName = tagName.toUpperCase(); this.children = []; this.className = ""; this.classList = new ClassList(this); this.style = { setProperty(name, value) { this[name] = value; } }; this.options = {}; this.textContent = ""; this.disabled = false; this.listeners = new Map(); this.dataset = {}; } append(...items) { this.children.push(...items); } prepend(...items) { this.children.unshift(...items); } replaceChildren(...items) { this.children = [...items]; } addEventListener(type, listener) { this.listeners.set(type, listener); } setAttribute(name, value) { this[name] = value; } dispatch(type, event = { stopPropagation() {} }) { return this.listeners.get(type)?.(event); } focus() { document.activeElement = this; } } const createdElements = []; const elementsById = new Map(); const document = { activeElement: null, head: { append(element) { if (element.id) elementsById.set(element.id, element); }, appendChild(element) { if (element.id) elementsById.set(element.id, element); }, }, body: { append(element) { if (element.id) elementsById.set(element.id, element); }, }, createElement(tagName) { const element = new Element(tagName); createdElements.push(element); return element; }, createElementNS(_namespace, tagName) { const element = new Element(tagName); createdElements.push(element); return element; }, getElementById(id) { return elementsById.get(id) || null; }, querySelector(selector) { return this._querySelector?.(selector) || null; }, addEventListener() {}, removeEventListener() {}, }; let nextNodeId = 1; let nextLinkId = 1; const graph = { nodes: new Map(), links: new Map(), add(node) { node.id = nextNodeId++; node.graph = this; this.nodes.set(node.id, node); }, getNodeById(id) { return this.nodes.get(id); }, beforeChange() {}, afterChange() {}, setDirtyCanvas() {}, }; const LiteGraph = { createNode(type) { return { type, comfyClass: type, pos: [0, 0], size: [300, 260], properties: {}, inputs: [{ link: null }], widgets: [], }; }, }; const app = { graph, registerExtension(extension) { this.extension = extension; }, extensionManager: {}, }; const executedEvents = []; const statusEvents = []; const apiListeners = new Map(); const api = { apiURL: (value) => value, dispatchCustomEvent(type, detail) { if (type === "executed") { executedEvents.push(detail); app.nodeOutputs ??= {}; app.nodeOutputs[String(detail.display_node)] = detail.output; graph.getNodeById(detail.display_node)?.onExecuted?.(detail.output); } else if (type === "status") { statusEvents.push(detail); } for (const listener of apiListeners.get(type) || []) listener({ detail }); }, addEventListener(type, listener) { if (!apiListeners.has(type)) apiListeners.set(type, []); apiListeners.get(type).push(listener); }, getQueue: async () => ({ Running: [], Pending: [{ id: "native-pending", status: "pending", create_time: 1, priority: 1 }], }), getHistory: async () => [ { id: "native-completed", status: "completed", create_time: 1, priority: 1 }, ], getJobDetail: async () => undefined, cancelJob: async () => {}, cancelJobs: async () => {}, deleteItem: async () => {}, clearItems: async () => {}, }; const editorRequests = []; const openReferenceImageEditor = async (options) => { editorRequests.push(options); return null; }; const context = vm.createContext({ app, api, document, window: { LiteGraph }, URL, URLSearchParams, FormData, File, Event, openReferenceImageEditor, crypto: webcrypto, console, clearTimeout, setTimeout, requestAnimationFrame: (callback) => callback(), }); vm.runInContext(source, context, { filename: sourcePath.pathname }); { const nodeData = { name: "O1keyImageGenerator", input: { optional: { external_prompt: ["STRING", {}], keep: ["IMAGE", {}] } }, }; await app.extension.beforeRegisterNodeDef(function GeneratorNode() {}, nodeData); assert.equal("external_prompt" in nodeData.input.optional, false); assert.equal("keep" in nodeData.input.optional, true); } const setStatus = vm.runInContext("setStatus", context); const makeDropdown = vm.runInContext("makeDropdown", context); const isGeneratedSeedControl = vm.runInContext("isGeneratedSeedControl", context); const hideBackendWidgets = vm.runInContext("hideBackendWidgets", context); const queueGeneration = vm.runInContext("queueGeneration", context); const modelOptions = vm.runInContext("MODEL_OPTIONS", context); const routeOptions = vm.runInContext("ROUTES", context); const maxReferences = vm.runInContext("MAX_REFERENCES", context); const maxRequestReferences = vm.runInContext("MAX_REQUEST_REFERENCES", context); const referenceLimit = vm.runInContext("referenceLimit", context); const syncModelOptions = vm.runInContext("syncModelOptions", context); const syncGeneratorOutputVisibility = vm.runInContext("syncGeneratorOutputVisibility", context); const syncGptOutputOptions = vm.runInContext("syncGptOutputOptions", context); const optimizePrompt = vm.runInContext("optimizePrompt", context); const applyGeneratorDefaultSize = vm.runInContext("applyGeneratorDefaultSize", context); const createSaveNodeForBatch = vm.runInContext("createSaveNodeForBatch", context); const acquireSaveNodeForBatch = vm.runInContext("acquireSaveNodeForBatch", context); const acquireLayerSaveNodeForBatch = vm.runInContext("acquireLayerSaveNodeForBatch", context); const splitLayerSaveResults = vm.runInContext("splitLayerSaveResults", context); const prepareStandardQueue = vm.runInContext("prepareStandardQueue", context); const startNextStandardExecution = vm.runInContext("startNextStandardExecution", context); const finishStandardQueue = vm.runInContext("finishStandardQueue", context); const takeStandardQueueRoutes = vm.runInContext("takeStandardQueueRoutes", context); const filterStandardQueueOutputs = vm.runInContext("filterStandardQueueOutputs", context); const reroutePartialExecutionTargets = vm.runInContext("reroutePartialExecutionTargets", context); const buildSavePanel = vm.runInContext("buildSavePanel", context); const generatorSaveSettings = vm.runInContext("generatorSaveSettings", context); const beginGenerationBatch = vm.runInContext("beginGenerationBatch", context); const finishGenerationBatch = vm.runInContext("finishGenerationBatch", context); const handleParallelImageJob = vm.runInContext("handleParallelImageJob", context); const recoverParallelBatch = vm.runInContext("recoverParallelBatch", context); const detachParallelBatchNode = vm.runInContext("detachParallelBatchNode", context); const installNativeTaskQueueBridge = vm.runInContext("installNativeTaskQueueBridge", context); const updateNativeQueueJob = vm.runInContext("updateNativeQueueJob", context); const acceptFiles = vm.runInContext("acceptFiles", context); const validateSeedreamReferenceDimensions = vm.runInContext("validateSeedreamReferenceDimensions", context); const validateSeedreamReferenceFile = vm.runInContext("validateSeedreamReferenceFile", context); const bindImageFileDropTarget = vm.runInContext("bindImageFileDropTarget", context); const canvasImageCandidates = vm.runInContext("canvasImageCandidates", context); const importCanvasImage = vm.runInContext("importCanvasImage", context); const readCanvasImageFile = vm.runInContext("readCanvasImageFile", context); const renderSaveResults = vm.runInContext("renderSaveResults", context); const storedSaveResults = vm.runInContext("storedSaveResults", context); const storedSaveSlots = vm.runInContext("storedSaveSlots", context); const reconcileSaveSlotsWithResults = vm.runInContext("reconcileSaveSlotsWithResults", context); const expandPanelPromptTasks = vm.runInContext("expandPanelPromptTasks", context); const expandPanelGenerationTasks = vm.runInContext("expandPanelGenerationTasks", context); const referenceBadgeDetails = vm.runInContext("referenceBadgeDetails", context); const referenceDropDestination = vm.runInContext("referenceDropDestination", context); const moveReference = vm.runInContext("moveReference", context); const renderReferenceRole = vm.runInContext("renderReferenceRole", context); const editReference = vm.runInContext("editReference", context); const replaceReference = vm.runInContext("replaceReference", context); const updateBatchReferenceHint = vm.runInContext("updateBatchReferenceHint", context); const updateBatchSummary = vm.runInContext("updateBatchSummary", context); const initializeSaveSlots = vm.runInContext("initializeSaveSlots", context); const updateSaveSlotsForState = vm.runInContext("updateSaveSlotsForState", context); const applyPartialSaveSlots = vm.runInContext("applyPartialSaveSlots", context); const applyCompletedSaveSlots = vm.runInContext("applyCompletedSaveSlots", context); const retryFailedSaveSlot = vm.runInContext("retryFailedSaveSlot", context); const saveSlotsWidgetHeight = vm.runInContext("saveSlotsWidgetHeight", context); const syncSaveSlotsWidget = vm.runInContext("syncSaveSlotsWidget", context); const preferredNativeSavePreviewHeight = vm.runInContext("preferredNativeSavePreviewHeight", context); const fitSaveNodeToNativeImages = vm.runInContext("fitSaveNodeToNativeImages", context); const syncNativeQueueCounts = vm.runInContext("syncNativeQueueCounts", context); const restoreNativeSavePreview = vm.runInContext("restoreNativeSavePreview", context); const regenerateFromSaveNode = vm.runInContext("regenerateFromSaveNode", context); const syncNativePreviewAction = vm.runInContext("syncNativePreviewAction", context); const formatImageGenerationError = vm.runInContext("formatImageGenerationError", context); const formatSeedanceGenerationError = vm.runInContext("formatSeedanceGenerationError", context); const seedanceErrorNodeTypes = vm.runInContext("SEEDANCE_ERROR_NODE_TYPES", context); const updateExecutionErrorOverlay = vm.runInContext("updateExecutionErrorOverlay", context); installNativeTaskQueueBridge(); function createRecoveryNodes(batchId, generatorId, saveNodeId) { const linkId = nextLinkId++; const generator = { id: generatorId, type: "O1keyImageGenerator", comfyClass: "O1keyImageGenerator", graph, outputs: [{ links: [linkId] }], widgets: [ { name: "模型", value: "Nano Banana 2" }, { name: "filename_prefix", value: "o1key" }, { name: "格式", value: "原始" }, { name: "保存位置", value: "" }, { name: "命名规则", value: "自定义前缀" }, ], }; const saveNode = { id: saveNodeId, type: "O1keyImageSave", comfyClass: "O1keyImageSave", graph, inputs: [{ link: linkId }], properties: { o1keyBatchId: batchId, o1keyGeneratorNodeId: generatorId, }, widgets: [], _o1igsLastResults: [], setDirtyCanvas() {}, }; graph.nodes.set(generatorId, generator); graph.nodes.set(saveNodeId, saveNode); graph.links.set(linkId, { origin_id: generatorId, target_id: saveNodeId }); return { generator, saveNode, linkId }; } { const seed = { name: "seed", value: 123, options: {} }; const generatedControl = { name: "control_after_generate", value: "randomize", options: {}, }; const prefixedGeneratedControl = { name: "O1keyImageGenerator control_after_generate", value: "randomize", options: {}, }; const unrelatedControl = { name: "control_before_generate", options: {} }; const node = { widgets: [seed, generatedControl, prefixedGeneratedControl, unrelatedControl], }; assert.equal(isGeneratedSeedControl(generatedControl), true); assert.equal(isGeneratedSeedControl(prefixedGeneratedControl), true); assert.equal(isGeneratedSeedControl(unrelatedControl), false); hideBackendWidgets(node); assert.equal(seed.hidden, true); assert.equal(generatedControl.hidden, true); assert.equal(generatedControl.options.hidden, true); const hiddenSize = generatedControl.computeSize(); assert.equal(hiddenSize[0], 0); assert.equal(hiddenSize[1], -4); assert.equal(prefixedGeneratedControl.hidden, true); assert.equal(unrelatedControl.hidden, undefined); } { const linkId = 8800; const generator = { id: 880, type: "O1keyImageGenerator", comfyClass: "O1keyImageGenerator", graph, inputs: [], outputs: [{ links: [linkId] }], widgets: [], _o1igPrompt: { value: "", focus() {} }, _o1igModel: { value: "Nano Banana 2" }, _o1igRoute: { value: "畅速" }, _o1igThinking: { value: "低" }, _o1igResolution: { value: "智能" }, _o1igRatio: { value: "智能" }, _o1igCount: { value: "1" }, _o1igSeed: { value: "0" }, _o1igReferences: [], _o1igModelReferences: [], _o1igMask: null, _o1igQuality: { value: "自动" }, _o1igOutputFormat: { value: "jpeg" }, _o1igResize: { value: "不缩放" }, _o1igBackground: { value: "auto" }, _o1igBatchEnabled: false, _o1igBatchMode: { value: "一组搭配+多模特" }, _o1igNamingRule: { value: "自定义前缀" }, _o1igFilenamePrefix: { value: "o1key" }, _o1igSaveFormat: { value: "原始" }, _o1igSaveLocation: { value: "" }, _o1igLayerDecomposition: { value: "关闭" }, _o1igPending: [], _o1igModelPending: [], _o1igMaskPending: false, _o1igGenerate: new Element("button"), _o1igStatus: new Element("div"), _o1igPanel: new Element("div"), }; const saveNode = { id: 881, type: "O1keyImageSave", comfyClass: "O1keyImageSave", graph, inputs: [{ link: linkId }], widgets: [], properties: {}, }; graph.nodes.set(generator.id, generator); graph.nodes.set(saveNode.id, saveNode); graph.links.set(linkId, { origin_id: generator.id, origin_slot: 0, target_id: saveNode.id, target_slot: 0, }); const queueCalls = []; const originalQueuePrompt = app.queuePrompt; app.queuePrompt = async (...args) => { queueCalls.push(args); return true; }; await queueGeneration(generator); assert.deepEqual(queueCalls, []); assert.equal(generator._o1igStatus.textContent, "请输入提示词"); generator._o1igPrompt.value = "测试替换等待"; generator._o1igReplacing = new Map([[{}, "replacement.png"]]); await queueGeneration(generator); assert.deepEqual(queueCalls, []); assert.equal(generator._o1igStatus.textContent, "请等待图片上传完成"); app.queuePrompt = originalQueuePrompt; graph.links.delete(linkId); graph.nodes.delete(generator.id); graph.nodes.delete(saveNode.id); } { const raw = "status_code=400, content rejected: the image was flagged as unsafe by the content safety system"; assert.equal( formatImageGenerationError(raw), "内容被拒绝:该图像被内容安全系统标记为不安全。", ); assert.equal( formatImageGenerationError("status_code=400, Your request was rejected by the safety system"), "您的请求已被安全系统拒绝", ); assert.equal( formatImageGenerationError("status_code=403, insufficient balance"), "上游额度不足!", ); assert.equal( formatImageGenerationError("status_code=502, Image generation returned empty response"), "图片生成过程中被内容审查机制拒绝!", ); assert.equal( formatImageGenerationError("status_code=451, The provided prompt is considered unsafe and it cannot be used to generate content"), "提供的提示被认为是不安全的,不能用于生成内容。", ); assert.equal(formatImageGenerationError("其他生成错误"), "其他生成错误"); } { const raw = "The request failed because the output video may be related to copyright restriction"; const policyRaw = "OutputVideoSensitiveContentDetected.PolicyViolation: The request failed because the output video may be related to copyright restrictions"; assert.equal( formatSeedanceGenerationError(`生成失败,响应:${raw}`), "输出视频触发版权审查被拒绝生成!", ); assert.equal( formatSeedanceGenerationError(policyRaw), "输出视频触发版权审查被拒绝生成!", ); assert.equal(formatSeedanceGenerationError("其他视频错误"), "其他视频错误"); assert.equal(seedanceErrorNodeTypes.has("SeedanceAutoPass"), true); assert.equal(seedanceErrorNodeTypes.has("SeedanceMultiModal"), true); assert.equal(seedanceErrorNodeTypes.has("SeedanceAutoPassBatch"), false); } { const messageElement = new Element("p"); const messageContainer = { querySelector(selector) { return selector === "p" ? messageElement : null; }, }; const overlay = { querySelector(selector) { return selector === '[data-testid="error-overlay-messages"]' ? messageContainer : null; }, }; const root = { querySelector(selector) { return selector === '[data-testid="error-overlay"]' ? overlay : null; }, }; assert.equal(updateExecutionErrorOverlay("上游额度不足!", root), true); assert.equal(messageElement.textContent, "上游额度不足!"); assert.equal(updateExecutionErrorOverlay("不会写入", { querySelector: () => null }), false); } { const requests = []; api.fetchApi = (_path, options) => new Promise((resolve) => { requests.push({ path: _path, body: options.body, resolve, }); }); const firstFile = new Blob(["first"], { type: "image/jpeg" }); const secondFile = new Blob(["second"], { type: "image/jpeg" }); Object.defineProperty(firstFile, "name", { value: "same.jpg" }); Object.defineProperty(secondFile, "name", { value: "same.jpg" }); const uploadNode = { _o1igReferences: [], _o1igPending: [], _o1igRefs: new Element("div"), _o1igAdd: new Element("button"), _o1igGenerate: { disabled: false, textContent: "" }, _o1igStatus: { className: "", textContent: "" }, _o1igPanel: { classList: new ClassList({}) }, widgets: [{ name: "参考图清单", value: "[]" }], }; const uploads = acceptFiles(uploadNode, [firstFile, secondFile]); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(requests.length, 1); assert.equal(requests[0].path, "/upload/image"); assert.equal(requests[0].body.get("subfolder"), null); assert.equal(requests[0].body.get("type"), "input"); assert.equal(requests[0].body.get("overwrite"), "false"); requests[0].resolve({ ok: true, json: async () => ({ name: "same.jpg", subfolder: "" }), }); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(requests.length, 2); assert.equal(requests[1].body.get("subfolder"), null); requests[1].resolve({ ok: true, json: async () => ({ name: "same (1).jpg", subfolder: "" }), }); await uploads; assert.deepEqual( JSON.parse(JSON.stringify(uploadNode._o1igReferences)), [ { name: "same.jpg", subfolder: "", type: "input" }, { name: "same (1).jpg", subfolder: "", type: "input" }, ], ); } { const previousFetchApi = api.fetchApi; api.fetchApi = async (path) => { assert.equal(path, "/upload/image"); return { ok: true, json: async () => ({ name: "dropped.png", subfolder: "" }), }; }; const dropTarget = new Element("div"); const dropNode = { _o1igReferences: [], _o1igPending: [], _o1igRefs: dropTarget, _o1igAdd: new Element("button"), _o1igGenerate: { disabled: false, textContent: "" }, _o1igStatus: { className: "", textContent: "" }, _o1igPanel: { classList: new ClassList({}) }, widgets: [{ name: "参考图清单", value: "[]" }], }; bindImageFileDropTarget(dropNode, dropTarget); let prevented = 0; let stopped = 0; dropTarget.dispatch("dragover", { dataTransfer: { types: ["text/plain"], files: [] }, preventDefault() { prevented += 1; }, stopPropagation() { stopped += 1; }, }); assert.equal(prevented, 0); assert.equal(stopped, 0); assert.equal(dropTarget.classList.contains("drag"), false); const droppedFile = new Blob(["pixels"], { type: "image/png" }); Object.defineProperty(droppedFile, "name", { value: "dropped.png" }); const dataTransfer = { types: ["Files"], files: [droppedFile], dropEffect: "none" }; dropTarget.dispatch("dragover", { dataTransfer, preventDefault() { prevented += 1; }, stopPropagation() { stopped += 1; }, }); assert.equal(prevented, 1); assert.equal(stopped, 1); assert.equal(dataTransfer.dropEffect, "copy"); assert.equal(dropTarget.classList.contains("drag"), true); dropTarget.dispatch("drop", { dataTransfer, preventDefault() { prevented += 1; }, stopPropagation() { stopped += 1; }, }); await new Promise((resolve) => setTimeout(resolve, 0)); await dropNode._o1igReferenceCommit; assert.equal(prevented, 2); assert.equal(stopped, 2); assert.equal(dropTarget.classList.contains("drag"), false); assert.deepEqual( JSON.parse(JSON.stringify(dropNode._o1igReferences)), [{ name: "dropped.png", subfolder: "", type: "input" }], ); const nestedUploadTarget = new Element("button"); dropTarget.append(nestedUploadTarget); bindImageFileDropTarget(dropNode, nestedUploadTarget); const nestedTransfer = { types: ["Files"], files: [], dropEffect: "none" }; dropTarget.dispatch("dragover", { dataTransfer: nestedTransfer, preventDefault() {}, stopPropagation() {}, }); nestedUploadTarget.dispatch("dragover", { dataTransfer: nestedTransfer, preventDefault() {}, stopPropagation() {}, }); assert.equal(dropTarget.classList.contains("drag"), true); assert.equal(nestedUploadTarget.classList.contains("drag"), true); nestedUploadTarget.dispatch("drop", { dataTransfer: nestedTransfer, preventDefault() {}, stopPropagation() {}, }); assert.equal(dropTarget.classList.contains("drag"), false); assert.equal(nestedUploadTarget.classList.contains("drag"), false); api.fetchApi = previousFetchApi; } { const target = { id: 910, comfyClass: "O1keyImageGenerator" }; const cropNode = { id: 911, title: "快速裁剪", imgs: [{ src: "/view?filename=crop-preview.png&type=temp&subfolder=preview" }], }; const saveNode = { id: 912, comfyClass: "SaveImage", _o1igsLastResults: [ { filename: "saved.png", subfolder: "session", type: "output" }, ], }; target.graph = { _nodes: [target, cropNode, saveNode] }; const previousOutputs = app.nodeOutputs; app.nodeOutputs = { "911": { images: [ { filename: "cropped.png", subfolder: "", type: "temp" }, { filename: "cropped.png", subfolder: "", type: "temp" }, ], }, }; const candidates = canvasImageCandidates(target); assert.deepEqual( JSON.parse(JSON.stringify(candidates)), [ { nodeId: "911", sourceLabel: "快速裁剪", descriptor: { filename: "cropped.png", subfolder: "", type: "temp" }, }, { nodeId: "911", sourceLabel: "快速裁剪", descriptor: { filename: "crop-preview.png", subfolder: "preview", type: "temp" }, }, { nodeId: "912", sourceLabel: "SaveImage", descriptor: { filename: "saved.png", subfolder: "session", type: "output" }, }, ], ); app.nodeOutputs = previousOutputs; } { const calls = []; const previousFetchApi = api.fetchApi; api.fetchApi = async (path, options) => { calls.push({ path, options }); if (path.startsWith("/view?")) { return { ok: true, blob: async () => new Blob(["cropped-pixels"], { type: "image/png" }), }; } assert.equal(path, "/upload/image"); return { ok: true, json: async () => ({ name: "cropped (1).png", subfolder: "" }), }; }; const target = { _o1igReferences: [], _o1igPending: [], _o1igRefs: new Element("div"), _o1igAdd: new Element("button"), _o1igGenerate: { disabled: false, textContent: "" }, _o1igStatus: { className: "", textContent: "" }, _o1igPanel: { classList: new ClassList({}) }, widgets: [{ name: "参考图清单", value: "[]" }], }; await importCanvasImage(target, { filename: "cropped.png", subfolder: "preview", type: "temp", }); assert.match(calls[0].path, /^\/view\?/); assert.match(calls[0].path, /filename=cropped\.png/); assert.match(calls[0].path, /subfolder=preview/); assert.match(calls[0].path, /type=temp/); assert.equal(calls[1].path, "/upload/image"); assert.equal(calls[1].options.body.get("image").name, "cropped.png"); assert.deepEqual( JSON.parse(JSON.stringify(target._o1igReferences)), [{ name: "cropped (1).png", subfolder: "", type: "input" }], ); api.fetchApi = previousFetchApi; } { const node = { _o1igGenerate: { disabled: true, textContent: "" }, _o1igStatus: { className: "", textContent: "" }, _o1igPanel: { classList: new ClassList({}) }, }; setStatus(node, "", "busy"); assert.equal(node._o1igGenerate.disabled, false); assert.equal(node._o1igGenerate.textContent, "开始生成"); assert.equal(node._o1igStatus.textContent, ""); assert.doesNotMatch(node._o1igStatus.className, /visible/); } { const firstId = "77777777-7777-4777-8777-777777777777"; const secondId = "88888888-8888-4888-8888-888888888888"; updateNativeQueueJob({ batch_id: firstId, generator_node_id: 701, save_node_id: 702, state: "queued", }); updateNativeQueueJob({ batch_id: secondId, generator_node_id: 703, save_node_id: 704, state: "queued", }); let queue = await api.getQueue(); assert.equal(queue.Pending.filter((job) => job.id.startsWith("o1key:")).length, 2); assert.equal(queue.Pending.some((job) => job.id === "native-pending"), true); updateNativeQueueJob({ batch_id: firstId, generator_node_id: 701, save_node_id: 702, state: "running", }); queue = await api.getQueue(); assert.equal(queue.Running.some((job) => job.id === `o1key:${firstId}`), true); assert.equal(queue.Pending.some((job) => job.id === `o1key:${secondId}`), true); updateNativeQueueJob({ batch_id: firstId, generator_node_id: 701, save_node_id: 702, state: "completed", images: [{ filename: "queue-result.png", subfolder: "", type: "output" }], }); updateNativeQueueJob({ batch_id: secondId, generator_node_id: 703, save_node_id: 704, state: "failed", error: "test failure", }); const persistedId = "66666666-6666-4666-8666-666666666666"; const previousFetchApi = api.fetchApi; api.fetchApi = async (path) => { assert.equal(path, "/o1key/image/jobs/history?limit=64"); return { ok: true, json: async () => ({ items: [{ batch_id: persistedId, generator_node_id: 699, save_node_id: 700, state: "completed", images: [{ filename: "persisted.png", subfolder: "", type: "output" }], total_count: 1, create_time: 1000, execution_start_time: 1100, execution_end_time: 1200, }], }), }; }; const history = await api.getHistory(); api.fetchApi = previousFetchApi; assert.equal(history.some((job) => job.id === "native-completed"), true); assert.equal(history.find((job) => job.id === `o1key:${firstId}`).status, "completed"); assert.equal(history.find((job) => job.id === `o1key:${secondId}`).status, "failed"); assert.equal(history.find((job) => job.id === `o1key:${persistedId}`).preview_output.filename, "persisted.png"); assert.equal(history.find((job) => job.id === `o1key:${persistedId}`).execution_end_time, 1200); const detail = await api.getJobDetail(`o1key:${firstId}`); assert.equal(detail.outputs["702"].images[0].filename, "queue-result.png"); assert.equal(statusEvents.some((item) => item?.o1key_virtual_queue_update), true); let deletedHistoryBatch = null; api.fetchApi = async (path, options) => { assert.equal(path, "/o1key/image/jobs/history"); assert.equal(options.method, "POST"); deletedHistoryBatch = JSON.parse(options.body).batch_id; return { ok: true, json: async () => ({ ok: true }) }; }; await api.deleteItem("history", `o1key:${persistedId}`); api.fetchApi = previousFetchApi; assert.equal(deletedHistoryBatch, persistedId); assert.equal((await api.getHistory()).some((job) => job.id === `o1key:${persistedId}`), false); const countId = "99999999-9999-4999-8999-999999999999"; const queueItem = new Element("div"); queueItem.querySelector = (selector) => queueItem.children.find( (child) => selector.includes(child.dataset?.o1keyQueueCount), ) || null; const queueRow = new Element("div"); queueRow.append(queueItem); document.querySelectorAll = (selector) => selector.includes(`o1key:${countId}`) ? [queueRow] : []; updateNativeQueueJob({ batch_id: countId, generator_node_id: 705, save_node_id: 706, state: "queued", total_count: 9, }); syncNativeQueueCounts(); const countBadge = queueItem.children.find((child) => child.dataset?.o1keyQueueCount === `o1key:${countId}`); assert.equal(countBadge.tagName, "BUTTON"); assert.match(countBadge.className, /\bbg-secondary-background\b/); assert.match(countBadge.className, /\bh-8\b/); assert.match(countBadge.className, /\brounded-lg\b/); assert.match(countBadge.className, /\bgap-2\b/); assert.match(countBadge.className, /\bfont-medium\b/); assert.equal(countBadge.children[0].className, "icon-[lucide--layers] size-4"); assert.equal(countBadge.children[1].textContent, "9"); assert.equal(countBadge.title, "批次共 9 张"); delete document.querySelectorAll; } { const calls = []; let resolveRequest; api.fetchApi = async (path, options) => { calls.push({ path, body: JSON.parse(options.body) }); return new Promise((resolve) => { resolveRequest = () => resolve({ ok: true, status: 200, async json() { return { prompt: "优化后的视觉提示词" }; }, }); }); }; const prompt = new Element("textarea"); prompt.value = "让参考图人物站在雨夜街头"; const optimizeButton = new Element("button"); const optimizeStatus = new Element("div"); const generateButton = new Element("button"); const node = { widgets: [{ name: "prompt", value: prompt.value }], _o1igPrompt: prompt, _o1igPromptOptimize: optimizeButton, _o1igPromptOptimizeStatus: optimizeStatus, _o1igGenerate: generateButton, _o1igStatus: new Element("div"), _o1igPanel: new Element("div"), _o1igReferences: [ { name: "person.png", subfolder: "o1key_uploads/test", type: "input" }, ], _o1igPending: [], _o1igMaskPending: false, _o1igRunning: false, }; const optimization = optimizePrompt(node); await Promise.resolve(); assert.equal(optimizeStatus.textContent, "AI帮写中…"); assert.match(optimizeStatus.className, /visible busy/); assert.equal(node._o1igStatus.textContent, ""); resolveRequest(); await optimization; assert.equal(calls.length, 1); assert.equal(calls[0].path, "/o1key/image/prompt-optimize"); assert.equal(calls[0].body.prompt, "让参考图人物站在雨夜街头"); assert.deepEqual( JSON.parse(JSON.stringify(calls[0].body.references)), [{ name: "person.png", subfolder: "o1key_uploads/test", type: "input" }], ); assert.equal(prompt.value, "优化后的视觉提示词"); assert.equal(node.widgets[0].value, "优化后的视觉提示词"); assert.equal(prompt.readOnly, false); assert.equal(optimizeButton.disabled, false); assert.equal(optimizeButton.classList.contains("busy"), false); assert.equal(optimizeStatus.textContent, "AI帮写完成"); assert.match(optimizeStatus.className, /visible ok/); assert.equal(node._o1igStatus.textContent, ""); } { const dropdown = makeDropdown(["1K", "2K"], "1K", "分辨率"); const trigger = dropdown.children[0]; const selectedDescription = trigger.children[1]; const caret = trigger.children[trigger.children.length - 1]; const caretIcon = caret.children[0]; const caretPath = caretIcon.children[0]; assert.equal(caret.tagName, "SPAN"); assert.equal(caretIcon.tagName, "SVG"); assert.equal(caretIcon.viewBox, "0 0 12 12"); assert.equal(caretPath.d, "M2.25 4.25 6 8l3.75-3.75"); assert.equal(caretPath["stroke-linecap"], "round"); assert.equal(selectedDescription.textContent, ""); assert.equal(caret.textContent, ""); } { const dropdown = makeDropdown(modelOptions, "Nano Banana 2", "模型"); const descriptions = [...dropdown._o1igMenu.children].map((button) => ({ model: button.children[0].textContent, description: button.children[1].textContent, styled: button.classList.contains("has-description"), })); assert.deepEqual(JSON.parse(JSON.stringify(descriptions)), [ { model: "Nano Banana 2", description: "快速,批量", styled: true }, { model: "Nano Banana Pro", description: "高质量资产", styled: true }, { model: "GPT Image 2", description: "高质量,编辑", styled: true }, { model: "GPT Image 2.5 Sunburst", description: "最新,高质量", styled: true }, { model: "GPT Image 2.5 Flare", description: "快速,日常", styled: true }, { model: "Seedream 5.0 Pro", description: "高质量,参考图,分层", styled: true }, { model: "Nano Banana 2 Lite", description: "快速,草稿", styled: true }, { model: "Nano Banana", description: "快速,草稿", styled: true }, ]); assert.equal(dropdown._o1igValueLabel.textContent, "Nano Banana 2"); assert.equal(dropdown._o1igValueDescription.textContent, "快速,批量"); assert.equal(dropdown._o1igTrigger.title, "Nano Banana 2 · 快速,批量"); } { assert.doesNotThrow(() => validateSeedreamReferenceDimensions(15, 15, "reference.png")); assert.doesNotThrow(() => validateSeedreamReferenceDimensions(240, 15, "reference.png")); assert.throws( () => validateSeedreamReferenceDimensions(14, 240, "reference.png"), /宽和高都必须大于 14px/, ); assert.throws( () => validateSeedreamReferenceDimensions(241, 15, "reference.png"), /宽高比必须在 1:16~16:1/, ); assert.throws( () => validateSeedreamReferenceDimensions(6001, 6000, "reference.png"), /总像素不能超过/, ); assert.throws( () => validateSeedreamReferenceDimensions( 511, 512, "layers.png", { layerDecomposition: true }, ), /总像素必须在 512×512/, ); let bitmapClosed = false; context.createImageBitmap = async () => ({ width: 14, height: 240, close() { bitmapClosed = true; }, }); await assert.rejects( () => validateSeedreamReferenceFile( { _o1igModel: { value: "Seedream 5.0 Pro" }, _o1igLayerDecomposition: { value: "关闭" }, }, new File(["image"], "tiny.png", { type: "image/png" }), ), /宽和高都必须大于 14px/, ); assert.equal(bitmapClosed, true); await assert.rejects( () => validateSeedreamReferenceFile( { _o1igModel: { value: "Seedream 5.0 Pro" }, _o1igLayerDecomposition: { value: "关闭" }, }, { name: "large.png", size: 30 * 1024 * 1024 + 1 }, ), /文件不能超过 30MB/, ); } { const dropdown = makeDropdown(routeOptions, "直连", "模型线路"); assert.equal(dropdown.value, "直连"); assert.equal(dropdown._o1igValueLabel.textContent, "优质"); assert.equal(dropdown._o1igValueDescription.textContent, "小贵"); assert.deepEqual( JSON.parse(JSON.stringify(dropdown._o1igOptions)), [ { value: "畅速", label: "特价", description: "便宜" }, { value: "直连", label: "优质", description: "小贵" }, { value: "专线", label: "企业", description: "贵" }, ], ); } { const widgets = [ { name: "模型", value: "Nano Banana 2" }, { name: "分辨率", value: "512" }, { name: "宽高比", value: "智能" }, { name: "输出格式", value: "jpeg" }, { name: "背景", value: "auto" }, { name: "在线搜索", value: "关闭" }, { name: "命名规则", value: "自定义前缀" }, { name: "filename_prefix", value: "catalog" }, { name: "格式", value: "webp" }, { name: "保存位置", value: "project/session" }, { name: "图层拆分", value: false }, { name: "生图数量", value: "1" }, ]; const node = { widgets, _o1igModel: { value: "Nano Banana 2" }, _o1igResolution: makeDropdown(["512", "1K", "2K", "4K"], "512"), _o1igRatio: makeDropdown(["智能", "1:1"], "智能"), _o1igRatioField: { style: {} }, _o1igCount: makeDropdown(["1", "2", "4", "9"], "1"), _o1igCountField: { style: {} }, _o1igThinkingField: { style: {} }, _o1igOnlineSearch: makeDropdown(["关闭", "打开"], "关闭"), _o1igOnlineSearchField: { style: {} }, _o1igLayerDecomposition: makeDropdown(["关闭", "开启"], "关闭"), _o1igLayerDecompositionField: { style: {} }, _o1igQuality: makeDropdown(["高", "中", "低", "自动"], "自动"), _o1igQualityField: { style: {} }, _o1igOutputFormat: makeDropdown([ { value: "png", label: "PNG" }, { value: "webp", label: "WebP" }, { value: "jpeg", label: "JPEG" }, ], "jpeg"), _o1igOutputFormatField: { style: {} }, _o1igBackground: makeDropdown([ { value: "auto", label: "自动" }, { value: "transparent", label: "透明" }, { value: "opaque", label: "不透明" }, ], "auto"), _o1igBackgroundField: { style: {} }, _o1igResize: { value: "智能缩放" }, _o1igResizeField: { style: {} }, _o1igResizeWarning: new Element("div"), _o1igMaskField: { style: {} }, _o1igNamingRule: makeDropdown([ { value: "自定义前缀", label: "自定义" }, { value: "自然数字", label: "自然数字" }, ], "自定义前缀"), _o1igFilenamePrefix: { value: "catalog" }, _o1igFilenamePrefixField: { style: {} }, _o1igSaveFormat: makeDropdown(["原始", "png", "jpg", "webp"], "webp"), _o1igSaveFormatField: { style: {} }, _o1igSaveLocation: { value: "project/session" }, _o1igBatchBar: { style: {} }, _o1igBatchEnabled: false, }; syncModelOptions(node); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igResolution._o1igOptions.map((option) => option.value))), ["智能", "1K", "2K", "4K"], ); assert.equal(node._o1igResolution.value, "智能"); assert.equal(node._o1igThinkingField.style.display, ""); assert.equal(node._o1igOnlineSearchField.style.display, ""); assert.equal(node._o1igQualityField.style.display, "none"); assert.equal(node._o1igOutputFormatField.style.display, "none"); assert.equal(node._o1igBackgroundField.style.display, "none"); assert.equal(node._o1igResizeField.style.display, ""); assert.equal(node._o1igMaskField.style.display, "none"); assert.equal(node._o1igFilenamePrefixField.style.display, ""); assert.equal(node._o1igSaveFormatField.style.display, ""); assert.equal(node._o1igResizeWarning.classList.contains("visible"), true); node._o1igModel.value = "Nano Banana 2 Lite"; syncModelOptions(node); assert.equal(node._o1igResolution._o1igOptions.some((option) => option.value === "512"), true); assert.equal(node._o1igThinkingField.style.display, "none"); assert.equal(node._o1igOnlineSearchField.style.display, "none"); assert.equal(node._o1igQualityField.style.display, "none"); assert.equal(node._o1igResizeField.style.display, ""); assert.equal(node._o1igMaskField.style.display, "none"); node._o1igModel.value = "Nano Banana Pro"; syncModelOptions(node); assert.equal(node._o1igResolution._o1igOptions.some((option) => option.value === "512"), false); assert.equal(node._o1igRatio._o1igOptions.some((option) => option.value === "1:8"), false); assert.equal(node._o1igThinkingField.style.display, "none"); assert.equal(node._o1igOnlineSearchField.style.display, "none"); assert.equal(node._o1igQualityField.style.display, "none"); assert.equal(node._o1igResizeField.style.display, ""); assert.equal(node._o1igMaskField.style.display, "none"); node._o1igModel.value = "Nano Banana"; syncModelOptions(node); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igResolution._o1igOptions.map((option) => option.value))), ["智能", "1K"], ); assert.equal(node._o1igThinkingField.style.display, "none"); assert.equal(node._o1igOnlineSearchField.style.display, "none"); assert.equal(node._o1igQualityField.style.display, "none"); assert.equal(node._o1igResizeField.style.display, ""); assert.equal(node._o1igMaskField.style.display, "none"); node._o1igModel.value = "gpt-image-2"; syncModelOptions(node); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igResolution._o1igOptions.map((option) => option.value))), ["智能", "1K", "2K", "4K"], ); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igRatio._o1igOptions.map((option) => option.value))), ["智能", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"], ); assert.equal(node._o1igRatioField.style.display, ""); assert.equal(node._o1igThinkingField.style.display, "none"); assert.equal(node._o1igOnlineSearchField.style.display, "none"); assert.equal(node._o1igQualityField.style.display, ""); assert.equal(node._o1igOutputFormatField.style.display, ""); assert.equal(node._o1igBackgroundField.style.display, ""); assert.equal(node._o1igResizeField.style.display, ""); assert.equal(node._o1igMaskField.style.display, ""); assert.equal(node._o1igSaveFormatField.style.display, "none"); assert.equal(node._o1igResizeWarning.classList.contains("visible"), true); assert.equal(node._o1igQuality.value, "自动"); assert.equal(node._o1igOutputFormat.value, "jpeg"); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igCount._o1igOptions.map((option) => option.value))), ["1", "2", "3", "4", "5", "6", "7", "8"], ); node._o1igCount.value = "3"; syncModelOptions(node); assert.equal(widgets.find((widget) => widget.name === "生图数量").value, "3"); assert.deepEqual(JSON.parse(JSON.stringify(generatorSaveSettings(node))), { filename_prefix: "catalog", format: "原始", save_location: "project/session", naming_rule: "自定义前缀", }); for (const model of ["gpt-image-2.5-sunburst", "gpt-image-2.5-flare"]) { node._o1igModel.value = model; syncModelOptions(node); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igResolution._o1igOptions.map((option) => option.value))), ["智能", "1K", "2K", "4K"], ); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igRatio._o1igOptions.map((option) => option.value))), ["智能", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"], ); assert.equal(node._o1igQualityField.style.display, ""); assert.equal(node._o1igOutputFormatField.style.display, ""); assert.equal(node._o1igBackgroundField.style.display, ""); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igQuality._o1igOptions.map((option) => option.value))), ["高", "中", "低", "自动", "超高", "最高"], ); assert.equal(node._o1igMaskField.style.display, ""); } node._o1igQuality.value = "最高"; node._o1igModel.value = "gpt-image-2"; syncModelOptions(node); assert.equal(node._o1igQuality.value, "自动"); node._o1igApplyGptDefaults = true; node._o1igOutputTouched = false; node._o1igResizeTouched = false; syncModelOptions(node); assert.equal(node._o1igOutputFormat.value, "png"); assert.equal(node._o1igResize.value, "智能缩放"); node._o1igBackground.value = "transparent"; syncGptOutputOptions(node); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igOutputFormat._o1igOptions.map((option) => option.value))), ["png", "webp"], ); assert.equal(node._o1igOutputFormat.value, "png"); assert.equal(widgets.find((widget) => widget.name === "背景").value, "transparent"); node._o1igModel.value = "Seedream 5.0 Pro"; syncModelOptions(node); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igResolution._o1igOptions.map((option) => option.value))), ["智能", "1K", "2K"], ); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igRatio._o1igOptions.map((option) => option.value))), ["智能", "1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"], ); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igOutputFormat._o1igOptions.map((option) => option.value))), ["png", "jpeg"], ); assert.equal(node._o1igOutputFormatField.style.display, ""); assert.equal(node._o1igBackgroundField.style.display, "none"); assert.equal(node._o1igResizeField.style.display, "none"); assert.equal(node._o1igMaskField.style.display, "none"); assert.equal(node._o1igSaveFormatField.style.display, "none"); assert.deepEqual(JSON.parse(JSON.stringify(generatorSaveSettings(node))), { filename_prefix: "catalog", format: "原始", save_location: "project/session", naming_rule: "自定义前缀", }); assert.equal(node._o1igLayerDecompositionField.style.display, ""); node._o1igLayerDecomposition.value = "开启"; syncModelOptions(node); assert.deepEqual( JSON.parse(JSON.stringify(node._o1igResolution._o1igOptions.map((option) => option.value))), ["智能", "1K", "1.5K", "2K"], ); assert.equal(node._o1igRatioField.style.display, "none"); assert.equal(node._o1igCountField.style.display, "none"); assert.equal(node._o1igOutputFormatField.style.display, "none"); assert.equal(node._o1igBatchBar.style.display, "none"); assert.equal(node._o1igOutputFormat.value, "png"); assert.equal(widgets.find((widget) => widget.name === "图层拆分").value, true); node._o1igModel.value = "Nano Banana 2"; syncModelOptions(node); assert.equal(node._o1igThinkingField.style.display, ""); assert.equal(node._o1igOnlineSearchField.style.display, ""); assert.equal(node._o1igQualityField.style.display, "none"); assert.equal(node._o1igOutputFormatField.style.display, "none"); assert.equal(node._o1igBackgroundField.style.display, "none"); assert.equal(node._o1igResizeField.style.display, ""); assert.equal(node._o1igMaskField.style.display, "none"); assert.equal(node._o1igResize.value, "智能缩放"); assert.equal(node._o1igResizeWarning.classList.contains("visible"), true); } { const node = { size: [320, 200], setSize(size) { this.size = size; }, setDirtyCanvas() {}, }; applyGeneratorDefaultSize(node); assert.deepEqual(JSON.parse(JSON.stringify(node.size)), [560, 1035]); node.size = [520, 420]; applyGeneratorDefaultSize(node); assert.deepEqual(node.size, [520, 420]); } { const outputs = [ { name: "IMAGE", links: [] }, { name: "LAYERS", links: [] }, { name: "LAYER_MASKS", links: [] }, { name: "LAYER_INFO", links: [] }, ]; const node = { outputs, _o1igModel: { value: "Nano Banana 2" }, _o1igLayerDecomposition: { value: "关闭" }, setDirtyCanvas() {}, }; syncGeneratorOutputVisibility(node); assert.deepEqual(node.outputs.map((output) => output.name), ["IMAGE"]); const restoredImageOutput = { name: "IMAGE", links: [41] }; node.outputs = [restoredImageOutput]; syncGeneratorOutputVisibility(node); assert.equal(node.outputs[0], restoredImageOutput); node._o1igModel.value = "Seedream 5.0 Pro"; node._o1igLayerDecomposition.value = "开启"; syncGeneratorOutputVisibility(node); assert.equal(node.outputs[0], restoredImageOutput); assert.deepEqual(node.outputs.map((output) => output.name), [ "IMAGE", "LAYERS", "LAYER_MASKS", ]); outputs[3].links.push(98); syncGeneratorOutputVisibility(node); assert.deepEqual(node.outputs.map((output) => output.name), [ "IMAGE", "LAYERS", "LAYER_MASKS", "LAYER_INFO", ]); outputs[3].links.length = 0; syncGeneratorOutputVisibility(node); assert.equal(node.outputs.length, 3); node._o1igLayerDecomposition.value = "关闭"; outputs[1].links.push(99); syncGeneratorOutputVisibility(node); assert.deepEqual(node.outputs.map((output) => output.name), ["IMAGE", "LAYERS"]); outputs[1].links.length = 0; syncGeneratorOutputVisibility(node); assert.deepEqual(node.outputs.map((output) => output.name), ["IMAGE"]); } { const generator = { id: 10100, type: "O1keyImageGenerator", comfyClass: "O1keyImageGenerator", graph, pos: [30, 40], size: [500, 1025], outputs: [ { name: "IMAGE", links: [] }, { name: "LAYERS", links: [] }, { name: "LAYER_MASKS", links: [] }, { name: "LAYER_INFO", links: [] }, ], _o1igGenerate: { disabled: false, textContent: "" }, _o1igStatus: { className: "", textContent: "" }, _o1igPanel: { classList: new ClassList({}) }, connect(originSlot, target) { const linkId = nextLinkId++; graph.links.set(linkId, { origin_id: this.id, origin_slot: originSlot, target_id: target.id, target_slot: 0, }); this.outputs[originSlot].links.push(linkId); target.inputs[0].link = linkId; }, }; graph.nodes.set(generator.id, generator); const batchId = "12121212-1212-4212-8212-121212121212"; const imageSaveNode = createSaveNodeForBatch(generator, batchId, 0); const layerSaveNode = acquireLayerSaveNodeForBatch(generator, imageSaveNode); assert.equal(generator.outputs[0].links.length, 1); assert.equal(generator.outputs[1].links.length, 1); assert.notEqual(imageSaveNode.id, layerSaveNode.id); assert.equal(layerSaveNode.title, "o1key 保存图层"); assert.equal(layerSaveNode.properties.o1keySaveRole, "layers"); assert.equal(layerSaveNode.properties.o1keyImageSaveNodeId, imageSaveNode.id); assert.equal(acquireLayerSaveNodeForBatch(generator, imageSaveNode), layerSaveNode); assert.deepEqual( JSON.parse(JSON.stringify(splitLayerSaveResults([ { filename: "base.png", type: "output", layer: { z_index: 0, name: "底图" } }, { filename: "subject.png", type: "output", layer: { z_index: 1, name: "主体" } }, { filename: "text.png", type: "output", layer: { z_index: 2, name: "文字" } }, ]))), { imageResults: [ { filename: "base.png", subfolder: "", type: "output", layer: { z_index: 0, name: "底图" } }, ], layerResults: [ { filename: "subject.png", subfolder: "", type: "output", layer: { z_index: 1, name: "主体" } }, { filename: "text.png", subfolder: "", type: "output", layer: { z_index: 2, name: "文字" } }, ], }, ); let imageMessage = null; let layerMessage = null; imageSaveNode.onExecuted = (message) => { imageMessage = message; finishGenerationBatch(imageSaveNode, "生成完成", batchId); }; layerSaveNode.onExecuted = (message) => { layerMessage = message; finishGenerationBatch(layerSaveNode, "生成完成", batchId); }; beginGenerationBatch(generator, imageSaveNode, batchId); context.testLayerGenerator = generator; context.testImageSaveNode = imageSaveNode; context.testLayerSaveNode = layerSaveNode; vm.runInContext( `parallelBatches.set("${batchId}", { source: testLayerGenerator, saveNode: testImageSaveNode, generatorNodeId: testLayerGenerator.id, saveNodeId: testImageSaveNode.id, layerSaveNode: testLayerSaveNode, layerSaveNodeId: testLayerSaveNode.id, layerDecomposition: true })`, context, ); handleParallelImageJob({ batch_id: batchId, generator_node_id: generator.id, save_node_id: imageSaveNode.id, state: "completed", images: [ { batch_id: batchId, filename: "base.png", type: "output", result_index: 1 }, { batch_id: batchId, filename: "subject.png", type: "output", result_index: 2 }, { batch_id: batchId, filename: "text.png", type: "output", result_index: 3 }, ], }); assert.deepEqual(imageMessage.images.map((item) => item.filename), ["base.png"]); assert.deepEqual(layerMessage.images.map((item) => item.filename), ["subject.png", "text.png"]); assert.deepEqual( executedEvents.slice(-2).map((event) => event.display_node), [layerSaveNode.id, imageSaveNode.id], ); } { const generator = { id: 100, graph, pos: [20, 30], size: [410, 340], outputs: [{ links: [] }], _o1igGenerate: { disabled: false, textContent: "" }, _o1igStatus: { className: "", textContent: "" }, _o1igPanel: { classList: new ClassList({}) }, connect(_originSlot, target) { const linkId = nextLinkId++; graph.links.set(linkId, { origin_id: this.id, target_id: target.id }); this.outputs[0].links.push(linkId); target.inputs[0].link = linkId; }, }; graph.nodes.set(generator.id, generator); const firstBatchId = "11111111-1111-4111-8111-111111111111"; const secondBatchId = "22222222-2222-4222-8222-222222222222"; const first = createSaveNodeForBatch(generator, firstBatchId); const second = createSaveNodeForBatch(generator, secondBatchId); assert.notEqual(first, second); assert.deepEqual(first.widgets, []); assert.equal(generator.outputs[0].links.length, 2); assert.equal(first.properties.o1keyBatchIndex, 1); assert.equal(second.properties.o1keyBatchIndex, 2); assert.notDeepEqual(first.pos, second.pos); beginGenerationBatch(generator, first); beginGenerationBatch(generator, second); assert.equal(generator._o1igPendingBatchIds.size, 2); assert.equal(generator._o1igGenerate.disabled, false); assert.equal(generator._o1igStatus.textContent, ""); assert.equal(generator._o1igGenerate.textContent, "开始生成"); finishGenerationBatch(first, "生成完成"); assert.equal(generator._o1igPendingBatchIds.size, 1); assert.equal(generator._o1igStatus.textContent, ""); finishGenerationBatch(second, "生成完成"); assert.equal(generator._o1igPendingBatchIds.size, 0); assert.equal(generator._o1igStatus.textContent, ""); assert.equal(generator._o1igGenerate.textContent, "开始生成"); let received = null; let receivedCount = 0; second.onExecuted = (message) => { received = message; receivedCount += 1; finishGenerationBatch(second, "生成完成"); }; beginGenerationBatch(generator, second); const progressBody = new Element("div"); progressBody.querySelector = () => progressBody.children.find( (child) => child.dataset.o1keySaveProgress === String(second.id), ) || null; const progressContainer = new Element("div"); progressContainer.querySelector = () => progressBody; document._querySelector = (selector) => selector.includes(`data-node-id="${second.id}"`) ? progressContainer : null; context.testGenerator = generator; context.testSaveNode = second; vm.runInContext( `parallelBatches.set("${secondBatchId}", { source: testGenerator, saveNode: testSaveNode })`, context, ); handleParallelImageJob({ batch_id: secondBatchId, generator_node_id: generator.id, save_node_id: second.id, state: "running", progress: 0.37, images: [], }); const progressTrack = progressBody.children[0]; assert.equal(progressBody.classList.contains("o1igs-progress-host"), true); assert.equal(progressContainer.classList.contains("o1key-image-save-node"), true); assert.equal(progressTrack.style["--o1igs-progress"], "37%"); assert.equal(progressTrack.classList.contains("busy"), true); assert.equal(generator._o1igStatus.textContent, ""); assert.equal(generator._o1igGenerate.textContent, "开始生成"); handleParallelImageJob({ batch_id: secondBatchId, generator_node_id: generator.id, save_node_id: second.id, state: "queued", queue_position: 2, progress: 0, }); assert.equal(generator._o1igStatus.textContent, ""); handleParallelImageJob({ batch_id: secondBatchId, generator_node_id: generator.id, save_node_id: first.id, state: "completed", images: [{ batch_id: secondBatchId, filename: "wrong.png", type: "output" }], }); assert.equal(received, null); handleParallelImageJob({ batch_id: secondBatchId, generator_node_id: generator.id, save_node_id: second.id, state: "completed", images: [ { batch_id: firstBatchId, filename: "wrong-batch.png", type: "output" }, { batch_id: secondBatchId, filename: "correct.png", type: "output" }, ], }); assert.equal(received.images.length, 1); assert.equal(received.images[0].filename, "correct.png"); assert.equal(executedEvents.at(-1).display_node, second.id); assert.equal(executedEvents.at(-1).output.images[0].filename, "correct.png"); assert.equal(app.nodeOutputs[String(second.id)].images[0].filename, "correct.png"); const nativeHistory = await api.getHistory(); const nativeCompleted = nativeHistory.find((job) => job.id === `o1key:${secondBatchId}`); assert.equal(nativeCompleted.status, "completed"); assert.equal(nativeCompleted.preview_output.filename, "correct.png"); handleParallelImageJob({ batch_id: secondBatchId, generator_node_id: generator.id, save_node_id: second.id, state: "completed", images: [{ batch_id: secondBatchId, filename: "duplicate.png", type: "output" }], }); assert.equal(receivedCount, 1); document._querySelector = null; } { const batchId = "66666666-6666-4666-8666-666666666666"; const oldNodes = createRecoveryNodes(batchId, 9100, 9101); let staleExecutions = 0; oldNodes.saveNode.onExecuted = () => { staleExecutions += 1; }; beginGenerationBatch(oldNodes.generator, oldNodes.saveNode); context.testDetachedGenerator = oldNodes.generator; context.testDetachedSaveNode = oldNodes.saveNode; vm.runInContext( `parallelBatches.set("${batchId}", { source: testDetachedGenerator, saveNode: testDetachedSaveNode, generatorNodeId: 9100, saveNodeId: 9101 })`, context, ); detachParallelBatchNode(oldNodes.generator); detachParallelBatchNode(oldNodes.saveNode); graph.nodes.delete(oldNodes.generator.id); graph.nodes.delete(oldNodes.saveNode.id); graph.links.delete(oldNodes.linkId); const previousFetchApi = api.fetchApi; let saveRequests = 0; api.fetchApi = async (path) => { assert.equal(path, "/o1key/image/save"); saveRequests += 1; return { ok: true, status: 200, json: async () => ({ images: [{ batch_id: batchId, filename: "while-away.png", type: "output" }], }), }; }; handleParallelImageJob({ batch_id: batchId, generator_node_id: 9100, save_node_id: 9101, state: "completed", images: [{ batch_id: batchId, filename: `${batchId}_0001_01.png`, type: "temp" }], }); assert.equal(staleExecutions, 0); assert.equal(saveRequests, 0); assert.equal( vm.runInContext(`parallelBatches.get("${batchId}").lastDetail.state`, context), "completed", ); const activeNodes = createRecoveryNodes(batchId, 9100, 9101); let restored = null; activeNodes.saveNode.onExecuted = (message) => { restored = message; finishGenerationBatch(activeNodes.saveNode, "生成完成"); }; await recoverParallelBatch(activeNodes.saveNode); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(saveRequests, 1); assert.equal(restored.images[0].filename, "while-away.png"); assert.equal(staleExecutions, 0); assert.equal(vm.runInContext(`parallelBatches.has("${batchId}")`, context), false); graph.nodes.delete(activeNodes.generator.id); graph.nodes.delete(activeNodes.saveNode.id); graph.links.delete(activeNodes.linkId); api.fetchApi = previousFetchApi; } { const batchId = "77777777-7777-4777-8777-777777777777"; const activeNodes = createRecoveryNodes(batchId, 9200, 9201); let restored = null; activeNodes.saveNode.onExecuted = (message) => { restored = message; finishGenerationBatch(activeNodes.saveNode, "生成完成"); }; const previousFetchApi = api.fetchApi; let statusRequest = ""; api.fetchApi = async (path) => { statusRequest = path; return { ok: true, status: 200, json: async () => ({ batch_id: batchId, generator_node_id: 9200, save_node_id: 9201, state: "completed", images: [{ batch_id: batchId, filename: "after-refresh.png", type: "output" }], }), }; }; vm.runInContext(`parallelBatches.delete("${batchId}")`, context); await recoverParallelBatch(activeNodes.saveNode); assert.match(statusRequest, new RegExp(`/o1key/image/jobs/${batchId}\\?`)); assert.match(statusRequest, /generator_node_id=9200/); assert.match(statusRequest, /save_node_id=9201/); assert.equal(restored.images[0].filename, "after-refresh.png"); assert.equal(activeNodes.saveNode._o1igsBusy, false); api.fetchApi = previousFetchApi; graph.nodes.delete(activeNodes.generator.id); graph.nodes.delete(activeNodes.saveNode.id); graph.links.delete(activeNodes.linkId); } { const batchId = "44444444-4444-4444-8444-444444444444"; const generator = { id: 145, graph, pos: [0, 0], size: [400, 300], outputs: [{ links: [] }], _o1igGenerate: new Element("button"), _o1igStatus: new Element("div"), _o1igPanel: new Element("div"), connect(_originSlot, target) { const linkId = nextLinkId++; graph.links.set(linkId, { origin_id: this.id, target_id: target.id }); this.outputs[0].links.push(linkId); target.inputs[0].link = linkId; }, }; graph.nodes.set(generator.id, generator); const saveNode = createSaveNodeForBatch(generator, batchId); beginGenerationBatch(generator, saveNode); context.testCancelGenerator = generator; context.testCancelSaveNode = saveNode; vm.runInContext( `parallelBatches.set("${batchId}", { source: testCancelGenerator, saveNode: testCancelSaveNode })`, context, ); updateNativeQueueJob({ batch_id: batchId, generator_node_id: generator.id, save_node_id: saveNode.id, state: "queued", }); const queued = await api.getQueue(); assert.equal(queued.Pending.some((job) => job.id === `o1key:${batchId}`), true); api.fetchApi = async (path, options) => { assert.equal(path, `/o1key/image/jobs/${batchId}/cancel`); assert.equal(options.method, "POST"); return { ok: true, json: async () => ({ batch_id: batchId, generator_node_id: generator.id, save_node_id: saveNode.id, state: "cancelled", error: "任务已取消", }), }; }; await api.cancelJob(`o1key:${batchId}`); assert.equal(generator._o1igPendingBatchIds.size, 0); assert.equal(generator._o1igStatus.textContent, ""); assert.equal(generator._o1igGenerate.textContent, "开始生成"); const afterCancel = await api.getQueue(); assert.equal(afterCancel.Pending.some((job) => job.id === `o1key:${batchId}`), false); const cancelledHistory = await api.getHistory(); assert.equal( cancelledHistory.find((job) => job.id === `o1key:${batchId}`).status, "cancelled", ); } { const generator = { id: 120, type: "O1keyImageGenerator", comfyClass: "O1keyImageGenerator", graph, pos: [0, 0], size: [400, 300], outputs: [{ links: [] }], connect(_originSlot, target) { const linkId = nextLinkId++; graph.links.set(linkId, { origin_id: this.id, target_id: target.id }); this.outputs[0].links.push(linkId); target.inputs[0].link = linkId; }, }; graph.nodes.set(generator.id, generator); const filled = createSaveNodeForBatch(generator, "filled-batch"); filled.properties.o1keyImageSaveResults = [ { filename: "filled.png", subfolder: "", type: "output" }, ]; const blank = createSaveNodeForBatch(generator, "old-empty-batch"); const reused = acquireSaveNodeForBatch(generator, "new-batch"); assert.equal(reused, blank); assert.equal(reused.properties.o1keyBatchId, "new-batch"); assert.equal(generator.outputs[0].links.length, 2); const rerouted = reroutePartialExecutionTargets([filled.id, 987654]); assert.deepEqual(JSON.parse(JSON.stringify(rerouted)), [blank.id, 987654]); assert.equal(generator._o1igsRequestedStandardSaveNodeId, blank.id); assert.equal( prepareStandardQueue(generator, { isPartialExecution: true }), blank, ); const routes = takeStandardQueueRoutes(); const promptResult = { output: { [generator.id]: { class_type: "O1keyImageGenerator" }, [filled.id]: { class_type: "O1keyImageSave" }, [blank.id]: { class_type: "O1keyImageSave" }, 987652: { class_type: "FilledBranchOutput", inputs: { image: [filled.id, 0] }, }, 987653: { class_type: "BlankBranchOutput", inputs: { image: [blank.id, 0] }, }, 987654: { class_type: "OtherOutput" }, }, }; filterStandardQueueOutputs(promptResult, routes); assert.equal(promptResult.output[filled.id], undefined); assert.equal(promptResult.output[987652], undefined); assert.equal(promptResult.output[blank.id].class_type, "O1keyImageSave"); assert.equal(promptResult.output[987653].class_type, "BlankBranchOutput"); assert.equal(promptResult.output[987654].class_type, "OtherOutput"); assert.equal(startNextStandardExecution(generator), blank); assert.equal(blank._o1igsBusy, true); assert.equal(blank._o1igsStandardExecutionPending, true); assert.notEqual(filled._o1igsBusy, true); finishGenerationBatch(blank, "生成完成"); assert.equal(blank._o1igsBusy, false); assert.equal(blank._o1igsStandardExecutionPending, undefined); finishStandardQueue(generator); blank.properties.o1keyImageSaveResults = [ { filename: "now-filled.jpg", subfolder: "", type: "output" }, ]; const downstreamLinkId = nextLinkId++; const downstream = { id: 121, type: "OtherOutput", graph, inputs: [{ link: downstreamLinkId }], }; graph.nodes.set(downstream.id, downstream); graph.links.set(downstreamLinkId, { origin_id: blank.id, target_id: downstream.id, }); assert.deepEqual( JSON.parse(JSON.stringify(reroutePartialExecutionTargets([downstream.id]))), [downstream.id], ); assert.equal(generator._o1igsRequestedStandardSaveNodeId, blank.id); assert.equal(prepareStandardQueue(generator, { isPartialExecution: true }), blank); assert.equal(startNextStandardExecution(generator), blank); finishGenerationBatch(blank, "生成完成"); finishStandardQueue(generator); const created = prepareStandardQueue(generator, { isPartialExecution: false }); assert.notEqual(created, filled); assert.notEqual(created, blank); assert.equal(generator.outputs[0].links.length, 3); assert.deepEqual(created.widgets, []); finishStandardQueue(generator); } { const generator = { id: 140, type: "O1keyImageGenerator", comfyClass: "O1keyImageGenerator", graph, pos: [0, 0], size: [400, 300], outputs: [{ links: [] }], _o1igGenerate: { disabled: false, textContent: "" }, _o1igStatus: { className: "", textContent: "" }, _o1igPanel: { classList: new ClassList({}) }, widgets: [ { name: "模型", value: "Nano Banana 2" }, { name: "filename_prefix", value: "catalog" }, { name: "格式", value: "webp" }, { name: "保存位置", value: "D:/renders/session-a" }, { name: "命名规则", value: "自定义前缀" }, ], connect(_originSlot, target) { const linkId = nextLinkId++; graph.links.set(linkId, { origin_id: this.id, target_id: target.id }); this.outputs[0].links.push(linkId); target.inputs[0].link = linkId; }, }; graph.nodes.set(generator.id, generator); const batchId = "33333333-3333-4333-8333-333333333333"; const saveNode = createSaveNodeForBatch(generator, batchId); let received = null; saveNode.onExecuted = (message) => { received = message; finishGenerationBatch(saveNode, "生成完成"); }; beginGenerationBatch(generator, saveNode); context.testTempGenerator = generator; context.testTempSaveNode = saveNode; vm.runInContext( `parallelBatches.set("${batchId}", { source: testTempGenerator, saveNode: testTempSaveNode })`, context, ); let saveRequest = null; let fallbackSerializeCalls = 0; app.graphToPrompt = async () => ({ output: { 140: { class_type: "O1keyImageGenerator" } }, workflow: { nodes: [{ id: 140 }, { id: saveNode.id }] }, }); graph.serialize = () => { fallbackSerializeCalls += 1; return { nodes: [{ id: "stale-fallback" }] }; }; api.fetchApi = async (path, options) => { assert.equal(path, "/o1key/image/save"); saveRequest = JSON.parse(options.body); return { ok: true, json: async () => ({ images: [{ filename: "provider.jpg", type: "temp", external_saved: true }], }), }; }; handleParallelImageJob({ batch_id: batchId, generator_node_id: generator.id, save_node_id: saveNode.id, state: "completed", images: [{ batch_id: batchId, filename: `${batchId}.jpg`, type: "temp" }], }); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(saveRequest.filename_prefix, "catalog"); assert.equal(saveRequest.format, "webp"); assert.equal(saveRequest.save_location, "D:/renders/session-a"); assert.equal(saveRequest.naming_rule, "自定义前缀"); assert.equal(saveRequest.extra_pnginfo.workflow.nodes.length, 2); assert.equal(saveRequest.prompt[140].class_type, "O1keyImageGenerator"); assert.equal(fallbackSerializeCalls, 0); assert.equal(received.images[0].filename, "provider.jpg"); assert.equal(received.images[0].type, "temp"); assert.equal(received.images[0].external_saved, true); } { createdElements.length = 0; const saveNode = { id: 776, graph, properties: {}, size: [300, 260], widgets: [], addDOMWidget() { throw new Error("save preview must not reserve DOM widget layout space"); }, setSize(size) { this.size = size; }, setDirtyCanvas() {}, }; buildSavePanel(saveNode); assert.equal(saveNode._o1igsPanelReady, true); assert.equal(saveNode._o1igsProgressTrack, undefined); assert.deepEqual(saveNode.widgets, []); saveNode.imgs = [{ src: "native-preview" }]; saveNode.imageIndex = 0; renderSaveResults(saveNode, [ { filename: "one.png", type: "output" }, { filename: "two.png", type: "output" }, { filename: "three.png", type: "output" }, { filename: "four.png", type: "output" }, ]); assert.equal(createdElements.filter((element) => element.tagName === "IMG").length, 0); assert.deepEqual(saveNode.imgs, [{ src: "native-preview" }]); assert.equal(saveNode.imageIndex, 0); assert.equal(saveNode.properties.o1keyImageSaveResults.length, 4); assert.equal(saveNode.properties.o1keyImageSaveResults[1].filename, "two.png"); } { createdElements.length = 0; const slotContainer = new Element("div"); const saveNode = { id: 780, comfyClass: "O1keyImageSave", graph, properties: {}, size: [300, 260], inputs: [{ link: 7800 }], widgets: [], addDOMWidget(_name, _type, element, options) { this.slotWidgetOptions = options; const owner = this; const widget = { options, onRemove() { owner.slotWidgetRemoveCount = (owner.slotWidgetRemoveCount || 0) + 1; }, }; this.widgets.push(widget); return widget; }, removeWidget(widget) { const index = this.widgets.indexOf(widget); assert.notEqual(index, -1); widget.onRemove?.(); this.widgets.splice(index, 1); }, setSize(size) { this.size = size; }, setDirtyCanvas() {}, }; const sourceNode = { id: 781, comfyClass: "O1keyImageGenerator", graph, widgets: [{ name: "seed", value: 0 }], _o1igSeed: { value: "0" }, _o1igPrompt: { value: "甲\n---\n乙" }, }; graph.nodes.set(saveNode.id, saveNode); graph.nodes.set(sourceNode.id, sourceNode); graph.links.set(7800, { origin_id: sourceNode.id, target_id: saveNode.id }); document._querySelector = (selector) => selector.includes('data-node-id="780"') ? slotContainer : null; const prompts = expandPanelPromptTasks("甲\n---\n乙", 2); assert.deepEqual(JSON.parse(JSON.stringify(prompts)), ["甲", "甲", "乙", "乙"]); const outfits = Array.from({ length: 10 }, (_, index) => ({ name: `outfit-${index}.png` })); const models = Array.from({ length: 10 }, (_, index) => ({ name: `model-${index}.png` })); const groupedTasks = expandPanelGenerationTasks({ prompt: "换装", imageCount: 1, batchEnabled: true, batchMode: "一组搭配+多模特", references: outfits.slice(0, 3), modelReferences: models, }); assert.equal(groupedTasks.length, 10); assert.equal(groupedTasks[0].references.length, 4); assert.equal(groupedTasks[9].references[3].name, "model-9.png"); const cartesianTasks = expandPanelGenerationTasks({ prompt: "换装", imageCount: 1, batchEnabled: true, batchMode: "全部搭配×全部模特", references: outfits, modelReferences: models, }); assert.equal(cartesianTasks.length, 100); assert.equal(cartesianTasks[0].references[0].name, "outfit-0.png"); assert.equal(cartesianTasks[9].references[1].name, "model-9.png"); assert.equal(cartesianTasks[10].references[0].name, "outfit-1.png"); const singleReferenceTasks = expandPanelGenerationTasks({ prompt: "换个动作", imageCount: 1, batchEnabled: true, batchMode: "单图素材批量", references: outfits.slice(0, 3), modelReferences: models, }); assert.equal(singleReferenceTasks.length, 3); assert.deepEqual( JSON.parse(JSON.stringify(singleReferenceTasks.map((task) => task.references.map((item) => item.name)))), [["outfit-0.png"], ["outfit-1.png"], ["outfit-2.png"]], ); const groupedBadgeNode = { _o1igBatchEnabled: true, _o1igBatchMode: { value: "一组搭配+多模特" }, _o1igReferences: outfits.slice(0, 3), }; assert.deepEqual( JSON.parse(JSON.stringify(referenceBadgeDetails(groupedBadgeNode, "references", 1))), { label: "图2", title: "素材图2 · 在各自请求中是图2" }, ); assert.deepEqual( JSON.parse(JSON.stringify(referenceBadgeDetails(groupedBadgeNode, "models", 7))), { label: "图4", title: "目标图8 · 在各自请求中是图4" }, ); const cartesianBadgeNode = { _o1igBatchEnabled: true, _o1igBatchMode: { value: "全部搭配×全部模特" }, _o1igReferences: outfits, }; assert.equal(referenceBadgeDetails(cartesianBadgeNode, "references", 6).label, "图1"); assert.equal(referenceBadgeDetails(cartesianBadgeNode, "models", 6).label, "图2"); assert.deepEqual( JSON.parse(JSON.stringify(referenceBadgeDetails({ _o1igBatchEnabled: true, _o1igBatchMode: { value: "单图素材批量" }, }, "references", 1))), { label: "图1", title: "素材图2 · 作为单张参考图独立生成" }, ); assert.equal(referenceBadgeDetails({ _o1igBatchEnabled: false }, "references", 2).label, "图3"); assert.equal( referenceBadgeDetails({ _o1igBatchEnabled: false }, "references", 0).title, "图1 · 主图/色彩基准(按当前顺序)", ); assert.equal(referenceDropDestination(0, 2, false), 1); assert.equal(referenceDropDestination(0, 2, true), 2); assert.equal(referenceDropDestination(2, 0, false), 0); const sortableReferences = [ { name: "first.png", subfolder: "", type: "input" }, { name: "second.png", subfolder: "", type: "input" }, { name: "third.png", subfolder: "", type: "input" }, ]; const sortableWidget = { name: "参考图清单", value: JSON.stringify(sortableReferences) }; const sortableNode = { _o1igRefs: new Element("div"), _o1igAdd: new Element("button"), _o1igReferences: sortableReferences, _o1igPending: [], widgets: [sortableWidget], }; assert.equal(moveReference(sortableNode, "references", 0, 2), true); assert.deepEqual( JSON.parse(JSON.stringify(sortableNode._o1igReferences.map((item) => item.name))), ["second.png", "third.png", "first.png"], ); assert.deepEqual( JSON.parse(sortableWidget.value).map((item) => item.name), ["second.png", "third.png", "first.png"], ); const firstSortTarget = sortableNode._o1igReferenceHandles.references[0]; const firstSortTile = sortableNode._o1igRefs.children[0]; assert.equal(firstSortTarget.className, "o1ig-previewable"); assert.equal(firstSortTile.draggable, true); assert.equal(firstSortTile.listeners.has("dragstart"), true); assert.equal(firstSortTile.listeners.has("drop"), true); firstSortTarget.dispatch("keydown", { key: "End", altKey: true, preventDefault() {}, stopPropagation() {}, }); assert.deepEqual( JSON.parse(JSON.stringify(sortableNode._o1igReferences.map((item) => item.name))), ["third.png", "first.png", "second.png"], ); const dragTile = sortableNode._o1igRefs.children[0]; const dropTile = sortableNode._o1igRefs.children[2]; dropTile.getBoundingClientRect = () => ({ left: 0, width: 100 }); const dataTransfer = { setData() {} }; dragTile.dispatch("dragstart", { dataTransfer, preventDefault() {}, stopPropagation() {}, }); assert.equal(dragTile.classList.contains("o1ig-dragging"), true); assert.equal(sortableNode._o1igRefs.classList.contains("o1ig-reordering"), true); dragTile.children[0].dispatch("click", { preventDefault() {}, stopPropagation() {}, }); assert.equal(document.getElementById("o1key-image-reference-lightbox"), null); dropTile.dispatch("dragover", { clientX: 90, dataTransfer, preventDefault() {}, stopPropagation() {}, }); assert.equal(dropTile.classList.contains("o1ig-drop-after"), true); dropTile.dispatch("drop", { clientX: 90, dataTransfer, preventDefault() {}, stopPropagation() {}, }); assert.deepEqual( JSON.parse(JSON.stringify(sortableNode._o1igReferences.map((item) => item.name))), ["first.png", "second.png", "third.png"], ); sortableNode._o1igPending.push({ name: "uploading.png", url: "blob:uploading" }); renderReferenceRole(sortableNode, "references"); assert.equal(sortableNode._o1igRefs.children[0].draggable, false); assert.equal(sortableNode._o1igRefs.children[0].classList.contains("sort-locked"), true); assert.equal(moveReference(sortableNode, "references", 0, 1), false); const independentNode = { _o1igReferences: [{ name: "source.png", subfolder: "", type: "input" }], _o1igPending: [], _o1igModelReferences: [ { name: "target-a.png", subfolder: "", type: "input" }, { name: "target-b.png", subfolder: "", type: "input" }, ], _o1igModelPending: [], widgets: [ { name: "参考图清单", value: "[]" }, { name: "模特图清单", value: "[]" }, ], }; assert.equal(moveReference(independentNode, "models", 0, 1), true); assert.deepEqual( JSON.parse(JSON.stringify(independentNode._o1igReferences.map((item) => item.name))), ["source.png"], ); assert.deepEqual( JSON.parse(JSON.stringify(independentNode._o1igModelReferences.map((item) => item.name))), ["target-b.png", "target-a.png"], ); const referenceList = new Element("div"); const addReference = new Element("button"); renderReferenceRole({ _o1igRefs: referenceList, _o1igAdd: addReference, _o1igReferences: [ { name: "large.png", subfolder: "product", type: "input" }, { name: "second.png", subfolder: "product", type: "input" }, ], _o1igPending: [], }); assert.equal(referenceList.children.length, 3); const trailingUpload = referenceList.children[2]; assert.equal(trailingUpload.className, "o1ig-empty-reference"); assert.equal(trailingUpload.children[1].textContent, "添加参考图"); assert.equal(trailingUpload.listeners.has("drop"), true); const uploadedImage = referenceList.children[0].children[0]; const editButton = referenceList.children[0].children[1]; assert.equal(editButton.className, "o1ig-edit"); assert.equal(editButton.title, "编辑图片"); assert.equal(editButton["aria-label"], "编辑参考图1"); assert.equal(uploadedImage["aria-label"], "查看大图;按住拖动可排序"); assert.equal( uploadedImage.src, "/o1key/image/thumbnail?filename=large.png&type=input&subfolder=product", ); assert.equal(uploadedImage.loading, "lazy"); assert.equal(uploadedImage.decoding, "async"); uploadedImage.dispatch("click"); const lightbox = document.getElementById("o1key-image-reference-lightbox"); assert.equal(lightbox["aria-hidden"], "false"); assert.equal(lightbox._o1igImage.src, "/view?filename=large.png&type=input&subfolder=product"); assert.equal(lightbox._o1igPrevious.hidden, false); lightbox.dispatch("keydown", { key: "ArrowRight", preventDefault() {}, stopPropagation() {}, }); assert.equal(lightbox._o1igImage.src, "/view?filename=second.png&type=input&subfolder=product"); lightbox.dispatch("keydown", { key: "Escape", preventDefault() {}, stopPropagation() {}, }); assert.equal(lightbox["aria-hidden"], "true"); createdElements.length = 0; const editableReferences = [{ name: "portrait.jpg", subfolder: "people", type: "input" }]; const editableWidget = { name: "参考图清单", value: JSON.stringify(editableReferences) }; const editableNode = { _o1igRefs: new Element("div"), _o1igAdd: new Element("button"), _o1igReferences: editableReferences, _o1igPending: [], widgets: [editableWidget], }; editorRequests.length = 0; await editReference(editableNode, "references", 0); assert.equal(editorRequests.length, 1); assert.equal(editorRequests[0].sourceUrl, "/view?filename=portrait.jpg&type=input&subfolder=people"); assert.equal(editorRequests[0].filename, "portrait.jpg"); const previousEditorFetch = api.fetchApi; api.fetchApi = async (path, options) => { assert.equal(path, "/upload/image"); assert.equal(options.method, "POST"); return { ok: true, json: async () => ({ name: "portrait_edited.png", subfolder: "", type: "input" }), }; }; await editorRequests[0].onConfirm({ blob: new Blob(["edited"], { type: "image/png" }), filename: "portrait_edited.png", }); assert.deepEqual( JSON.parse(JSON.stringify(editableNode._o1igReferences)), [{ name: "portrait_edited.png", subfolder: "", type: "input" }], ); assert.deepEqual( JSON.parse(editableWidget.value), JSON.parse(JSON.stringify(editableNode._o1igReferences)), ); api.fetchApi = previousEditorFetch; createdElements.length = 0; const replacementReferences = [ { name: "first.png", subfolder: "", type: "input" }, { name: "middle.png", subfolder: "", type: "input" }, { name: "last.png", subfolder: "", type: "input" }, ]; const replacementWidget = { name: "参考图清单", value: JSON.stringify(replacementReferences) }; const replacementInput = new Element("input"); let pickerClicks = 0; replacementInput.click = () => { pickerClicks += 1; }; const replacementNode = { _o1igRefs: new Element("div"), _o1igAdd: new Element("button"), _o1igReplaceInput: replacementInput, _o1igReferences: replacementReferences, _o1igPending: [], widgets: [replacementWidget], }; renderReferenceRole(replacementNode, "references"); const replaceButton = replacementNode._o1igRefs.children[1].children.find( (child) => child.className === "o1ig-replace", ); assert.equal(replaceButton.textContent, "替换"); assert.equal(replaceButton["aria-label"], "替换参考图2"); replaceButton.dispatch("click", { preventDefault() {}, stopPropagation() {} }); assert.equal(pickerClicks, 1); assert.equal(replacementNode._o1igReplaceTarget.original, replacementReferences[1]); let finishUpload; api.fetchApi = (_path, options) => new Promise((resolve) => { assert.equal(_path, "/upload/image"); assert.equal(options.body.get("overwrite"), "false"); finishUpload = resolve; }); const replacementFile = new File(["new image"], "replacement.png", { type: "image/png" }); const replacing = replaceReference(replacementNode, "references", replacementReferences[1], replacementFile); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(replacementNode._o1igReplacing.size, 1); assert.deepEqual(replacementReferences.map((item) => item.name), ["first.png", "middle.png", "last.png"]); assert.equal(replacementNode._o1igRefs.children[1].className.includes("replacing"), true); finishUpload({ ok: true, json: async () => ({ name: "replacement.png", subfolder: "" }) }); assert.equal(await replacing, true); assert.deepEqual(replacementReferences.map((item) => item.name), ["first.png", "replacement.png", "last.png"]); assert.deepEqual(JSON.parse(replacementWidget.value).map((item) => item.name), ["first.png", "replacement.png", "last.png"]); api.fetchApi = async () => ({ ok: false }); const failedFile = new File(["broken"], "broken.png", { type: "image/png" }); assert.equal(await replaceReference(replacementNode, "references", replacementReferences[1], failedFile), false); assert.deepEqual(replacementReferences.map((item) => item.name), ["first.png", "replacement.png", "last.png"]); const targetImages = [ { name: "target-a.png", subfolder: "", type: "input" }, { name: "target-b.png", subfolder: "", type: "input" }, ]; const targetWidget = { name: "模特图清单", value: JSON.stringify(targetImages) }; const targetNode = { _o1igReferences: replacementReferences, _o1igModelReferences: targetImages, _o1igModelRefs: new Element("div"), _o1igModelAdd: new Element("button"), _o1igModelPending: [], widgets: [targetWidget], }; api.fetchApi = async () => ({ ok: true, json: async () => ({ name: "new-target.png", subfolder: "" }), }); assert.equal(await replaceReference( targetNode, "models", targetImages[0], new File(["target"], "new-target.png", { type: "image/png" }), ), true); assert.deepEqual(targetImages.map((item) => item.name), ["new-target.png", "target-b.png"]); assert.deepEqual(replacementReferences.map((item) => item.name), ["first.png", "replacement.png", "last.png"]); assert.deepEqual(JSON.parse(targetWidget.value).map((item) => item.name), ["new-target.png", "target-b.png"]); api.fetchApi = previousEditorFetch; const emptyReferenceList = new Element("div"); renderReferenceRole({ _o1igRefs: emptyReferenceList, _o1igAdd: new Element("button"), _o1igReferences: [], _o1igPending: [], }); assert.equal(emptyReferenceList.children.length, 1); assert.equal(emptyReferenceList.children[0].className, "o1ig-empty-reference"); assert.equal(emptyReferenceList.children[0].children[1].textContent, "添加参考图"); const summaryNode = { _o1igBatchEnabled: true, _o1igBatchMode: { value: "一组搭配+多模特" }, _o1igReferences: [], _o1igModelReferences: [], _o1igCount: { value: "2" }, _o1igPrompt: { value: "甲\n---\n乙" }, _o1igBatchSummary: new Element("div"), _o1igReferenceHint: new Element("span"), }; updateBatchSummary(summaryNode); assert.equal(summaryNode._o1igBatchSummary.textContent, ""); assert.equal(summaryNode._o1igBatchSummary.classList.contains("visible"), false); summaryNode._o1igModelReferences = models.slice(0, 2); updateBatchSummary(summaryNode); assert.equal(summaryNode._o1igBatchSummary.classList.contains("visible"), false); summaryNode._o1igReferences = outfits.slice(0, 3); updateBatchSummary(summaryNode); assert.equal(summaryNode._o1igBatchSummary.classList.contains("visible"), true); assert.equal( summaryNode._o1igBatchSummary.textContent, "1 组素材 × 2 个目标 = 2 个组合 × 每组 2 张 × 2 条提示词,共 8 张", ); summaryNode._o1igBatchMode.value = "全部搭配×全部模特"; updateBatchSummary(summaryNode); assert.equal( summaryNode._o1igBatchSummary.textContent, "3 个素材 × 2 个目标 = 6 个组合 × 每组 2 张 × 2 条提示词,共 24 张", ); summaryNode._o1igBatchMode.value = "单图素材批量"; updateBatchSummary(summaryNode); assert.equal( summaryNode._o1igBatchSummary.textContent, "3 张素材(每张独立) × 每组 2 张 × 2 条提示词,共 12 张", ); const hintNode = { _o1igBatchEnabled: true, _o1igBatchMode: { value: "一组搭配+多模特" }, _o1igReferences: outfits.slice(0, 3), _o1igModelReferences: models.slice(0, 2), _o1igReferenceTitle: new Element("strong"), _o1igReferenceHint: new Element("span"), _o1igModelHint: new Element("span"), }; updateBatchReferenceHint(hintNode); assert.equal(hintNode._o1igReferenceTitle.textContent, "素材图"); assert.equal(hintNode._o1igReferenceHint.textContent, "3 张 · 整组参与"); assert.equal(hintNode._o1igModelHint.textContent, "2 张 · 每张目标图单独参与组合"); hintNode._o1igBatchMode.value = "全部搭配×全部模特"; updateBatchReferenceHint(hintNode); assert.equal(hintNode._o1igReferenceHint.textContent, "3 张 · 匹配时每张独立"); hintNode._o1igBatchMode.value = "单图素材批量"; updateBatchReferenceHint(hintNode); assert.equal(hintNode._o1igReferenceHint.textContent, "3 张 · 每张独立生成"); hintNode._o1igBatchEnabled = false; updateBatchReferenceHint(hintNode); assert.equal(hintNode._o1igReferenceTitle.textContent, "参考图"); assert.equal(hintNode._o1igReferenceHint.textContent, "3 张 · 最多 10 张"); initializeSaveSlots(saveNode, prompts.map((prompt, index) => ({ prompt, references: [{ name: `pair-${index + 1}.png`, subfolder: "batch", type: "input", url: "https://signed.invalid/secret", }], }))); assert.equal(saveNode._o1igsSlotsElement.children.length, 4); assert.equal(saveNode.properties.o1keyImageSlots.length, 4); assert.deepEqual( JSON.parse(JSON.stringify(saveNode.properties.o1keyImageSlots[1].references)), [{ name: "pair-2.png", subfolder: "batch", type: "input" }], ); assert.equal(saveNode._o1igsSlotsWidget.computeSize(300)[1] > 0, true); updateSaveSlotsForState(saveNode, { state: "running" }); assert.equal(saveNode._o1igsSlots.every((slot) => slot.state === "running"), true); applyPartialSaveSlots(saveNode, [ { filename: "third-preview.png", type: "temp", request_index: 3, result_index: 1 }, ]); assert.deepEqual( JSON.parse(JSON.stringify(saveNode._o1igsSlots.map((slot) => slot.state))), ["running", "running", "success", "running"], ); assert.equal(saveNode._o1igsSlots[2].image.filename, "third-preview.png"); assert.equal(saveNode._o1igsSlotsElement.children[2].children[1].tagName, "IMG"); createdElements.length = 0; const completeImages = applyCompletedSaveSlots(saveNode, { total_count: 4, failed_items: [ { request_index: 2, error: "第二张失败" }, { request_index: 4, error: "第四张失败" }, ], }, [ { filename: "first.png", type: "output", request_index: 1 }, { filename: "third.png", type: "output", request_index: 3 }, ]); assert.equal(completeImages.length, 2); assert.deepEqual( JSON.parse(JSON.stringify(saveNode._o1igsSlots.map((slot) => slot.state))), ["success", "failed", "success", "failed"], ); assert.equal(saveNode._o1igsSlotsElement.children.length, 4); assert.equal(createdElements.filter((element) => element.tagName === "IMG").length, 2); assert.equal(storedSaveSlots(saveNode)[1].error, "第二张失败"); assert.equal(slotContainer.classList.contains("o1key-slots-visible"), true); context.retryCalls = []; vm.runInContext( "queueGeneration = async (node, imageCount, options) => retryCalls.push({ node, imageCount, options })", context, ); await retryFailedSaveSlot(saveNode, 2); assert.equal(context.retryCalls.length, 1); assert.equal(context.retryCalls[0].node, sourceNode); assert.equal(context.retryCalls[0].imageCount, 1); assert.equal(context.retryCalls[0].options.targetSaveNode, saveNode); assert.equal(context.retryCalls[0].options.retrySlotIndex, 2); assert.equal(context.retryCalls[0].options.promptOverride, "甲"); assert.deepEqual( JSON.parse(JSON.stringify(context.retryCalls[0].options.referenceOverride)), [{ name: "pair-2.png", subfolder: "batch", type: "input" }], ); context.retryCalls = []; saveNode._o1igsBusy = true; syncSaveSlotsWidget(saveNode); const failedRetryButtons = saveNode._o1igsSlotsElement.children .filter((card) => card.className.includes("failed")) .map((card) => card.children.find((child) => child.className === "o1igs-slot-retry")); assert.equal(failedRetryButtons.every((button) => button.disabled === false), true); await Promise.all([ retryFailedSaveSlot(saveNode, 2), retryFailedSaveSlot(saveNode, 4), ]); assert.deepEqual( JSON.parse(JSON.stringify(context.retryCalls.map((call) => call.options.retrySlotIndex))), [2, 4], ); const firstRetryBatch = "12121212-1212-4212-8212-121212121212"; const secondRetryBatch = "13131313-1313-4313-8313-131313131313"; beginGenerationBatch(sourceNode, saveNode, firstRetryBatch); beginGenerationBatch(sourceNode, saveNode, secondRetryBatch); finishGenerationBatch(saveNode, "第一张完成", firstRetryBatch); assert.equal(saveNode._o1igsBusy, true); assert.equal(sourceNode._o1igPendingBatchIds.has(saveNode.id), true); finishGenerationBatch(saveNode, "第二张完成", secondRetryBatch); assert.equal(saveNode._o1igsBusy, false); assert.equal(sourceNode._o1igPendingBatchIds.has(saveNode.id), false); saveNode._o1igsBusy = false; saveNode._o1igsSlots = saveNode._o1igsSlots.map((slot) => ({ ...slot, state: "success", image: slot.image || { filename: `completed-${slot.index}.png`, type: "output" }, })); syncSaveSlotsWidget(saveNode); assert.equal(saveNode._o1igsSlotsWidget, null); assert.equal(saveNode._o1igsSlotsElement, null); assert.equal(saveNode.widgets.length, 0); assert.equal(saveNode.slotWidgetRemoveCount, 1); assert.equal(slotContainer.classList.contains("o1key-slots-visible"), false); document._querySelector = null; } { const exactGridNode = { size: [407, 413], _o1igsSlots: [ { index: 1, state: "failed" }, { index: 2, state: "failed" }, ], _o1igsSlotsElement: { offsetWidth: 383 }, }; assert.equal(saveSlotsWidgetHeight(exactGridNode), 195); const previewWidget = { type: "custom", serialize: false, computedHeight: 424, }; const squareNode = { size: [407, 620], imageIndex: 0, imgs: [{ naturalWidth: 2048, naturalHeight: 2048 }], widgets: [previewWidget], setSize(size) { this.size = size; }, setDirtyCanvas() {}, }; assert.equal(preferredNativeSavePreviewHeight(squareNode), 422); assert.equal(fitSaveNodeToNativeImages(squareNode), true); assert.deepEqual(JSON.parse(JSON.stringify(squareNode.size)), [407, 618]); squareNode.size = [407, 618]; previewWidget.computedHeight = 422; squareNode.imgs = [{ naturalWidth: 2048, naturalHeight: 1152 }]; assert.equal(preferredNativeSavePreviewHeight(squareNode), 244); assert.equal(fitSaveNodeToNativeImages(squareNode), true); assert.deepEqual(JSON.parse(JSON.stringify(squareNode.size)), [407, 440]); } { createdElements.length = 0; const savedImages = [ { filename: "kept-one.png", subfolder: "o1key", type: "output" }, { filename: "kept-two.png", subfolder: "o1key", type: "output" }, ]; let restoredMessage = null; const restoredNode = { id: 778, graph, properties: { o1keyImageSaveResults: savedImages }, _o1igsLastResults: storedSaveResults(null, { properties: { o1keyImageSaveResults: savedImages }, }), onExecuted(message) { restoredMessage = message; }, }; graph.nodes.set(restoredNode.id, restoredNode); app.nodeOutputs = {}; restoreNativeSavePreview(restoredNode); assert.equal(restoredMessage.images.length, 2); assert.equal(restoredMessage.images[0].filename, "kept-one.png"); assert.equal(app.nodeOutputs[String(restoredNode.id)].images[1].filename, "kept-two.png"); assert.equal(createdElements.filter((element) => element.tagName === "IMG").length, 0); } { class ReloadedSaveNode { constructor() { this.id = 779; this.comfyClass = "O1keyImageSave"; this.graph = graph; this.properties = {}; this.size = [300, 260]; this.widgets = []; } addDOMWidget() { return {}; } setSize(size) { this.size = size; } setDirtyCanvas() {} onExecuted(message) { this.nativeExecutedMessage = message; } } await app.extension.beforeRegisterNodeDef(ReloadedSaveNode, { name: "O1keyImageSave" }); const reloadedNode = new ReloadedSaveNode(); graph.nodes.set(reloadedNode.id, reloadedNode); const savedImages = [{ filename: "lifecycle.png", type: "output" }]; reloadedNode.onConfigure({ properties: { o1keyImageSaveResults: savedImages } }); app.extension.loadedGraphNode(reloadedNode); assert.equal(reloadedNode.nativeExecutedMessage.images[0].filename, "lifecycle.png"); assert.equal(app.nodeOutputs[String(reloadedNode.id)].images[0].filename, "lifecycle.png"); const serialized = {}; reloadedNode.onSerialize(serialized); assert.equal(serialized.properties.o1keyImageSaveResults[0].filename, "lifecycle.png"); assert.equal(serialized.o1key_image_save_results[0].filename, "lifecycle.png"); reloadedNode.onExecutionStart(); assert.equal(reloadedNode._o1igsBusy, false); reloadedNode._o1igsStandardQueueReserved = true; reloadedNode.onExecutionStart(); assert.equal(reloadedNode._o1igsBusy, true); } { const nativeIcon = { className: "icon-[lucide--download] size-4" }; const nativeButton = new Element("button"); nativeButton.className = "native-white-background black-icon"; nativeButton.querySelector = (selector) => selector.includes("lucide--download") ? nativeIcon : null; const preview = { querySelectorAll: () => [nativeButton], querySelector: () => ({ src: "/view?filename=two.png&type=output" }), }; const saveNode = { id: 777, _o1igsBusy: false, _o1igsLastResults: [ { filename: "one.png", type: "output" }, { filename: "two.png", type: "output" }, ], }; document._querySelector = (selector) => selector.includes('data-node-id="777"') ? preview : null; syncNativePreviewAction(saveNode); assert.equal(nativeButton.className, "native-white-background black-icon"); assert.equal(nativeButton.title, "重新生成"); assert.equal(nativeButton["aria-label"], "重新生成"); assert.match(nativeIcon.className, /lucide--refresh-cw/); assert.doesNotMatch(nativeIcon.className, /lucide--download/); assert.equal(nativeButton.dataset.o1keyRegenerate, "777"); document._querySelector = null; } { const sourceNode = { id: 900, comfyClass: "O1keyImageGenerator", graph, widgets: [{ name: "seed", value: 0 }], _o1igSeed: { value: "0" }, }; const saveNode = { id: 901, comfyClass: "O1keyImageSave", graph, inputs: [{ link: 9090 }], }; graph.nodes.set(sourceNode.id, sourceNode); graph.nodes.set(saveNode.id, saveNode); graph.links.set(9090, { origin_id: sourceNode.id, target_id: saveNode.id }); context.regenerationCalls = []; vm.runInContext( "queueGeneration = async (node, imageCount) => regenerationCalls.push({ node, imageCount })", context, ); await regenerateFromSaveNode(saveNode, 2); assert.equal(context.regenerationCalls.length, 1); assert.equal(context.regenerationCalls[0].node, sourceNode); assert.equal(context.regenerationCalls[0].imageCount, 1); } assert.match(source, /await queueGeneration\(source, 1\)/); assert.match(source, /: acquireSaveNodeForBatch\(node, batchId\)/); assert.match(source, /installStandardQueueRouting\(\)/); assert.match(source, /reroutePartialExecutionTargets\(queueNodeIds\)/); assert.match(source, /for \(const nodeId of removed\) delete output\[nodeId\]/); assert.match(source, /startNextStandardExecution\(this\)/); assert.match(source, /Promise\.allSettled/); assert.match(source, /imageUploadQueue\.then\(\(\) => uploadImage\(file\)\)/); assert.doesNotMatch(source, /URL\.createObjectURL|URL\.revokeObjectURL/); assert.match(source, /function thumbnailUrl\(item\)/); assert.match(rawSource, /import \{ openReferenceImageEditor \} from "\.\/o1keyReferenceImageEditor\.js"/); assert.match(source, /className = "o1ig-edit"/); assert.match(source, /sourceUrl: viewUrl\(original\)/); assert.match(source, /state\.items\.splice\(currentIndex, 1, uploaded\)/); assert.doesNotMatch(source, /o1key_uploads/); assert.match(source, /fetchParallelBatchStatus/); assert.match(source, /recoverParallelBatch/); assert.match(source, /detachParallelBatchNode/); assert.match(source, /\.o1key-slots-visible \.image-preview\{display:none!important;\}/); assert.doesNotMatch(source, /resizeNativeSavePreview/); assert.doesNotMatch(source, /\.o1key-image-save-node \.lg-node-content/); assert.doesNotMatch(source, /\.o1key-image-save-node \.image-preview\s*\{/); assert.doesNotMatch(source, /className = "o1igs-card"/); const saveRendererSource = source.slice( source.indexOf("function renderSaveResults"), source.indexOf("function setSaveBusy"), ); assert.doesNotMatch(saveRendererSource, /createElement\("img"\)/); const savePanelSource = source.slice( source.indexOf("function buildSavePanel"), source.indexOf("function installNativeMinimumSize"), ); assert.doesNotMatch(savePanelSource, /addDOMWidget/); assert.doesNotMatch(savePanelSource, /prefixWidget\.hidden = true/); assert.doesNotMatch(source, /installNativeMinimumSize\(nodeType, SAVE_NODE_LAYOUT_SIZE\)/); assert.match(source, /lucide--download.*lucide--refresh-cw/); assert.match(source, /font-size:12px;font-weight:500;line-height:1\.4;letter-spacing:\.035em/); assert.match(source, /o1ig-select-caret svg\{display:block;width:12px;height:12px/); assert.match(source, /const GENERATOR_DEFAULT_SIZE = \[560, 1035\]/); assert.doesNotMatch(source, /o1key-save-custom-prefix/); assert.match(source, /const GENERATOR_MIN_SIZE = \[500, 1025\]/); assert.match(source, /const GENERATOR_DENSE_MIN_HEIGHT = 1110/); assert.match(source, /const GENERATOR_BATCH_MIN_HEIGHT = 1215/); assert.match(source, /const GENERATOR_DENSE_BATCH_MIN_HEIGHT = 1300/); assert.match(source, /\.o1ig-prompt-wrap\{[^\n]*height:auto;min-height:142px;flex:1 1 142px/); assert.match(source, /\.o1ig-prompt\{[\s\S]*?min-height:142px;max-height:none;resize:none/); assert.doesNotMatch(source, /isExternalPromptConnected|useExternalPrompt|o1ig-prompt-input-slot/); assert.match(source, /delete nodeData\.input\.optional\.external_prompt/); assert.doesNotMatch(source, /app\.queuePrompt\(0, 1, \[saveNode\.id\]\)/); assert.match(source, /\.o1ig-refs\{[\s\S]*?flex:0 0 109px[\s\S]*?min-height:109px/); assert.match(source, /\.o1ig-thumb\{[\s\S]*?flex:0 0 102px;height:102px/); assert.match(source, /\.o1ig-thumb img\{[\s\S]*?object-fit:contain;background:#111/); assert.match(source, /referenceActions\.append\(add, canvasPick\)/); assert.match(source, /modelActions\.append\(modelAdd, modelCanvasPick\)/); assert.match(source, /window\.o1keyCanvasImagePicker = Object\.freeze/); assert.equal(typeof context.window.o1keyCanvasImagePicker.open, "function"); assert.equal(context.window.o1keyCanvasImagePicker.readFile, readCanvasImageFile); assert.match(source, /state\.container\.replaceChildren\(\);/); assert.doesNotMatch(source, /className = "o1ig-order-handle"/); assert.match(source, /tile\.draggable = !sortingLocked/); assert.match(source, /tile\.addEventListener\("dragstart", \(event\) => beginReferenceDrag/); assert.match(source, /node\._o1igSuppressReferenceClickUntil = Date\.now\(\) \+ 350/); assert.match(source, /\.o1ig-thumb\.o1ig-dragging::before\{[\s\S]*?content:"移动中"/); assert.match(source, /\.o1ig-refs\.o1ig-reordering\{/); assert.match(source, /\.o1ig-thumb\.o1ig-drop-before\{transform:translateX\(7px\)/); assert.match(source, /\.o1ig-empty-reference:hover,\.o1ig-empty-reference\.drag\{/); assert.match(source, /\.o1ig-toolbar\{[^\n]*grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/); assert.match(source, /\.o1ig-field\{display:flex;flex-direction:column/); assert.match(source, /makePanelSectionTitle\("画面描述"\)/); assert.match(source, /makePanelSectionTitle\("生成参数"\)/); assert.match(source, /makePanelSectionTitle\("保存设置"\)/); assert.match(source, /filenamePrefixField\.classList\.add\("o1ig-field-wide"\)/); assert.match(source, /saveLocationField\.classList\.add\("o1ig-field-wide"\)/); assert.match(source, /o1ig-resize-warning\{display:none;color:#d5aa62/); assert.match(source, /o1ig-resize-warning\.visible\{display:block/); assert.match(source, /o1ig-seed\{[\s\S]*grid-template-columns:minmax\(0,1fr\) 34px;gap:0/); assert.match(source, /o1ig-seed button\{[\s\S]*border-left:1px solid/); assert.match(source, /className = "o1ig-prompt-optimize"/); assert.match(source, /className = "o1ig-prompt-optimize-status"/); assert.match(source, /promptOptimizeIcon\.textContent = "✨";[\s\S]*?promptOptimizeLabel\.textContent = "AI帮写";[\s\S]*?promptOptimize\.append\(promptOptimizeIcon, promptOptimizeLabel\)/); assert.match(source, /o1ig-prompt-optimize\{[\s\S]*?min-width:96px[\s\S]*?display:flex[\s\S]*?gap:5px/); assert.match(source, /o1ig-prompt-optimize-icon\{[\s\S]*?font-size:15px/); assert.match(source, /o1ig-prompt-optimize-status\{[\s\S]*?right:112px[\s\S]*?max-width:calc\(100% - 128px\)/); assert.doesNotMatch(source, /o1ig-prompt-guide|promptGuide|updateBatchPromptGuide|描述示例/); assert.doesNotMatch(source, /先上传素材图和目标图|批量任务会在提交前显示总数/); assert.match(source, /if \(singleReferences \? !outfits : \(!outfits \|\| !models\)\) \{/); assert.match(source, /modelHint\.textContent = "每张目标图单独参与组合"/); assert.match(source, /_o1igModelAssets\?\.classList\.toggle\("visible", enabled && !singleReferences\)/); assert.match(source, /单图批量至少需要上传1张素材图/); assert.match(source, /summary\.textContent = `\$\{pairingText\} × 每组 \$\{perPair\} 张\$\{promptText\},共 \$\{total\} 张`/); assert.doesNotMatch(source, /整组参与 \/ 全匹配时每张独立/); assert.match(source, /index\.className = "o1ig-reference-index";[\s\S]*?index\.textContent = badges\.label/); assert.doesNotMatch(source, /o1ig-request-index/); assert.match(source, /const REFERENCE_LIGHTBOX_ID = "o1key-image-reference-lightbox"/); assert.match(source, /function showReferenceLightbox\(node, role, listIndex\)/); assert.match(source, /image\.setAttribute\("aria-label", sortingLocked \? "查看大图" : "查看大图;按住拖动可排序"\)[\s\S]*?showReferenceLightbox\(node, role, entry\.index\)/); assert.match(source, /\.o1ig-lightbox\{[\s\S]*?position:fixed;inset:0;z-index:9999/); assert.doesNotMatch(source, /o1ig-thumb\.previewable::after|content:"查看"|openImageInNewTab|window\.open/); assert.match(source, /batchMode\.addEventListener\("change", \(\) => \{[\s\S]*?syncBatchLayout\(node\);/); assert.match(source, /promptOptimize\.title = "AI帮写"/); assert.match(source, /多个提示词用独占一行的 --- 分隔/); assert.match(source, /setPromptOptimizeStatus\(node, "AI帮写中…", "busy"\)/); assert.match(source, /await api\.fetchApi\("\/o1key\/image\/prompt-optimize"/); assert.match(source, /o1ig-select-option-description/); assert.match(source, /const THINKING_LEVEL_OPTIONS = \[/); assert.match(source, /value: "低", label: "低", description: "耗时低,智力低"/); assert.match(source, /value: "高", label: "高", description: "耗时高,智力高"/); assert.match(source, /makeSelect\(THINKING_LEVEL_OPTIONS, findWidget\(node, "思考等级"\)\?\.value \|\| "低"\)/); assert.match(source, /node\._o1igThinking\.value = findWidget\(node, "思考等级"\)\?\.value \|\| "低"/); assert.match(source, /value: "不缩放", label: "不缩放", description: "无大图"/); assert.match(source, /value: "智能缩放", label: "智能缩放", description: "有大图"/); assert.doesNotMatch(source, /色彩纠正|不改AI图|改AI图/); assert.match(source, /className = "o1ig-select-selected-description"/); assert.match(source, /trigger\.append\(valueLabel, valueDescription, caret\)/); assert.equal(maxReferences, 50); assert.equal(maxRequestReferences, 10); assert.equal(referenceLimit({ _o1igBatchEnabled: false }), 10); assert.equal(referenceLimit({ _o1igLayerDecomposition: { value: "开启" }, _o1igBatchEnabled: false, }), 1); assert.equal(referenceLimit({ _o1igBatchEnabled: true, _o1igBatchMode: { value: "一组搭配+多模特" }, }, "references"), 9); assert.equal(referenceLimit({ _o1igBatchEnabled: true, _o1igBatchMode: { value: "全部搭配×全部模特" }, }, "references"), 50); assert.equal(referenceLimit({ _o1igBatchEnabled: true, _o1igBatchMode: { value: "单图素材批量" }, }, "references"), 50); assert.equal(referenceLimit({ _o1igBatchEnabled: true }, "models"), 50); { const saveNode = { properties: {}, graph, widgets: [], _o1igsSlots: Array.from({ length: 4 }, (_, index) => ({ index: index + 1, state: "running", prompt: "", references: [], image: null, })), setDirtyCanvas() {}, }; const images = [ { filename: "first.png", type: "output", request_index: 1, result_index: 1 }, { filename: "second.png", type: "output", request_index: 2, result_index: 1 }, { filename: "third-a.png", type: "output", request_index: 3, result_index: 1 }, { filename: "third-b.png", type: "output", request_index: 3, result_index: 2 }, { filename: "fourth.png", type: "output", request_index: 4, result_index: 1 }, ]; const completed = applyCompletedSaveSlots(saveNode, { total_count: 4, request_count: 4, result_count: 5, }, images); assert.equal(completed.length, 5); assert.deepEqual( JSON.parse(JSON.stringify(saveNode._o1igsSlots.map((slot) => slot.state))), ["success", "success", "success", "success"], ); assert.deepEqual( JSON.parse(JSON.stringify(saveNode._o1igsSlots.map((slot) => slot.image.filename))), ["first.png", "second.png", "third-a.png", "fourth.png"], ); const restoredSlots = reconcileSaveSlotsWithResults( saveNode._o1igsSlots.map((slot, index) => ({ ...slot, state: index ? "running" : "success", image: index ? null : slot.image, })), images, ); assert.deepEqual( JSON.parse(JSON.stringify(restoredSlots.map((slot) => slot.state))), ["success", "success", "success", "success"], ); assert.deepEqual( JSON.parse(JSON.stringify(restoredSlots.map((slot) => slot.image.filename))), ["first.png", "second.png", "third-a.png", "fourth.png"], ); } { const saveNode = { properties: {}, graph, widgets: [], _o1igsSlots: [{ index: 1, state: "running", prompt: "", references: [], image: null }], setDirtyCanvas() {}, }; const images = [ { filename: "base.png", subfolder: "", type: "output", request_index: 1, result_index: 1, layer: { z_index: 0, name: "底图", url: "https://signed.invalid/secret" }, }, { filename: "subject.png", subfolder: "", type: "output", request_index: 1, result_index: 2, layer: { z_index: 1, name: "主体" }, }, ]; const completed = applyCompletedSaveSlots(saveNode, { total_count: 2, request_count: 1, result_count: 2, }, images, 0, true); assert.equal(completed.length, 2); assert.equal(completed[1].layer.name, "主体"); assert.equal(Object.hasOwn(completed[0].layer, "url"), false); } assert.match(source, /mask: isGptImageModel\(node\._o1igModel\.value\)/); assert.match(source, /resizeWarning\.textContent = "可能发生像素偏移"/); assert.match(source, /resize_mode: node\._o1igResize\.value/); assert.doesNotMatch(source, /color_correction/); assert.match(source, /if \(isGptImageModel\(node\._o1igModel\.value\)\)/); assert.match(source, /jobPayload\.output_format = node\._o1igOutputFormat\.value/); assert.match(source, /node\._o1igModel\.value === "Seedream 5\.0 Pro"/); assert.match(source, /save_format: saveSettings\.format/); assert.match(source, /jobPayload\.background = node\._o1igBackground\.value/); assert.doesNotMatch(source, /jobPayload\.moderation/); assert.match(source, /jobPayload\.google_search = true/); assert.match(source, /jobPayload\.layer_decomposition = true/); assert.match(source, /if \(layerDecomposition\) \{\s*layerSaveNode = acquireLayerSaveNodeForBatch\(node, saveNode\);/); assert.match(source, /layerDecomposition,\s*retrySlotIndex,/); assert.match(source, /node\._o1igModel\.value === "Nano Banana 2"/); assert.match(source, /node\._o1igOnlineSearch\.value === "打开"/); assert.match(source, /batch_enabled: retryTask \? false : Boolean\(node\._o1igBatchEnabled\)/); assert.match(source, /model_references: retryTask \|\| node\._o1igBatchMode\.value === BATCH_MODE_SINGLE_REFERENCES/); assert.match(source, /整组素材 → 多个目标/); assert.match(source, /全匹配(素材 × 目标)/); assert.match(source, /单图批量(每张素材独立)/); assert.match(source, /api\.fetchApi\("\/o1key\/image\/save"/); assert.match(source, /const saveSettings = generatorSaveSettings\(findConnectedGenerator\(saveNode\)\)/); assert.match(source, /format: saveSettings\.format/); assert.match(source, /save_location: saveSettings\.save_location/); assert.match(source, /naming_rule: saveSettings\.naming_rule/); assert.match(source, /api\.getQueue = async/); assert.match(source, /api\.cancelJob = async/); assert.match(source, /document\.createElement\("button"\)/); assert.match(source, /"bg-secondary-background"/); assert.doesNotMatch(source, /\.o1key-queue-count\s*\{/); assert.match(source, /total_count: taskPrompts\.length/); assert.match(source, /function retryFailedSaveSlot/); assert.match(source, /failed_items/); assert.match(source, /encodeURIComponent\(job\.batch_id\)\}\/cancel/); assert.match(source, /generate\.addEventListener\("click", \(\) => queueGeneration\(node\)\)/); assert.doesNotMatch(source, /取消当前任务/); assert.doesNotMatch(source, /makeAdvancedField\("色彩纠正"/); assert.match(source, /makeAdvancedField\("背景", background\)/); assert.doesNotMatch(source, /makeAdvancedField\("内容审查强度"/); assert.match(source, /makeAdvancedField\("在线搜索", onlineSearch\)/); assert.match(source, /makeAdvancedField\("图层拆分", layerDecomposition\)/); assert.match(source, /makeAdvancedField\("输出格式", outputFormat\)/); assert.match(source, /makeAdvancedField\("命名规则", namingRule\)/); assert.match(source, /makeAdvancedField\("文件名前缀", filenamePrefix\)/); assert.match(source, /makeAdvancedField\("格式", saveFormat\)/); assert.match(source, /makeAdvancedField\("保存位置", saveLocation\)/); assert.match(source, /留空为 output;或填写 D:\/图片/); assert.doesNotMatch(source, /色彩纠正/); assert.match(source, /const MODEL_CAPABILITIES = Object\.freeze/); assert.match(source, /setFieldVisible\(node\._o1igOnlineSearchField, capabilities\.search\)/); assert.match(source, /_o1igResizeField: resizeField/); assert.doesNotMatch(source, /参考图输入/); assert.doesNotMatch(source, /hasConnectedReferenceInput/); assert.doesNotMatch(source, /className = "o1ig-more"/); assert.doesNotMatch(source, /className = "o1ig-advanced"/); assert.match(source, /const GPT_OUTPUT_FORMATS = \[/); assert.match(source, /value: "jpeg", label: "JPEG"/); assert.match(source, /findWidget\(node, "输出格式"\)\?\.value \|\| "jpeg"/); assert.match(source, /setFieldVisible\(node\._o1igSaveFormatField, capabilities\.saveFormat\)/); assert.match(source, /const GPT_BACKGROUNDS = \[/); assert.doesNotMatch(source, /const GPT_MODERATION_OPTIONS = \[/); assert.match(source, /option\.value !== "jpeg"/); assert.match(migrationSource, /type: "O1keyImageGenerator"/); assert.match(migrationSource, /"不缩放"/); assert.match(migrationSource, /"不纠正"/); assert.match(migrationSource, /"auto"/); assert.match(migrationSource, /移除统一图片生成节点已停用的色彩纠正参数/); assert.match(migrationSource, /wv\.length < 15/); assert.match(migrationSource, /wv\.length < 18/); assert.match(migrationSource, /migrateUnifiedSaveSettings/); assert.match(migrationSource, /migrateUnifiedOnlineSearch/); assert.match(migrationSource, /migrateUnifiedExternalPromptInput/); assert.match(migrationSource, /type: "MiniMaxH3Video"/); assert.match(migrationSource, /wv\.push\("MiniMax-H3"\)/); assert.match(migrationSource, /原生 seed 参数/); { let migrationExtension; const migrationContext = { app: { registerExtension(extension) { migrationExtension = extension; } }, console: { log() {}, warn() {} }, }; vm.runInNewContext( migrationSource.replace(/^import .*;\s*$/gm, ""), migrationContext, ); const legacyRedCastValues = [1.0, 8.0, 0.1, "", 62.0, 15.0]; const legacyRedCastGraph = { nodes: [{ type: "O1keyAutoRedCast", widgets_values: legacyRedCastValues }], }; migrationExtension.beforeConfigureGraph(legacyRedCastGraph); assert.deepEqual(legacyRedCastValues, [1.0, 8.0, 0.1, "", 62.0, 15.0, 0]); migrationExtension.beforeConfigureGraph(legacyRedCastGraph); assert.equal(legacyRedCastValues.length, 7); const interimRedCastValues = [1.0, 8.0, 0.1, "", 1234, 62.0, 15.0]; const interimRedCastGraph = { nodes: [{ type: "O1keyAutoRedCast", widgets_values: interimRedCastValues }], }; migrationExtension.beforeConfigureGraph(interimRedCastGraph); assert.deepEqual(interimRedCastValues, [1.0, 8.0, 0.1, "", 62.0, 15.0, 1234]); migrationExtension.beforeConfigureGraph(interimRedCastGraph); assert.deepEqual(interimRedCastValues, [1.0, 8.0, 0.1, "", 62.0, 15.0, 1234]); const currentRedCastValues = [1.0, 8.0, 0.1, "", 62.0, 15.0, 77]; migrationExtension.beforeConfigureGraph({ nodes: [{ type: "O1keyAutoRedCast", widgets_values: currentRedCastValues }], }); assert.deepEqual(currentRedCastValues, [1.0, 8.0, 0.1, "", 62.0, 15.0, 77]); const legacyPromptGraph = { nodes: [ { id: 1, type: "TextSource", outputs: [{ links: [41, 42] }] }, { id: 2, type: "O1keyImageGenerator", inputs: [{ name: "prompt", link: null }, { name: "external_prompt", link: 41 }], widgets_values: ["保留面板提示词"], }, { id: 3, type: "OtherNode", inputs: [{ link: 42 }] }, ], links: [[41, 1, 0, 2, 1, "STRING"], [42, 1, 0, 3, 0, "STRING"]], }; migrationExtension.beforeConfigureGraph(legacyPromptGraph); assert.deepEqual(legacyPromptGraph.nodes[1].inputs.map((input) => input.name), ["prompt"]); assert.deepEqual(legacyPromptGraph.nodes[0].outputs[0].links, [42]); assert.deepEqual(legacyPromptGraph.links.map((link) => link[0]), [42]); assert.equal(legacyPromptGraph.nodes[1].widgets_values[0], "保留面板提示词"); migrationExtension.beforeConfigureGraph(legacyPromptGraph); assert.deepEqual(legacyPromptGraph.links.map((link) => link[0]), [42]); const nestedPromptGraph = { definitions: { subgraphs: [{ nodes: [ { id: 4, type: "TextSource", outputs: [{ links: [43] }] }, { id: 5, type: "O1keyImageGenerator", inputs: [{ name: "external_prompt", link: 43 }] }, ], links: [{ id: 43, origin_id: 4, target_id: 5 }], }] }, }; migrationExtension.beforeConfigureGraph(nestedPromptGraph); assert.deepEqual(nestedPromptGraph.definitions.subgraphs[0].nodes[0].outputs[0].links, []); assert.deepEqual(nestedPromptGraph.definitions.subgraphs[0].nodes[1].inputs, []); assert.deepEqual(nestedPromptGraph.definitions.subgraphs[0].links, []); const legacyValues = [ "prompt", "Nano Banana 2", "畅速", "高", "2K", "1:1", "1", 0, "[]", "自动", "png", "{}", "不缩放", ]; const graphData = { nodes: [{ type: "O1keyImageGenerator", widgets_values: legacyValues }], }; migrationExtension.beforeConfigureGraph(graphData); assert.equal(legacyValues.length, 23); const legacyVideoValues = Array.from({ length: 16 }, (_, index) => `value-${index}`); const legacyVideoGraph = { nodes: [{ type: "O1keyVideoGenerator", widgets_values: legacyVideoValues }], }; migrationExtension.beforeConfigureGraph(legacyVideoGraph); assert.equal(legacyVideoValues.length, 17); assert.equal(legacyVideoValues[16], "auto"); migrationExtension.beforeConfigureGraph(legacyVideoGraph); assert.equal(legacyVideoValues.length, 17); assert.equal(legacyValues[22], false); migrationExtension.beforeConfigureGraph(graphData); assert.equal(legacyValues.length, 23); const legacyMiniMaxValues = ["prompt", "文生视频", "16:9", "2K", 5]; const legacyMiniMaxGraph = { nodes: [{ type: "MiniMaxH3Video", widgets_values: legacyMiniMaxValues }], }; migrationExtension.beforeConfigureGraph(legacyMiniMaxGraph); assert.deepEqual(legacyMiniMaxValues.slice(-2), ["MiniMax-H3", 0]); assert.equal(legacyMiniMaxValues.length, 7); migrationExtension.beforeConfigureGraph(legacyMiniMaxGraph); assert.equal(legacyMiniMaxValues.length, 7); const modelOnlyMiniMaxValues = [ "prompt", "文生视频", "16:9", "2K", 5, "MiniMax-H3-MAX", ]; migrationExtension.beforeConfigureGraph({ nodes: [{ type: "MiniMaxH3Video", widgets_values: modelOnlyMiniMaxValues }], }); assert.deepEqual(modelOnlyMiniMaxValues.slice(-2), ["MiniMax-H3-MAX", 0]); assert.equal(legacyValues[13], "auto"); assert.equal(legacyValues[14], false); assert.equal(legacyValues[15], "一组搭配+多模特"); assert.equal(legacyValues[16], "[]"); assert.deepEqual(legacyValues.slice(17, 21), ["自定义前缀", "o1key", "原始", ""]); assert.equal(legacyValues[21], "关闭"); migrationExtension.beforeConfigureGraph(graphData); assert.equal(legacyValues.length, 23); assert.equal(legacyValues[22], false); const currentValues = [ "prompt", "gpt-image-2", "畅速", "高", "2K", "16:9", "1", 42, "[]", "自动", "webp", "{}", "不缩放", "不纠正", "transparent", ]; migrationExtension.beforeConfigureGraph({ nodes: [{ type: "O1keyImageGenerator", widgets_values: currentValues }], }); assert.equal(currentValues.length, 23); assert.equal(currentValues[22], false); assert.equal(currentValues[10], "webp"); assert.equal(currentValues[13], "transparent"); assert.equal(currentValues[14], false); assert.equal(currentValues[15], "一组搭配+多模特"); assert.equal(currentValues[16], "[]"); assert.deepEqual(currentValues.slice(17, 21), ["自定义前缀", "o1key", "原始", ""]); assert.equal(currentValues[21], "关闭"); const retiredModerationValues = [ "prompt", "gpt-image-2.5-flare", "直连", "低", "智能", "智能", "9", 17, "[]", "最高", "webp", "{}", "智能缩放", "opaque", "低", true, "单图素材批量", "[]", "自然数字", "saved", "原始", "archive", "关闭", false, ]; const retiredModerationGraph = { nodes: [{ type: "O1keyImageGenerator", widgets_values: retiredModerationValues }], }; migrationExtension.beforeConfigureGraph(retiredModerationGraph); assert.equal(retiredModerationValues.length, 23); assert.deepEqual(retiredModerationValues.slice(13, 17), ["opaque", true, "单图素材批量", "[]"]); assert.equal(retiredModerationValues[9], "最高"); assert.equal(retiredModerationValues[17], "自然数字"); migrationExtension.beforeConfigureGraph(retiredModerationGraph); assert.equal(retiredModerationValues.length, 23); const movedGeneratorValues = [ "prompt", "Nano Banana 2", "畅速", "低", "2K", "1:1", "1", 0, "[]", "自动", "png", "{}", "不缩放", "不纠正", "auto", "自动", false, "一组搭配+多模特", "[]", ]; const legacySaveValues = ["legacy-prefix", "jpg", "archive/session", "自然数字"]; const movedGraph = { nodes: [ { id: 10, type: "O1keyImageGenerator", widgets_values: movedGeneratorValues }, { id: 11, type: "O1keyImageSave", widgets_values: legacySaveValues, inputs: [{ link: 99 }], properties: {}, }, ], links: [[99, 10, 0, 11, 0, "IMAGE"]], }; migrationExtension.beforeConfigureGraph(movedGraph); assert.deepEqual( movedGeneratorValues.slice(17, 21), ["自然数字", "legacy-prefix", "jpg", "archive/session"], ); assert.deepEqual(legacySaveValues, []); migrationExtension.beforeConfigureGraph(movedGraph); assert.equal(movedGeneratorValues.length, 23); assert.equal(movedGeneratorValues[22], false); assert.equal(movedGeneratorValues[21], "关闭"); assert.deepEqual(legacySaveValues, []); const legacyNanoValues = [ "prompt", "Nano Banana 2", "畅速", "高", "2K", "1:1", "1", 0, ]; const legacyNanoGraph = { nodes: [{ type: "NanoBanana", widgets_values: legacyNanoValues }], }; migrationExtension.beforeConfigureGraph(legacyNanoGraph); assert.deepEqual(legacyNanoValues.slice(-1), ["不缩放"]); assert.equal(legacyNanoValues.length, 9); migrationExtension.beforeConfigureGraph(legacyNanoGraph); assert.equal(legacyNanoValues.length, 9); const legacyNanoWithColorCorrection = [ "prompt", "Nano Banana 2", "畅速", "高", "2K", "1:1", "1", 0, "不缩放", "智能纠正", ]; migrationExtension.beforeConfigureGraph({ nodes: [{ type: "NanoBanana", widgets_values: legacyNanoWithColorCorrection }], }); assert.deepEqual(legacyNanoWithColorCorrection.slice(-1), ["不缩放"]); assert.equal(legacyNanoWithColorCorrection.length, 9); const legacyBatchNanoValues = [ "prompt", "Nano Banana 2", "畅速", "高", "2K", "1:1", "1个路径", "D:\\images", "关闭", 0, "原始", 95, "和原始图片名保持一致", "", ]; const legacyBatchNanoGraph = { nodes: [{ type: "BatchNanoBananaPro", widgets_values: legacyBatchNanoValues }], }; migrationExtension.beforeConfigureGraph(legacyBatchNanoGraph); assert.deepEqual(legacyBatchNanoValues.slice(-6), [ "不缩放", "原始", 95, "和原始图片名保持一致", "", 0, ]); assert.equal(legacyBatchNanoValues[10], 95); assert.equal(legacyBatchNanoValues.length, 14); migrationExtension.beforeConfigureGraph(legacyBatchNanoGraph); assert.deepEqual(legacyBatchNanoValues.slice(-6), [ "不缩放", "原始", 95, "和原始图片名保持一致", "", 0, ]); const stringQualityValues = [ "prompt", "Nano Banana 2", "畅速", "高", "2K", "1:1", "1个路径", "D:\\images", "关闭", 0, "JPEG", "95", "和原始图片名保持一致", "", "不缩放", "不纠正", ]; const stringQualityGraph = { nodes: [{ type: "BatchNanoBananaPro", widgets_values: stringQualityValues }], }; migrationExtension.beforeConfigureGraph(stringQualityGraph); assert.equal(stringQualityValues[10], 95); assert.equal(typeof stringQualityValues[10], "number"); assert.deepEqual(stringQualityValues.slice(-6), [ "不缩放", "JPEG", 95, "和原始图片名保持一致", "", 0, ]); assert.equal(stringQualityValues.length, 14); migrationExtension.beforeConfigureGraph(stringQualityGraph); assert.equal(stringQualityValues[10], 95); assert.deepEqual(stringQualityValues.slice(-6), [ "不缩放", "JPEG", 95, "和原始图片名保持一致", "", 0, ]); const smartResizeValues = [ "prompt", "Nano Banana 2", "畅速", "高", "2K", "1:1", "1个路径", "D:\\images", "关闭", 0, "PNG", 95, "和原始图片名保持一致", "", "智能缩放", "智能纠正", ]; const smartResizeGraph = { nodes: [{ type: "BatchNanoBananaPro", widgets_values: smartResizeValues }], }; migrationExtension.beforeConfigureGraph(smartResizeGraph); assert.deepEqual(smartResizeValues.slice(-6), [ "智能缩放", "PNG", 95, "和原始图片名保持一致", "", 0, ]); migrationExtension.beforeConfigureGraph(smartResizeGraph); assert.deepEqual(smartResizeValues.slice(-6), [ "智能缩放", "PNG", 95, "和原始图片名保持一致", "", 0, ]); const multiPathValues = [ "prompt", "Nano Banana 2", "专线", "低", "4K", "16:9", "3个路径", "D:\\one", "D:\\two", "D:\\three", "同序号", "2", 7788, "WebP", 73, "自然数字", "D:\\output", "智能缩放", ]; const multiPathGraph = { nodes: [{ type: "BatchNanoBananaPro", widgets_values: multiPathValues }], }; migrationExtension.beforeConfigureGraph(multiPathGraph); assert.deepEqual(multiPathValues.slice(6, 11), [ "3个路径", "D:\\one", "D:\\two", "D:\\three", "同序号", ]); assert.deepEqual(multiPathValues.slice(-6), [ "智能缩放", "WebP", 73, "自然数字", "D:\\output", 7788, ]); migrationExtension.beforeConfigureGraph(multiPathGraph); assert.deepEqual(multiPathValues.slice(-6), [ "智能缩放", "WebP", 73, "自然数字", "D:\\output", 7788, ]); assert.equal(multiPathValues.length, 17); const randomInputGraph = { nodes: [ { id: 1, type: "TextSource", outputs: [{ links: [91] }] }, { id: 2, type: "ImageSource", outputs: [{ links: [92] }] }, { id: 3, type: "BatchNanoBananaPro", inputs: [ { name: "图片路径数量.参考图1(主图)", link: null }, { name: "图片路径数量.图片随机抽取", link: 91 }, { name: "参考图组.参考图1", link: 92 }, ], }, ], links: [[91, 1, 0, 3, 1, "STRING"], [92, 2, 0, 3, 2, "IMAGE"]], }; migrationExtension.beforeConfigureGraph(randomInputGraph); assert.deepEqual(randomInputGraph.nodes[2].inputs.map(input => input.name), [ "图片路径数量.参考图1(主图)", "参考图组.参考图1", ]); assert.deepEqual(randomInputGraph.nodes[0].outputs[0].links, []); assert.deepEqual(randomInputGraph.nodes[1].outputs[0].links, [92]); assert.deepEqual(randomInputGraph.links, [[92, 2, 0, 3, 1, "IMAGE"]]); migrationExtension.beforeConfigureGraph(randomInputGraph); assert.deepEqual(randomInputGraph.links, [[92, 2, 0, 3, 1, "IMAGE"]]); const legacyGPTValues = [ "prompt", "gpt-image-2", "畅速", "智能", 1, "自动", "png", 0, ]; const legacyGPTGraph = { nodes: [{ type: "O1keyGPTImage", widgets_values: legacyGPTValues }], }; migrationExtension.beforeConfigureGraph(legacyGPTGraph); assert.deepEqual( legacyGPTValues.slice(-3), ["auto", "智能缩放", 0], ); assert.equal(legacyGPTValues.length, 10); migrationExtension.beforeConfigureGraph(legacyGPTGraph); assert.equal(legacyGPTValues.length, 10); const legacyGPTWithColorCorrection = [ "prompt", "gpt-image-2", "畅速", "智能", 1, "自动", "png", 0, "不缩放", "智能纠正", "auto", "自动", ]; migrationExtension.beforeConfigureGraph({ nodes: [{ type: "O1keyGPTImage", widgets_values: legacyGPTWithColorCorrection }], }); assert.deepEqual(legacyGPTWithColorCorrection.slice(-3), ["auto", "不缩放", 0]); assert.equal(legacyGPTWithColorCorrection.length, 10); const currentGPT25Values = [ "prompt", "gpt-image-2.5-sunburst", "畅速", "智能", 1, "超高", "png", "opaque", "智能缩放", 0, ]; const currentGPT25Graph = { nodes: [{ type: "O1keyGPTImage", widgets_values: currentGPT25Values }], }; migrationExtension.beforeConfigureGraph(currentGPT25Graph); assert.equal(currentGPT25Values.length, 10); assert.deepEqual(currentGPT25Values.slice(-3), ["opaque", "智能缩放", 0]); migrationExtension.beforeConfigureGraph(currentGPT25Graph); assert.equal(currentGPT25Values.length, 10); const legacyGPT25Values = [ "prompt", "gpt-image-2.5-sunburst", "畅速", "智能", 1, "超高", "png", 0, "智能缩放", "transparent", "低", ]; const legacyGPT25Graph = { nodes: [{ type: "O1keyGPTImage", widgets_values: legacyGPT25Values }], }; migrationExtension.beforeConfigureGraph(legacyGPT25Graph); assert.equal(legacyGPT25Values.length, 10); assert.deepEqual(legacyGPT25Values.slice(-3), ["transparent", "智能缩放", 0]); migrationExtension.beforeConfigureGraph(legacyGPT25Graph); assert.equal(legacyGPT25Values.length, 10); const legacyGPTBatchValues = [ "prompt", "gpt-image-2", "畅速", "智能", 1, "自动", "1个路径", "D:\\images", "关闭", 3, 0, "原始", "和原始图片名保持一致", "", ]; const legacyGPTBatchGraph = { nodes: [{ type: "O1keyGPTImageBatch", widgets_values: legacyGPTBatchValues }], }; migrationExtension.beforeConfigureGraph(legacyGPTBatchGraph); assert.deepEqual( legacyGPTBatchValues.slice(-6), ["原始", "auto", "和原始图片名保持一致", "", "智能缩放", 0], ); assert.equal(legacyGPTBatchValues.length, 14); assert.equal(legacyGPTBatchValues[8], "原始"); migrationExtension.beforeConfigureGraph(legacyGPTBatchGraph); assert.equal(legacyGPTBatchValues.length, 14); const legacyGPTBatchWithRemovedControls = [ "prompt", "gpt-image-2.5-sunburst", "畅速", "智能", 1, "最高", "1个路径", "D:\\images", "关闭", 3, 123, "PNG", "自然数字", "D:\\output", "不缩放", "智能纠正", "transparent", "低", ]; const legacyGPTBatchWithRemovedControlsGraph = { nodes: [{ type: "O1keyGPTImageBatch", widgets_values: legacyGPTBatchWithRemovedControls }], }; migrationExtension.beforeConfigureGraph(legacyGPTBatchWithRemovedControlsGraph); assert.deepEqual( legacyGPTBatchWithRemovedControls.slice(-6), ["PNG", "transparent", "自然数字", "D:\\output", "不缩放", 123], ); assert.equal(legacyGPTBatchWithRemovedControls.length, 14); assert.equal(legacyGPTBatchWithRemovedControls[8], "PNG"); migrationExtension.beforeConfigureGraph(legacyGPTBatchWithRemovedControlsGraph); assert.equal(legacyGPTBatchWithRemovedControls.length, 14); const legacyGPTBatchTwoPaths = [ "prompt", "gpt-image-2.5-flare", "畅速", "智能", 1, "超高", "2个路径", "D:\\first", "D:\\second", "相同文件名", "关闭", 4, 456, "WebP", "和原始图片名保持一致", "D:\\output", "智能缩放", "不纠正", "opaque", "自动", ]; const legacyGPTBatchTwoPathsGraph = { nodes: [{ type: "O1keyGPTImageBatch", widgets_values: legacyGPTBatchTwoPaths }], }; migrationExtension.beforeConfigureGraph(legacyGPTBatchTwoPathsGraph); assert.deepEqual( legacyGPTBatchTwoPaths.slice(-6), ["WebP", "opaque", "和原始图片名保持一致", "D:\\output", "智能缩放", 456], ); assert.equal(legacyGPTBatchTwoPaths.length, 16); assert.equal(legacyGPTBatchTwoPaths[10], "WebP"); migrationExtension.beforeConfigureGraph(legacyGPTBatchTwoPathsGraph); assert.equal(legacyGPTBatchTwoPaths.length, 16); const currentGPTBatchWithRandomSelection = [ "prompt", "gpt-image-2.5-sunburst", "畅速", "智能", 1, "最高", "1个路径", "D:\\images", "1", "WebP", "opaque", "和原始图片名保持一致", "D:\\output", "智能缩放", 789, ]; const currentGPTBatchWithRandomSelectionGraph = { nodes: [{ type: "O1keyGPTImageBatch", widgets_values: currentGPTBatchWithRandomSelection, }], }; migrationExtension.beforeConfigureGraph(currentGPTBatchWithRandomSelectionGraph); assert.equal(currentGPTBatchWithRandomSelection.length, 14); assert.equal(currentGPTBatchWithRandomSelection[8], "WebP"); assert.deepEqual(currentGPTBatchWithRandomSelection.slice(-6), [ "WebP", "opaque", "和原始图片名保持一致", "D:\\output", "智能缩放", 789, ]); migrationExtension.beforeConfigureGraph(currentGPTBatchWithRandomSelectionGraph); assert.equal(currentGPTBatchWithRandomSelection.length, 14); const previousGPTBatchWithRemovedControls = [ "prompt", "gpt-image-2.5-sunburst", "畅速", "智能", 1, "最高", "1个路径", "D:\\images", "关闭", 4, "WebP", "opaque", "和原始图片名保持一致", "D:\\output", "智能缩放", 789, ]; const previousGPTBatchWithRemovedControlsGraph = { nodes: [{ type: "O1keyGPTImageBatch", widgets_values: previousGPTBatchWithRemovedControls, }], }; migrationExtension.beforeConfigureGraph(previousGPTBatchWithRemovedControlsGraph); assert.equal(previousGPTBatchWithRemovedControls.length, 14); assert.deepEqual(previousGPTBatchWithRemovedControls.slice(-6), [ "WebP", "opaque", "和原始图片名保持一致", "D:\\output", "智能缩放", 789, ]); migrationExtension.beforeConfigureGraph(previousGPTBatchWithRemovedControlsGraph); assert.equal(previousGPTBatchWithRemovedControls.length, 14); const legacyGPTBatchInputsNode = { type: "O1keyGPTImageBatch", widgets_values: [ "prompt", "gpt-image-2.5-sunburst", "畅速", "智能", 1, "自动", "2个路径", "D:\\first", "D:\\second", "相同文件名", "原始", "auto", "和原始图片名保持一致", "D:\\output", "智能缩放", 789, ], inputs: [ { name: "图片随机抽取", link: null }, { name: "文件夹1", link: null }, { name: "文件夹2", link: null }, { name: "图片配对模式", link: null }, ], }; migrationExtension.beforeConfigureGraph({ nodes: [legacyGPTBatchInputsNode] }); assert.deepEqual(legacyGPTBatchInputsNode.inputs.map(input => input.name), [ "图片路径数量.参考图1(主图)", "图片路径数量.参考图2", "图片路径数量.图片配对模式", ]); const removedNanoResolution = [ "prompt", "Nano Banana 2", "畅速", "高", "512", "1:1", "1", 0, "不缩放", "不纠正", ]; const removedBatchResolution = [ "prompt", "Nano Banana 2", "畅速", "高", "512px", "1:1", "1个路径", "D:\\images", "关闭", 0, "原始", 95, "和原始图片名保持一致", "", "不缩放", "不纠正", ]; const removedResolutionGraph = { nodes: [ { type: "NanoBanana", widgets_values: removedNanoResolution }, { type: "BatchNanoBananaPro", widgets_values: removedBatchResolution }, ], }; migrationExtension.beforeConfigureGraph(removedResolutionGraph); assert.equal(removedNanoResolution[4], "1K"); assert.equal(removedBatchResolution[4], "1K"); migrationExtension.beforeConfigureGraph(removedResolutionGraph); assert.equal(removedNanoResolution[4], "1K"); assert.equal(removedBatchResolution[4], "1K"); const legacyGrokValues = [ "continue the camera move", "CF加速", "grok-imagine-1.0-video", "16:9", 20, "720p", ]; const legacyGrokNode = { type: "O1keyGrokVideo", widgets_values: legacyGrokValues, inputs: [{ name: "参考图1", link: 42 }, { name: "参考图2", link: null }], }; migrationExtension.beforeConfigureGraph({ nodes: [legacyGrokNode] }); assert.deepEqual(legacyGrokValues, [ "参考生视频", "continue the camera move", "grok-imagine-video", 15, "16:9", "720p", "", ]); assert.deepEqual( legacyGrokNode.inputs.map(input => input.name), ["图片1", "图片2"], ); migrationExtension.beforeConfigureGraph({ nodes: [legacyGrokNode] }); assert.equal(legacyGrokValues.length, 7); const interimGrokValues = [ "文生视频", "prompt", "grok-imagine-video-1.5", 8, "16:9", "480p", ]; const interimGrokEditValues = ["编辑视频", "make it golden", 6]; const interimGrokGraph = { nodes: [ { type: "O1keyGrokVideo", widgets_values: interimGrokValues }, { type: "O1keyGrokVideoEdit", widgets_values: interimGrokEditValues }, ], }; migrationExtension.beforeConfigureGraph(interimGrokGraph); assert.equal(interimGrokValues[6], ""); assert.equal(interimGrokEditValues[3], "grok-imagine-video-1.5"); migrationExtension.beforeConfigureGraph(interimGrokGraph); assert.equal(interimGrokValues.length, 7); assert.equal(interimGrokEditValues.length, 4); } { let qualityExtension; const context = { app: { registerExtension(extension) { qualityExtension = extension; } }, requestAnimationFrame(callback) { callback(); }, }; vm.runInNewContext( batchNanoQualitySource.replace(/^import .*;\s*$/gm, ""), context, ); const formatWidget = { name: "图片输出格式", value: "JPEG", options: {} }; const qualityWidget = { name: "图片质量", value: "95", options: {} }; const node = { comfyClass: "BatchNanoBananaPro", widgets: [formatWidget, qualityWidget], setDirtyCanvas() {}, }; qualityExtension.nodeCreated(node); assert.equal(qualityWidget.value, 95); assert.equal(typeof qualityWidget.value, "number"); assert.equal(qualityWidget.hidden, false); } { let minimaxExtension; const notices = []; const context = { app: { registerExtension(extension) { minimaxExtension = extension; }, extensionManager: { toast: { add(notice) { notices.push(notice); } } }, }, }; vm.runInNewContext( minimaxH3ParameterGuardSource.replace(/^import .*;\s*$/gm, ""), context, ); const callbacks = []; const widget = (name, value) => ({ name, value, options: {}, callback(nextValue) { callbacks.push([name, nextValue]); }, }); const model = widget("模型", "MiniMax-H3"); const mode = widget("生成模式", "参考素材生视频"); const resolution = widget("分辨率", "2K"); const duration = widget("时长", 4); const node = { comfyClass: "MiniMaxH3Video", widgets: [mode, resolution, duration, model], setDirtyCanvas() {}, }; minimaxExtension.nodeCreated(node); model.value = "MiniMax-H3-MAX"; model.callback(model.value); assert.deepEqual(Array.from(resolution.options.values), ["768P", "480P"]); assert.equal(resolution.value, "768P"); assert.equal(duration.options.min, 5); assert.equal(duration.value, 5); assert.equal(mode.value, "文生视频"); mode.value = "参考素材生视频"; mode.callback(mode.value); assert.equal(mode.value, "文生视频"); assert.ok(notices.length >= 1); assert.ok(callbacks.some(([name, value]) => name === "生成模式" && value === "文生视频")); } { let routeLabelsExtension; const routeLabelsContext = { app: { registerExtension(extension) { routeLabelsExtension = extension; } }, }; vm.runInNewContext( nanoRouteLabelsSource.replace(/^import .*;\s*$/gm, ""), routeLabelsContext, ); for (const comfyClass of [ "NanoBanana", "BatchNanoBananaPro", "O1keyGPTImage", "O1keyGPTImageBatch", ]) { const routeWidget = { name: "模型线路", value: "畅速", options: { values: ["畅速", "直连", "专线"] }, }; const node = { comfyClass, widgets: [routeWidget], setDirtyCanvas() {}, }; routeLabelsExtension.nodeCreated(node); const getOptionLabel = routeWidget.options.getOptionLabel; assert.equal(getOptionLabel("畅速"), "特价"); assert.equal(getOptionLabel("直连"), "优质"); assert.equal(getOptionLabel("专线"), "企业"); assert.equal(getOptionLabel("未知线路"), "未知线路"); assert.equal(routeWidget.value, "畅速"); assert.deepEqual(routeWidget.options.values, ["畅速", "直连", "专线"]); routeLabelsExtension.loadedGraphNode(node); assert.equal(routeWidget.options.getOptionLabel, getOptionLabel); } } { let dynamicExtension; const dynamicContext = { app: { registerExtension(extension) { dynamicExtension = extension; }, extensionManager: { toast: { add() {} } }, }, console, }; vm.runInNewContext( seedanceAutoPassDynamicSource.replace(/^import .*;\s*$/gm, ""), dynamicContext, ); const modelWidget = { name: "主模型", value: "seedance 2.5", options: {} }; const routeWidget = { name: "模型线路", value: "海外HC", options: {} }; const resolutionWidget = { name: "分辨率", value: "4k", options: {} }; const durationWidget = { name: "时长", value: "30秒", options: {} }; const node = { comfyClass: "SeedanceAutoPass", widgets: [modelWidget, routeWidget, resolutionWidget, durationWidget], setDirtyCanvas() {}, }; dynamicExtension.nodeCreated(node); assert.deepEqual(Array.from(resolutionWidget.options.values), ["480p", "720p", "1080p", "4k"]); assert.equal(routeWidget.value, "海外"); assert.equal(resolutionWidget.value, "4k"); assert.equal(durationWidget.options.values.at(-1), "30秒"); routeWidget.value = "国内"; modelWidget.callback(modelWidget.value); assert.equal(routeWidget.value, "国内"); modelWidget.value = "seedance 2.0 fast"; modelWidget.callback(modelWidget.value); assert.deepEqual(Array.from(resolutionWidget.options.values), ["480p", "720p"]); assert.equal(resolutionWidget.value, "720p"); assert.equal(durationWidget.value, "15秒"); } { let guardExtension; const guardContext = { app: { registerExtension(extension) { guardExtension = extension; }, extensionManager: { toast: { add() {} } }, }, console, }; vm.runInNewContext( seedanceResolutionGuardSource.replace(/^import .*;\s*$/gm, ""), guardContext, ); const modelWidget = { name: "主模型", value: "seedance 2.5", options: {} }; const resolutionWidget = { name: "分辨率", value: "4k", options: {} }; const durationWidget = { name: "时长", value: "30秒", options: {} }; const node = { comfyClass: "SeedanceMultiModal", widgets: [modelWidget, resolutionWidget, durationWidget], setDirtyCanvas() {}, }; guardExtension.nodeCreated(node); assert.deepEqual(Array.from(resolutionWidget.options.values), ["720p", "1080p", "4k", "480p"]); assert.equal(resolutionWidget.value, "4k"); assert.equal(durationWidget.options.values.at(-1), "30秒"); modelWidget.value = "seedance 2.0 fast"; modelWidget.callback(modelWidget.value); assert.deepEqual(Array.from(resolutionWidget.options.values), ["720p", "480p"]); assert.equal(resolutionWidget.value, "720p"); assert.equal(durationWidget.value, "15秒"); guardExtension.loadedGraphNode(node); assert.equal(node.__o1keySeedanceParameterGuard, true); } { let dynamicExtension; const dynamicContext = { app: { registerExtension(extension) { dynamicExtension = extension; } }, console, }; vm.runInNewContext( seedanceMultiModalDynamicSource.replace(/^import .*;\s*$/gm, ""), dynamicContext, ); let originalCallbackCount = 0; let dirtyCount = 0; let resizeCount = 0; const widgets = []; for (const [prefix, maximum] of [["图片素材ID", 30], ["视频素材ID", 10], ["音频素材ID", 10]]) { for (let index = 1; index <= maximum; index++) { widgets.push({ name: `${prefix}${index}`, value: "", options: {}, callback() { originalCallbackCount++; }, }); } } const node = { comfyClass: "SeedanceMultiModal", widgets, size: [320, 1400], computeSize() { const visibleWidgetCount = this.widgets.filter(widget => !widget.hidden).length; return [480, 100 + visibleWidgetCount * 24]; }, setSize(size) { resizeCount++; this.size = size; }, setDirtyCanvas() { dirtyCount++; }, }; dynamicExtension.nodeCreated(node); assert.deepEqual(Array.from(node.size), [320, 172]); assert.equal(widgets.find(widget => widget.name === "图片素材ID1").hidden, false); assert.equal(widgets.find(widget => widget.name === "图片素材ID2").hidden, true); assert.equal(widgets.find(widget => widget.name === "视频素材ID1").hidden, false); assert.equal(widgets.find(widget => widget.name === "视频素材ID2").hidden, true); const firstImage = widgets.find(widget => widget.name === "图片素材ID1"); firstImage.value = "image-1"; firstImage.callback(firstImage.value); assert.equal(originalCallbackCount, 1); assert.deepEqual(Array.from(node.size), [320, 196]); assert.equal(widgets.find(widget => widget.name === "图片素材ID2").hidden, false); assert.equal(widgets.find(widget => widget.name === "图片素材ID3").hidden, true); const fourthImage = widgets.find(widget => widget.name === "图片素材ID4"); fourthImage.value = "legacy-image-4"; dynamicExtension.loadedGraphNode(node); assert.deepEqual(Array.from(node.size), [320, 268]); assert.equal(widgets.find(widget => widget.name === "图片素材ID5").hidden, false); assert.equal(widgets.find(widget => widget.name === "图片素材ID6").hidden, true); dynamicExtension.nodeCreated(node); firstImage.callback(firstImage.value); assert.equal(originalCallbackCount, 2); assert.equal(resizeCount, 4); assert.ok(dirtyCount >= 3); } { let migrationExtension; vm.runInNewContext( migrationSource.replace(/^import .*;\s*$/gm, ""), { app: { registerExtension(extension) { migrationExtension = extension; } }, console: { log() {}, warn() {} }, }, ); const node = { type: "SeedanceMultiModal", inputs: [ { name: "参考图片1" }, { name: "参考视频2" }, { name: "参考音频.参考音频1" }, { name: "真人素材ID1" }, ], widgets_values: [], }; migrationExtension.beforeConfigureGraph({ nodes: [node] }); assert.equal(node.inputs[0].name, "参考图片.参考图片1"); assert.equal(node.inputs[1].name, "参考视频.参考视频2"); assert.equal(node.inputs[2].name, "参考音频.参考音频1"); assert.equal(node.inputs[3].name, "图片素材ID1"); migrationExtension.beforeConfigureGraph({ nodes: [node] }); assert.equal(node.inputs[0].name, "参考图片.参考图片1"); assert.equal(node.inputs[1].name, "参考视频.参考视频2"); assert.equal(node.inputs[3].name, "图片素材ID1"); const elementNode = { type: "SeedanceElementCreate", inputs: [ { name: "真人照片" }, { name: "真人视频" }, { name: "真人音频" }, { name: "素材描述" }, ], widgets_values: [], }; migrationExtension.beforeConfigureGraph({ nodes: [elementNode] }); assert.deepEqual( Array.from(elementNode.inputs, input => input.name), ["照片", "视频", "音频", "素材描述"], ); migrationExtension.beforeConfigureGraph({ nodes: [elementNode] }); assert.deepEqual( Array.from(elementNode.inputs, input => input.name), ["照片", "视频", "音频", "素材描述"], ); const autoPassNode = { type: "SeedanceAutoPass", inputs: [], widgets_values: [ "提示词", "多模态参考生视频", "关闭", "seedance 2.5", "国内", "720p", "智能", "5秒", "关闭", 0, ], }; migrationExtension.beforeConfigureGraph({ nodes: [autoPassNode] }); assert.deepEqual( Array.from(autoPassNode.widgets_values), [ "提示词", "多模态", "seedance 2.5", "国内", "720p", "智能", "5秒", "关闭", "关闭", ...Array(50).fill(""), "关闭", 0, "关闭", ], ); migrationExtension.beforeConfigureGraph({ nodes: [autoPassNode] }); assert.equal(autoPassNode.widgets_values.length, 62); const legacyFrameNode = { type: "SeedanceAutoPass", inputs: [ { name: "生成模式.联网搜索" }, { name: "生成模式.首帧图片" }, ], widgets_values: [ "提示词", "图生视频-首帧", "打开", "seedance 2.0", "海外HC", "720p", "16:9", "5秒", "关闭", 9, "打开", ], }; migrationExtension.beforeConfigureGraph({ nodes: [legacyFrameNode] }); assert.deepEqual( Array.from(legacyFrameNode.widgets_values), [ "提示词", "首尾帧", "seedance 2.0", "海外", "720p", "16:9", "5秒", "关闭", "关闭", ...Array(50).fill(""), "打开", 9, "打开", ], ); assert.deepEqual( Array.from(legacyFrameNode.inputs, input => input.name), ["联网搜索", "首帧图片"], ); migrationExtension.beforeConfigureGraph({ nodes: [legacyFrameNode] }); assert.equal(legacyFrameNode.widgets_values.length, 62); const legacyIdNode = { type: "SeedanceAutoPass", inputs: [{name: "图片素材ID"}, {name: "素材创建.视频素材ID"}, {name: "素材创建"}], widgets_values: [ "提示词", "多模态", "seedance 2.5", "国内", "720p", "智能", "5秒", "关闭", "手动", "image-1,image-2\nasset://image-3", "video-1;video-2", "audio-1", "打开", 42, "打开", ], }; migrationExtension.beforeConfigureGraph({nodes: [legacyIdNode]}); assert.equal(legacyIdNode.widgets_values.length, 62); assert.equal(legacyIdNode.widgets_values[8], "打开"); assert.deepEqual(legacyIdNode.widgets_values.slice(9, 12), ["image-1", "image-2", "asset://image-3"]); assert.deepEqual(legacyIdNode.widgets_values.slice(39, 41), ["video-1", "video-2"]); assert.equal(legacyIdNode.widgets_values[49], "audio-1"); assert.deepEqual(legacyIdNode.widgets_values.slice(59), ["打开", 42, "打开"]); assert.deepEqual(legacyIdNode.inputs.map(input => input.name), ["图片素材ID1", "视频素材ID1", "素材创建模式"]); const migratedIds = [...legacyIdNode.widgets_values]; migrationExtension.beforeConfigureGraph({nodes: [legacyIdNode]}); assert.deepEqual(legacyIdNode.widgets_values, migratedIds); } { let extension; vm.runInNewContext(seedanceAutoPassDynamicSource.replace(/^import .*;\s*$/gm, ""), { app: {registerExtension(value) {extension = value;}}, console, }); let callbacks = 0; const assetMode = {name: "素材创建模式", value: "关闭", options: {}, callback() {callbacks++;}}; const ids = []; for (const [prefix, maximum] of [["图片素材ID", 30], ["视频素材ID", 10], ["音频素材ID", 10]]) { for (let index = 1; index <= maximum; index++) ids.push({ name: `${prefix}${index}`, value: "", options: {}, callback() {callbacks++;}, }); } const node = { comfyClass: "SeedanceAutoPass", widgets: [assetMode, ...ids], size: [360, 1500], computeSize() {return [480, 100 + this.widgets.filter(widget => !widget.hidden).length * 24];}, setSize(size) {this.size = size;}, setDirtyCanvas() {}, }; extension.nodeCreated(node); assert.ok(ids.every(widget => widget.hidden && widget.options.hidden)); assert.equal(assetMode.hidden, false); assert.equal(assetMode.options.hidden, false); assert.deepEqual(Array.from(node.size), [360, 124]); assetMode.value = "打开"; assetMode.callback(assetMode.value); assert.deepEqual(ids.filter(widget => !widget.hidden).map(widget => widget.name), [ "图片素材ID1", "视频素材ID1", "音频素材ID1", ]); ids[0].value = "image-1"; ids[0].callback(ids[0].value); assert.equal(ids[1].hidden, false); assert.equal(ids[2].hidden, true); assetMode.value = "关闭"; assetMode.callback(assetMode.value); assert.ok(ids.every(widget => widget.hidden)); assert.equal(ids[0].value, "image-1"); assetMode.value = "打开"; ids[3].value = "saved-image-4"; extension.loadedGraphNode(node); assert.equal(ids[4].hidden, false); assert.equal(ids[5].hidden, true); extension.nodeCreated(node); ids[0].callback(ids[0].value); assert.equal(callbacks, 4); assert.deepEqual(node.widgets.map(widget => widget.name), [assetMode, ...ids].map(widget => widget.name)); } { let extension; vm.runInNewContext(seedanceAutoPassDynamicSource.replace(/^import .*;\s*$/gm, ""), { app: {registerExtension(value) {extension = value;}}, console, }); const groups = ["参考图片", "参考视频", "参考音频"]; const configs = Object.fromEntries(groups.map(group => [group, {names: [`${group}1`, `${group}2`], min: 0}])); let modeCallbacks = 0; let connectionCallbacks = 0; const removedLinks = []; const mode = {name: "生成模式", value: "多模态", callback() {modeCallbacks++;}}; const node = { comfyClass: "SeedanceAutoPass", widgets: [mode], comfyDynamic: {autogrow: {...configs}}, inputs: [ ...groups.map((group, index) => ({name: `${group}.${group}1`, type: ["IMAGE", "VIDEO", "AUDIO"][index], label: `${group}1`, shape: 7, link: null})), {name: "首帧图片", type: "IMAGE", shape: 7, link: null}, {name: "尾帧图片", type: "IMAGE", shape: 7, link: null}, {name: "提示词", type: "STRING", link: 99}, ], addInput(name, type, options) {this.inputs.push({name, type, ...options, link: null});}, removeInput(index) { const input = this.inputs[index]; if (input.link != null) { removedLinks.push(input.link); this.onConnectionsChange(1, index, false); } this.inputs.splice(index, 1); }, onConnectionsChange(type, index, connected) { connectionCallbacks++; const group = this.inputs[index]?.name.split(".")[0]; if (connected && this.comfyDynamic.autogrow[group]) { this.addInput(`${group}.${group}2`, this.inputs[index].type, {label: `${group}2`, shape: 7}); } }, setDirtyCanvas() {}, }; const names = () => node.inputs.map(input => input.name); extension.nodeCreated(node); assert.deepEqual(names(), [...groups.map(group => `${group}.${group}1`), "提示词"]); node.inputs[0].link = 10; node.onConnectionsChange(1, 0, true); assert.ok(names().includes("参考图片.参考图片2")); const callbacksBeforeSwitch = connectionCallbacks; mode.value = "首尾帧"; mode.callback(mode.value); assert.deepEqual(names(), ["提示词", "首帧图片", "尾帧图片"]); assert.deepEqual(removedLinks, [10]); assert.equal(connectionCallbacks, callbacksBeforeSwitch, "mode removals must not schedule native Autogrow callbacks"); assert.deepEqual(Object.keys(node.comfyDynamic.autogrow), []); assert.equal(node.inputs[0].link, 99, "unrelated converted widget inputs retain links"); node.inputs.find(input => input.name === "首帧图片").link = 20; // Loading an older frame workflow may restore every schema socket. node.addInput("参考视频.参考视频1", "VIDEO", {label: "参考视频1"}); extension.loadedGraphNode(node); assert.deepEqual(names(), ["提示词", "首帧图片", "尾帧图片"]); assert.equal(node.inputs.find(input => input.name === "首帧图片").link, 20); extension.nodeCreated(node); extension.loadedGraphNode(node); mode.value = "多模态"; mode.callback(mode.value); assert.deepEqual(names(), ["提示词", ...groups.map(group => `${group}.${group}1`)]); assert.deepEqual(removedLinks, [10, 20]); assert.equal(modeCallbacks, 2, "repeated hooks do not duplicate mode callbacks"); for (const group of groups) assert.equal(node.comfyDynamic.autogrow[group], configs[group]); const imageIndex = node.inputs.findIndex(input => input.name === "参考图片.参考图片1"); assert.equal(node.inputs[imageIndex].shape, 7); node.onConnectionsChange(1, imageIndex, true); assert.ok(names().includes("参考图片.参考图片2"), "native progressive references resume after switching back"); mode.value = "首尾帧"; mode.callback(mode.value); assert.deepEqual(names(), ["提示词", "首帧图片", "尾帧图片"]); } { let extension; vm.runInNewContext(seedanceAutoPassDynamicSource.replace(/^import .*;\s*$/gm, ""), { app: {registerExtension(value) {extension = value;}}, console, }); const assetMode = {name: "素材创建模式", value: "关闭", options: {}}; const search = {name: "联网搜索", value: "打开", options: {}, computeSize() {return [100, 24];}}; const seed = {name: "seed", value: 42, options: {}}; const lastFrame = {name: "返回末帧图片", value: "打开", options: {}}; const widgets = [assetMode, search, seed, lastFrame]; const node = { comfyClass: "SeedanceAutoPass", widgets, size: [360, 500], computeSize() {return [480, 100 + this.widgets.filter(widget => !widget.hidden).length * 24];}, setSize(value) {this.size = value;}, setDirtyCanvas() {}, }; extension.nodeCreated(node); for (const widget of [search, lastFrame]) { assert.equal(widget.hidden, true); assert.equal(widget.options.hidden, true); assert.deepEqual(Array.from(widget.computeSize()), [0, -4]); assert.equal(widget.value, "打开", "hiding must preserve saved workflow values"); } assert.deepEqual(Array.from(node.size), [360, 148]); assert.equal(assetMode.hidden, false); assert.notEqual(seed.hidden, true); assert.equal(seed.value, 42); // Graph configuration may reset visibility flags; loading reapplies them. search.hidden = false; search.options.hidden = false; lastFrame.hidden = false; lastFrame.options.hidden = false; extension.loadedGraphNode(node); assetMode.value = "打开"; assetMode.callback(assetMode.value); assert.ok(search.hidden && lastFrame.hidden); assert.deepEqual(node.widgets, widgets); assert.deepEqual(node.widgets.map(widget => widget.value), ["打开", "打开", 42, "打开"]); const otherWidgets = [{name: "联网搜索", value: "关闭", options: {}}, {name: "返回末帧图片", value: "关闭", options: {}}]; extension.nodeCreated({comfyClass: "SeedanceAutoPassBatch", widgets: otherWidgets}); assert.ok(otherWidgets.every(widget => !widget.hidden), "hide only the single all-in-one node"); } console.log("o1key image generator frontend tests passed");