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

4036 lines
172 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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");