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.
This commit is contained in:
Jony
2026-09-24 19:56:48 +08:00
parent 3e337722ab
commit ba920f2b66
183 changed files with 49496 additions and 9934 deletions
+11
View File
@@ -0,0 +1,11 @@
# Test rules
These instructions apply to `tests/`.
- Tests must be offline and deterministic. Never consume credits, use a real API key, or depend on a developer's `.config`.
- Mock HTTP clients, ComfyUI globals, filesystem roots, clocks, sleeps, and downloads at the narrowest useful boundary.
- Some tests install ComfyUI stubs in `sys.modules`; run the suite with `run_all.py`, which isolates each file in its own process.
- Resolve the plugin root with `Path(__file__).resolve().parents[1]` when loading source files directly.
- Name Python tests `test_*.py`; keep direct execution support through `unittest.main()` where practical.
- Put live/manual diagnostics outside the repository or behind an explicit opt-in harness. They do not belong in the default suite.
- A regression test should describe the behavior being protected, especially node IDs, schema ordering, route selection, retry semantics, and workflow migration.
+50
View File
@@ -0,0 +1,50 @@
"""Run each test file in an isolated process.
Several tests install lightweight ComfyUI stubs in ``sys.modules``. Running
every file in one unittest discovery process lets those stubs leak between
modules, so isolation is intentional here.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
TEST_DIR = Path(__file__).resolve().parent
def main() -> int:
commands = [
[sys.executable, str(path)]
for path in sorted(TEST_DIR.glob("test_*.py"))
]
node = shutil.which("node")
if node:
commands.extend(
[node, str(path)]
for path in sorted(TEST_DIR.glob("test_*.mjs"))
)
failures = []
for command in commands:
print(f"\n>>> {' '.join(command)}", flush=True)
completed = subprocess.run(command, cwd=TEST_DIR.parent, check=False)
if completed.returncode:
failures.append((command[-1], completed.returncode))
if failures:
print("\nFailed tests:")
for path, returncode in failures:
print(f"- {path}: exit {returncode}")
return 1
print(f"\nAll {len(commands)} isolated test files passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
"""Offline coverage for the native seed on the red-cast correction node."""
import sys
import unittest
from pathlib import Path
import torch
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT.parent))
from comfyui_o1key.nodes.auto_red_cast import O1keyAutoRedCast # noqa: E402
class AutoRedCastSeedTests(unittest.TestCase):
def test_schema_exposes_native_seed_as_final_widget(self):
inputs = O1keyAutoRedCast.INPUT_TYPES()
required = inputs["required"]
self.assertEqual(
list(required),
["强度", "最大校正量", "高饱和保护", "图片路径"],
)
self.assertEqual(
list(inputs["optional"]),
["图像", "灰卡最低亮度", "灰卡最大色度", "seed"],
)
kind, options = inputs["optional"]["seed"]
self.assertEqual(kind, "INT")
self.assertEqual(options["default"], 0)
self.assertEqual(options["max"], 0xFFFFFFFFFFFFFFFF)
self.assertTrue(options["control_after_generate"])
def test_seed_is_accepted_without_changing_deterministic_correction(self):
image = torch.full((1, 40, 40, 3), 0.7)
image[..., 0] = 0.75
node = O1keyAutoRedCast()
first = node.correct(图像=image, seed=0)
second = node.correct(图像=image, seed=1234)
self.assertTrue(torch.equal(first[0], second[0]))
self.assertTrue(torch.equal(first[1], second[1]))
self.assertEqual(first[2], second[2])
if __name__ == "__main__":
unittest.main(verbosity=2)
+28
View File
@@ -0,0 +1,28 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class ChatPanelModelTests(unittest.TestCase):
def test_gpt_6_astra_is_the_frontend_and_proxy_default(self):
frontend = (ROOT / "web" / "js" / "chatPanel.js").read_text(encoding="utf-8")
backend = (ROOT / "__init__.py").read_text(encoding="utf-8")
self.assertIn('const DEFAULT_MODEL = "gpt-6-sol";', frontend)
self.assertIn("let currentModel = DEFAULT_MODEL;", frontend)
models = re.search(r"const MODELS = \[(.*?)\];", frontend, re.DOTALL)
self.assertIsNotNone(models)
self.assertEqual(models.group(1).strip().splitlines()[0].strip(), "DEFAULT_MODEL,")
self.assertIn('data.get("model", "gpt-6-sol")', backend)
self.assertRegex(
backend,
r'elif model in \([^\n]*"gpt-6-sol"[^\n]*\):\n\s+body\["reasoning_effort"\] = reasoning',
)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
"""Offline tests for the global O1Key network route setting."""
import os
import sys
import unittest
from unittest.mock import patch
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PLUGIN_DIR)
from utils.config import (
DEFAULT_NETWORK_ROUTE,
NETWORK_ROUTE_CONFIG_KEY,
NETWORK_ROUTES,
get_api_base_url,
get_async_api_base_url,
get_base_url_by_route,
get_network_route,
)
class GlobalNetworkRouteTests(unittest.TestCase):
def test_global_route_controls_all_base_url_helpers(self):
config = {NETWORK_ROUTE_CONFIG_KEY: "CF加速"}
with patch("utils.config.load_config", return_value=config):
self.assertEqual(get_network_route(), "CF加速")
self.assertEqual(get_base_url_by_route(), NETWORK_ROUTES["CF加速"])
self.assertEqual(get_api_base_url(), NETWORK_ROUTES["CF加速"])
self.assertEqual(get_async_api_base_url(), NETWORK_ROUTES["CF加速"])
def test_invalid_global_route_falls_back_safely(self):
with patch(
"utils.config.load_config",
return_value={NETWORK_ROUTE_CONFIG_KEY: "invalid"},
):
self.assertEqual(get_network_route(), DEFAULT_NETWORK_ROUTE)
self.assertEqual(
get_base_url_by_route(),
NETWORK_ROUTES[DEFAULT_NETWORK_ROUTE],
)
def test_explicit_legacy_route_is_still_resolvable(self):
with patch("utils.config.load_config", return_value={}):
self.assertEqual(
get_base_url_by_route("美国直连"),
NETWORK_ROUTES["美国直连"],
)
def test_custom_base_url_remains_fallback_before_global_route_is_saved(self):
custom_url = "https://gateway.example.com/"
with patch(
"utils.config.load_config",
return_value={"O1KEY_API_BASE_URL": custom_url},
):
self.assertEqual(get_api_base_url(), custom_url.rstrip("/"))
if __name__ == "__main__":
unittest.main(verbosity=2)
+344
View File
@@ -0,0 +1,344 @@
"""Offline regression tests for the standalone GPT Image nodes."""
import asyncio
import os
import sys
import unittest
from io import BytesIO
from types import ModuleType
from unittest.mock import AsyncMock, patch
import torch
from PIL import Image
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CUSTOM_NODES_DIR = os.path.dirname(PLUGIN_DIR)
COMFY_ROOT = os.path.dirname(CUSTOM_NODES_DIR)
for child in ("nodes", "utils", "clients"):
package = ModuleType(f"comfyui_o1key.{child}")
package.__path__ = [os.path.join(PLUGIN_DIR, child)]
sys.modules[package.__name__] = package
plugin_package = ModuleType("comfyui_o1key")
plugin_package.__path__ = [PLUGIN_DIR]
sys.modules[plugin_package.__name__] = plugin_package
model_management = ModuleType("comfy.model_management")
model_management.processing_interrupted = lambda: False
model_management.InterruptProcessingException = RuntimeError
sys.modules[model_management.__name__] = model_management
sys.path.insert(0, COMFY_ROOT)
sys.path.insert(0, CUSTOM_NODES_DIR)
from comfyui_o1key.clients.gpt_image_client import ( # noqa: E402
GptImageClient,
resolve_gpt_image_model,
)
from comfyui_o1key.nodes.gpt_image import ( # noqa: E402
O1keyGPTImage,
resolve_gpt_image_quality,
)
from comfyui_o1key.nodes.gpt_image_batch import ( # noqa: E402
O1keyGPTImageBatch,
_path_option,
)
from comfyui_o1key.utils.file_utils import ImageInfo # noqa: E402
class GPTImageNodeSchemaTests(unittest.TestCase):
def assert_combo_values_are_strings(self, schema):
for item in schema.inputs:
if item.io_type != "COMBO":
continue
self.assertTrue(
all(isinstance(option, str) for option in item.options),
item.id,
)
if item.default is not None:
self.assertIsInstance(item.default, str, item.id)
def test_single_node_keeps_request_controls_without_color_correction(self):
schema = O1keyGPTImage.define_schema()
schema.validate()
self.assert_combo_values_are_strings(schema)
inputs = {item.id: item for item in schema.inputs}
self.assertEqual(inputs["缩放图片"].options, ["不缩放", "智能缩放"])
self.assertEqual(inputs["缩放图片"].default, "智能缩放")
self.assertEqual(inputs["模型"].default, "gpt-image-2.5-sunburst")
self.assertNotIn("色彩纠正", inputs)
self.assertEqual(inputs["背景"].options, ["auto", "transparent", "opaque"])
self.assertNotIn("内容审查强度", inputs)
self.assertEqual(
[item.id for item in schema.inputs],
[
"prompt", "模型", "模型线路", "分辨率", "生图数量", "质量",
"输出格式", "背景", "遮罩", "参考图组", "缩放图片", "seed",
],
)
self.assertEqual(
inputs["质量"].options,
["", "", "", "自动", "超高", "最高"],
)
def test_25_quality_values_are_model_specific(self):
self.assertEqual(resolve_gpt_image_quality("gpt-image-2.5-sunburst", "超高"), "xhigh")
self.assertEqual(resolve_gpt_image_quality("gpt-image-2.5-flare", "最高"), "max")
self.assertEqual(resolve_gpt_image_quality("gpt-image-2", ""), "high")
with self.assertRaisesRegex(ValueError, "仅支持 GPT Image 2.5"):
resolve_gpt_image_quality("gpt-image-2", "超高")
def test_gpt_image_25_models_resolve_all_route_ids(self):
expected = {
("gpt-image-2.5-sunburst", "畅速"): "gpt-image-2.5-sunburst-sp",
("gpt-image-2.5-sunburst", "直连"): "gpt-image-2.5-sunburst-sd",
("gpt-image-2.5-sunburst", "专线"): "gpt-image-2.5-sunburst",
("gpt-image-2.5-flare", "畅速"): "gpt-image-2.5-flare-sp",
("gpt-image-2.5-flare", "直连"): "gpt-image-2.5-flare-sd",
("gpt-image-2.5-flare", "专线"): "gpt-image-2.5-flare",
}
for (model, route), actual_model in expected.items():
with self.subTest(model=model, route=route):
self.assertEqual(resolve_gpt_image_model(model, route), actual_model)
def test_batch_node_keeps_operational_controls_outside_advanced_inputs(self):
schema = O1keyGPTImageBatch.define_schema()
schema.validate()
self.assert_combo_values_are_strings(schema)
inputs = {item.id: item for item in schema.inputs}
self.assertEqual(inputs["模型"].default, "gpt-image-2.5-sunburst")
self.assertEqual(inputs["缩放图片"].default, "智能缩放")
self.assertEqual(inputs["质量"].options, ["", "", "", "自动", "超高", "最高"])
self.assertNotIn("色彩纠正", inputs)
self.assertNotIn("内容审查强度", inputs)
self.assertEqual(
[item.id for item in schema.inputs],
[
"prompt", "模型", "模型线路", "分辨率", "生图数量", "质量",
"图片路径数量", "遮罩", "参考图组", "图片输出格式",
"背景", "图片保存命名规则", "图片保存路径", "缩放图片", "seed",
],
)
self.assertNotIn("并发数", inputs)
self.assertEqual(
[item.id for item in _path_option(1).inputs],
["参考图1(主图)"],
)
self.assertEqual(
[item.id for item in _path_option(2).inputs],
["参考图1(主图)", "参考图2", "图片配对模式"],
)
for input_name in (
"seed",
"图片输出格式",
"图片保存命名规则",
"图片保存路径",
"缩放图片",
"背景",
):
self.assertFalse(inputs[input_name].advanced, input_name)
class GPTImageNodeExecutionTests(unittest.TestCase):
def test_single_node_forwards_request_controls_without_postprocess(self):
calls = []
generated = torch.ones((1, 4, 6, 3), dtype=torch.float32)
class FakeClient:
def __init__(self):
self.base_url = ""
self.response_log_enabled = True
self.poll_log_enabled = True
def generate_image_async_sync(self, **kwargs):
calls.append(kwargs)
return [Image.new("RGB", (6, 4), "green")]
@staticmethod
def _pil_list_to_tensor(_images):
return generated
with (
patch("comfyui_o1key.nodes.gpt_image.GptImageClient", FakeClient),
patch(
"comfyui_o1key.nodes.gpt_image.get_base_url_by_route",
return_value="https://example.invalid",
),
):
result = O1keyGPTImage.generate(
prompt="测试",
输出格式="webp",
缩放图片="智能缩放",
背景="transparent",
)
self.assertIs(result[0], generated)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0]["model"], "gpt-image-2.5-sunburst-sp")
self.assertEqual(calls[0]["resize_mode"], "智能缩放")
self.assertEqual(calls[0]["background"], "transparent")
self.assertNotIn("moderation", calls[0])
def test_single_node_forwards_25_quality_api_values(self):
calls = []
generated = torch.ones((1, 4, 6, 3), dtype=torch.float32)
class FakeClient:
def __init__(self):
self.base_url = ""
self.response_log_enabled = True
self.poll_log_enabled = True
def generate_image_async_sync(self, **kwargs):
calls.append(kwargs)
return [Image.new("RGB", (6, 4), "green")]
@staticmethod
def _pil_list_to_tensor(_images):
return generated
with (
patch("comfyui_o1key.nodes.gpt_image.GptImageClient", FakeClient),
patch(
"comfyui_o1key.nodes.gpt_image.get_base_url_by_route",
return_value="https://example.invalid",
),
):
for display_quality, api_quality in (("超高", "xhigh"), ("最高", "max")):
O1keyGPTImage.generate(
prompt="测试",
模型="gpt-image-2.5-sunburst",
质量=display_quality,
)
self.assertEqual([call["quality"] for call in calls], ["xhigh", "max"])
class GPTImageBatchExecutionTests(unittest.IsolatedAsyncioTestCase):
async def test_batch_task_forwards_request_controls_without_removed_options(self):
calls = []
reference = Image.new("RGB", (6, 4), "red")
generated = Image.new("RGB", (6, 4), "green")
pair = (ImageInfo(reference, "source", ".png", ""),)
class FakeClient:
async def generate_image_async(self, **kwargs):
calls.append(kwargs)
return [generated]
with (
patch.object(
O1keyGPTImageBatch,
"_save_images",
return_value=["output.png"],
) as save_images,
patch("builtins.print"),
):
result = await O1keyGPTImageBatch._run_task(
FakeClient(), pair, "测试", 0, 1,
"gpt-image-2-c-sp", "auto", "1024x1024", 1, 0, None, "webp",
"transparent", "智能缩放",
"output", "和原始图片名保持一致", asyncio.Lock(), None,
)
self.assertTrue(result["success"])
self.assertEqual(calls[0]["resize_mode"], "智能缩放")
self.assertEqual(calls[0]["background"], "transparent")
self.assertNotIn("moderation", calls[0])
self.assertIs(save_images.call_args.args[0][0], generated)
async def test_batch_starts_all_tasks_together(self):
task_count = 4
started = []
all_started = asyncio.Event()
async def fake_run_task(cls, *_args):
task_index = _args[3]
started.append(task_index)
if len(started) == task_count:
all_started.set()
await asyncio.wait_for(all_started.wait(), timeout=1)
return {
"task_index": task_index,
"success": True,
"generated_count": 1,
"saved_files": [],
"error": None,
}
task_defs = [(index, tuple(), f"prompt {index}") for index in range(task_count)]
with patch.object(O1keyGPTImageBatch, "_run_task", classmethod(fake_run_task)), patch("builtins.print"):
results = await O1keyGPTImageBatch._process_async(
object(), task_defs, "model", "auto", "auto", 1, 0,
None, "png", "auto", "智能缩放", "output",
"自然数字", None,
)
self.assertEqual(sorted(started), list(range(task_count)))
self.assertEqual(len(results), task_count)
self.assertTrue(all(item["success"] for item in results))
class GPTImageDownloadRetryTests(unittest.IsolatedAsyncioTestCase):
async def test_result_download_retries_transient_http_status_without_logging_url(self):
buffer = BytesIO()
Image.new("RGB", (3, 2), "purple").save(buffer, format="PNG")
image_bytes = buffer.getvalue()
class Content:
def __init__(self, body):
self.body = body
async def iter_chunked(self, _chunk_size):
yield self.body
class Response:
http_version = "HTTP/1.1"
def __init__(self, status, body):
self.status = status
self.headers = {"Content-Length": str(len(body))}
self.content = Content(body)
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _tb):
return None
class Session:
def __init__(self):
self.responses = [Response(503, b"busy"), Response(200, image_bytes)]
self.calls = 0
def get(self, _url, **_kwargs):
response = self.responses[self.calls]
self.calls += 1
return response
client = object.__new__(GptImageClient)
session = Session()
signed_url = "https://example.invalid/private?signature=secret"
with (
patch(
"comfyui_o1key.clients.gpt_image_client.asyncio.sleep",
new=AsyncMock(),
),
patch("builtins.print") as print_mock,
):
image, byte_count, _elapsed = await client._download_image_with_response_retry(
session,
signed_url,
"第 1 张图片",
)
self.assertEqual(session.calls, 2)
self.assertEqual(image.size, (3, 2))
self.assertEqual(byte_count, len(image_bytes))
rendered = " ".join(str(value) for call in print_mock.call_args_list for value in call.args)
self.assertNotIn(signed_url, rendered)
self.assertNotIn("signature=secret", rendered)
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
const sourcePath = new URL("../web/js/gptImageQuality.js", import.meta.url);
const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, "");
let extension;
vm.runInNewContext(source, {
app: { registerExtension(value) { extension = value; } },
});
const backgroundLabelsPath = new URL("../web/js/gptImageBackgroundLabels.js", import.meta.url);
const backgroundLabelsSource = fs.readFileSync(backgroundLabelsPath, "utf8").replace(/^import .*;\s*$/gm, "");
let backgroundLabelsExtension;
vm.runInNewContext(backgroundLabelsSource, {
app: { registerExtension(value) { backgroundLabelsExtension = value; } },
});
function makeNode(model, quality, nodeType = "O1keyGPTImage") {
const calls = [];
const modelWidget = {
name: "模型",
value: model,
callback() { calls.push("model"); },
};
const qualityWidget = {
name: "质量",
value: quality,
options: { values: ["高", "中", "低", "自动", "超高", "最高"] },
callback(value) { calls.push(value); },
};
return {
comfyClass: nodeType,
widgets: [modelWidget, qualityWidget],
dirty: 0,
setDirtyCanvas() { this.dirty += 1; },
modelWidget,
qualityWidget,
calls,
};
}
const node = makeNode("gpt-image-2", "自动");
extension.nodeCreated(node);
assert.deepEqual(Array.from(node.qualityWidget.options.values), ["高", "中", "低", "自动"]);
const newNode = makeNode("gpt-image-2.5-sunburst", "自动");
extension.nodeCreated(newNode);
assert.deepEqual(
Array.from(newNode.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
node.modelWidget.value = "gpt-image-2.5-flare";
node.modelWidget.callback();
assert.deepEqual(
Array.from(node.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
node.qualityWidget.value = "最高";
node.modelWidget.value = "gpt-image-2";
node.modelWidget.callback();
assert.equal(node.qualityWidget.value, "自动");
assert.deepEqual(Array.from(node.qualityWidget.options.values), ["高", "中", "低", "自动"]);
assert.equal(node.calls.at(-1), "自动");
const restoredNode = makeNode("gpt-image-2.5-sunburst", "最高");
extension.loadedGraphNode(restoredNode);
assert.equal(restoredNode.qualityWidget.value, "最高");
assert.deepEqual(
Array.from(restoredNode.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
const batchNode = makeNode("gpt-image-2.5-flare", "超高", "O1keyGPTImageBatch");
extension.nodeCreated(batchNode);
assert.deepEqual(
Array.from(batchNode.qualityWidget.options.values),
["高", "中", "低", "自动", "超高", "最高"],
);
const batchBackgroundWidget = {
name: "背景",
value: "opaque",
options: { values: ["auto", "transparent", "opaque"] },
};
backgroundLabelsExtension.nodeCreated({
comfyClass: "O1keyGPTImageBatch",
widgets: [batchBackgroundWidget],
});
assert.equal(batchBackgroundWidget.options.getOptionLabel("auto"), "自动");
assert.equal(batchBackgroundWidget.options.getOptionLabel("transparent"), "透明");
assert.equal(batchBackgroundWidget.options.getOptionLabel("opaque"), "不透明");
const backgroundWidget = {
name: "背景",
value: "transparent",
options: { values: ["auto", "transparent", "opaque"] },
};
const backgroundNode = {
comfyClass: "O1keyGPTImage",
widgets: [backgroundWidget],
};
backgroundLabelsExtension.nodeCreated(backgroundNode);
assert.equal(backgroundWidget.options.getOptionLabel("auto"), "自动");
assert.equal(backgroundWidget.options.getOptionLabel("transparent"), "透明");
assert.equal(backgroundWidget.options.getOptionLabel("opaque"), "不透明");
assert.equal(backgroundWidget.value, "transparent");
assert.deepEqual(Array.from(backgroundWidget.options.values), ["auto", "transparent", "opaque"]);
+106
View File
@@ -0,0 +1,106 @@
import base64
import importlib.util
import json
import sys
import types
import unittest
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
def _load_client():
if importlib.util.find_spec("numpy") is None:
numpy_stub = types.ModuleType("numpy")
numpy_stub.float32 = float
sys.modules["numpy"] = numpy_stub
if importlib.util.find_spec("torch") is None:
torch_stub = types.ModuleType("torch")
torch_stub.Tensor = object
sys.modules["torch"] = torch_stub
comfy = types.ModuleType("comfy")
comfy.__path__ = []
model_management = types.ModuleType("comfy.model_management")
model_management.processing_interrupted = lambda: False
model_management.InterruptProcessingException = RuntimeError
sys.modules[comfy.__name__] = comfy
sys.modules[model_management.__name__] = model_management
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(ROOT)]
clients_package = types.ModuleType("comfyui_o1key.clients")
clients_package.__path__ = [str(ROOT / "clients")]
utils_package = types.ModuleType("comfyui_o1key.utils")
utils_package.__path__ = [str(ROOT / "utils")]
sys.modules[package.__name__] = package
sys.modules[clients_package.__name__] = clients_package
sys.modules[utils_package.__name__] = utils_package
config = types.ModuleType("comfyui_o1key.utils.config")
config.get_api_key_or_raise = lambda *_args, **_kwargs: "secret"
config.get_base_url_by_route = lambda _route=None: "https://api.o1key.cn"
sys.modules[config.__name__] = config
image_utils = types.ModuleType("comfyui_o1key.utils.image_utils")
image_utils.tensor_to_pil = lambda value: [value]
sys.modules[image_utils.__name__] = image_utils
spec = importlib.util.spec_from_file_location(
"comfyui_o1key.clients.grok_image_client",
ROOT / "clients" / "grok_image_client.py",
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
GROK = _load_client()
class GrokReferenceTests(unittest.TestCase):
def _build(self, images):
return GROK.GrokImageClient._build_edit_body(
prompt="combine references",
model="Grok Image Pro",
aspect_ratio="auto",
resolution="1k",
image_list=images,
)
def test_single_reference_keeps_legacy_image_field(self):
body = self._build([Image.new("RGB", (8, 8), "red")])
self.assertIn("image", body)
self.assertNotIn("images", body)
self.assertTrue(base64.b64decode(body["image"]).startswith(b"\x89PNG"))
def test_three_references_use_multi_image_field(self):
body = self._build([
Image.new("RGB", (8, 8), "red"),
Image.new("RGB", (8, 8), "green"),
Image.new("RGB", (8, 8), "blue"),
])
self.assertNotIn("image", body)
self.assertEqual(len(body["images"]), 3)
for item in body["images"]:
self.assertEqual(item["type"], "image_url")
self.assertTrue(item["url"].startswith("data:image/png;base64,"))
self.assertLessEqual(
len(json.dumps(body, ensure_ascii=False).encode("utf-8")),
GROK._MAX_BODY_BYTES,
)
def test_more_than_three_references_are_rejected(self):
images = [Image.new("RGB", (2, 2)) for _ in range(4)]
with self.assertRaisesRegex(ValueError, "最多支持 3 张"):
self._build(images)
if __name__ == "__main__":
unittest.main(verbosity=2)
+203
View File
@@ -0,0 +1,203 @@
"""Offline regression tests for the current O1Key Grok Video API contract."""
import sys
import unittest
from inspect import signature
from pathlib import Path
from unittest.mock import AsyncMock, patch
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT.parent))
from comfyui_o1key.clients.grok_video_client import GrokVideoClient
from comfyui_o1key.nodes import grok_video
class GrokVideoPayloadTests(unittest.TestCase):
def test_poll_deadline_is_shared_2000_seconds(self):
self.assertEqual(GrokVideoClient.POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(
signature(GrokVideoClient.run_video_sync)
.parameters["timeout"].default,
2000,
)
def build(self, **overrides):
values = {
"operation": "generate",
"prompt": "cinematic shot",
"model": "grok-imagine-video-1.5",
"duration": 8,
"aspect_ratio": "16:9",
"resolution": "480p",
}
values.update(overrides)
return GrokVideoClient.build_video_body(**values)
def test_text_generation_supports_15_seconds_and_1080p_on_15(self):
body = self.build(duration=15, resolution="1080p")
self.assertEqual(body, {
"model": "grok-imagine-video-1.5",
"prompt": "cinematic shot",
"duration": 15,
"aspect_ratio": "16:9",
"resolution": "1080p",
})
def test_image_generation_prompt_is_optional_and_preserves_image_url(self):
body = self.build(
prompt="",
resolution="1080p",
image={"image_url": "data:image/png;base64,AAAA"},
)
self.assertNotIn("prompt", body)
self.assertEqual(body["image"], {"image_url": "data:image/png;base64,AAAA"})
def test_multireference_generation_supports_15_seconds_on_both_models(self):
for model in GrokVideoClient.MODEL_OPTIONS:
with self.subTest(model=model):
body = self.build(
model=model,
duration=15,
resolution="720p",
reference_images=[{"url": "https://example.invalid/character.png"}],
reference_audios=[{"voice_id": "nova"}],
)
self.assertEqual(body["duration"], 15)
self.assertEqual(body["reference_audios"], [{"voice_id": "nova"}])
def test_multireference_generation_rejects_1080p(self):
with self.assertRaisesRegex(ValueError, "不支持 1080p"):
self.build(
resolution="1080p",
reference_images=[{"url": "https://example.invalid/reference.png"}],
)
def test_base_model_rejects_1080p(self):
with self.assertRaisesRegex(ValueError, "仅支持 grok-imagine-video-1.5"):
self.build(model="grok-imagine-video", resolution="1080p")
def test_image_and_reference_images_are_mutually_exclusive(self):
with self.assertRaisesRegex(ValueError, "image 和 reference_images"):
self.build(
image={"url": "https://example.invalid/input.png"},
reference_images=[{"url": "https://example.invalid/reference.png"}],
)
def test_reference_audio_accepts_only_url_or_voice_id_and_caps_at_three(self):
body = self.build(reference_audios=[
{"url": "https://example.invalid/one.wav"},
{"voice_id": "nova"},
{"voice_id": "alloy"},
])
self.assertEqual(len(body["reference_audios"]), 3)
with self.assertRaisesRegex(ValueError, "最多支持 3"):
self.build(reference_audios=[{"voice_id": str(index)} for index in range(4)])
with self.assertRaisesRegex(ValueError, "url、voice_id"):
self.build(reference_audios=[{"file_id": "not-supported"}])
def test_edit_and_extension_payloads_use_their_exact_parameter_sets(self):
for model in GrokVideoClient.MODEL_OPTIONS:
with self.subTest(operation="edit", model=model):
edit = self.build(
operation="edit",
model=model,
video={"file_id": "file_grok_1"},
duration=9,
resolution="1080p",
)
self.assertEqual(edit, {
"model": model,
"prompt": "cinematic shot",
"video": {"file_id": "file_grok_1"},
})
with self.subTest(operation="extend", model=model):
extension = self.build(
operation="extend",
model=model,
video={"url": "data:video/mp4;base64,AAAA"},
duration=5,
)
self.assertEqual(extension, {
"model": model,
"prompt": "cinematic shot",
"video": {"url": "data:video/mp4;base64,AAAA"},
"duration": 5,
})
def test_extension_duration_is_limited_to_two_through_ten_seconds(self):
for duration in (1, 11):
with self.subTest(duration=duration):
with self.assertRaisesRegex(ValueError, "2 到 10 秒"):
self.build(
operation="extend",
video={"url": "https://example.invalid/input.mp4"},
duration=duration,
)
def test_error_sanitizer_removes_temporary_urls_and_base64(self):
message = GrokVideoClient._safe_error_message(
"failed https://cdn.example.invalid/result.mp4?signature=secret "
"data:video/mp4;base64,QUJDREVGRw=="
)
self.assertNotIn("signature=secret", message)
self.assertNotIn("QUJDREVGRw", message)
self.assertIn("<temporary URL omitted>", message)
self.assertIn("<base64 omitted>", message)
class GrokVideoNodeTests(unittest.TestCase):
def test_generation_schema_exposes_current_models_defaults_and_three_audio_inputs(self):
required = grok_video.O1keyGrokVideo.INPUT_TYPES()["required"]
optional = grok_video.O1keyGrokVideo.INPUT_TYPES()["optional"]
self.assertEqual(required["模型"][1]["default"], "grok-imagine-video-1.5")
self.assertEqual(required["分辨率"][1]["default"], "480p")
self.assertIn("参考音色ID(逗号分隔)", required)
self.assertEqual(
[name for name in optional if name.startswith("音频素材")],
["音频素材", "音频素材2", "音频素材3"],
)
def test_edit_schema_appends_model_selector(self):
required = grok_video.O1keyGrokVideoEdit.INPUT_TYPES()["required"]
self.assertEqual(
list(required),
["操作", "提示词", "续写时长(秒)", "模型"],
)
self.assertEqual(required["模型"][1]["default"], "grok-imagine-video-1.5")
def test_edit_rejects_video_over_87_seconds_before_upload(self):
video = type("Video", (), {"get_duration": lambda self: 8.71})()
with (
patch.object(grok_video, "VideoFromFile", object),
patch.object(grok_video, "upload_video", new=AsyncMock()) as upload,
):
with self.assertRaisesRegex(ValueError, "不能超过 8.7 秒"):
grok_video.O1keyGrokVideoEdit().generate(**{
"操作": "编辑视频",
"提示词": "make the sky golden",
"续写时长(秒)": 6,
"模型": "grok-imagine-video-1.5",
"视频素材": video,
})
upload.assert_not_awaited()
def test_voice_ids_split_on_chinese_comma_comma_and_newline(self):
self.assertEqual(
grok_video._parse_voice_ids("novaalloy\necho"),
["nova", "alloy", "echo"],
)
if __name__ == "__main__":
unittest.main()
+197
View File
@@ -0,0 +1,197 @@
import importlib.util
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
def _load_module():
spec = importlib.util.spec_from_file_location(
"o1key_http2_client_test_module",
ROOT / "utils" / "http2_client.py",
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _load_module_without_httpx():
module_name = "o1key_http2_client_without_httpx_test_module"
spec = importlib.util.spec_from_file_location(
module_name,
ROOT / "utils" / "http2_client.py",
)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"httpx": None}):
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
HTTP2_CLIENT = _load_module()
class _ChunkContent:
def __init__(self, chunks, error=None):
self.chunks = chunks
self.error = error
async def iter_chunked(self, _chunk_size):
for chunk in self.chunks:
yield chunk
if self.error is not None:
raise self.error
class _TraceResponse:
def __init__(self, chunks, headers=None, error=None):
self.status = 200
self.http_version = "HTTP/2"
self.headers = headers or {}
self.content = _ChunkContent(chunks, error=error)
class Http2ClientTests(unittest.TestCase):
def test_enables_http2_when_runtime_support_is_present(self):
fake_client = MagicMock()
with (
patch.object(HTTP2_CLIENT, "http2_runtime_available", return_value=True),
patch.object(HTTP2_CLIENT.httpx, "AsyncClient", return_value=fake_client) as constructor,
):
client = HTTP2_CLIENT.O1keyAsyncHttpClient(http2=True)
self.assertTrue(client.http2_enabled)
self.assertTrue(constructor.call_args.kwargs["http2"])
self.assertTrue(constructor.call_args.kwargs["verify"])
def test_falls_back_to_http11_when_h2_runtime_is_missing(self):
fake_client = MagicMock()
with (
patch.object(HTTP2_CLIENT, "http2_runtime_available", return_value=False),
patch.object(HTTP2_CLIENT.httpx, "AsyncClient", return_value=fake_client) as constructor,
):
client = HTTP2_CLIENT.O1keyAsyncHttpClient(http2=True)
self.assertFalse(client.http2_enabled)
self.assertFalse(constructor.call_args.kwargs["http2"])
def test_task_id_validation_uses_only_explicit_task_fields(self):
payload = {"id": "result-image-id", "data": {"taskId": "task-7"}}
self.assertEqual(HTTP2_CLIENT.response_task_id(payload), "task-7")
self.assertEqual(
HTTP2_CLIENT.validate_response_task_id(payload, "task-7"),
"task-7",
)
with self.assertRaisesRegex(
HTTP2_CLIENT.ResponseTaskIdMismatchError,
"requested_task_id=task-8.*response_task_id=task-7",
):
HTTP2_CLIENT.validate_response_task_id(payload, "task-8")
class ResponseBodyDiagnosticsTests(unittest.IsolatedAsyncioTestCase):
async def test_exact_content_length_is_reported_as_match(self):
response = _TraceResponse(
[b'{"ok":', b'true}'],
headers={"Content-Length": "11"},
)
body, diagnostics = await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
self.assertEqual(body, b'{"ok":true}')
self.assertEqual(diagnostics["declared_bytes"], 11)
self.assertEqual(diagnostics["received_bytes"], 11)
self.assertEqual(diagnostics["length_check"], "match")
async def test_short_content_length_raises_with_received_byte_count(self):
response = _TraceResponse(
[b"1234"],
headers={"Content-Length": "10"},
)
with self.assertRaisesRegex(
HTTP2_CLIENT.ResponseBodyIntegrityError,
r"Content-Length=10.*received=4B.*length_check=mismatch",
):
await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
async def test_stream_failure_keeps_partial_received_byte_count(self):
response = _TraceResponse(
[b"1234"],
headers={"Content-Length": "10"},
error=OSError("connection closed"),
)
with self.assertRaisesRegex(
HTTP2_CLIENT.ResponseBodyIntegrityError,
r"读取提前中断.*Content-Length=10.*received=4B.*connection closed",
):
await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
async def test_compressed_response_does_not_compare_decoded_size(self):
response = _TraceResponse(
[b"decoded body"],
headers={"Content-Length": "5", "Content-Encoding": "gzip"},
)
_body, diagnostics = await HTTP2_CLIENT.read_response_body_with_diagnostics(response)
self.assertEqual(diagnostics["length_check"], "skipped-compressed")
class AiohttpFallbackTests(unittest.IsolatedAsyncioTestCase):
async def test_missing_httpx_uses_working_aiohttp_session(self):
module = _load_module_without_httpx()
self.assertFalse(module.HTTPX_AVAILABLE)
self.assertFalse(module.http2_runtime_available())
self.assertIsInstance(
module.create_timeout(
120.0,
connect=30.0,
read=60.0,
write=30.0,
pool=30.0,
),
module.aiohttp.ClientTimeout,
)
client = module.O1keyAsyncHttpClient(http2=True)
self.assertEqual(client.backend, "aiohttp")
self.assertFalse(client.http2_enabled)
async with client as active_client:
self.assertIsInstance(active_client._client, module.aiohttp.ClientSession)
async def test_missing_httpx_converts_files_to_aiohttp_multipart(self):
module = _load_module_without_httpx()
client = module.O1keyAsyncHttpClient(http2=True)
fake_session = MagicMock()
sentinel_context = object()
fake_session.post.return_value = sentinel_context
client._client = fake_session
result = client.post(
"https://example.invalid/upload",
headers={"Authorization": "Bearer test"},
files={"file": ("reference.jpg", b"jpeg", "image/jpeg")},
timeout=module.create_timeout(
120.0,
connect=30.0,
read=60.0,
write=30.0,
pool=30.0,
),
)
self.assertIs(result, sentinel_context)
payload = fake_session.post.call_args.kwargs["data"]
self.assertIsInstance(payload, module.aiohttp.FormData)
self.assertEqual(len(payload._fields), 1)
if __name__ == "__main__":
unittest.main(verbosity=2)
+597
View File
@@ -0,0 +1,597 @@
"""Offline tests for the MiniMax-H3 node and New API response parsing."""
import json
import os
import sys
import tempfile
import unittest
from inspect import signature
from unittest.mock import AsyncMock, MagicMock, call, patch
import av
import numpy as np
import torch
from PIL import Image
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CUSTOM_NODES_DIR = os.path.dirname(PLUGIN_DIR)
COMFY_ROOT = os.environ.get(
"COMFYUI_ROOT",
r"F:\ComfyUI_windows_portable\ComfyUI",
)
sys.path.insert(0, COMFY_ROOT)
sys.path.insert(0, CUSTOM_NODES_DIR)
from comfyui_o1key.clients.minimax_h3_client import ( # noqa: E402
FAILURE_STATUSES,
MiniMaxH3Client,
PENDING_STATUSES,
SUCCESS_STATUSES,
extract_public_task_id,
parse_task_snapshot,
)
from comfyui_o1key.clients.newapi_veo_client import NewAPIVeoClient # noqa: E402
from comfyui_o1key.nodes.minimax_h3_video import ( # noqa: E402
MAX_SEED,
MODEL_MAX_ID,
MODE_FIRST,
MODE_FIRST_LAST,
MODE_LAST,
MODE_REFERENCE,
MODE_TEXT,
MiniMaxH3Video,
_make_progress_callbacks,
build_request_body,
)
from comfyui_o1key.utils.minimax_h3_media import ( # noqa: E402
inspect_audio,
probe_video,
validate_reference_audios,
validate_reference_videos,
validate_image,
validate_video_info,
)
from comfyui_o1key.utils.video_task import POLL_DEADLINE_SECONDS # noqa: E402
class MiniMaxH3PayloadTests(unittest.TestCase):
def test_text_to_video_payload(self):
body = build_request_body(
prompt="月球上的宇航员",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
)
self.assertEqual(body["model"], "MiniMax-H3")
self.assertEqual(body["seed"], 0)
self.assertEqual(body["ratio"], "16:9")
self.assertEqual(body["content"], [{"type": "text", "text": "月球上的宇航员"}])
self.assertNotIn("callback_url", body)
self.assertNotIn("aigc_watermark", body)
def test_native_seed_is_forwarded_and_validated(self):
body = build_request_body(
prompt="固定镜头",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
seed=123456789,
)
self.assertEqual(body["seed"], 123456789)
for invalid_seed in (-1, MAX_SEED + 1, True, 1.5):
with self.subTest(seed=invalid_seed), self.assertRaisesRegex(
ValueError,
"seed 必须是",
):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
seed=invalid_seed,
)
def test_first_frame_payload_uses_adaptive(self):
body = build_request_body(
prompt="镜头缓慢推进",
resolution="768P",
duration=4,
mode=MODE_FIRST,
first_url="https://cdn.example.com/first.png",
)
self.assertEqual(body["ratio"], "adaptive")
self.assertEqual(body["content"][1]["role"], "first_frame")
def test_first_last_payload_roles(self):
body = build_request_body(
prompt="自然过渡",
resolution="2K",
duration=15,
mode=MODE_FIRST_LAST,
first_url="https://cdn.example.com/first.png",
last_url="https://cdn.example.com/last.png",
)
self.assertEqual(
[item.get("role") for item in body["content"][1:]],
["first_frame", "last_frame"],
)
def test_last_frame_only_payload(self):
body = build_request_body(
prompt="镜头最终停在城市夜景",
resolution="2K",
duration=6,
mode=MODE_LAST,
last_url="https://cdn.example.com/last.png",
)
self.assertEqual(body["ratio"], "adaptive")
self.assertEqual(body["content"][1]["role"], "last_frame")
def test_reference_payload_roles(self):
body = build_request_body(
prompt="保持参考人物外观",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
ratio="4:3",
reference_image_urls=[
"https://cdn.example.com/person.png",
"https://cdn.example.com/style.png",
],
reference_video_urls=["https://cdn.example.com/motion.mp4"],
reference_audio_urls=["https://cdn.example.com/voice.wav"],
)
self.assertEqual(body["ratio"], "4:3")
self.assertEqual(
[item.get("role") for item in body["content"][1:]],
["reference_image", "reference_image", "reference_video", "reference_audio"],
)
def test_reference_ratio_defaults_to_adaptive(self):
body = build_request_body(
prompt="保持参考风格",
resolution="768P",
duration=4,
mode=MODE_REFERENCE,
reference_image_urls=["https://cdn.example.com/person.png"],
)
self.assertEqual(body["ratio"], "adaptive")
def test_reference_max_counts(self):
body = build_request_body(
prompt="多素材参考",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
reference_image_urls=[f"https://cdn.example.com/image-{i}.png" for i in range(6)],
reference_video_urls=[f"https://cdn.example.com/video-{i}.mp4" for i in range(3)],
reference_audio_urls=[f"https://cdn.example.com/audio-{i}.wav" for i in range(3)],
)
roles = [item.get("role") for item in body["content"][1:]]
self.assertEqual(roles.count("reference_image"), 6)
self.assertEqual(roles.count("reference_video"), 3)
self.assertEqual(roles.count("reference_audio"), 3)
def test_reference_material_total_cannot_exceed_twelve(self):
with self.assertRaisesRegex(ValueError, "合计最多 12 个"):
build_request_body(
prompt="多素材参考",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
reference_image_urls=[
f"https://cdn.example.com/image-{i}.png" for i in range(9)
],
reference_video_urls=[
f"https://cdn.example.com/video-{i}.mp4" for i in range(3)
],
reference_audio_urls=["https://cdn.example.com/audio.wav"],
)
def test_h3_max_payload_and_model_specific_limits(self):
body = build_request_body(
prompt="电影感城市延时",
model=MODEL_MAX_ID,
resolution="480P",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
)
self.assertEqual(body["model"], "MiniMax-H3-MAX")
self.assertEqual(body["resolution"], "480P")
invalid_cases = [
{"resolution": "2K", "duration": 5, "mode": MODE_TEXT, "ratio": "16:9"},
{"resolution": "480P", "duration": 4, "mode": MODE_TEXT, "ratio": "16:9"},
{
"resolution": "768P",
"duration": 5,
"mode": MODE_REFERENCE,
"reference_image_urls": ["https://cdn.example.com/reference.png"],
},
]
for case in invalid_cases:
with self.subTest(case=case), self.assertRaises(ValueError):
build_request_body(prompt="x", model=MODEL_MAX_ID, **case)
def test_validation(self):
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="adaptive",
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x" * 7001,
resolution="2K",
duration=5,
mode=MODE_TEXT,
ratio="16:9",
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=3,
mode=MODE_TEXT,
ratio="16:9",
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_FIRST_LAST,
first_url="https://cdn.example.com/first.png",
last_url="https://cdn.example.com/last.png",
reference_image_urls=["https://cdn.example.com/reference.png"],
)
with self.assertRaises(ValueError):
build_request_body(
prompt="x",
resolution="2K",
duration=5,
mode=MODE_REFERENCE,
reference_image_urls=[f"https://cdn.example.com/{i}.png" for i in range(10)],
)
class MiniMaxH3ClientTests(unittest.TestCase):
def test_video_clients_share_2000_second_poll_deadline(self):
self.assertEqual(POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(MiniMaxH3Client.POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(NewAPIVeoClient.POLL_DEADLINE_SECONDS, 2000)
self.assertEqual(
signature(NewAPIVeoClient.poll_video_status_async)
.parameters["timeout"].default,
2000,
)
self.assertEqual(
signature(NewAPIVeoClient.generate_video_sync)
.parameters["timeout"].default,
2000,
)
def test_public_id_precedence(self):
self.assertEqual(
extract_public_task_id({"id": "public-id", "task_id": "fallback-id"}),
"public-id",
)
def test_wrapped_success_snapshot(self):
snapshot = parse_task_snapshot({
"code": "success",
"data": {
"status": "SUCCESS",
"progress": "100%",
"result_url": "https://cdn.example.com/result.mp4",
},
})
self.assertEqual(snapshot["status"], "SUCCESS")
self.assertEqual(snapshot["progress"], 100)
self.assertEqual(snapshot["result_url"], "https://cdn.example.com/result.mp4")
def test_http_200_failure_snapshot(self):
snapshot = parse_task_snapshot({
"code": "success",
"data": {
"status": "FAILURE",
"fail_reason": "上游拒绝",
},
})
self.assertEqual(snapshot["status"], "FAILURE")
self.assertEqual(snapshot["fail_reason"], "上游拒绝")
def test_official_v2_success_snapshot(self):
snapshot = parse_task_snapshot({
"task": {
"id": "424010985738629",
"status": "succeeded",
"content": {"url": "https://cdn.example.com/official.mp4"},
}
})
self.assertEqual(snapshot["status"], "SUCCEEDED")
self.assertEqual(snapshot["result_url"], "https://cdn.example.com/official.mp4")
def test_official_v2_failure_snapshot(self):
snapshot = parse_task_snapshot({
"task": {
"status": "failed",
"error": {"code": "1026", "message": "sensitive content"},
}
})
self.assertEqual(snapshot["status"], "FAILED")
self.assertIn("1026", snapshot["fail_reason"])
def test_official_status_sets(self):
self.assertIn("RUNNING", PENDING_STATUSES)
self.assertIn("UNKNOWN", PENDING_STATUSES)
self.assertIn("SUCCEEDED", SUCCESS_STATUSES)
self.assertIn("CANCELLED", FAILURE_STATUSES)
def test_latest_completed_and_failed_response_shapes(self):
completed = parse_task_snapshot({
"id": "task-public-id",
"status": "completed",
"progress": 100,
"metadata": {"url": "https://cdn.example.com/metadata-result.mp4"},
})
self.assertEqual(completed["status"], "COMPLETED")
self.assertEqual(
completed["result_url"],
"https://cdn.example.com/metadata-result.mp4",
)
failed = parse_task_snapshot({
"task_id": "task-public-id",
"status": "failed",
"error": {"code": "upstream_rejected", "message": "内容被拒绝"},
})
self.assertEqual(failed["status"], "FAILED")
self.assertIn("upstream_rejected", failed["fail_reason"])
def test_headers_do_not_include_management_user_header(self):
headers = MiniMaxH3Client(
base_url="https://new-api.example.com/",
api_key="test-token",
)._headers()
self.assertEqual(headers["Authorization"], "Bearer test-token")
self.assertNotIn("New-Api-User", headers)
def test_v3_schema_and_registration(self):
schema = MiniMaxH3Video.define_schema()
schema.validate()
self.assertIsNotNone(schema)
self.assertEqual(
[item.id for item in schema.inputs[-2:]],
["模型", "seed"],
)
seed_input = schema.inputs[-1]
self.assertEqual(seed_input.default, 0)
self.assertEqual(seed_input.min, 0)
self.assertEqual(seed_input.max, MAX_SEED)
from comfyui_o1key import NODE_CLASS_MAPPINGS
self.assertIs(NODE_CLASS_MAPPINGS["MiniMaxH3Video"], MiniMaxH3Video)
@patch("comfy.utils.ProgressBar")
def test_node_progress_mirrors_gateway_percentage_without_regressing(self, progress_cls):
progress_bar = progress_cls.return_value
on_stage, on_progress = _make_progress_callbacks()
on_stage("submitting")
on_stage("submitted:task-public-id")
on_progress(20)
on_progress(20)
on_progress(10)
on_progress(50)
on_progress(100)
on_stage("downloading")
on_stage("done")
self.assertEqual(
progress_bar.update_absolute.call_args_list,
[call(0, 100), call(20, 100), call(50, 100), call(100, 100)],
)
class MiniMaxH3PollingTests(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _response(payload):
response = AsyncMock()
response.text = AsyncMock(return_value=json.dumps(payload))
return response
async def test_documented_unknown_continues_until_gateway_syncs(self):
request = AsyncMock(side_effect=[
self._response({
"id": "task-public-id",
"status": "unknown",
"progress": 0,
}),
self._response({
"data": {
"status": "QUEUED",
"progress": 1,
"result_url": "",
}
}),
self._response({
"data": {
"status": "SUCCESS",
"progress": 100,
"result_url": "https://cdn.example.com/result.mp4",
}
}),
])
sleep = AsyncMock()
client = MiniMaxH3Client(
base_url="https://new-api.example.com",
api_key="test-token",
)
with (
patch(
"comfyui_o1key.clients.minimax_h3_client.async_request_with_retry",
request,
),
patch(
"comfyui_o1key.clients.minimax_h3_client.interruptible_sleep",
sleep,
),
):
result_url = await client.poll_async("task-public-id", object())
self.assertEqual(result_url, "https://cdn.example.com/result.mp4")
self.assertEqual(request.await_count, 3)
self.assertEqual(sleep.await_args_list, [call(10.0), call(10.0)])
self.assertEqual(
request.await_args_list[0].args[2],
"https://new-api.example.com/v1/videos/task-public-id",
)
async def test_generate_downloads_completed_result_without_api_headers(self):
session = object()
session_context = MagicMock()
session_context.__aenter__ = AsyncMock(return_value=session)
session_context.__aexit__ = AsyncMock(return_value=False)
submit = AsyncMock(return_value="task-public-id")
poll = AsyncMock(return_value="https://cdn.example.com/result.mp4")
download = AsyncMock(return_value="result.mp4")
stages = []
client = MiniMaxH3Client(
base_url="https://new-api.example.com",
api_key="test-token",
)
with (
patch(
"comfyui_o1key.clients.minimax_h3_client.aiohttp.TCPConnector",
return_value=object(),
),
patch(
"comfyui_o1key.clients.minimax_h3_client.aiohttp.ClientSession",
return_value=session_context,
),
patch.object(client, "submit_async", submit),
patch.object(client, "poll_async", poll),
patch(
"comfyui_o1key.clients.minimax_h3_client.download_video_to_file",
download,
),
):
result = await client.generate_async(
body={"model": "MiniMax-H3"},
save_path="result.mp4",
on_stage=stages.append,
)
self.assertEqual(result, ("result.mp4", "task-public-id"))
self.assertEqual(
stages,
["submitting", "submitted:task-public-id", "downloading", "done"],
)
download.assert_awaited_once_with(
session,
"https://cdn.example.com/result.mp4",
"result.mp4",
label="MiniMax H3 task-public-id",
)
class MiniMaxH3MediaValidationTests(unittest.TestCase):
def test_image_limits(self):
info = validate_image(Image.new("RGB", (256, 256)), "测试图片")
self.assertEqual(info["width"], 256)
with self.assertRaises(ValueError):
validate_image(Image.new("RGB", (255, 256)), "过小图片")
def test_audio_duration(self):
audio = {
"waveform": torch.zeros((1, 1, 48000 * 2)),
"sample_rate": 48000,
}
info = inspect_audio(audio)
self.assertEqual(info["duration"], 2.0)
with self.assertRaises(ValueError):
inspect_audio({
"waveform": torch.zeros((1, 1, 48000)),
"sample_rate": 48000,
})
with self.assertRaises(ValueError):
validate_reference_audios([
{"waveform": torch.zeros((1, 1, 48000 * 8)), "sample_rate": 48000},
{"waveform": torch.zeros((1, 1, 48000 * 8)), "sample_rate": 48000},
])
def test_video_metadata_limits(self):
valid = {
"size": 1024,
"width": 1920,
"height": 1080,
"duration": 5.0,
"fps": 24.0,
"video_codec": "h264",
"audio_codecs": {"aac"},
"format_names": {"mov", "mp4"},
}
validate_video_info(valid)
# The current API contract constrains the MP4/MOV container, duration,
# and frame rate, but does not impose a client-side codec allowlist.
validate_video_info(dict(valid, video_codec="vp9", audio_codecs={"opus"}))
invalid = dict(valid, format_names={"matroska"})
with self.assertRaises(ValueError):
validate_video_info(invalid)
with patch(
"comfyui_o1key.utils.minimax_h3_media.probe_video",
side_effect=[dict(valid, duration=8.0), dict(valid, duration=8.0)],
):
with self.assertRaises(ValueError):
validate_reference_videos([object(), object()])
def test_probe_real_mp4(self):
fd, path = tempfile.mkstemp(suffix=".mp4", prefix="minimax_h3_probe_")
os.close(fd)
try:
container = av.open(path, mode="w")
stream = container.add_stream("libx264", rate=24)
stream.width = 256
stream.height = 256
stream.pix_fmt = "yuv420p"
frame_data = np.zeros((256, 256, 3), dtype=np.uint8)
for _ in range(72):
frame = av.VideoFrame.from_ndarray(frame_data, format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
container.close()
info = probe_video(path)
validate_video_info(info)
self.assertEqual(info["video_codec"], "h264")
self.assertGreaterEqual(info["duration"], 2.0)
finally:
try:
os.remove(path)
except OSError:
pass
if __name__ == "__main__":
unittest.main(verbosity=2)
+418
View File
@@ -0,0 +1,418 @@
import asyncio
import base64
import importlib.util
import io
import json
import sys
import threading
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
def _load_download_module():
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(ROOT)]
utils_package = types.ModuleType("comfyui_o1key.utils")
utils_package.__path__ = [str(ROOT / "utils")]
clients_package = types.ModuleType("comfyui_o1key.clients")
clients_package.__path__ = [str(ROOT / "clients")]
sys.modules[package.__name__] = package
sys.modules[utils_package.__name__] = utils_package
sys.modules[clients_package.__name__] = clients_package
http_error_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.utils.http_error",
ROOT / "utils" / "http_error.py",
)
http_error = importlib.util.module_from_spec(http_error_spec)
sys.modules[http_error_spec.name] = http_error
http_error_spec.loader.exec_module(http_error)
gemini_module = types.ModuleType("comfyui_o1key.clients.gemini_client")
gemini_module.GeminiAPIClient = object
sys.modules[gemini_module.__name__] = gemini_module
module_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.utils.nano_banana_async",
ROOT / "utils" / "nano_banana_async.py",
)
module = importlib.util.module_from_spec(module_spec)
sys.modules[module_spec.name] = module
module_spec.loader.exec_module(module)
return module
NANO_ASYNC = _load_download_module()
def _load_batch_module():
torch_module = types.ModuleType("torch")
torch_module.Tensor = type("Tensor", (), {})
sys.modules["torch"] = torch_module
numpy_module = types.ModuleType("numpy")
numpy_module.random = types.SimpleNamespace(seed=lambda _seed: None)
sys.modules["numpy"] = numpy_module
comfy_api = types.ModuleType("comfy_api")
comfy_latest = types.ModuleType("comfy_api.latest")
comfy_latest.io = types.SimpleNamespace(ComfyNode=object, NodeOutput=object)
sys.modules["comfy_api"] = comfy_api
sys.modules["comfy_api.latest"] = comfy_latest
comfy = types.ModuleType("comfy")
comfy.__path__ = []
comfy_utils = types.ModuleType("comfy.utils")
comfy_utils.ProgressBar = object
comfy_model_management = types.ModuleType("comfy.model_management")
comfy_model_management.processing_interrupted = lambda: False
comfy_model_management.InterruptProcessingException = type(
"InterruptProcessingException",
(RuntimeError,),
{},
)
sys.modules["comfy"] = comfy
sys.modules["comfy.utils"] = comfy_utils
sys.modules["comfy.model_management"] = comfy_model_management
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_output_directory = lambda: str(ROOT)
sys.modules["folder_paths"] = folder_paths
psutil = types.ModuleType("psutil")
psutil.Process = object
sys.modules["psutil"] = psutil
image_utils = types.ModuleType("comfyui_o1key.utils.image_utils")
image_utils.tensor_to_pil = lambda _value: []
image_utils.pil_to_tensor = lambda value: value
image_utils.parse_batch_prompts = lambda _value: []
sys.modules[image_utils.__name__] = image_utils
file_utils = types.ModuleType("comfyui_o1key.utils.file_utils")
file_utils.ImageInfo = type("ImageInfo", (), {})
for name in (
"load_images_from_folder",
"pair_images_indexed",
"pair_images_by_name",
"pair_images_cartesian",
"generate_timestamp_filename",
"save_image",
):
setattr(file_utils, name, lambda *_args, **_kwargs: [])
sys.modules[file_utils.__name__] = file_utils
config = types.ModuleType("comfyui_o1key.utils.config")
config.NETWORK_ROUTE_OPTIONS = []
config.get_base_url_by_route = lambda _route: "https://example.invalid"
config.get_api_key_or_raise = lambda _name: "test"
sys.modules[config.__name__] = config
models_config = types.ModuleType("comfyui_o1key.models_config")
models_config.get_model_supported_aspect_ratios = lambda _model: []
models_config.get_all_supported_aspect_ratios = lambda: []
models_config.get_model_supported_resolutions = lambda _model: []
models_config.get_all_supported_resolutions = lambda: []
sys.modules[models_config.__name__] = models_config
module_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.nodes.batch_nano_banana",
ROOT / "nodes" / "batch_nano_banana.py",
)
module = importlib.util.module_from_spec(module_spec)
sys.modules[module_spec.name] = module
module_spec.loader.exec_module(module)
return module
BATCH_NODE = _load_batch_module()
def _png_bytes():
buffer = io.BytesIO()
Image.new("RGB", (2, 2), (12, 34, 56)).save(buffer, format="PNG")
return buffer.getvalue()
class _FakeContent:
def __init__(self, chunks, delay=0):
self._chunks = chunks
self._delay = delay
async def iter_chunked(self, _chunk_size):
for chunk in self._chunks:
if self._delay:
await asyncio.sleep(self._delay)
yield chunk
class _FakeResponse:
def __init__(self, session, chunks, headers=None, delay=0, status=200):
self._session = session
self.status = status
self.headers = headers or {}
self.content = _FakeContent(chunks, delay=delay)
async def __aenter__(self):
self._session.active += 1
self._session.max_active = max(self._session.max_active, self._session.active)
return self
async def __aexit__(self, _exc_type, _exc, _tb):
self._session.active -= 1
class _FakeSession:
def __init__(self, chunks, headers=None, delay=0, status=200):
self._chunks = chunks
self._headers = headers
self._delay = delay
self._status = status
self.calls = []
self.active = 0
self.max_active = 0
def get(self, url, **_kwargs):
self.calls.append(url)
return _FakeResponse(
self,
self._chunks,
headers=self._headers,
delay=self._delay,
status=self._status,
)
class DownloadTests(unittest.IsolatedAsyncioTestCase):
async def test_successful_task_query_keeps_transport_trace_silent(self):
body = json.dumps(
{"task_id": "nano-trace", "status": "SUCCESS", "data": {"images": []}},
separators=(",", ":"),
).encode("utf-8")
session = _FakeSession(
[body],
headers={"Content-Length": str(len(body))},
)
with patch("builtins.print") as print_mock:
payload = await NANO_ASYNC._poll_task(
session,
"https://example.invalid",
"test-key",
"nano-trace",
"Nano Banana",
log_success=False,
initial_delay=False,
)
self.assertEqual(payload["status"], "SUCCESS")
rendered = " ".join(str(value) for call in print_mock.call_args_list for value in call.args)
self.assertNotIn("任务查询传输追踪", rendered)
async def test_task_query_retries_when_content_length_is_short(self):
body = b'{"task_id":"nano-short","status":"SUCCESS"}'
session = _FakeSession(
[body],
headers={"Content-Length": str(len(body) + 12)},
)
with patch.object(
NANO_ASYNC,
"_interruptible_sleep",
new=AsyncMock(),
):
with self.assertRaisesRegex(
RuntimeError,
rf"Content-Length={len(body) + 12}.*received={len(body)}B.*length_check=mismatch",
):
await NANO_ASYNC._poll_task(
session,
"https://example.invalid",
"test-key",
"nano-short",
"Nano Banana",
log_success=False,
initial_delay=False,
)
self.assertEqual(len(session.calls), 4)
def test_unparseable_response_log_never_prints_partial_base64(self):
partial_secret = "A" * 4097
with patch("builtins.print") as print_mock:
NANO_ASYNC._log_body(
"task response",
'{"data":{"images":[{"b64_json":"' + partial_secret,
)
rendered = " ".join(str(value) for call in print_mock.call_args_list for value in call.args)
self.assertNotIn(partial_secret, rendered)
self.assertIn("content omitted", rendered)
async def test_downloaded_result_is_decoded_only_once(self):
image_bytes = _png_bytes()
session = _FakeSession([image_bytes])
original_open = NANO_ASYNC._open_result_image
with patch.object(NANO_ASYNC, "_open_result_image", wraps=original_open) as open_image:
image = await NANO_ASYNC._image_from_url_or_data(
"https://example.invalid/result.png",
session,
)
self.assertEqual(image.size, (2, 2))
self.assertEqual(open_image.call_count, 1)
async def test_deduplicates_urls_and_caps_parallel_downloads_at_50(self):
image_bytes = _png_bytes()
session = _FakeSession(
[image_bytes],
delay=0.01,
)
urls = [f"https://example.invalid/{index}.png" for index in range(60)]
payload = {
"images": urls,
"result": {"image_url": urls[0]},
}
images = await NANO_ASYNC._parse_direct_images(
payload,
session,
download_semaphore=asyncio.Semaphore(50),
)
self.assertEqual(len(images), 60)
self.assertEqual(len(session.calls), 60)
self.assertEqual(session.max_active, 50)
async def test_retries_when_downloaded_bytes_are_not_a_complete_image(self):
session = _FakeSession([b"not a valid image"])
original_delays = NANO_ASYNC._DOWNLOAD_RETRY_DELAYS
NANO_ASYNC._DOWNLOAD_RETRY_DELAYS = (0, 0)
try:
with self.assertRaisesRegex(RuntimeError, "transport failed"):
await NANO_ASYNC._image_from_url_or_data(
"https://example.invalid/truncated.png",
session,
)
finally:
NANO_ASYNC._DOWNLOAD_RETRY_DELAYS = original_delays
self.assertEqual(len(session.calls), 3)
async def test_interrupt_check_cancels_during_stream(self):
session = _FakeSession([b"1234", b"5678"], delay=0.01)
checks = 0
def check_interrupt():
nonlocal checks
checks += 1
if checks >= 2:
raise asyncio.CancelledError()
with self.assertRaises(asyncio.CancelledError):
await NANO_ASYNC._download_image_bytes(
"https://example.invalid/cancel.png",
session,
check_interrupt=check_interrupt,
)
async def test_refetches_same_task_when_inline_base64_is_incomplete(self):
invalid_payload = {
"status": "SUCCESS",
"data": {"images": [{"b64_json": "truncated-base64"}]},
}
valid_payload = {
"status": "SUCCESS",
"data": {"images": [{
"b64_json": base64.b64encode(_png_bytes()).decode("ascii"),
}]},
}
session = object()
with (
patch.object(
NANO_ASYNC,
"_poll_task",
new=AsyncMock(return_value=valid_payload),
) as poll_task,
patch.object(
NANO_ASYNC,
"_interruptible_sleep",
new=AsyncMock(),
) as retry_sleep,
):
final_payload, parsed = await NANO_ASYNC._parse_completed_task_images_with_retry(
invalid_payload,
session=session,
base_url="https://example.invalid",
api_key="test-key",
task_id="task-1",
node_label="Nano Banana",
)
images, metrics = parsed
try:
self.assertIs(final_payload, valid_payload)
self.assertEqual(len(images), 1)
self.assertEqual(images[0].size, (2, 2))
self.assertEqual(metrics["inline_images"], 1)
retry_sleep.assert_awaited_once()
poll_task.assert_awaited_once_with(
session,
"https://example.invalid",
"test-key",
"task-1",
"Nano Banana",
check_interrupt=None,
log_body_enabled=False,
progress_callback=None,
log_success=False,
initial_delay=False,
)
finally:
for image in images:
image.close()
class BatchCancellationTests(unittest.IsolatedAsyncioTestCase):
def test_timeout_scales_per_50_task_batch(self):
per_batch = BATCH_NODE._PER_BATCH_TIMEOUT_SECONDS
grace = BATCH_NODE._BATCH_TIMEOUT_GRACE_SECONDS
self.assertEqual(BATCH_NODE._batch_timeout_seconds(1), per_batch + grace)
self.assertEqual(BATCH_NODE._batch_timeout_seconds(50), per_batch + grace)
self.assertEqual(BATCH_NODE._batch_timeout_seconds(51), per_batch * 2 + grace)
async def test_stop_event_cancels_active_coroutine(self):
stop_event = threading.Event()
started = asyncio.Event()
cleaned_up = asyncio.Event()
async def active_work():
started.set()
try:
await asyncio.sleep(60)
finally:
cleaned_up.set()
task = asyncio.create_task(
BATCH_NODE._run_with_interrupt(active_work(), stop_event)
)
await started.wait()
stop_event.set()
with self.assertRaises(BATCH_NODE._BatchStopRequested):
await asyncio.wait_for(task, timeout=1)
self.assertTrue(cleaned_up.is_set())
if __name__ == "__main__":
unittest.main()
+334
View File
@@ -0,0 +1,334 @@
"""Offline tests for Nano Banana's dynamic reference-image inputs."""
import asyncio
import os
import sys
import unittest
from unittest.mock import AsyncMock, patch
import torch
from PIL import Image
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CUSTOM_NODES_DIR = os.path.dirname(PLUGIN_DIR)
COMFY_ROOT = os.environ.get("COMFYUI_ROOT", r"D:\o1key\ComfyUI")
sys.path.insert(0, COMFY_ROOT)
sys.path.insert(0, CUSTOM_NODES_DIR)
from comfyui_o1key.nodes.nano_banana import ( # noqa: E402
MAX_REFERENCE_IMAGES,
NanoBanana,
_collect_autogrow_inputs,
)
from comfyui_o1key.utils.nano_banana_models import ( # noqa: E402
NANO_BANANA_MODEL_OPTIONS,
NANO_BANANA_MODEL_MATRIX,
NANO_BANANA_ROUTE_OPTIONS,
resolve_nano_banana_model,
)
from comfyui_o1key.nodes.batch_nano_banana import ( # noqa: E402
BatchNanoBananaPro,
_MAX_FIXED_REFERENCE_IMAGES,
_MAX_FOLDER_INPUTS,
_generate_single_async,
_normalize_image_quality,
_path_count_from_label,
)
from comfyui_o1key.utils.file_utils import ImageInfo # noqa: E402
class NanoBananaDynamicInputTests(unittest.TestCase):
def test_schema_uses_empty_prompt_and_autogrow_reference_images(self):
schema = NanoBanana.define_schema()
schema.validate()
for item in schema.inputs:
if item.io_type == "COMBO":
self.assertTrue(all(isinstance(option, str) for option in item.options), item.id)
if item.default is not None:
self.assertIsInstance(item.default, str, item.id)
inputs = {item.id: item for item in schema.inputs}
self.assertEqual(inputs["prompt"].default, "")
self.assertIn("参考图组", inputs)
self.assertNotIn("网络", inputs)
self.assertFalse(inputs["模型线路"].advanced)
self.assertEqual(inputs["模型"].options, NANO_BANANA_MODEL_OPTIONS)
self.assertEqual(inputs["模型线路"].options, NANO_BANANA_ROUTE_OPTIONS)
self.assertEqual(inputs["分辨率"].options, ["1K", "2K", "4K"])
self.assertEqual(
[item.id for item in schema.inputs[:3]],
["prompt", "模型", "模型线路"],
)
self.assertFalse(inputs["seed"].advanced)
self.assertEqual(inputs["缩放图片"].options, ["不缩放", "智能缩放"])
self.assertEqual(inputs["缩放图片"].default, "不缩放")
self.assertNotIn("色彩纠正", inputs)
self.assertFalse(inputs["缩放图片"].advanced)
template = inputs["参考图组"].as_dict()["template"]
self.assertEqual(template["min"], 0)
self.assertEqual(
template["names"],
[f"参考图{i}" for i in range(1, MAX_REFERENCE_IMAGES + 1)],
)
self.assertTrue(schema.accept_all_inputs)
def test_single_node_rejects_removed_512_resolution(self):
with self.assertRaisesRegex(ValueError, "分辨率 '512' 无效"):
NanoBanana._validate_model_config("Nano Banana 2", "1:1", "512")
def test_collects_only_connected_autogrow_slots_in_order(self):
first = object()
third = object()
self.assertEqual(
_collect_autogrow_inputs({
"参考图1": first,
"参考图2": None,
"参考图3": third,
}),
[first, third],
)
def test_tolerates_legacy_single_value(self):
image = object()
self.assertEqual(_collect_autogrow_inputs(image), [image])
self.assertEqual(_collect_autogrow_inputs(None), [])
def test_model_route_matrix_matches_api_model_names(self):
expected = {
("Nano Banana Pro", "畅速"): "gemini-3-pro-image-c-sp",
("Nano Banana 2", "畅速"): "gemini-3.1-flash-image-c-sp",
("Nano Banana 2 Lite", "畅速"): "gemini-3.1-flash-lite-image-c-sp",
("Nano Banana", "畅速"): "nano-banana",
("Nano Banana Pro", "直连"): "gemini-3-pro-image-c-sd",
("Nano Banana 2", "直连"): "gemini-3.1-flash-image-c-sd",
("Nano Banana 2 Lite", "直连"): "gemini-3.1-flash-lite-image-c-sd",
("Nano Banana", "直连"): "nano-banana",
("Nano Banana Pro", "专线"): "gemini-3-pro-image",
("Nano Banana 2", "专线"): "gemini-3.1-flash-image",
("Nano Banana 2 Lite", "专线"): "gemini-3.1-flash-lite-image",
("Nano Banana", "专线"): "gemini-2.5-flash-image",
}
self.assertEqual(NANO_BANANA_MODEL_MATRIX, expected)
for key, model_id in expected.items():
self.assertEqual(resolve_nano_banana_model(*key), model_id)
def test_legacy_billing_values_resolve_to_equivalent_routes(self):
self.assertEqual(
resolve_nano_banana_model("Nano Banana Pro", "特价"),
"gemini-3-pro-image-c-sp",
)
self.assertEqual(
resolve_nano_banana_model("Nano Banana Pro", "官方"),
"gemini-3-pro-image",
)
class BatchNanoBananaDynamicInputTests(unittest.TestCase):
def test_schema_uses_dynamic_folders_autogrow_and_visible_options(self):
schema = BatchNanoBananaPro.define_schema()
schema.validate()
for item in schema.inputs:
if item.io_type == "COMBO":
self.assertTrue(all(isinstance(option, str) for option in item.options), item.id)
if item.default is not None:
self.assertIsInstance(item.default, str, item.id)
inputs = {item.id: item for item in schema.inputs}
self.assertEqual(schema.display_name, "Nano Banana 批量跑图")
self.assertEqual(
[item.id for item in schema.inputs[:7]],
["prompt", "模型", "模型线路", "思考等级", "分辨率", "宽高比", "图片路径数量"],
)
self.assertEqual(
[item.id for item in schema.inputs[-7:]],
["参考图组", "缩放图片", "图片输出格式", "图片质量", "图片保存命名规则", "图片保存路径", "seed"],
)
self.assertNotIn("图片路径1", inputs)
self.assertNotIn("参考图1", inputs)
self.assertTrue(schema.accept_all_inputs)
folder_options = inputs["图片路径数量"].as_dict()["options"]
self.assertEqual(
[option["key"] for option in folder_options],
[f"{count}个路径" for count in range(1, _MAX_FOLDER_INPUTS + 1)],
)
one_folder = folder_options[0]["inputs"]["required"]
five_folders = folder_options[-1]["inputs"]["required"]
self.assertEqual(list(one_folder), ["参考图1(主图)"])
self.assertEqual(
list(five_folders),
[
"参考图1(主图)", "参考图2", "参考图3", "参考图4", "参考图5",
"图片配对模式",
],
)
self.assertNotIn("图片随机抽取", one_folder)
self.assertNotIn("图片随机抽取", five_folders)
self.assertEqual(
five_folders["图片配对模式"][1]["options"],
["相同文件名", "同序号", "全匹配", "不配对"],
)
reference_template = inputs["参考图组"].as_dict()["template"]
self.assertEqual(reference_template["min"], 0)
self.assertEqual(
reference_template["names"],
[f"参考图{index}" for index in range(1, _MAX_FIXED_REFERENCE_IMAGES + 1)],
)
self.assertFalse(inputs["模型线路"].advanced)
self.assertEqual(inputs["分辨率"].options, ["1K", "2K", "4K"])
self.assertEqual(inputs["图片质量"].io_type, "INT")
self.assertEqual(inputs["图片质量"].default, 95)
self.assertEqual(inputs["图片质量"].min, 1)
self.assertEqual(inputs["图片质量"].max, 100)
for input_name in (
"seed", "图片输出格式", "图片质量", "图片保存命名规则", "图片保存路径",
"缩放图片",
):
self.assertFalse(inputs[input_name].advanced)
self.assertEqual(inputs["缩放图片"].options, ["不缩放", "智能缩放"])
self.assertNotIn("色彩纠正", inputs)
def test_batch_quality_accepts_serialized_string_and_returns_integer(self):
self.assertEqual(_normalize_image_quality("95"), 95)
self.assertIsInstance(_normalize_image_quality("95"), int)
self.assertEqual(_normalize_image_quality("87"), 87)
def test_batch_node_rejects_removed_512_resolution(self):
with self.assertRaisesRegex(ValueError, "分辨率 '512' 无效"):
BatchNanoBananaPro._validate_model_config("Nano Banana 2", "1:1", "512")
def test_execute_unpacks_dynamic_folders_and_autogrow_references(self):
first_reference = object()
third_reference = object()
output = object()
folder_group = {
"图片路径数量": "3个路径",
"参考图1(主图)": r"D:\images\one",
"参考图2": r"D:\images\two",
"参考图3": r"D:\images\three",
"图片配对模式": "相同文件名",
}
with patch.object(BatchNanoBananaPro, "process_batch", return_value=(output,)) as process:
result = BatchNanoBananaPro.execute(
prompt="test",
模型="Nano Banana 2",
图片路径数量=folder_group,
参考图组={
"参考图1": first_reference,
"参考图2": None,
"参考图3": third_reference,
},
缩放图片="智能缩放",
)
self.assertIs(result.result[0], output)
arguments = process.call_args.kwargs
self.assertEqual(
[arguments[f"文件夹{index}"] for index in range(1, 6)],
[r"D:\images\one", r"D:\images\two", r"D:\images\three", "", ""],
)
self.assertEqual(arguments["图片配对模式"], "相同文件名")
self.assertNotIn("随机抽取路径", arguments)
self.assertIs(arguments["参考图1"], first_reference)
self.assertIs(arguments["参考图2"], third_reference)
self.assertEqual(arguments["缩放图片"], "智能缩放")
self.assertNotIn("色彩纠正", arguments)
def test_batch_request_forwards_resize_mode_to_shared_nano_transport(self):
async def scenario():
with patch(
"comfyui_o1key.nodes.batch_nano_banana.generate_nano_banana_async",
new=AsyncMock(return_value=([], {})),
) as generate:
await _generate_single_async(
session=object(),
base_url="https://example.invalid",
api_key="test",
prompt="test",
model="nano-test",
resolution="2K",
aspect_ratio="1:1",
resize_mode="智能缩放",
)
self.assertEqual(generate.await_args.kwargs["resize_mode"], "智能缩放")
asyncio.run(scenario())
def test_batch_task_saves_generated_image_without_colour_postprocessing(self):
source = Image.new("RGB", (4, 4), color=(12, 34, 56))
generated = Image.new("RGB", (4, 4), color=(90, 80, 70))
info = ImageInfo(
image=source,
filename="source",
extension=".png",
source_path="",
)
async def scenario():
node = BatchNanoBananaPro()
with (
patch(
"comfyui_o1key.nodes.batch_nano_banana._generate_single_async",
new=AsyncMock(return_value=[generated]),
),
patch("comfyui_o1key.nodes.batch_nano_banana._save_generated_image") as save,
):
result = await node._generate_single_task(
session=object(),
base_url="https://example.invalid",
api_key="test",
prompt="test",
model="nano-test",
resolution="2K",
aspect_ratio="1:1",
thinking_level=None,
images=[info],
output_folder=os.path.abspath(os.curdir),
task_index=0,
)
self.assertTrue(result["success"])
self.assertIs(save.call_args.args[0], generated)
asyncio.run(scenario())
def test_path_count_parser_accepts_new_and_legacy_labels(self):
self.assertEqual(_path_count_from_label("3个路径"), 3)
self.assertEqual(_path_count_from_label("4个文件夹"), 4)
self.assertEqual(_path_count_from_label("invalid"), 1)
def test_execute_still_accepts_legacy_numbered_inputs(self):
reference = object()
output = object()
with patch.object(BatchNanoBananaPro, "process_batch", return_value=(output,)) as process:
BatchNanoBananaPro.execute(
prompt="legacy",
模型="Nano Banana Pro",
图片路径1=r"D:\legacy\one",
图片路径4=r"D:\legacy\four",
图片配对模式="1*N",
图片随机抽取="4",
参考图1=reference,
)
arguments = process.call_args.kwargs
self.assertEqual(arguments["文件夹1"], r"D:\legacy\one")
self.assertEqual(arguments["文件夹4"], r"D:\legacy\four")
self.assertEqual(arguments["图片配对模式"], "全匹配")
self.assertNotIn("随机抽取路径", arguments)
self.assertIs(arguments["参考图1"], reference)
def test_same_index_pairing_uses_shortest_folder_length(self):
first = [object(), object(), object()]
second = [object(), object()]
pairs = BatchNanoBananaPro()._create_pairs([first, second], "同序号")
self.assertEqual(
pairs,
[(first[0], second[0]), (first[1], second[1])],
)
if __name__ == "__main__":
unittest.main(verbosity=2)
+389
View File
@@ -0,0 +1,389 @@
import asyncio
import base64
import importlib.util
import io
import json
import random
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
def _load_module():
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(ROOT)]
utils_package = types.ModuleType("comfyui_o1key.utils")
utils_package.__path__ = [str(ROOT / "utils")]
clients_package = types.ModuleType("comfyui_o1key.clients")
clients_package.__path__ = [str(ROOT / "clients")]
sys.modules[package.__name__] = package
sys.modules[utils_package.__name__] = utils_package
sys.modules[clients_package.__name__] = clients_package
http_error_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.utils.http_error",
ROOT / "utils" / "http_error.py",
)
http_error = importlib.util.module_from_spec(http_error_spec)
sys.modules[http_error_spec.name] = http_error
http_error_spec.loader.exec_module(http_error)
gemini_module = types.ModuleType("comfyui_o1key.clients.gemini_client")
gemini_module.GeminiAPIClient = object
sys.modules[gemini_module.__name__] = gemini_module
module_spec = importlib.util.spec_from_file_location(
"comfyui_o1key.utils.nano_banana_async",
ROOT / "utils" / "nano_banana_async.py",
)
module = importlib.util.module_from_spec(module_spec)
sys.modules[module_spec.name] = module
module_spec.loader.exec_module(module)
return module
NANO_ASYNC = _load_module()
class _FakeResponse:
def __init__(self, status, payload, headers=None):
self.status = status
self._text = json.dumps(payload)
self.headers = headers or {}
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _tb):
return False
async def text(self):
return self._text
class _FakeSession:
def __init__(self, response):
self.response = response
self.calls = []
def post(self, url, **kwargs):
self.calls.append((url, kwargs))
return self.response
def _noise_image(width=64, height=64):
rng = random.Random(7)
data = bytes(rng.randrange(256) for _ in range(width * height * 3))
return Image.frombytes("RGB", (width, height), data)
def _jpeg_image_bytes(image, quality=95):
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=quality)
return buffer.getvalue()
class TempUploadTests(unittest.IsolatedAsyncioTestCase):
def test_png_upload_payload_keeps_original_bytes(self):
image = _noise_image(16, 12)
original = NANO_ASYNC._png_bytes(image)
image._o1key_original_format = "PNG"
image._o1key_original_bytes = original
data, extension, content_type = NANO_ASYNC._image_to_upload_payload(image)
self.assertEqual(data, original)
self.assertEqual(extension, ".png")
self.assertEqual(content_type, "image/png")
def test_jpeg_upload_payload_keeps_original_bytes(self):
image = _noise_image(32, 24)
original = _jpeg_image_bytes(image, quality=95)
image._o1key_original_format = "JPEG"
image._o1key_original_bytes = original
data, extension, content_type = NANO_ASYNC._image_to_upload_payload(image)
self.assertEqual(data, original)
self.assertEqual(extension, ".jpg")
self.assertEqual(content_type, "image/jpeg")
async def test_upload_posts_multipart_file_and_returns_https_url(self):
expected_url = "https://cf-api.o1key.com/tmp/input/reference.png"
response = _FakeResponse(200, {
"url": expected_url,
"filename": "reference.png",
"content_type": "image/png",
"size": 100,
"expires_at": 1_787_495_062,
})
session = _FakeSession(response)
urls = await NANO_ASYNC.upload_nano_banana_images_to_temp_urls(
session=session,
base_url="https://cf-api.o1key.com/",
api_key="secret",
images=[Image.new("RGB", (2, 2), "red")],
)
self.assertEqual(urls, [expected_url])
self.assertEqual(len(session.calls), 1)
url, kwargs = session.calls[0]
self.assertEqual(url, "https://cf-api.o1key.com/v1/o1key/uploads")
self.assertEqual(kwargs["headers"], {"Authorization": "Bearer secret"})
self.assertNotIn("Content-Type", kwargs["headers"])
filename, field_value, content_type = kwargs["files"]["file"]
self.assertEqual(filename, "reference_1.png")
self.assertEqual(content_type, "image/png")
self.assertTrue(field_value.startswith(b"\x89PNG"))
async def test_original_jpeg_upload_uses_jpeg_multipart(self):
expected_url = "https://cf-api.o1key.com/tmp/input/reference.jpg"
response = _FakeResponse(200, {"url": expected_url})
session = _FakeSession(response)
image = _noise_image(16, 12)
image._o1key_original_format = "JPEG"
image._o1key_original_bytes = _jpeg_image_bytes(image)
urls = await NANO_ASYNC.upload_nano_banana_images_to_temp_urls(
session=session,
base_url="https://cf-api.o1key.com/",
api_key="secret",
images=[image],
)
self.assertEqual(urls, [expected_url])
_, kwargs = session.calls[0]
filename, field_value, content_type = kwargs["files"]["file"]
self.assertEqual(filename, "reference_1.jpg")
self.assertEqual(content_type, "image/jpeg")
self.assertTrue(field_value.startswith(b"\xff\xd8"))
async def test_concurrent_tasks_reuse_one_upload(self):
image = Image.new("RGB", (2, 2), "blue")
cache = {}
calls = 0
async def fake_upload(**_kwargs):
nonlocal calls
calls += 1
await asyncio.sleep(0)
return "https://example.invalid/reference.png"
with patch.object(
NANO_ASYNC,
"_upload_nano_banana_temp_image",
side_effect=fake_upload,
):
first, second = await asyncio.gather(
NANO_ASYNC.upload_nano_banana_images_to_temp_urls(
session=object(),
base_url="https://example.invalid",
api_key="key",
images=[image],
upload_cache=cache,
),
NANO_ASYNC.upload_nano_banana_images_to_temp_urls(
session=object(),
base_url="https://example.invalid",
api_key="key",
images=[image],
upload_cache=cache,
),
)
self.assertEqual(calls, 1)
self.assertEqual(first, second)
async def test_all_reference_uploads_run_concurrently_and_keep_order(self):
active = 0
max_active = 0
async def fake_upload(**kwargs):
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.01)
active -= 1
return f"https://example.invalid/{kwargs['filename']}"
images = [Image.new("RGB", (2, 2), color) for color in ("red", "green", "blue")]
with patch.object(
NANO_ASYNC,
"_upload_nano_banana_temp_image",
side_effect=fake_upload,
):
urls = await NANO_ASYNC.upload_nano_banana_images_to_temp_urls(
session=object(),
base_url="https://example.invalid",
api_key="key",
images=images,
)
self.assertEqual(max_active, 3)
self.assertEqual(urls, [
"https://example.invalid/reference_1.png",
"https://example.invalid/reference_2.png",
"https://example.invalid/reference_3.png",
])
async def test_generation_body_contains_inline_png_without_upload(self):
image = Image.new("RGB", (2, 2), "green")
result_image = Image.new("RGB", (2, 2), "white")
with (
patch.object(
NANO_ASYNC,
"upload_nano_banana_images_to_temp_urls",
new=AsyncMock(),
) as upload,
patch.object(
NANO_ASYNC,
"_submit_task",
new=AsyncMock(return_value="task-1"),
) as submit,
patch.object(
NANO_ASYNC,
"_poll_task",
new=AsyncMock(return_value={"status": "success"}),
),
patch.object(
NANO_ASYNC,
"_parse_task_images",
new=AsyncMock(return_value=[result_image]),
),
):
images, _ = await NANO_ASYNC.generate_nano_banana_async(
session=object(),
base_url="https://example.invalid",
api_key="key",
prompt="test",
model="model",
resolution="1K",
aspect_ratio="1:1",
images=[image],
)
self.assertEqual(images, [result_image])
upload.assert_not_awaited()
body = submit.await_args.args[3]
self.assertEqual(len(body["images"]), 1)
inline_data = body["images"][0]["inlineData"]
self.assertEqual(inline_data["mimeType"], "image/png")
self.assertTrue(base64.b64decode(inline_data["data"]).startswith(b"\x89PNG\r\n\x1a\n"))
self.assertNotIn("data:image", inline_data["data"])
sanitized = NANO_ASYNC._shorten_base64_for_log(body)
self.assertEqual(
sanitized["images"][0]["inlineData"]["data"],
f"<base64 data, {len(inline_data['data'])} chars>",
)
def test_submit_body_declares_exact_jpeg_mime_type(self):
image = _noise_image(16, 12)
original = _jpeg_image_bytes(image)
image._o1key_original_format = "JPEG"
image._o1key_original_bytes = original
body = NANO_ASYNC.build_nano_banana_submit_body(
model="model",
prompt="test",
resolution="1K",
aspect_ratio="1:1",
images=[image],
)
inline_data = body["images"][0]["inlineData"]
self.assertEqual(inline_data["mimeType"], "image/jpeg")
self.assertEqual(base64.b64decode(inline_data["data"]), original)
def test_submit_body_only_includes_google_search_when_enabled(self):
disabled = NANO_ASYNC.build_nano_banana_submit_body(
model="model",
prompt="test",
resolution="1K",
aspect_ratio="1:1",
)
enabled = NANO_ASYNC.build_nano_banana_submit_body(
model="model",
prompt="test",
resolution="1K",
aspect_ratio="1:1",
google_search=True,
)
self.assertNotIn("google_search", disabled)
self.assertIs(enabled["google_search"], True)
def test_submit_body_omits_size_for_smart_resolution(self):
body = NANO_ASYNC.build_nano_banana_submit_body(
model="model",
prompt="test",
resolution="智能",
aspect_ratio="智能",
)
self.assertNotIn("size", body)
def test_submit_body_rejects_reference_urls(self):
with self.assertRaisesRegex(ValueError, "inlineData"):
NANO_ASYNC.build_nano_banana_submit_body(
model="model",
prompt="test",
resolution="1K",
aspect_ratio="1:1",
image_urls=["https://example.invalid/reference.png"],
)
async def test_no_resize_rejects_the_exact_json_body_above_the_local_limit(self):
image = _noise_image(256, 128)
with patch.object(NANO_ASYNC, "NANO_BANANA_REQUEST_BODY_LIMIT_BYTES", 20 * 1024):
with self.assertRaisesRegex(ValueError, "当前设置为“不缩放”"):
await NANO_ASYNC.prepare_nano_banana_inline_images(
[image],
model="model",
prompt="test",
resolution="1K",
aspect_ratio="2:1",
resize_mode="不缩放",
)
async def test_smart_resize_preserves_ratio_and_fits_the_exact_json_body(self):
image = _noise_image(256, 128)
with (
patch.object(NANO_ASYNC, "NANO_BANANA_REQUEST_BODY_LIMIT_BYTES", 20 * 1024),
patch.object(NANO_ASYNC, "_SMART_RESIZE_MIN_LONG_EDGE", 32),
):
inline_images = await NANO_ASYNC.prepare_nano_banana_inline_images(
[image],
model="model",
prompt="test",
resolution="1K",
aspect_ratio="2:1",
resize_mode="智能缩放",
)
body = NANO_ASYNC.build_nano_banana_submit_body(
model="model",
prompt="test",
resolution="1K",
aspect_ratio="2:1",
inline_images=inline_images,
)
body_size = NANO_ASYNC.validate_nano_banana_request_body(body)
decoded = base64.b64decode(inline_images[0]["inlineData"]["data"])
with Image.open(io.BytesIO(decoded)) as resized:
self.assertLess(resized.width, image.width)
self.assertAlmostEqual(resized.width / resized.height, 2.0, places=1)
self.assertLessEqual(body_size, 20 * 1024)
if __name__ == "__main__":
unittest.main(verbosity=2)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+338
View File
@@ -0,0 +1,338 @@
"""Offline regression tests for o1key's format-aware save boundary."""
import json
import os
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
import sys
import tempfile
import unittest
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
CUSTOM_NODES_ROOT = ROOT.parent
COMFY_ROOT = CUSTOM_NODES_ROOT.parent
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(CUSTOM_NODES_ROOT))
from comfyui_o1key.utils.image_utils import pil_to_tensor # noqa: E402
from comfyui_o1key.utils.o1key_image_save import ( # noqa: E402
detect_image_format,
normalize_naming_rule,
normalize_save_location,
save_temp_images,
save_tensor_images,
)
def _folder_paths(output_dir: str):
return SimpleNamespace(
get_save_image_path=lambda prefix, _root, _width, _height: (
output_dir,
prefix,
1,
"",
prefix,
)
)
def _encoded_image(image_format: str, *, mode="RGB") -> bytes:
image = Image.new(mode, (6, 4), (20, 40, 60, 90) if mode == "RGBA" else (20, 40, 60))
buffer = BytesIO()
kwargs = {"format": image_format}
if image_format == "JPEG":
kwargs.update(quality=91)
if image_format == "WEBP":
kwargs.update(lossless=True)
image.save(buffer, **kwargs)
image.close()
return buffer.getvalue()
class O1keyImageSaveTests(unittest.TestCase):
def test_missing_naming_rule_defaults_to_custom_prefix(self):
self.assertEqual(normalize_naming_rule(None), "自定义前缀")
def test_save_location_supports_output_subfolders_and_absolute_directories(self):
raw = _encoded_image("PNG")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source = os.path.join(temp_dir, "provider.png")
Path(source).write_bytes(raw)
def get_save_image_path(prefix, root, _width, _height):
normalized = prefix.replace("\\", "/")
subfolder, filename = normalized.rsplit("/", 1)
full_output = os.path.join(root, *subfolder.split("/"))
return full_output, filename, 1, subfolder, prefix
folder_paths = SimpleNamespace(get_save_image_path=get_save_image_path)
results = save_temp_images(
[source],
"o1key",
"原始",
output_dir,
folder_paths,
save_location="project/session-a",
naming_rule="自定义前缀",
)
self.assertEqual(results[0]["subfolder"].replace("\\", "/"), "project/session-a")
self.assertTrue(Path(output_dir, "project", "session-a", results[0]["filename"]).is_file())
self.assertEqual(normalize_save_location(""), "")
self.assertEqual(normalize_save_location("output"), "")
with tempfile.TemporaryDirectory() as external_dir:
self.assertEqual(
normalize_save_location(external_dir),
os.path.normpath(external_dir),
)
with self.assertRaisesRegex(ValueError, r"不能包含 \.\."):
normalize_save_location("../outside")
with self.assertRaisesRegex(ValueError, "完整绝对路径"):
normalize_save_location("C:outside")
raw = _encoded_image("PNG")
with (
tempfile.TemporaryDirectory() as source_dir,
tempfile.TemporaryDirectory() as output_dir,
tempfile.TemporaryDirectory() as preview_dir,
tempfile.TemporaryDirectory() as external_parent,
):
source = os.path.join(source_dir, "provider.png")
Path(source).write_bytes(raw)
external_dir = os.path.join(external_parent, "new", "destination")
def get_save_image_path(prefix, root, _width, _height):
subfolder = os.path.dirname(os.path.normpath(prefix))
filename = os.path.basename(os.path.normpath(prefix))
return os.path.join(root, subfolder), filename, 1, subfolder, prefix
folder_paths = SimpleNamespace(
get_save_image_path=get_save_image_path,
get_temp_directory=lambda: preview_dir,
)
results = save_temp_images(
[source],
"external",
"原始",
output_dir,
folder_paths,
save_location=external_dir,
naming_rule="自定义前缀",
)
self.assertTrue(Path(external_dir, "external_00001_.png").is_file())
self.assertEqual(list(Path(output_dir).iterdir()), [])
self.assertEqual(results[0]["filename"], "external_00001_.png")
self.assertEqual(results[0]["type"], "temp")
self.assertIs(results[0]["external_saved"], True)
self.assertNotIn(external_dir, results[0]["subfolder"])
self.assertTrue(
Path(preview_dir, results[0]["subfolder"], results[0]["filename"]).is_file()
)
def test_metadata_bearing_jpeg_uses_native_recoverable_png_container(self):
raw = _encoded_image("JPEG")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source = os.path.join(temp_dir, "provider.jpg")
Path(source).write_bytes(raw)
workflow = {"nodes": [], "extra": {"note": "x" * 70000}}
results = save_temp_images(
[source],
"o1key",
"原始",
output_dir,
_folder_paths(output_dir),
prompt={"1": {"class_type": "O1keyImageGenerator"}},
extra_pnginfo={"workflow": workflow},
naming_rule="自定义前缀",
)
saved_path = Path(output_dir, results[0]["filename"])
saved = saved_path.read_bytes()
self.assertEqual(results[0]["filename"], "o1key_00001_.png")
self.assertEqual(detect_image_format(saved), "PNG")
with Image.open(saved_path) as image:
self.assertEqual(
image.text["prompt"],
'{"1": {"class_type": "O1keyImageGenerator"}}',
)
self.assertEqual(image.text["workflow"], json.dumps(workflow))
def test_original_jpeg_without_workflow_metadata_keeps_provider_bytes(self):
raw = _encoded_image("JPEG")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source = os.path.join(temp_dir, "provider.jpg")
Path(source).write_bytes(raw)
results = save_temp_images(
[source],
"o1key",
"原始",
output_dir,
_folder_paths(output_dir),
naming_rule="自定义前缀",
)
saved = Path(output_dir, results[0]["filename"]).read_bytes()
self.assertEqual(results[0]["filename"], "o1key_00001_.jpg")
self.assertEqual(saved, raw)
def test_original_png_keeps_idat_and_embeds_native_workflow_fields(self):
raw = _encoded_image("PNG", mode="RGBA")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source = os.path.join(temp_dir, "provider.png")
Path(source).write_bytes(raw)
results = save_temp_images(
[source],
"o1key",
"原始",
output_dir,
_folder_paths(output_dir),
prompt={"prompt": True},
extra_pnginfo={"workflow": {"last_node_id": 2}},
naming_rule="自定义前缀",
)
saved_path = Path(output_dir, results[0]["filename"])
saved = saved_path.read_bytes()
self.assertEqual(detect_image_format(saved), "PNG")
idat_offset = raw.index(b"IDAT") - 4
idat_length = int.from_bytes(raw[idat_offset : idat_offset + 4], "big")
idat_chunk = raw[idat_offset : idat_offset + 12 + idat_length]
self.assertIn(idat_chunk, saved)
with Image.open(saved_path) as image:
self.assertEqual(image.getchannel("A").getextrema(), (90, 90))
self.assertIn("prompt", image.text)
self.assertIn("workflow", image.text)
def test_explicit_png_jpg_and_webp_are_selected_only_by_the_save_node(self):
raw = _encoded_image("JPEG")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source = os.path.join(temp_dir, "provider.jpg")
Path(source).write_bytes(raw)
expected = {"png": "PNG", "jpg": "JPEG", "webp": "WEBP"}
for save_format, image_format in expected.items():
results = save_temp_images(
[source],
f"o1key-{save_format}",
save_format,
output_dir,
_folder_paths(output_dir),
naming_rule="自定义前缀",
)
saved = Path(output_dir, results[0]["filename"]).read_bytes()
self.assertEqual(detect_image_format(saved), image_format)
with Image.open(BytesIO(raw)) as source_image, Image.open(
Path(output_dir, "o1key-webp_00001_.webp")
) as image:
self.assertEqual(image.getpixel((0, 0)), source_image.getpixel((0, 0)))
def test_original_webp_adds_workflow_exif_without_reencoding_pixels(self):
raw = _encoded_image("WEBP", mode="RGBA")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source = os.path.join(temp_dir, "provider.webp")
Path(source).write_bytes(raw)
results = save_temp_images(
[source],
"o1key-webp",
"原始",
output_dir,
_folder_paths(output_dir),
extra_pnginfo={"workflow": {"nodes": []}},
naming_rule="自定义前缀",
)
saved_path = Path(output_dir, results[0]["filename"])
saved = saved_path.read_bytes()
self.assertEqual(detect_image_format(saved), "WEBP")
with Image.open(BytesIO(raw)) as before, Image.open(saved_path) as after:
self.assertEqual(after.getpixel((0, 0)), before.getpixel((0, 0)))
self.assertIn("workflow:", str(after.getexif().get(0x010F, "")))
def test_original_tensor_uses_provider_bytes_and_plain_tensor_falls_back_to_png(self):
raw = _encoded_image("WEBP")
with Image.open(BytesIO(raw)) as opened:
opened.load()
source = opened.copy()
source.format = "WEBP"
source._o1key_original_format = "WEBP"
source._o1key_original_bytes = raw
tensor = pil_to_tensor([source])
source.close()
plain = tensor.clone()
with tempfile.TemporaryDirectory() as output_dir:
preserved = save_tensor_images(
tensor,
"preserved",
"原始",
output_dir,
_folder_paths(output_dir),
naming_rule="自定义前缀",
)
fallback = save_tensor_images(
plain,
"fallback",
"原始",
output_dir,
_folder_paths(output_dir),
naming_rule="自定义前缀",
)
self.assertTrue(preserved[0]["filename"].endswith(".webp"))
self.assertTrue(fallback[0]["filename"].endswith(".png"))
def test_main_image_rule_uses_the_first_reference_stem_and_never_overwrites(self):
raw = _encoded_image("PNG")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source_one = os.path.join(temp_dir, "provider-one.png")
source_two = os.path.join(temp_dir, "provider-two.png")
Path(source_one).write_bytes(raw)
Path(source_two).write_bytes(raw)
first = save_temp_images(
[source_one, source_two],
"ignored",
"png",
output_dir,
_folder_paths(output_dir),
naming_rule="和主图一致",
main_filename="主图.jpeg",
)
second = save_temp_images(
[source_one],
"ignored",
"png",
output_dir,
_folder_paths(output_dir),
naming_rule="和主图一致",
main_filename="主图.jpeg",
)
self.assertEqual([item["filename"] for item in first], ["主图.png", "主图1.png"])
self.assertEqual(second[0]["filename"], "主图2.png")
self.assertEqual(len(list(Path(output_dir).glob("*.png"))), 3)
def test_natural_number_rule_continues_after_existing_files(self):
raw = _encoded_image("PNG")
with tempfile.TemporaryDirectory() as temp_dir, tempfile.TemporaryDirectory() as output_dir:
source = os.path.join(temp_dir, "provider.png")
Path(source).write_bytes(raw)
Path(output_dir, "1.png").write_bytes(b"keep-existing")
results = save_temp_images(
[source, source],
"ignored",
"png",
output_dir,
_folder_paths(output_dir),
naming_rule="自然数字",
)
self.assertEqual([item["filename"] for item in results], ["2.png", "3.png"])
self.assertEqual(Path(output_dir, "1.png").read_bytes(), b"keep-existing")
if __name__ == "__main__":
unittest.main(verbosity=2)
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import importlib.util
from io import BytesIO
from pathlib import Path
import tempfile
import unittest
from PIL import Image
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = PLUGIN_ROOT / "utils" / "o1key_image_thumbnail.py"
SPEC = importlib.util.spec_from_file_location("o1key_image_thumbnail_under_test", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(MODULE)
class _FolderPaths:
def __init__(self, roots):
self.roots = roots
def get_directory_by_type(self, folder_type):
return self.roots.get(folder_type)
class O1keyImageThumbnailTests(unittest.TestCase):
def test_thumbnail_is_bounded_and_source_is_untouched(self):
with tempfile.TemporaryDirectory() as directory:
source = Path(directory, "large.png")
Image.new("RGB", (1800, 900), (30, 80, 120)).save(source)
original = source.read_bytes()
encoded = MODULE.render_reference_thumbnail(str(source))
self.assertEqual(source.read_bytes(), original)
with Image.open(BytesIO(encoded)) as thumbnail:
self.assertEqual(thumbnail.format, "WEBP")
self.assertEqual(thumbnail.size, (256, 128))
def test_resolver_accepts_scoped_descriptor_and_rejects_traversal(self):
with tempfile.TemporaryDirectory() as directory:
input_root = Path(directory, "input")
nested = input_root / "legacy"
nested.mkdir(parents=True)
source = nested / "reference.png"
source.write_bytes(b"image")
folder_paths = _FolderPaths({"input": str(input_root)})
resolved = MODULE.resolve_thumbnail_source(
folder_paths,
"reference.png",
"legacy",
"input",
)
self.assertEqual(Path(resolved), source.resolve())
with self.assertRaisesRegex(ValueError, "子目录无效"):
MODULE.resolve_thumbnail_source(
folder_paths,
"reference.png",
"../outside",
"input",
)
with self.assertRaisesRegex(ValueError, "目录类型无效"):
MODULE.resolve_thumbnail_source(
folder_paths,
"reference.png",
"legacy",
"private",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
const sourcePath = new URL("../web/js/o1keyReferenceImageEditor.js", import.meta.url);
const source = fs.readFileSync(sourcePath, "utf8");
const executableSource = source.replace(/^export /gm, "");
const context = vm.createContext({ console });
vm.runInContext(executableSource, context, { filename: sourcePath.pathname });
const fitCropToRatio = vm.runInContext("fitCropToRatio", context);
const constrainCropRect = vm.runInContext("constrainCropRect", context);
const editedReferenceFilename = vm.runInContext("editedReferenceFilename", context);
const fitStickerTransform = vm.runInContext("fitStickerTransform", context);
const stickerControlGeometry = vm.runInContext("stickerControlGeometry", context);
const hitTestSticker = vm.runInContext("hitTestSticker", context);
const arrowHeadGeometry = vm.runInContext("arrowHeadGeometry", context);
const ratios = vm.runInContext("REFERENCE_EDITOR_RATIOS", context);
assert.deepEqual(
JSON.parse(JSON.stringify(fitCropToRatio(1600, 900, 1))),
{ x: 350, y: 0, width: 900, height: 900 },
);
assert.deepEqual(
JSON.parse(JSON.stringify(fitStickerTransform(1000, 500, 800, 600))),
{ x: 400, y: 300, width: 400, height: 200, rotation: 0, opacity: 1 },
);
const rotatedSticker = { x: 100, y: 100, width: 40, height: 20, rotation: Math.PI / 2 };
const rotatedGeometry = JSON.parse(JSON.stringify(stickerControlGeometry(rotatedSticker, 30)));
assert.ok(Math.abs(rotatedGeometry.corners[0].x - 110) < 1e-9);
assert.ok(Math.abs(rotatedGeometry.corners[0].y - 80) < 1e-9);
assert.equal(hitTestSticker({ ...rotatedSticker, rotation: 0 }, { x: 100, y: 100 }), "move");
assert.equal(hitTestSticker({ ...rotatedSticker, rotation: 0 }, { x: 80, y: 90 }), "scale");
assert.equal(hitTestSticker({ ...rotatedSticker, rotation: 0 }, { x: 100, y: 60 }, 8, 30), "rotate");
assert.equal(hitTestSticker({ ...rotatedSticker, rotation: 0 }, { x: 10, y: 10 }), null);
const arrowHead = JSON.parse(JSON.stringify(arrowHeadGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 4)));
assert.deepEqual(arrowHead.tip, { x: 100, y: 0 });
assert.ok(Math.abs(arrowHead.left.x - arrowHead.right.x) < 1e-9);
assert.ok(Math.abs(arrowHead.left.y + arrowHead.right.y) < 1e-9);
assert.ok(arrowHead.left.x < arrowHead.tip.x);
assert.deepEqual(
JSON.parse(JSON.stringify(fitCropToRatio(1600, 900, 4 / 3))),
{ x: 200, y: 0, width: 1200, height: 900 },
);
assert.deepEqual(
JSON.parse(JSON.stringify(fitCropToRatio(600, 900, "original"))),
{ x: 0, y: 0, width: 600, height: 900 },
);
assert.deepEqual(
JSON.parse(JSON.stringify(constrainCropRect({ x: -20, y: 880, width: 300, height: 100 }, 1000, 900))),
{ x: 0, y: 800, width: 300, height: 100 },
);
assert.deepEqual(
JSON.parse(JSON.stringify(constrainCropRect({ x: 950, y: 850, width: 5000, height: 5000 }, 1000, 900))),
{ x: 0, y: 0, width: 1000, height: 900 },
);
assert.equal(editedReferenceFilename("products/look.v2.jpg"), "look.v2_edited.png");
assert.equal(editedReferenceFilename("bad:name?.webp"), "bad_name__edited.png");
assert.deepEqual(
JSON.parse(JSON.stringify(ratios.map((item) => item.key))),
["free", "original", "1:1", "4:3", "3:4", "3:2", "2:3", "16:9", "9:16"],
);
assert.match(source, /\["crop", "裁剪"/);
assert.match(source, /\["mask", "遮罩"/);
assert.match(source, /\["brush", "画笔"/);
assert.match(source, /\["sticker", "贴图"/);
assert.match(source, /\["arrow", "箭头"/);
assert.match(source, /不会创建 MASK 数据/);
assert.match(source, /#\$\{EDITOR_ID\} \[hidden\]\{display:none!important;\}/);
assert.match(source, /overflow-x:hidden;overflow-y:auto/);
assert.match(source, /historyRow\.append\(undo, redo\)/);
assert.match(source, /sidebarActions\.append\(historyRow, clearMarks, reset\)/);
assert.match(source, /clearMarks\.hidden = state\.mode !== "mask" && state\.mode !== "brush" && state\.mode !== "arrow"/);
assert.match(source, /state\.mode === "sticker" \? "重置贴图"/);
assert.match(source, /white-space:nowrap/);
assert.match(source, /cursor:not-allowed/);
assert.doesNotMatch(source, /cursor:wait/);
assert.match(source, /outputContext\.drawImage\(/);
assert.match(source, /URL\.createObjectURL\(file\)/);
assert.match(source, /URL\.revokeObjectURL\(objectUrl\)/);
assert.match(source, /drawSticker\(outputContext, state\.sticker/);
assert.match(source, /state\.activeStroke\.tool === "arrow"/);
assert.match(source, /松开位置就是箭头尖端/);
assert.match(source, /await onConfirm\(\{/);
assert.match(source, /canvas\.toBlob/);
assert.doesNotMatch(source, /api\.fetchApi|authorization|base64/iu);
console.log("o1key reference image editor tests passed");
+92
View File
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
const sourcePath = new URL("../web/js/o1keyUpdateButton.js", import.meta.url);
const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, "");
class Element {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.listeners = new Map();
this.style = {};
this.disabled = false;
this.textContent = "";
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; }
setAttribute(name, value) { this[name] = value; }
addEventListener(name, listener) { this.listeners.set(name, listener); }
}
let extension;
let tab;
let requests = 0;
let fetchFailure = false;
let response = { ok: true, async json() { return { updated: true, version: "abc1234", requirements_changed: false }; } };
const app = {
registerExtension(value) { extension = value; },
extensionManager: {
getSidebarTabs() { return tab ? [tab] : []; },
registerSidebarTab(value) { tab = value; },
},
};
const api = {
async fetchApi(path, options) {
requests++;
assert.equal(path, "/o1key/update");
assert.equal(options.method, "POST");
assert.equal(options.headers["X-O1Key-Update"], "1");
if (fetchFailure) throw new Error("offline");
return response;
},
};
vm.runInNewContext(source, { app, api, document: { createElement: (tag) => new Element(tag) }, window: { confirm: () => true } });
extension.setup();
assert.equal(tab.id, "o1key-update");
assert.equal(tab.title, "更新");
const firstTab = tab;
extension.setup();
assert.equal(tab, firstTab);
const container = new Element("div");
tab.render(container);
const [, description, sourceLabel, button, status, suggestion] = container.children[0].children;
assert.match(description.textContent, /安全快进/);
assert.match(sourceLabel.textContent, /git\.o1key\.com\/publisher\/comfyui_o1key/);
await button.listeners.get("click")();
assert.equal(requests, 1);
assert.match(status.textContent, /abc1234/);
assert.match(suggestion.textContent, /重启 ComfyUI/);
response = { ok: true, async json() { return { updated: true, version: "def5678", requirements_changed: true }; } };
await button.listeners.get("click")();
assert.equal(requests, 2);
assert.match(suggestion.textContent, /安装 requirements.txt/);
response = { ok: true, async json() { return { updated: false, version: "def5678" }; } };
await button.listeners.get("click")();
assert.match(status.textContent, /已是最新版本/);
assert.match(suggestion.textContent, /无需重启/);
response = { ok: false, status: 409, async json() { return { code: "local_changes", error: "插件目录有本地修改", suggestion: "请先保存修改" }; } };
await button.listeners.get("click")();
assert.match(status.textContent, /本地修改/);
assert.match(suggestion.textContent, /请先保存修改/);
response = { ok: false, status: 404 };
await button.listeners.get("click")();
assert.match(status.textContent, /尚未加载/);
assert.match(suggestion.textContent, /重启 ComfyUI/);
response = { ok: false, status: 500, async json() { throw new Error("invalid JSON"); } };
await button.listeners.get("click")();
assert.match(status.textContent, /无法读取/);
fetchFailure = true;
await button.listeners.get("click")();
assert.match(status.textContent, /无法连接本地 ComfyUI/);
assert.equal(button.disabled, false);
+496
View File
@@ -0,0 +1,496 @@
"""Offline regression tests for the panel-driven o1key video generator."""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
import sys
import tempfile
import time
import types
import unittest
import uuid
from unittest.mock import AsyncMock, patch
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
CUSTOM_NODES_ROOT = ROOT.parent
COMFY_ROOT = CUSTOM_NODES_ROOT.parent
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(CUSTOM_NODES_ROOT))
# Import the focused modules without executing the plugin-wide registry. The
# portable test environment intentionally has a CPU-only torch build, while
# unrelated legacy nodes probe CUDA during package import.
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(ROOT)]
sys.modules["comfyui_o1key"] = package
for child in ("nodes", "utils", "clients"):
module = types.ModuleType(f"comfyui_o1key.{child}")
module.__path__ = [str(ROOT / child)]
sys.modules[f"comfyui_o1key.{child}"] = module
from comfyui_o1key.nodes.o1key_video_generator import ( # noqa: E402
O1keyVideoGenerator,
O1keyVideoResult,
_parse_result_descriptor,
)
from comfyui_o1key.utils import o1key_video_jobs as JOBS # noqa: E402
from comfyui_o1key.utils.o1key_video_catalog import ( # noqa: E402
normalize_seedance_parameters,
public_video_capabilities,
resolve_seedance_model,
)
def payload(**overrides):
value = {
"batch_id": f"video_{uuid.uuid4().hex}",
"generator_node_id": 1,
"result_node_id": 2,
"provider": "seedance",
"model": "seedance-2.0",
"route": "domestic",
"generation_mode": "text",
"asset_creation_mode": "auto",
"prompt": "一只猫穿过有雾的森林",
"resolution": "720p",
"aspect_ratio": "16:9",
"duration": "5",
"generate_audio": False,
"return_last_frame": False,
"seed": 42,
"filename_prefix": "video",
"save_location": "video",
"media": {},
"assets": {},
}
value.update(overrides)
return value
class CatalogTests(unittest.TestCase):
def test_overseas_route_uses_short_display_label(self):
routes = public_video_capabilities()["providers"][0]["routes"]
self.assertIn({"value": "overseas_hc", "label": "海外"}, routes)
def test_model_route_matrix_and_duration_caps(self):
self.assertEqual(
resolve_seedance_model("seedance-2.0", "overseas_hc"),
"dreamina-seedance-2-0-hc",
)
with self.assertRaises(ValueError):
normalize_seedance_parameters(payload(model="seedance-2.0-fast", resolution="1080p"))
with self.assertRaises(ValueError):
normalize_seedance_parameters(payload(model="seedance-2.0", duration="16"))
result = normalize_seedance_parameters(payload(model="seedance-2.5", duration="30", resolution="4k"))
self.assertEqual(result["duration"], 30)
def test_asset_creation_mode_defaults_and_options(self):
normalized = normalize_seedance_parameters({
key: value for key, value in payload().items()
if key != "asset_creation_mode"
})
self.assertEqual(normalized["asset_creation_mode"], "auto")
modes = public_video_capabilities()["providers"][0]["asset_creation_modes"]
self.assertEqual(modes, [
{"value": "auto", "label": "自动创建"},
{"value": "manual", "label": "手动"},
])
with self.assertRaisesRegex(ValueError, "素材创建模式"):
normalize_seedance_parameters(payload(asset_creation_mode="invalid"))
class NodeSchemaTests(unittest.TestCase):
def test_generator_exposes_completed_media_without_queue_generation_side_effects(self):
schema = O1keyVideoGenerator.define_schema()
schema.validate()
self.assertEqual(schema.node_id, "O1keyVideoGenerator")
self.assertEqual([item.id for item in schema.outputs], ["VIDEO", "LAST_FRAME"])
self.assertTrue(schema.not_idempotent)
generation_mode = next(item for item in schema.inputs if item.id == "generation_mode")
self.assertEqual(generation_mode.default, "multimodal")
self.assertEqual(
[item.id for item in schema.inputs],
[
"prompt", "provider", "model", "route", "generation_mode",
"resolution", "aspect_ratio", "duration", "generate_audio",
"return_last_frame", "seed", "media_manifest", "asset_manifest",
"provider_options", "filename_prefix", "save_location",
"asset_creation_mode", "video_manifest", "last_frame_manifest",
],
)
output = O1keyVideoGenerator.execute(prompt="不会发起请求")
self.assertIsNone(output.result)
with patch(
"comfyui_o1key.nodes.o1key_video_generator._result_values",
return_value=("native-video", "last-frame"),
):
completed = O1keyVideoGenerator.execute(
video_manifest='{"filename":"video.mp4"}',
last_frame_manifest='{"filename":"frame.png"}',
)
self.assertEqual(completed.result, ("native-video", "last-frame"))
def test_result_node_exposes_native_video_and_last_frame(self):
schema = O1keyVideoResult.define_schema()
schema.validate()
self.assertEqual(schema.node_id, "O1keyVideoResult")
self.assertTrue(schema.is_deprecated)
self.assertEqual([item.id for item in schema.outputs], ["VIDEO", "LAST_FRAME"])
def test_result_descriptor_rejects_traversal(self):
with self.assertRaises(ValueError):
_parse_result_descriptor({"filename": "video.mp4", "subfolder": "../private", "type": "output"})
self.assertEqual(
_parse_result_descriptor({"filename": "video.mp4", "subfolder": "clips", "type": "output"}),
{"filename": "video.mp4", "subfolder": "clips", "type": "output"},
)
class PayloadAndBodyTests(unittest.TestCase):
def test_modes_are_validated_before_media_upload(self):
with self.assertRaisesRegex(ValueError, "首帧"):
JOBS.normalize_video_job_payload(payload(generation_mode="first_frame", prompt=""))
with self.assertRaisesRegex(ValueError, "不能携带"):
JOBS.normalize_video_job_payload(payload(media={"first_frame": {"name": "a.png", "type": "input"}}))
def test_first_last_body_preserves_roles_and_has_no_search_option(self):
job = JOBS.normalize_video_job_payload(payload(
generation_mode="first_last_frame",
media={
"first_frame": {"name": "first.png", "type": "input"},
"last_frame": {"name": "last.png", "type": "input"},
},
))
body = JOBS.build_seedance_video_body(job, {
"first_frame": "https://example.invalid/first.png",
"last_frame": "https://example.invalid/last.png",
"reference_images": [],
"reference_videos": [],
"reference_audios": [],
})
self.assertEqual([item.get("role") for item in body["content"][1:]], ["first_frame", "last_frame"])
self.assertNotIn("web_search", body)
self.assertNotIn("online_search", body)
def test_multimodal_manual_asset_ids_respect_model_limits(self):
with self.assertRaisesRegex(ValueError, "最多支持 9"):
JOBS.normalize_video_job_payload(payload(
generation_mode="multimodal",
asset_creation_mode="manual",
assets={"images": [f"image-{index}" for index in range(10)]},
))
def test_legacy_mixed_multimodal_request_keeps_combined_limit(self):
references = [{"name": f"{index}.png", "type": "input"} for index in range(9)]
legacy = payload(
generation_mode="multimodal",
media={"reference_images": references},
assets={"persons": ["legacy-image"]},
)
legacy.pop("asset_creation_mode")
with self.assertRaisesRegex(ValueError, "最多支持 9"):
JOBS.normalize_video_job_payload(legacy)
def test_asset_ids_cannot_hide_temporary_urls(self):
with self.assertRaisesRegex(ValueError, "无效素材 ID"):
JOBS.normalize_video_job_payload(payload(
generation_mode="multimodal",
asset_creation_mode="manual",
assets={"images": ["https://signed.example.invalid/image?id=secret"]},
))
def test_manual_asset_mode_drives_frame_and_multimodal_requests(self):
first = JOBS.normalize_video_job_payload(payload(
generation_mode="first_frame",
asset_creation_mode="manual",
assets={"images": ["image-first"]},
))
first_body = JOBS.build_seedance_video_body(first, {
"first_frame": None,
"last_frame": None,
"reference_images": [],
"reference_videos": [],
"reference_audios": [],
})
self.assertEqual(first_body["content"][1]["image_url"]["url"], "asset://image-first")
multimodal = JOBS.normalize_video_job_payload(payload(
generation_mode="multimodal",
asset_creation_mode="manual",
assets={"images": ["image-1"], "videos": ["video-1"], "audios": ["audio-1"]},
))
self.assertEqual(multimodal["assets"]["images"], ["image-1"])
body = JOBS.build_seedance_video_body(multimodal, {
"first_frame": None,
"last_frame": None,
"reference_images": [],
"reference_videos": [],
"reference_audios": [],
})
self.assertEqual(
[item.get("type") for item in body["content"][1:]],
["image_url", "video_url", "audio_url"],
)
def test_manual_asset_mode_rejects_uploads_and_missing_ids(self):
with self.assertRaisesRegex(ValueError, "文生视频模式不需要"):
JOBS.normalize_video_job_payload(payload(asset_creation_mode="manual"))
with self.assertRaisesRegex(ValueError, "只能填写素材 ID"):
JOBS.normalize_video_job_payload(payload(
generation_mode="multimodal",
asset_creation_mode="manual",
media={"reference_images": [{"name": "image.png", "type": "input"}]},
assets={"images": ["image-1"]},
))
with self.assertRaisesRegex(ValueError, "至少需要填写一个素材 ID"):
JOBS.normalize_video_job_payload(payload(
generation_mode="multimodal",
asset_creation_mode="manual",
prompt="prompt alone is not enough in manual mode",
))
def test_legacy_person_assets_infer_manual_mode(self):
legacy = payload(generation_mode="multimodal", assets={"persons": ["legacy-image"]})
legacy.pop("asset_creation_mode")
normalized = JOBS.normalize_video_job_payload(legacy)
self.assertEqual(normalized["asset_creation_mode"], "manual")
self.assertEqual(normalized["assets"]["images"], ["legacy-image"])
class OutputAllocationTests(unittest.TestCase):
def test_parallel_output_names_are_reserved_before_copy(self):
with tempfile.TemporaryDirectory() as output_dir:
job = {"filename_prefix": "clip", "save_location": "video"}
first = JOBS._allocate_output_path(job, output_dir)
second = JOBS._allocate_output_path(job, output_dir)
self.assertNotEqual(first[0], second[0])
self.assertEqual(os.path.getsize(first[0]), 0)
self.assertEqual(os.path.getsize(second[0]), 0)
class ReferenceMediaResolutionTests(unittest.TestCase):
def test_all_reference_image_roles_use_official_dimensions_and_ratio(self):
with tempfile.TemporaryDirectory() as temp_dir:
too_narrow = os.path.join(temp_dir, "too-narrow.png")
valid_small_total = os.path.join(temp_dir, "valid-small-total.png")
bad_ratio = os.path.join(temp_dir, "bad-ratio.png")
Image.new("RGB", (299, 750)).save(too_narrow)
Image.new("RGB", (300, 300)).save(valid_small_total)
Image.new("RGB", (300, 751)).save(bad_ratio)
for role in ("first_frame", "last_frame", "reference_images"):
with self.subTest(role=role):
with self.assertRaisesRegex(ValueError, "宽高必须分别在 3006000px"):
JOBS._validate_media_file(too_narrow, role)
with self.assertRaisesRegex(ValueError, "宽高比必须在 0.42.5"):
JOBS._validate_media_file(bad_ratio, role)
JOBS._validate_media_file(valid_small_total, role)
def test_reference_video_uses_official_total_pixel_range(self):
JOBS._validate_reference_dimensions(614, 664, "参考视频", require_video_pixel_range=True)
JOBS._validate_reference_dimensions(3326, 2494, "参考视频", require_video_pixel_range=True)
with self.assertRaisesRegex(ValueError, "407,6968,295,044"):
JOBS._validate_reference_dimensions(613, 664, "参考视频", require_video_pixel_range=True)
with self.assertRaisesRegex(ValueError, "407,6968,295,044"):
JOBS._validate_reference_dimensions(3327, 2494, "参考视频", require_video_pixel_range=True)
def test_reference_video_path_is_probed_before_snapshot(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = os.path.join(temp_dir, "reference.mp4")
with open(path, "wb") as handle:
handle.write(b"not-a-real-video")
with patch.object(JOBS, "_probe_video_dimensions", return_value=(854, 480)) as probe:
JOBS._validate_media_file(path, "reference_videos")
probe.assert_called_once_with(path)
class AutomaticAssetPreparationTests(unittest.IsolatedAsyncioTestCase):
async def test_both_routes_create_assets_with_bounded_ordered_concurrency(self):
for route, request_type in (("overseas_hc", "hc"), ("domestic", "doubao")):
with self.subTest(route=route), tempfile.TemporaryDirectory() as temp_dir:
paths = []
for index in range(5):
path = os.path.join(temp_dir, f"image-{index}.png")
Image.new("RGB", (2, 2), (index, 0, 0)).save(path)
paths.append(path)
active = 0
maximum = 0
calls = []
class FakeClient:
async def create_hc_asset_and_wait(self, **kwargs):
nonlocal active, maximum
active += 1
maximum = max(maximum, active)
calls.append(kwargs)
await asyncio.sleep((6 - int(kwargs["name"].rsplit("-", 1)[1])) * 0.002)
active -= 1
return {"Id": f"asset-{kwargs['name']}"}
job = {
"route": route,
"asset_creation_mode": "auto",
"snapshot_media": {
"first_frame": None,
"last_frame": None,
"reference_images": paths,
"reference_videos": [],
"reference_audios": [],
},
}
with (
patch.object(JOBS, "SeedanceElementClient", return_value=FakeClient()),
patch.object(
JOBS,
"upload_image",
new=AsyncMock(side_effect=lambda *_args, **_kwargs: "https://upload.invalid/image"),
),
patch.object(JOBS, "get_base_url_by_route", return_value="https://api.invalid"),
):
prepared = await JOBS._prepare_seedance_media(job, lambda **_values: None)
self.assertLessEqual(maximum, 3)
self.assertEqual([call["request_type"] for call in calls], [request_type] * 5)
self.assertEqual(
prepared["reference_images"],
[f"asset://asset-o1key-image-{index}" for index in range(1, 6)],
)
self.assertEqual(
job["resolved_assets"]["images"],
[f"asset-o1key-image-{index}" for index in range(1, 6)],
)
class ParallelManagerTests(unittest.IsolatedAsyncioTestCase):
def test_video_error_formatter_covers_review_subjects_and_preserves_other_errors(self):
cases = {
"The request failed because the output audio may be related to copyright restrictions":
"请求失败,输出视频中音频触发版权限制!",
"upstream rejected: field=video, reason=copyright":
"请求失败,输出视频触发版权限制!",
"field=content; reason=Copyright policy":
"请求失败,提示词触发版权限制!",
"field=real; reason=COPYRIGHT restriction":
"请求失败,真人内容触发版权限制!",
'{"field":"content","reason":"blocked by safety policy"}':
"请求失败,提示词触发审查!",
"moderation rejected; field: real":
"请求失败,真人内容触发审查!",
"OutputVideoSensitiveContentDetected.PolicyViolation":
"请求失败,输出视频触发审查!",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(JOBS.format_o1key_video_error(raw), expected)
self.assertEqual(
JOBS.format_o1key_video_error("provider connection timed out"),
"provider connection timed out",
)
self.assertEqual(
JOBS.format_o1key_video_error("request rejected: invalid API parameter"),
"request rejected: invalid API parameter",
)
async def test_manager_formats_errors_for_future_video_providers(self):
async def executor(_job, _update):
raise RuntimeError(
"The request failed because the output audio may be related "
"to copyright restrictions"
)
async def sender(_event, _payload):
return None
manager = JOBS.ParallelVideoJobManager(executor, sender)
with tempfile.TemporaryDirectory() as temp_dir:
job = {
"batch_id": "video_future_provider_error",
"generator_node_id": 1,
"result_node_id": 2,
"provider": "future-provider",
"model": "future-video-model",
"submitted_at": time.time(),
"temp_directory": temp_dir,
}
await manager.submit(job)
await manager.jobs[job["batch_id"]].task
self.assertEqual(
manager.status(job["batch_id"])["error"],
"请求失败,输出视频中音频触发版权限制!",
)
async def test_failed_video_job_keeps_resolved_asset_ids_for_retry(self):
async def executor(job, _update):
job["resolved_assets"] = {"images": ["safe-image-id"], "videos": [], "audios": []}
raise RuntimeError("provider failed")
async def sender(_event, _payload):
return None
manager = JOBS.ParallelVideoJobManager(executor, sender)
with tempfile.TemporaryDirectory() as temp_dir:
job = {
"batch_id": "video_failed_assets",
"generator_node_id": 1,
"result_node_id": 2,
"provider": "seedance",
"model": "seedance-2.0",
"submitted_at": time.time(),
"temp_directory": temp_dir,
}
await manager.submit(job)
await manager.jobs[job["batch_id"]].task
self.assertEqual(
manager.status(job["batch_id"])["resolved_assets"],
{"images": ["safe-image-id"], "videos": [], "audios": []},
)
async def test_repeated_submissions_start_immediately_without_concurrency_limit(self):
active = 0
maximum = 0
release = asyncio.Event()
async def executor(job, update):
nonlocal active, maximum
active += 1
maximum = max(maximum, active)
update(stage="polling", progress=0.5, provider_task_id=f"remote-{job['batch_id']}")
await release.wait()
active -= 1
return {"video": {"filename": f"{job['batch_id']}.mp4", "subfolder": "video", "type": "output"}}
async def sender(_event, _payload):
return None
manager = JOBS.ParallelVideoJobManager(executor, sender)
with tempfile.TemporaryDirectory() as temp_dir:
submitted = []
for index in range(3):
job = {
"batch_id": f"video_parallel_{index}",
"generator_node_id": 1,
"result_node_id": index + 2,
"provider": "seedance",
"model": "seedance-2.0",
"submitted_at": time.time(),
"temp_directory": temp_dir,
}
submitted.append(await manager.submit(job))
await asyncio.sleep(0.05)
self.assertEqual(maximum, 3)
self.assertTrue(all(manager.status(item["batch_id"])["state"] == "running" for item in submitted))
self.assertTrue(all("max_concurrent_jobs" not in manager.status(item["batch_id"]) for item in submitted))
release.set()
await asyncio.gather(*(record.task for record in manager.jobs.values()))
self.assertTrue(all(manager.status(item["batch_id"])["state"] == "completed" for item in submitted))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const source = fs.readFileSync(path.join(here, "..", "web", "js", "o1keyVideoGenerator.js"), "utf8");
assert.match(source, /const GENERATOR = "O1keyVideoGenerator"/);
assert.match(source, /const RESULT = "O1keyVideoResult"/);
assert.match(source, /const SAVE_VIDEO = "SaveVideo"/);
assert.match(source, /const SAVE_IMAGE = "SaveImage"/);
assert.match(source, /import \{ openReferenceImageEditor \} from "\.\/o1keyReferenceImageEditor\.js"/);
assert.match(source, /import "\.\/o1keyImageGenerator\.js"/);
assert.doesNotMatch(source, /LiteGraph\.createNode\(RESULT\)/);
assert.match(source, /LiteGraph\.createNode\(SAVE_VIDEO\)/);
assert.match(source, /request\.return_last_frame \? LiteGraph\.createNode\(SAVE_IMAGE\) : null/);
assert.match(source, /source\.connect\(0, saveVideo, 0\)/);
assert.match(source, /source\.connect\(1, saveImage, 0\)/);
assert.match(source, /function createNativeOutputNodes\(source, request, batchId\)/);
assert.match(source, /function dispatchNativePreview\(node, descriptor, animated = false\)/);
assert.match(source, /api\.dispatchCustomEvent\("executed"/);
assert.match(source, /output\.animated = \[true\]/);
assert.match(source, /async function submitNativeJob\(source, targets, request, batchId\)/);
assert.match(source, /result_node_id: Number\(targets\.saveVideo\.id\)/);
assert.match(source, /createNativeOutputNodes\(node, request, batchId\)/);
assert.match(source, /function recoverNativeSaveVideo\(node\)/);
assert.match(source, /recoverNativeSaveVideo\(node\)/);
assert.match(source, /String\(result\.properties\?\.o1keyVideoBatchId \|\| ""\) === batchId/);
assert.match(source, /requestJson\("\/o1key\/video\/jobs"/);
assert.match(source, /api\.addEventListener\("o1key\.video_job"/);
assert.match(source, /run\.disabled = false/);
assert.doesNotMatch(source, /独立任务 · 可连续提交 · 提交即运行|o1vg-summary/);
assert.doesNotMatch(source, /最多并行|等待后台槽位/);
assert.match(source, /value: "overseas_hc", label: "海外"/);
assert.doesNotMatch(source, /label: "海外 HC"/);
assert.match(source, /const ASSET_CREATION_OPTIONS/);
assert.match(source, /value: "auto", label: "自动创建"/);
assert.match(source, /value: "manual", label: "手动"/);
assert.match(source, /makeField\("素材创建", assetCreation\)/);
assert.match(source, /assetCreationField\.style\.display = isSeedance \? "grid" : "none"/);
assert.match(source, /assetCreation\._trigger\.disabled = !assetModeAvailable/);
assert.match(source, /imageIdField\.style\.display = manualAssets \? "grid" : "none"/);
assert.match(source, /videoIdField\.style\.display = manualAssets && activeMode === "multimodal"/);
assert.match(source, /asset_creation_mode: manualAssets \? "manual" : "auto"/);
assert.match(source, /assets: manualAssets \? manualRequestAssets : \{\}/);
assert.match(source, /function requestForRetry\(node\)/);
assert.match(source, /request\.asset_creation_mode !== "auto"/);
assert.match(source, /return \{ \.\.\.request, asset_creation_mode: "manual", media: \{\}, assets \}/);
assert.match(source, /resolved_assets: detail\.resolved_assets \|\| null/);
assert.match(source, /const STAGE_LABELS = \{ starting: "正在启动"/);
assert.match(source, /STAGE_LABELS\[detail\.stage\] \|\| detail\.stage/);
assert.match(source, /const SEEDANCE_REFERENCE_LIMITS = Object\.freeze/);
assert.match(source, /minDimension: 300/);
assert.match(source, /maxDimension: 6000/);
assert.match(source, /minVideoPixels: 407696/);
assert.match(source, /maxVideoPixels: 8295044/);
assert.match(source, /async function validateReferenceImageFile\(file\)/);
assert.match(source, /async function validateReferenceVideoFile\(file\)/);
assert.match(source, /if \(kind === "image"\) await validateReferenceImageFile\(file\); else if \(kind === "video"\) await validateReferenceVideoFile\(file\)/);
assert.match(source, /宽高 3006000px · 比例 0\.42\.5/);
assert.match(source, /title: "参考视频", shortTitle: "视频", hint: "40\.77万~829\.50万像素"/);
assert.doesNotMatch(source, /参考图至少需要 480p|≥ 41万像素/);
assert.match(source, /window\.o1keyCanvasImagePicker/);
assert.match(source, /canvasPick\.append\(el\("b", "", "▣"\), el\("span", "", "画布取图"\)\)/);
assert.match(source, /actions\.append\(canvasPick\)/);
assert.match(source, /const file = await picker\.readFile\(descriptor\)/);
assert.match(source, /media\[key\] = multiple \? \[\.\.\.\(media\[key\] \|\| \[\]\), uploaded\] : uploaded/);
assert.doesNotMatch(source, /真人素材 ID/);
// Keep the video node on the same visual component language as the image node.
assert.match(source, /\.o1vg-field/);
assert.match(source, /\.o1vg-select-trigger/);
assert.match(source, /\.o1vg-thumb/);
assert.match(source, /\.o1vg-generate/);
assert.match(source, /\.o1vg-prompt-write/);
assert.match(source, /\.o1vg-prompt-wrap\{[^\n]*height:auto;min-height:142px;flex:1 1 142px/);
assert.match(source, /\.o1vg-prompt\{[\s\S]*?min-height:142px;max-height:none;resize:none/);
assert.match(source, /promptWrite\.title = "AI帮写"/);
assert.match(source, /promptWrite\.append\(promptWriteIcon, el\("span", "", "AI帮写"\)\)/);
assert.match(source, /requestJson\("\/o1key\/video\/prompt-write"/);
assert.match(source, /setPromptWriteStatus\(node, "AI帮写中…", "busy"\)/);
assert.match(source, /role: "首帧"/);
assert.match(source, /role: index === 0 \? "首帧" : "尾帧"/);
assert.match(source, /reference_video_count:/);
assert.match(source, /reference_audio_count:/);
assert.match(source, /function makeVideoPreview\(item, title\)/);
assert.match(source, /video\.preload = "metadata"/);
assert.match(source, /video\.currentTime = previewTime/);
assert.match(source, /video\.src = descriptorUrl\(item\)/);
assert.match(source, /releaseVideoPreviews\(track\)/);
assert.match(source, /video\.removeAttribute\("src"\)/);
assert.match(source, /if \(kind === "video"\) tile\.append\(makeVideoPreview\(item, title\)\)/);
assert.doesNotMatch(source, /drawImage\([^)]*video|canvas\.toDataURL/);
assert.match(source, /function makeSelect/);
assert.match(source, /function mediaDropDestination/);
assert.match(source, /function moveMediaItem/);
assert.match(source, /function editMediaImage/);
assert.match(source, /openReferenceImageEditor\(\{/);
assert.match(source, /addEventListener\("dragstart"/);
assert.match(source, /addEventListener\("dragover"/);
assert.match(source, /addEventListener\("drop"/);
assert.match(source, /\.o1vg-thumb-edit/);
assert.match(source, /\.o1vg-thumb-index/);
assert.match(source, /function makeMultimodalMediaSection\(node\)/);
assert.match(source, /meta\.append\(el\("strong", "", "参考素材"\), hintElement\)/);
assert.match(source, /upload\.append\(el\("b", "", ""\), el\("span", "", "上传"\)\)/);
assert.match(source, /input\.accept = sources\.map\(\(source\) => source\.accept\)\.join\(","\)/);
assert.match(source, /const sourceForFile = \(file\) =>/);
assert.match(source, /addFiles\(files\)/);
const multimodalSource = source.slice(source.indexOf("function makeMultimodalMediaSection"), source.indexOf("function nativeSavePrefix"));
assert.equal((multimodalSource.match(/actions\.append\(/g) || []).length, 1);
assert.doesNotMatch(multimodalSource, /canvasPick/);
assert.doesNotMatch(multimodalSource, /o1vg-empty/);
assert.doesNotMatch(multimodalSource, /添加\$\{source\.shortTitle\}/);
assert.doesNotMatch(multimodalSource, /sourceInput|source\.input/);
assert.match(source, /\.o1vg-multimodal-assets \.o1vg-add\{min-width:58px;height:29px;gap:4px;padding:0 10px;[^}]*font-size:11px;font-weight:600/);
assert.match(source, /mediaHost\.append\(firstSection, lastSection, multimodalSection\)/);
assert.match(source, /multimodalSection\.style\.display = !manualAssets && activeMode === "multimodal" \? "flex" : "none"/);
assert.match(source, /automaticHeights = \{ text: 679, first_frame: 824, first_last_frame: 964, multimodal: 824 \}/);
assert.match(source, /widget\(node, "generation_mode"\)\?\.value \|\| "multimodal"/);
assert.match(source, /const restore = \(\) =>/);
assert.match(source, /mode\.value = value\("generation_mode", "multimodal"\)/);
assert.match(source, /firstSection\._render\?\.\(\)/);
assert.match(source, /multimodalSection\._render\?\.\(\)/);
assert.match(source, /node\._o1vgRestore = restore/);
assert.match(source, /nodeType\.prototype\.onConfigure = function \(\)/);
assert.match(source, /this\._o1vgRestore\?\.\(\)/);
assert.match(source, /loadedGraphNode\(node\).*node\._o1vgRestore\?\.\(\)/s);
assert.doesNotMatch(source, /const imagesSection = makeMediaSection/);
assert.doesNotMatch(source, /const videosSection = makeMediaSection/);
assert.doesNotMatch(source, /const audiosSection = makeMediaSection/);
assert.match(source, /function applyGeneratorDefaultSize/);
assert.match(source, /node\.setSize\?\.\(\[560, 679\]\)/);
assert.match(source, /target\.hidden = true/);
assert.match(source, /target\.options\.hidden = true/);
assert.doesNotMatch(source, /document\.createElement\("select"\)/);
// A click creates an independent O1Key job; it must never enter ComfyUI's native queue.
assert.doesNotMatch(source, /queuePrompt|app\.queuePrompt|fetchApi\("\/prompt"/);
console.log("o1key video generator frontend checks passed");
+261
View File
@@ -0,0 +1,261 @@
"""Offline contracts for the native Omni Flash video node."""
import sys
import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT.parent))
import comfy.options # noqa: E402
comfy.options.enable_args_parsing()
sys.argv = [sys.argv[0], "--cpu"]
from comfyui_o1key.clients.omni_flash_client import OmniFlashClient, build_video_body, _log_response_body, _response_error, _safe_error, _submission_payload, _task_error # noqa: E402
from comfyui_o1key.nodes.omni_flash_video import O1keyOmniFlashVideo, _make_progress_callback # noqa: E402
sys.argv = [sys.argv[0]]
def inputs(**changes):
result = {"提示词": "clouds over mountains", "生成模式": "文生视频",
"分辨率": "720p", "宽高比": "16:9"}
result.update(changes)
return result
class OmniFlashContractTests(unittest.TestCase):
def test_client_uses_existing_o1key_token_and_route(self):
with patch("comfyui_o1key.clients.omni_flash_client.get_api_key_or_raise", return_value="fake-key") as read_key, \
patch("comfyui_o1key.clients.omni_flash_client.get_base_url_by_route", return_value="https://api.o1key.invalid"):
client = OmniFlashClient()
read_key.assert_called_once_with("O1KEY_API_KEY")
self.assertEqual(client.base_url, "https://api.o1key.invalid")
def test_schema_is_single_native_video_node(self):
schema = O1keyOmniFlashVideo.define_schema()
schema.validate()
self.assertEqual(schema.node_id, "O1keyOmniFlashVideo")
self.assertNotIn("模型", [item.id for item in schema.inputs])
self.assertEqual([item.id for item in schema.outputs], ["VIDEO"])
self.assertTrue(schema.is_output_node)
self.assertTrue(schema.not_idempotent)
def test_repeated_runs_get_distinct_cache_fingerprints(self):
first = O1keyOmniFlashVideo.fingerprint_inputs(**inputs())
second = O1keyOmniFlashVideo.fingerprint_inputs(**inputs())
self.assertNotEqual(first, second)
def test_documented_payload_modes(self):
base = dict(prompt="sky", resolution="720p", aspect_ratio="16:9")
text = build_video_body(model="omni_flash_8s", mode="text", **base)
self.assertNotIn("input_reference", text)
url = "https://example.invalid/first.png"
reference = build_video_body(model="omni_flash_8s", mode="reference", references=[url], **base)
self.assertEqual(reference["input_reference"], url)
frames = build_video_body(model="omni_flash_10s", mode="first_last_frame", references=[url, url], **base)
self.assertTrue(frames["first_last_frame"])
self.assertEqual(frames["input_reference"], [url, url])
first_only = build_video_body(model="omni_flash_10s", mode="first_last_frame", references=[url], **base)
self.assertEqual(first_only["input_reference"], url)
self.assertNotIn("first_last_frame", first_only)
edit = build_video_body(model="omni_flash_abra_edit", mode="edit",
source_video_url="https://example.invalid/video.mp4", **base)
self.assertEqual(edit["model"], "omni_flash_abra_edit")
def test_invalid_payload_and_sensitive_error(self):
with self.assertRaises(ValueError):
build_video_body(model="omni_flash_8s", mode="reference", prompt="sky",
resolution="720p", aspect_ratio="16:9")
self.assertNotIn("token=secret", _safe_error("https://host.invalid/a?token=secret"))
self.assertNotIn("token123", _safe_error("Bearer token123"))
def test_gateway_400_preserves_useful_detail_without_urls(self):
message = _response_error({"message": "invalid image https://example.invalid/a?token=secret"}, 400)
self.assertIn("invalid image", message)
self.assertNotIn("token=secret", message)
self.assertIn("HTTP 400", message)
def test_documented_error_codes_have_specific_messages(self):
self.assertIn("令牌管理", _response_error({"error": {"code": "invalid_api_key"}}, 401))
self.assertIn("余额不足", _response_error({"error": {"code": "insufficient_balance"}}, 402))
self.assertIn("请求过于频繁", _response_error({"error": {"code": "rate_limit_exceeded"}}, 429))
self.assertIn("参考图", _task_error({"data": {"error": {"code": "image_url_required_for_i2v"}}}))
def test_response_log_keeps_status_and_masks_sensitive_values(self):
body = json.dumps({
"status": "completed", "url": "https://cdn.example.invalid/video?token=secret",
"api_key": "secret-key", "result": {"progress": 100},
})
with patch("builtins.print") as output:
_log_response_body("查询", 200, body)
logged = output.call_args.args[0]
self.assertIn('"status":"completed"', logged)
self.assertIn('"progress":100', logged)
self.assertNotIn("secret-key", logged)
self.assertNotIn("token=secret", logged)
def test_several_references_use_repeated_multipart_field(self):
first, last = "https://example.invalid/first.png", "https://example.invalid/last.png"
body = build_video_body(model="omni_flash_8s", prompt="sky", resolution="720p",
aspect_ratio="16:9", mode="first_last_frame",
references=[first, last])
submitted = _submission_payload(body)
self.assertNotIn("json", submitted)
form = submitted["data"]
self.assertTrue(form.is_multipart)
fields = [(options["name"], value) for options, _headers, value in form._fields]
self.assertEqual([value for name, value in fields if name == "input_reference"], [first, last])
self.assertIn(("first_last_frame", "true"), fields)
def test_first_last_still_requires_first_frame(self):
with self.assertRaisesRegex(ValueError, "首帧"):
build_video_body(model="omni_flash_8s", mode="first_last_frame", prompt="sky",
resolution="720p", aspect_ratio="16:9", references=[])
def test_edit_source_limit_before_upload(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "source.mp4"
with path.open("wb") as stream:
stream.truncate(20 * 1024 * 1024 + 1)
with self.assertRaisesRegex(ValueError, "20 MB"):
O1keyOmniFlashVideo._check_source_video(SimpleNamespace(get_stream_source=lambda: str(path)))
def test_provider_progress_maps_to_native_node_bar(self):
with patch("comfy.utils.ProgressBar") as progress_bar:
update = _make_progress_callback()
update("polling", 0, "task_12345678")
update("polling", 50, "task_12345678")
update("polling", 25, "task_12345678")
update("downloading", 100, "task_12345678")
update("done", 100, "task_12345678")
self.assertEqual(
[call.args for call in progress_bar.return_value.update_absolute.call_args_list],
[(0, 100), (50, 100), (99, 100), (100, 100)],
)
class OmniFlashExecutionTests(unittest.IsolatedAsyncioTestCase):
async def test_invalid_mode_rejects_before_upload_and_submit(self):
with patch("comfyui_o1key.nodes.omni_flash_video.upload_image", new_callable=AsyncMock) as upload, \
patch("comfyui_o1key.nodes.omni_flash_video.OmniFlashClient") as client:
with self.assertRaises(ValueError):
await O1keyOmniFlashVideo.execute(**inputs(**{"生成模式": "参考图视频"}))
upload.assert_not_awaited()
client.assert_not_called()
async def test_native_queue_execution_returns_video(self):
async def fake_generate(_client, body, path, progress=None):
self.assertEqual(body["model"], "omni_flash_10s")
self.assertNotIn("input_reference", body)
self.assertEqual(_client.api_key, "fake-key")
self.assertEqual(_client.base_url, "https://api.o1key.invalid")
Path(path).write_bytes(b"video")
return "task_12345678"
with tempfile.TemporaryDirectory() as directory:
with patch("comfyui_o1key.nodes.omni_flash_video.folder_paths.get_output_directory", return_value=directory), \
patch("comfyui_o1key.nodes.omni_flash_video.get_api_key_or_raise", return_value="fake-key"), \
patch("comfyui_o1key.nodes.omni_flash_video.get_base_url_by_route", return_value="https://api.o1key.invalid"), \
patch("comfyui_o1key.nodes.omni_flash_video.OmniFlashClient.generate", fake_generate), \
patch("comfyui_o1key.nodes.omni_flash_video.InputImpl.VideoFromFile", side_effect=lambda path: path):
result = await O1keyOmniFlashVideo.execute(**inputs())
self.assertEqual(len(list(Path(directory, "omni_flash").glob("*.mp4"))), 1)
self.assertFalse(list(Path(directory, "omni_flash").glob("*.part")))
self.assertIsNone(result.ui)
async def test_only_first_frame_uploads_one_image(self):
async def fake_generate(_client, body, path, progress=None):
self.assertNotIn("first_last_frame", body)
self.assertEqual(body["input_reference"], "https://example.invalid/first.png")
Path(path).write_bytes(b"video")
image = SimpleNamespace(convert=lambda _mode: "rgb-image")
with tempfile.TemporaryDirectory() as directory:
with patch("comfyui_o1key.nodes.omni_flash_video.folder_paths.get_output_directory", return_value=directory), \
patch("comfyui_o1key.nodes.omni_flash_video.get_api_key_or_raise", return_value="fake-key"), \
patch("comfyui_o1key.nodes.omni_flash_video.get_base_url_by_route", return_value="https://api.o1key.invalid"), \
patch("comfyui_o1key.nodes.omni_flash_video.tensor_to_pil", return_value=[image]), \
patch("comfyui_o1key.nodes.omni_flash_video.upload_image", new_callable=AsyncMock, return_value="https://example.invalid/first.png") as upload, \
patch("comfyui_o1key.nodes.omni_flash_video.OmniFlashClient.generate", fake_generate), \
patch("comfyui_o1key.nodes.omni_flash_video.InputImpl.VideoFromFile", side_effect=lambda path: path):
await O1keyOmniFlashVideo.execute(**inputs(**{"生成模式": "首尾帧", "首帧图片": object()}))
upload.assert_awaited_once()
async def test_client_submits_once_then_polls_and_downloads(self):
class Response:
def __init__(self, data, content_type="application/json"):
self.data, self.status = data, 200
self.headers = {"Content-Type": content_type}
async def __aenter__(self): return self
async def __aexit__(self, *_): return False
async def json(self, **_): return self.data
async def text(self): return json.dumps(self.data)
class Session:
def __init__(self):
self.posts = []
self.gets = []
self.statuses = [
Response({"status": "unknown", "progress": "25%"}),
Response({"status": "success", "data": {"task_status": "processing", "progress": "50%"}}),
Response({"data": {"status": "succeeded"}}),
Response({}, "video/mp4"),
]
async def __aenter__(self): return self
async def __aexit__(self, *_): return False
def post(self, url, **kwargs):
self.posts.append((url, kwargs))
return Response({"id": "task_12345678"})
def get(self, *_args, **kwargs):
self.gets.append(kwargs)
return self.statuses.pop(0)
session = Session()
client = OmniFlashClient(base_url="https://example.invalid", api_key="fake-key")
body = build_video_body(model="omni_flash_8s", prompt="sky", resolution="720p",
aspect_ratio="16:9", mode="text")
with patch("comfyui_o1key.clients.omni_flash_client.aiohttp.ClientSession", return_value=session), \
patch("comfyui_o1key.clients.omni_flash_client.interruptible_sleep", new_callable=AsyncMock), \
patch("comfyui_o1key.clients.omni_flash_client.download_video_to_file", new_callable=AsyncMock) as download:
task_id = await client.generate(body, "result.mp4")
self.assertEqual(task_id, "task_12345678")
self.assertEqual(len(session.posts), 1)
self.assertEqual(session.posts[0][1]["json"], body)
self.assertNotIn("X-No-Watermark", session.posts[0][1]["headers"])
self.assertIn("/v1/videos/task_12345678/content", download.call_args.args[1])
edit_body = build_video_body(
model="omni_flash_abra_edit", prompt="edit", resolution="720p",
aspect_ratio="16:9", mode="edit",
source_video_url="https://example.invalid/source.mp4",
)
edit_session = Session()
with patch("comfyui_o1key.clients.omni_flash_client.aiohttp.ClientSession", return_value=edit_session), \
patch("comfyui_o1key.clients.omni_flash_client.interruptible_sleep", new_callable=AsyncMock), \
patch("comfyui_o1key.clients.omni_flash_client.download_video_to_file", new_callable=AsyncMock):
await client.generate(edit_body, "edited.mp4")
self.assertEqual(edit_session.posts[0][1]["headers"]["X-No-Watermark"], "video")
self.assertTrue(all("X-No-Watermark" not in request["headers"] for request in edit_session.gets))
async def test_completed_content_json_uses_result_link_without_auth_header(self):
class Response:
status = 200
headers = {"Content-Type": "application/json"}
async def __aenter__(self): return self
async def __aexit__(self, *_): return False
async def json(self, **_): return {"data": {"download_url": "https://cdn.example.invalid/video.mp4"}}
async def text(self): return json.dumps(await self.json())
session = SimpleNamespace(get=lambda *_args, **_kwargs: Response())
client = OmniFlashClient(base_url="https://example.invalid", api_key="fake-key")
with patch("comfyui_o1key.clients.omni_flash_client.download_video_to_file", new_callable=AsyncMock) as download:
await client._download_completed(session, "task_12345678", "result.mp4",
{"Authorization": "Bearer fake-key"}, {"status": "completed"})
self.assertEqual(download.call_args.args[1], "https://cdn.example.invalid/video.mp4")
self.assertIsNone(download.call_args.kwargs["headers"])
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const source = readFileSync(new URL("../web/js/omniFlashVideo.js", import.meta.url), "utf8")
.replace(/^import .*;\s*/m, "");
const migrationSource = readFileSync(new URL("../web/js/migrateWorkflow.js", import.meta.url), "utf8")
.replace(/^import .*;\s*/gm, "");
let extension;
const queued = [];
const app = {
registerExtension(value) { extension = value; },
async queuePrompt(...args) { queued.push(args); },
};
runInNewContext(source, { app });
const node = {
comfyClass: "O1keyOmniFlashVideo", id: 42, widgets: [],
addWidget(_type, name, _value, callback, options) {
const widget = { name, callback, options };
this.widgets.push(widget);
return widget;
},
};
extension.nodeCreated(node);
extension.loadedGraphNode(node);
assert.equal(node.widgets.length, 1);
assert.equal(node.widgets[0].name, "开始生成");
assert.equal(node.widgets[0].options.serialize, false);
await node.widgets[0].callback();
assert.deepEqual(JSON.parse(JSON.stringify(queued)), [[0, 1, [42]]]);
const mode = { name: "生成模式", value: "文生视频", callback() {} };
const autogrow = { names: ["参考图片1", "参考图片2"], min: 0 };
const removedLinks = [];
const videoNode = {
comfyClass: "O1keyOmniFlashVideo", id: 43, widgets: [mode],
comfyDynamic: { autogrow: { 参考图片: autogrow } },
inputs: [
{ name: "参考图片.参考图片1", type: "IMAGE", link: null },
{ name: "首帧图片", type: "IMAGE", link: null },
{ name: "尾帧图片", type: "IMAGE", link: null },
{ name: "源视频", type: "VIDEO", link: null },
{ name: "提示词", type: "STRING", link: 99 },
],
addWidget: node.addWidget,
addInput(name, type, options) { this.inputs.push({ name, type, ...options, link: null }); },
removeInput(index) {
if (this.inputs[index].link != null) removedLinks.push(this.inputs[index].link);
this.onConnectionsChange?.(1, index, false);
this.inputs.splice(index, 1);
},
};
const names = () => videoNode.inputs.map((input) => input.name);
extension.nodeCreated(videoNode);
assert.deepEqual(names(), ["提示词"]);
mode.value = "参考图视频";
mode.callback(mode.value);
assert.deepEqual(names(), ["提示词", "参考图片.参考图片1"]);
videoNode.inputs.at(-1).link = 12;
mode.value = "视频编辑";
mode.callback(mode.value);
assert.deepEqual(names(), ["提示词", "参考图片.参考图片1", "源视频"]);
assert.equal(videoNode.inputs.find((input) => input.name === "参考图片.参考图片1").link, 12);
mode.value = "首尾帧";
mode.callback(mode.value);
assert.deepEqual(names(), ["提示词", "首帧图片", "尾帧图片"]);
assert.deepEqual(removedLinks, [12]);
assert.equal(videoNode.comfyDynamic.autogrow.参考图片, undefined);
extension.loadedGraphNode(videoNode);
assert.deepEqual(names(), ["提示词", "首帧图片", "尾帧图片"]);
mode.value = "文生视频";
mode.callback(mode.value);
assert.deepEqual(names(), ["提示词"]);
mode.value = "参考图视频";
mode.callback(mode.value);
assert.equal(videoNode.comfyDynamic.autogrow.参考图片, autogrow);
assert.deepEqual(names(), ["提示词", "参考图片.参考图片1"]);
let migrationExtension;
runInNewContext(migrationSource, {
app: { registerExtension(extension) { migrationExtension = extension; } },
console: { log() {}, warn() {} },
});
const legacyValues = ["提示词", "文生视频", "omni_flash_8s", "1080p", "9:16"];
const graph = { nodes: [{ type: "O1keyOmniFlashVideo", widgets_values: legacyValues }] };
migrationExtension.beforeConfigureGraph(graph);
assert.deepEqual(legacyValues, ["提示词", "文生视频", "1080p", "9:16"]);
migrationExtension.beforeConfigureGraph(graph);
assert.deepEqual(legacyValues, ["提示词", "文生视频", "1080p", "9:16"]);
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import importlib.util
import math
from pathlib import Path
import sys
import types
import unittest
from unittest import mock
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
def _load_source_module(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载测试模块:{path}")
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
def _load_prompt_module():
root_package = types.ModuleType("comfyui_o1key")
root_package.__path__ = [str(PLUGIN_ROOT)]
sys.modules["comfyui_o1key"] = root_package
for package_name, directory in (
("comfyui_o1key.utils", PLUGIN_ROOT / "utils"),
("comfyui_o1key.nodes", PLUGIN_ROOT / "nodes"),
):
package = types.ModuleType(package_name)
package.__path__ = [str(directory)]
sys.modules[package_name] = package
_load_source_module(
"comfyui_o1key.utils.image_utils",
PLUGIN_ROOT / "utils" / "image_utils.py",
)
return _load_source_module(
"comfyui_o1key.nodes.prompt_multi_function",
PLUGIN_ROOT / "nodes" / "prompt_multi_function.py",
)
prompt_module = _load_prompt_module()
O1keyPromptMultiFunction = prompt_module.O1keyPromptMultiFunction
def _prompts(count: int) -> str:
return "\n---\n".join(f"提示词 {index}" for index in range(1, count + 1))
class PromptMultiFunctionTests(unittest.TestCase):
def setUp(self):
self.node = O1keyPromptMultiFunction()
def test_schema_appends_multi_select_widgets_without_changing_legacy_positions(self):
required = O1keyPromptMultiFunction.INPUT_TYPES()["required"]
self.assertEqual(list(required), ["提示词", "功能", "抽取数量", "指定序号"])
self.assertEqual(required["功能"][0], ["全部使用", "随机抽取n套", "指定序号"])
self.assertEqual(required["抽取数量"][1]["default"], 3)
self.assertEqual(required["指定序号"][1]["default"], "1,2,3")
def test_legacy_random_one_mode_still_accepts_the_old_two_argument_call(self):
with mock.patch.object(prompt_module.random, "choice", return_value="提示词 2"):
result = self.node.process(_prompts(3), "随机抽取1套")
self.assertEqual(result, ("提示词 2",))
def test_random_many_samples_without_replacement_and_restores_source_order(self):
with mock.patch.object(prompt_module.random, "sample", return_value=[4, 1, 3]) as sample:
result = self.node.process(_prompts(5), "随机抽取n套", 3)
sample.assert_called_once()
population, count = sample.call_args.args
self.assertEqual(list(population), [0, 1, 2, 3, 4])
self.assertEqual(count, 3)
self.assertEqual(result, ("提示词 2\n---\n提示词 4\n---\n提示词 5",))
def test_random_many_rejects_a_count_larger_than_the_prompt_set(self):
with self.assertRaisesRegex(ValueError, "抽取数量 4 超过当前提示词总数 3"):
self.node.process(_prompts(3), "随机抽取n套", 4)
def test_selected_indices_support_common_separators_ranges_and_input_order(self):
result = self.node.process(
_prompts(5),
"指定序号",
指定序号="52 3-4",
)
self.assertEqual(
result,
("提示词 5\n---\n提示词 2\n---\n提示词 3\n---\n提示词 4",),
)
def test_selected_indices_reject_duplicates_and_out_of_range_values(self):
with self.assertRaisesRegex(ValueError, "序号 2 重复"):
self.node.process(_prompts(3), "指定序号", 指定序号="2,2")
with self.assertRaisesRegex(ValueError, "序号 4 超出范围"):
self.node.process(_prompts(3), "指定序号", 指定序号="1,4")
def test_only_random_modes_disable_execution_caching(self):
self.assertTrue(math.isnan(O1keyPromptMultiFunction.IS_CHANGED("a", "随机抽取1套")))
self.assertTrue(math.isnan(O1keyPromptMultiFunction.IS_CHANGED("a", "随机抽取多套")))
self.assertTrue(math.isnan(O1keyPromptMultiFunction.IS_CHANGED("a", "随机抽取n套")))
selected_key = O1keyPromptMultiFunction.IS_CHANGED("a", "指定序号", 3, "1,3")
self.assertEqual(selected_key, "指定序号|1,3|a")
self.assertEqual(
O1keyPromptMultiFunction.IS_CHANGED("a", "全部使用", 9, "2"),
"全部使用|a",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
const sourcePath = new URL("../web/js/promptMultiFunctionDynamic.js", import.meta.url);
const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, "");
let extension;
const context = {
app: {
registerExtension(value) {
extension = value;
},
},
requestAnimationFrame(callback) {
callback();
},
Set,
};
vm.runInNewContext(source, context, { filename: sourcePath.pathname });
function createNode(modeValue = "全部使用") {
let modeCallbackCount = 0;
const countComputeSize = () => [220, 20];
const indicesComputeSize = () => [220, 20];
const mode = {
name: "功能",
value: modeValue,
callback() {
modeCallbackCount += 1;
},
};
const count = {
name: "抽取数量",
value: 5,
options: {},
computeSize: countComputeSize,
};
const indices = {
name: "指定序号",
value: "1,3,5",
options: {},
computeSize: indicesComputeSize,
};
const node = {
comfyClass: "O1keyPromptMultiFunction",
widgets: [mode, count, indices],
dirtyCalls: 0,
setDirtyCanvas() {
this.dirtyCalls += 1;
},
};
return {
node,
mode,
count,
indices,
countComputeSize,
indicesComputeSize,
getModeCallbackCount: () => modeCallbackCount,
};
}
const state = createNode();
extension.nodeCreated(state.node);
assert.equal(state.count.hidden, true, "全部使用时应隐藏抽取数量");
assert.equal(state.indices.hidden, true, "全部使用时应隐藏指定序号");
assert.deepEqual(Array.from(state.count.computeSize()), [0, -4]);
assert.deepEqual(Array.from(state.indices.computeSize()), [0, -4]);
state.mode.value = "随机抽取n套";
state.mode.callback(state.mode.value);
assert.equal(state.count.hidden, false, "随机模式应显示抽取数量");
assert.equal(state.indices.hidden, true, "随机模式应隐藏指定序号");
assert.equal(state.count.computeSize, state.countComputeSize);
assert.equal(state.count.value, 5, "隐藏切换不得清空抽取数量");
state.mode.value = "指定序号";
state.mode.callback(state.mode.value);
assert.equal(state.count.hidden, true, "指定序号模式应隐藏抽取数量");
assert.equal(state.indices.hidden, false, "指定序号模式应显示序号输入");
assert.equal(state.indices.computeSize, state.indicesComputeSize);
assert.equal(state.indices.value, "1,3,5", "隐藏切换不得清空指定序号");
extension.nodeCreated(state.node);
state.mode.callback(state.mode.value);
assert.equal(state.getModeCallbackCount(), 3, "重复初始化不得叠加控件回调");
const loaded = createNode("指定序号");
loaded.node.comfyClass = undefined;
loaded.node.type = "O1keyPromptMultiFunction";
extension.loadedGraphNode(loaded.node);
assert.equal(loaded.count.hidden, true);
assert.equal(loaded.indices.hidden, false);
console.log("prompt multi-function dynamic visibility tests passed");
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
const migrationPath = new URL("../web/js/migrateWorkflow.js", import.meta.url);
const migrationSource = fs.readFileSync(migrationPath, "utf8");
let migrationExtension;
const context = {
app: {
registerExtension(extension) {
migrationExtension = extension;
},
},
console: { log() {}, warn() {} },
};
vm.runInNewContext(
migrationSource.replace(/^import .*;\s*$/gm, ""),
context,
{ filename: migrationPath.pathname },
);
const legacyValues = ["第一套\n---\n第二套", "随机抽取1套"];
const legacyGraph = {
nodes: [{ type: "O1keyPromptMultiFunction", widgets_values: legacyValues }],
};
migrationExtension.beforeConfigureGraph(legacyGraph);
assert.deepEqual(legacyValues, [
"第一套\n---\n第二套",
"随机抽取n套",
1,
"1,2,3",
]);
migrationExtension.beforeConfigureGraph(legacyGraph);
assert.equal(legacyValues.length, 4, "迁移重复执行时不得再次追加控件值");
const partialValues = ["提示词", "随机抽取多套", 5];
migrationExtension.beforeConfigureGraph({
nodes: [{ type: "O1keyPromptMultiFunction", widgets_values: partialValues }],
});
assert.deepEqual(partialValues, ["提示词", "随机抽取n套", 5, "1,2,3"]);
console.log("prompt multi-function frontend migration tests passed");
+176
View File
@@ -0,0 +1,176 @@
"""Offline tests for the image and video AI-writing helpers."""
from __future__ import annotations
import asyncio
import base64
from io import BytesIO
import json
from pathlib import Path
import sys
import tempfile
import unittest
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from utils import chat_support as CHAT # noqa: E402
class _FakeResponse:
def __init__(self, status, payload):
self.status = status
self.payload = payload
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _tb):
return False
async def json(self, **_kwargs):
return self.payload
class _FakeSession:
def __init__(self, response):
self.response = response
self.calls = []
def post(self, url, **kwargs):
self.calls.append((url, kwargs))
return self.response
class PromptOptimizerTests(unittest.TestCase):
def test_payload_uses_fixed_model_high_reasoning_and_ordered_images(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
Image.new("RGB", (80, 120), "red").save(root / "first.png")
Image.new("RGB", (120, 80), "blue").save(root / "second.png")
payload = CHAT.build_prompt_optimization_payload(
"保留人物身份,改成雨夜街景",
[
{"name": "first.png", "subfolder": "", "type": "input"},
{"name": "second.png", "subfolder": "", "type": "input"},
],
directory,
)
self.assertEqual(payload["model"], "gpt-5.6-sol")
self.assertEqual(payload["reasoning_effort"], "high")
self.assertFalse(payload["stream"])
self.assertIn("视觉元素绑定优先", payload["messages"][0]["content"])
self.assertIn("必须保持不变", payload["messages"][0]["content"])
content = payload["messages"][1]["content"]
self.assertEqual(content[1]["text"], "参考图 1(上传顺序第 1 张)")
self.assertEqual(content[3]["text"], "参考图 2(上传顺序第 2 张)")
image_items = [item for item in content if item["type"] == "image_url"]
self.assertEqual(len(image_items), 2)
for item in image_items:
url = item["image_url"]["url"]
self.assertTrue(url.startswith("data:image/jpeg;base64,"))
with Image.open(BytesIO(base64.b64decode(url.split(",", 1)[1]))) as image:
self.assertEqual(image.format, "JPEG")
body_size = len(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
self.assertLessEqual(body_size, CHAT.PROMPT_OPTIMIZER_BODY_LIMIT_BYTES)
def test_rejects_reference_path_outside_input_directory(self):
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(ValueError, "路径不安全"):
CHAT.build_prompt_optimization_payload(
"测试提示词",
[{"name": "outside.png", "subfolder": "..", "type": "input"}],
directory,
)
def test_non_streaming_request_extracts_only_the_final_prompt(self):
session = _FakeSession(_FakeResponse(200, {
"choices": [{"message": {"content": "```text\n最终视觉提示词\n```"}}],
}))
with tempfile.TemporaryDirectory() as directory:
result = asyncio.run(CHAT.optimize_image_prompt(
session,
"https://example.invalid",
"offline-test-token",
"原始提示词",
[],
directory,
))
self.assertEqual(result, "最终视觉提示词")
self.assertEqual(len(session.calls), 1)
url, kwargs = session.calls[0]
self.assertEqual(url, "https://example.invalid/v1/chat/completions")
self.assertEqual(kwargs["json"]["model"], "gpt-5.6-sol")
self.assertEqual(kwargs["json"]["reasoning_effort"], "high")
self.assertFalse(kwargs["json"]["stream"])
def test_video_payload_uses_dedicated_preset_and_ordered_frame_roles(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
Image.new("RGB", (96, 128), "green").save(root / "start.png")
Image.new("RGB", (128, 96), "yellow").save(root / "end.png")
payload = CHAT.build_video_prompt_writing_payload(
"人物从门口走到窗前",
[
{"name": "start.png", "subfolder": "", "type": "input"},
{"name": "end.png", "subfolder": "", "type": "input"},
],
directory,
{
"generation_mode": "first_last_frame",
"duration": "8",
"aspect_ratio": "16:9",
"generate_audio": True,
"reference_video_count": 1,
"reference_audio_count": 2,
},
)
self.assertEqual(payload["model"], "gpt-5.6-sol")
self.assertEqual(payload["reasoning_effort"], "high")
self.assertFalse(payload["stream"])
system_prompt = payload["messages"][0]["content"]
self.assertIn("连续的时间过程", system_prompt)
self.assertIn("首尾帧生视频", system_prompt)
content = payload["messages"][1]["content"]
self.assertIn("生成模式:首尾帧生视频", content[0]["text"])
self.assertIn("目标时长:8", content[0]["text"])
self.assertIn("生成音频:开启", content[0]["text"])
self.assertIn("参考视频 1 个、参考音频 2 个", content[0]["text"])
self.assertEqual(content[1]["text"], "首帧(第 1 张分析图)")
self.assertEqual(content[3]["text"], "尾帧(第 2 张分析图)")
self.assertEqual(len([item for item in content if item["type"] == "image_url"]), 2)
def test_video_request_uses_non_streaming_completion(self):
session = _FakeSession(_FakeResponse(200, {
"choices": [{"message": {"content": "连续镜头中的最终视频提示词"}}],
}))
with tempfile.TemporaryDirectory() as directory:
result = asyncio.run(CHAT.write_video_prompt(
session,
"https://example.invalid",
"offline-test-token",
"原始视频创意",
[],
directory,
{"generation_mode": "text", "duration": "5"},
))
self.assertEqual(result, "连续镜头中的最终视频提示词")
self.assertEqual(session.calls[0][0], "https://example.invalid/v1/chat/completions")
request = session.calls[0][1]["json"]
self.assertIn("AI 视频生成提示词编写助手", request["messages"][0]["content"])
self.assertFalse(request["stream"])
if __name__ == "__main__":
unittest.main(verbosity=2)
+80
View File
@@ -0,0 +1,80 @@
from pathlib import Path
import sys
import unittest
import torch
ROOT = Path(__file__).resolve().parents[1]
CUSTOM_NODES_ROOT = ROOT.parent
COMFY_ROOT = CUSTOM_NODES_ROOT.parent
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(CUSTOM_NODES_ROOT))
from comfyui_o1key.utils.reference_color_correction import ( # noqa: E402
_lab_to_srgb_unclamped,
_srgb_to_lab,
correct_tensor_with_reference,
)
class ReferenceColorCorrectionTests(unittest.TestCase):
@staticmethod
def _reference() -> torch.Tensor:
height, width = 96, 128
y = torch.linspace(0.12, 0.88, height).view(height, 1)
x = torch.linspace(0.08, 0.92, width).view(1, width)
red = (0.25 + 0.55 * x).expand(height, width)
green = (0.20 + 0.52 * y).expand(height, width)
blue = 0.18 + 0.32 * (1.0 - x) + 0.22 * y
return torch.stack((red, green, blue), dim=-1).clamp(0.0, 1.0).unsqueeze(0)
def test_red_yellow_cast_moves_back_toward_reference_without_geometry_change(self):
reference = self._reference()
lightness, a, b = _srgb_to_lab(reference)
cast = _lab_to_srgb_unclamped(lightness, a + 9.0, b + 4.0).clamp(0.0, 1.0)
corrected, reports = correct_tensor_with_reference(cast, reference)
corrected_l, corrected_a, corrected_b = _srgb_to_lab(corrected)
before_error = torch.hypot((a + 9.0) - a, (b + 4.0) - b).mean()
after_error = torch.hypot(corrected_a - a, corrected_b - b).mean()
self.assertEqual(tuple(corrected.shape), tuple(cast.shape))
self.assertLess(float(after_error), float(before_error) * 0.65)
self.assertLess(float((corrected_l - lightness).abs().mean()), 0.15)
self.assertTrue(reports[0].applied)
self.assertLess(reports[0].shift_a, -4.0)
self.assertLess(reports[0].shift_b, -1.0)
def test_matching_reference_is_an_exact_noop(self):
reference = self._reference()
corrected, reports = correct_tensor_with_reference(reference, reference)
self.assertTrue(torch.equal(corrected, reference))
self.assertFalse(reports[0].applied)
def test_alpha_and_batch_shape_are_preserved(self):
reference = self._reference()
alpha = torch.linspace(0.0, 1.0, reference.shape[2]).view(1, 1, -1, 1)
alpha = alpha.expand(2, reference.shape[1], reference.shape[2], 1)
batch = torch.cat((reference.repeat(2, 1, 1, 1), alpha), dim=-1)
corrected, reports = correct_tensor_with_reference(batch, reference)
self.assertEqual(tuple(corrected.shape), tuple(batch.shape))
self.assertTrue(torch.equal(corrected[..., 3:], alpha))
self.assertEqual(len(reports), 2)
def test_low_confidence_unrelated_aspect_and_luminance_is_skipped(self):
target = torch.full((1, 128, 16, 3), 0.08, dtype=torch.float32)
reference = torch.full((1, 16, 128, 3), 0.92, dtype=torch.float32)
corrected, reports = correct_tensor_with_reference(target, reference)
self.assertTrue(torch.equal(corrected, target))
self.assertFalse(reports[0].applied)
self.assertLess(reports[0].confidence, 0.18)
if __name__ == "__main__":
unittest.main(verbosity=2)
+143
View File
@@ -0,0 +1,143 @@
"""Offline tests for shared Seedance asset creation and ID reuse."""
from __future__ import annotations
import json
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, Mock
PACKAGE_DIR = Path(__file__).resolve().parents[1]
COMFY_ROOT = PACKAGE_DIR.parent.parent
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(PACKAGE_DIR.parent))
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(PACKAGE_DIR)]
sys.modules["comfyui_o1key"] = package
for child in ("utils", "clients"):
module = types.ModuleType(f"comfyui_o1key.{child}")
module.__path__ = [str(PACKAGE_DIR / child)]
sys.modules[f"comfyui_o1key.{child}"] = module
from comfyui_o1key.utils.seedance_assets import ( # noqa: E402
SeedanceAssetService,
seedance_asset_fingerprint,
seedance_asset_request_type,
)
class SeedanceAssetServiceTests(unittest.IsolatedAsyncioTestCase):
def test_video_routes_map_to_the_create_node_request_types(self):
self.assertEqual(seedance_asset_request_type("overseas_hc"), "hc")
self.assertEqual(seedance_asset_request_type("domestic"), "doubao")
with self.assertRaisesRegex(ValueError, "素材线路"):
seedance_asset_request_type("unknown")
async def test_active_content_cache_skips_upload_and_creation(self):
with tempfile.TemporaryDirectory() as temp_dir:
media_path = Path(temp_dir, "reference.bin")
media_path.write_bytes(b"same-content")
cache_path = Path(temp_dir, "seedance_asset_cache.json")
fingerprint = seedance_asset_fingerprint(str(media_path), "doubao", "video")
first_client = type("FirstClient", (), {})()
first_client.create_hc_asset_and_wait = AsyncMock(return_value={
"Id": "asset-safe-id",
"Status": "Active",
})
first_client.get_hc_asset = AsyncMock()
first_upload = AsyncMock(return_value="https://upload.example.invalid/reference-video")
first = SeedanceAssetService(client=first_client, cache_path=str(cache_path))
created = await first.create_from_url(
name="reference",
asset_url_factory=first_upload,
asset_type="video",
request_type="doubao",
fingerprint=fingerprint,
)
self.assertFalse(created["_reused"])
first_upload.assert_awaited_once()
first_client.create_hc_asset_and_wait.assert_awaited_once()
second_client = type("SecondClient", (), {})()
second_client.get_hc_asset = AsyncMock(return_value={
"Id": "asset-safe-id",
"Status": "Active",
})
second_client.create_hc_asset_and_wait = AsyncMock()
second_upload = AsyncMock(return_value="https://upload.example.invalid/should-not-upload")
second = SeedanceAssetService(client=second_client, cache_path=str(cache_path))
reused = await second.create_from_url(
name="reference",
asset_url_factory=second_upload,
asset_type="video",
request_type="doubao",
fingerprint=fingerprint,
)
self.assertTrue(reused["_reused"])
self.assertEqual(reused["Id"], "asset-safe-id")
second_upload.assert_not_awaited()
second_client.create_hc_asset_and_wait.assert_not_awaited()
cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
serialized = json.dumps(cache_payload, ensure_ascii=False)
self.assertIn("asset-safe-id", serialized)
self.assertNotIn("upload.example.invalid", serialized)
self.assertNotIn(str(media_path), serialized)
stale_client = type("StaleClient", (), {})()
stale_client.get_hc_asset = AsyncMock(
side_effect=RuntimeError("查询 Doubao 素材失败 (404): missing")
)
stale_client.create_hc_asset_and_wait = AsyncMock(return_value={
"Id": "asset-recreated-id",
"Status": "Active",
})
stale_upload = AsyncMock(return_value="https://upload.example.invalid/recreated")
stale = SeedanceAssetService(client=stale_client, cache_path=str(cache_path))
recreated = await stale.create_from_url(
name="reference",
asset_url_factory=stale_upload,
asset_type="video",
request_type="doubao",
fingerprint=fingerprint,
)
self.assertFalse(recreated["_reused"])
self.assertEqual(recreated["Id"], "asset-recreated-id")
stale_upload.assert_awaited_once()
stale_client.create_hc_asset_and_wait.assert_awaited_once()
async def test_cache_write_failure_does_not_discard_created_asset(self):
with tempfile.TemporaryDirectory() as temp_dir:
media_path = Path(temp_dir, "reference.bin")
media_path.write_bytes(b"content")
client = type("Client", (), {})()
client.create_hc_asset_and_wait = AsyncMock(return_value={
"Id": "asset-created-id",
"Status": "Active",
})
client.get_hc_asset = AsyncMock()
service = SeedanceAssetService(
client=client,
cache_path=str(Path(temp_dir, "cache.json")),
)
service.cache.put = Mock(side_effect=OSError("read only"))
result = await service.create_from_url(
name="reference",
asset_url="https://upload.example.invalid/reference",
asset_type="image",
request_type="hc",
fingerprint=seedance_asset_fingerprint(str(media_path), "hc", "image"),
)
self.assertEqual(result["Id"], "asset-created-id")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
"""统一 Seedance HC 素材创建/查询接口测试。"""
import json
import sys
import types
import unittest
from pathlib import Path
PACKAGE_DIR = Path(__file__).resolve().parents[1]
COMFY_ROOT = PACKAGE_DIR.parent.parent
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(PACKAGE_DIR.parent))
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(PACKAGE_DIR)]
sys.modules["comfyui_o1key"] = package
for child in ("utils", "clients"):
module = types.ModuleType(f"comfyui_o1key.{child}")
module.__path__ = [str(PACKAGE_DIR / child)]
sys.modules[f"comfyui_o1key.{child}"] = module
from comfyui_o1key.clients.seedance_element_client import SeedanceElementClient
class _FakeResponse:
def __init__(self, payload, status=200):
self.status = status
self._text = json.dumps(payload)
async def text(self):
return self._text
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
class _FakeSession:
def __init__(self, post_payload=None, get_payload=None):
self.post_payload = post_payload
self.get_payload = get_payload
self.post_call = None
self.get_call = None
def post(self, url, **kwargs):
self.post_call = (url, kwargs)
return _FakeResponse(self.post_payload)
def get(self, url, **kwargs):
self.get_call = (url, kwargs)
return _FakeResponse(self.get_payload)
class SeedanceHCAssetClientTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.client = SeedanceElementClient(
api_key="test-key",
base_url="https://cf-api.o1key.com",
)
async def test_create_uses_unified_seedance_asset_endpoint(self):
session = _FakeSession(post_payload={
"success": True,
"data": {"Id": "asset-test", "base_resp": {"status_code": 0}},
})
result = await self.client.create_hc_asset(
name="参考视频1",
asset_url="https://upload.o1key.com/uploads/reference.mp4",
asset_type="Video",
session=session,
)
url, kwargs = session.post_call
self.assertEqual(url, "https://cf-api.o1key.com/v1/seedance/assets")
self.assertEqual(kwargs["json"], {
"type": "hc",
"url": "https://upload.o1key.com/uploads/reference.mp4",
"name": "参考视频1",
"asset_type": "video",
})
self.assertEqual(result["Id"], "asset-test")
async def test_query_uses_hc_type_query_parameter(self):
session = _FakeSession(get_payload={
"success": True,
"data": {"Id": "asset/a b", "Status": "Active"},
})
result = await self.client.get_hc_asset("asset/a b", session=session)
url, kwargs = session.get_call
self.assertEqual(
url,
"https://cf-api.o1key.com/v1/seedance/assets/asset%2Fa%20b",
)
self.assertEqual(kwargs["params"], {"type": "hc"})
self.assertEqual(result["Status"], "Active")
async def test_create_omits_empty_optional_name(self):
session = _FakeSession(post_payload={
"success": True,
"data": {"Id": "asset-unnamed"},
})
await self.client.create_hc_asset(
name="",
asset_url="https://upload.o1key.com/uploads/reference.png",
asset_type="image",
session=session,
)
_, kwargs = session.post_call
self.assertNotIn("name", kwargs["json"])
async def test_doubao_type_is_used_for_create_and_query(self):
session = _FakeSession(
post_payload={
"success": True,
"data": {"Id": "asset-doubao"},
},
get_payload={
"success": True,
"data": {"Id": "asset-doubao", "Status": "Active"},
},
)
await self.client.create_hc_asset(
name="豆包真人素材",
asset_url="https://upload.o1key.com/uploads/reference.png",
asset_type="image",
session=session,
request_type="doubao",
)
_, create_kwargs = session.post_call
self.assertEqual(create_kwargs["json"]["type"], "doubao")
await self.client.get_hc_asset(
"asset-doubao",
session=session,
request_type="doubao",
)
_, query_kwargs = session.get_call
self.assertEqual(query_kwargs["params"], {"type": "doubao"})
if __name__ == "__main__":
unittest.main()
+294
View File
@@ -0,0 +1,294 @@
"""Seedance 真人素材与多模态节点参数同步测试。"""
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
PACKAGE_DIR = Path(__file__).resolve().parents[1]
COMFY_ROOT = PACKAGE_DIR.parent.parent
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(PACKAGE_DIR.parent))
# Load only the focused modules. Importing the plugin-wide registry would make
# this offline schema test depend on the portable build's CUDA availability.
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(PACKAGE_DIR)]
sys.modules["comfyui_o1key"] = package
for child in ("nodes", "utils", "clients"):
module = types.ModuleType(f"comfyui_o1key.{child}")
module.__path__ = [str(PACKAGE_DIR / child)]
sys.modules[f"comfyui_o1key.{child}"] = module
from comfyui_o1key.nodes import seedance_element
from comfyui_o1key.nodes.seedance_element import SeedanceElementCreate
from comfyui_o1key.nodes import seedance_video
from comfyui_o1key.nodes.seedance_video import (
SeedanceMultiModal,
_check_fast_resolution,
_is_new_format_model,
_resolve_model_matrix,
)
from comfy_api.latest import InputImpl, io
from comfy_api.latest._io import build_nested_inputs, get_finalized_class_inputs
class SeedanceParameterSyncTests(unittest.TestCase):
def test_element_schema_uses_hc_without_image_url(self):
inputs = SeedanceElementCreate.INPUT_TYPES()
self.assertEqual(inputs["required"]["素材名称"][1]["default"], "")
self.assertEqual(
inputs["required"]["请求模式"],
(["HC", "Doubao"], {"default": "HC"}),
)
self.assertNotIn("图片链接", inputs["optional"])
self.assertEqual(
set(inputs["optional"]),
{"照片", "视频", "音频", "素材描述"},
)
def test_multimodal_model_and_route_match_latest_options(self):
inputs = {
input_def.id: input_def
for input_def in SeedanceMultiModal.GET_SCHEMA().inputs
}
self.assertEqual(inputs["主模型"].options, [
"seedance 2.0",
"seedance 2.0 fast",
"seedance 2.0 mini",
"seedance 2.5",
])
self.assertEqual(inputs["模型线路"].options, ["海外", "国内"])
self.assertEqual(inputs["模型线路"].default, "国内")
self.assertEqual(inputs["分辨率"].options, ["720p", "1080p", "4k", "480p"])
self.assertEqual(
inputs["宽高比"].options,
["智能", "16:9", "9:16", "4:3", "3:4", "1:1", "21:9"],
)
self.assertIn("30秒", inputs["时长"].options)
self.assertEqual(inputs["seed"].max, 0xffffffffffffffff)
self.assertEqual(
_resolve_model_matrix("seedance 2.5", "海外HC"),
"dreamina-seedance-2-5-hc",
)
domestic_models = {
"seedance 2.0": "doubao-seedance-2-0-260128-max",
"seedance 2.0 fast": "doubao-seedance-2-0-fast-260128-max",
"seedance 2.0 mini": "doubao-seedance-2-0-mini-260615-max",
"seedance 2.5": "doubao-seedance-2-5-260628-max",
}
for base_model, expected_model in domestic_models.items():
with self.subTest(base_model=base_model):
resolved_model = _resolve_model_matrix(base_model, "国内")
self.assertEqual(resolved_model, expected_model)
self.assertTrue(_is_new_format_model(resolved_model))
self.assertTrue(_is_new_format_model("dreamina-seedance-2-0-hc"))
self.assertTrue(_is_new_format_model("dreamina-seedance-2-5-hc"))
_check_fast_resolution("dreamina-seedance-2-5-hc", "1080p")
_check_fast_resolution("dreamina-seedance-2-5-hc", "4k")
with self.assertRaisesRegex(ValueError, "不支持 4k"):
_check_fast_resolution("dreamina-seedance-2-0-fast-hc", "4k")
for limited_model in (
"doubao-seedance-2-0-fast-260128-max",
"doubao-seedance-2-0-mini-260615-max",
):
with self.subTest(limited_model=limited_model):
with self.assertRaisesRegex(ValueError, "不支持 4k"):
_check_fast_resolution(limited_model, "4k")
_check_fast_resolution("doubao-seedance-2-5-260628-max", "4k")
def test_multimodal_uses_v3_autogrow_media_inputs(self):
self.assertTrue(issubclass(SeedanceMultiModal, io.ComfyNode))
schema = SeedanceMultiModal.GET_SCHEMA()
inputs = {input_def.id: input_def for input_def in schema.inputs}
expected = {
"参考图片": (30, [f"参考图片{i}" for i in range(1, 31)]),
"参考视频": (10, [f"参考视频{i}" for i in range(1, 11)]),
"参考音频": (10, [f"参考音频{i}" for i in range(1, 11)]),
}
for group_name, (maximum, names) in expected.items():
input_def = inputs[group_name]
self.assertEqual(input_def.io_type, "COMFY_AUTOGROW_V3")
self.assertTrue(input_def.optional)
self.assertEqual(input_def.template.min, 0)
self.assertEqual(len(input_def.template.names), maximum)
self.assertEqual(input_def.template.names, names)
self.assertEqual(
[input_def.id for input_def in schema.inputs[-50:]],
[
*[f"图片素材ID{i}" for i in range(1, 10)],
*[f"视频素材ID{i}" for i in range(1, 4)],
*[f"音频素材ID{i}" for i in range(1, 4)],
*[f"图片素材ID{i}" for i in range(10, 31)],
*[f"视频素材ID{i}" for i in range(4, 11)],
*[f"音频素材ID{i}" for i in range(4, 11)],
],
)
self.assertEqual(SeedanceMultiModal.FUNCTION, "EXECUTE_NORMALIZED_ASYNC")
self.assertEqual(SeedanceMultiModal.RETURN_TYPES, ["VIDEO", "IMAGE"])
self.assertEqual(SeedanceMultiModal.RETURN_NAMES, ["视频", "末帧图片"])
def test_multimodal_autogrow_values_keep_order_and_accept_legacy_slots(self):
first = object()
third = object()
self.assertEqual(
SeedanceMultiModal._autogrow_values(
{"参考图片": {"参考图片1": first, "参考图片2": None, "参考图片3": third}},
"参考图片",
"参考图片",
30,
),
[first, third],
)
self.assertEqual(
SeedanceMultiModal._autogrow_values(
{"参考视频1": [first], "参考视频2": [], "参考视频3": [third]},
"参考视频",
"参考视频",
10,
),
[first, third],
)
def test_comfyui_builds_multimodal_autogrow_groups(self):
live_inputs = {
"提示词": "prompt",
"参考图片.参考图片1": "image-1",
"参考视频.参考视频1": "video-1",
"参考音频.参考音频1": "audio-1",
}
_, _, v3_data = get_finalized_class_inputs(
SeedanceMultiModal.INPUT_TYPES(),
live_inputs,
)
nested = build_nested_inputs(live_inputs, v3_data)
self.assertEqual(nested["参考图片"], {"参考图片1": "image-1"})
self.assertEqual(nested["参考视频"], {"参考视频1": "video-1"})
self.assertEqual(nested["参考音频"], {"参考音频1": "audio-1"})
class SeedanceParameterValidationTests(unittest.IsolatedAsyncioTestCase):
async def test_element_accepts_new_and_legacy_image_names(self):
fake_image = type("FakeImage", (), {"mode": "RGB"})()
for input_name in ("照片", "真人照片"):
with self.subTest(input_name=input_name):
fake_client = type("FakeSeedanceElementClient", (), {})()
fake_client.create_hc_asset_and_wait = AsyncMock(return_value={
"Id": "asset-doubao",
"_create_response": {"success": True},
})
with (
patch.object(seedance_element, "tensor_to_pil", return_value=[fake_image]),
patch.object(
seedance_element,
"upload_image",
new=AsyncMock(return_value="https://upload.example.com/person.png"),
),
patch.object(
seedance_element,
"get_base_url_by_route",
return_value="https://api.example.com",
),
patch.object(
seedance_element,
"SeedanceElementClient",
return_value=fake_client,
),
patch("builtins.print") as print_mock,
):
result = await SeedanceElementCreate().create_element(**{
"素材名称": "豆包真人素材",
"请求模式": "Doubao",
input_name: object(),
})
self.assertEqual(result[1], "asset-doubao")
fake_client.create_hc_asset_and_wait.assert_awaited_once_with(
name="豆包真人素材",
asset_url="https://upload.example.com/person.png",
asset_type="Image",
request_type="doubao",
)
logged_text = "\n".join(
" ".join(str(part) for part in call.args)
for call in print_mock.call_args_list
)
self.assertNotIn("https://upload.example.com/person.png", logged_text)
async def test_domestic_route_submits_exact_model_ids(self):
domestic_models = {
"seedance 2.0": "doubao-seedance-2-0-260128-max",
"seedance 2.0 fast": "doubao-seedance-2-0-fast-260128-max",
"seedance 2.0 mini": "doubao-seedance-2-0-mini-260615-max",
"seedance 2.5": "doubao-seedance-2-5-260628-max",
}
for base_model, expected_model in domestic_models.items():
with self.subTest(base_model=base_model):
fake_client = type("FakeSeedanceClient", (), {})()
fake_client.base_url = ""
fake_client.generate_async = AsyncMock(
return_value=("result.mp4", None)
)
with (
patch.object(seedance_video, "SeedanceClient", return_value=fake_client),
patch.object(seedance_video, "_show_balance"),
patch.object(
seedance_video.tempfile,
"mkstemp",
return_value=(0, "unused.mp4"),
),
patch.object(InputImpl, "VideoFromFile", return_value=object()),
patch.object(
seedance_video,
"get_base_url_by_route",
return_value="https://api.example.com",
),
):
await SeedanceMultiModal.generate(**{
"提示词": "test prompt",
"主模型": base_model,
"模型线路": "国内",
"分辨率": "720p",
"宽高比": "16:9",
"时长": "5秒",
"生成音频": "关闭",
"联网搜索": "关闭",
"返回末帧图片": "关闭",
"seed": 0,
})
request = fake_client.generate_async.await_args.kwargs
self.assertEqual(request["body"]["model"], expected_model)
self.assertTrue(request["use_new_format"])
async def test_multimodal_combines_direct_media_and_asset_ids_for_limits(self):
images = {f"参考图片{i}": object() for i in range(1, 31)}
for id_name in ("图片素材ID1", "真人素材ID1"):
with self.subTest(id_name=id_name):
with self.assertRaisesRegex(ValueError, "最多支持 30 个参考图片"):
await SeedanceMultiModal.generate(**{
"提示词": "test prompt",
"主模型": "seedance 2.5",
"模型线路": "海外HC",
"分辨率": "4k",
"宽高比": "16:9",
"时长": "30秒",
"生成音频": "关闭",
"联网搜索": "关闭",
"返回末帧图片": "关闭",
"seed": 0,
"参考图片": images,
id_name: "asset-image-1",
})
if __name__ == "__main__":
unittest.main()
+307
View File
@@ -0,0 +1,307 @@
"""Offline tests for the Seedream image transport."""
import sys
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
CUSTOM_NODES_ROOT = ROOT.parent
COMFY_ROOT = CUSTOM_NODES_ROOT.parent
sys.path.insert(0, str(COMFY_ROOT))
sys.path.insert(0, str(CUSTOM_NODES_ROOT))
from comfyui_o1key.clients import seedream_image_client as CLIENT # noqa: E402
class SeedreamBodyTests(unittest.TestCase):
def test_model_routes_resolve_to_the_documented_identifier(self):
for route in ("畅速", "直连", "专线"):
self.assertEqual(
CLIENT.resolve_seedream_model("Seedream 5.0 Pro", route),
CLIENT.SEEDREAM_API_MODEL_ID,
)
def test_body_keeps_reference_order_and_fixed_watermark(self):
body = CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt=" 一只宇航猫 ",
size="2368x1776",
output_format="PNG",
image_urls=[
"https://example.invalid/one.png",
"https://example.invalid/two.jpg",
],
)
self.assertEqual(body, {
"model": CLIENT.SEEDREAM_API_MODEL_ID,
"prompt": "一只宇航猫",
"n": 1,
"size": "2368x1776",
"output_format": "png",
"watermark": False,
"images": [
"https://example.invalid/one.png",
"https://example.invalid/two.jpg",
],
})
def test_body_rejects_webp_and_non_https_references(self):
with self.assertRaisesRegex(ValueError, "png 或 jpeg"):
CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt="test",
size="1024x1024",
output_format="webp",
)
with self.assertRaisesRegex(ValueError, "HTTPS URL"):
CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt="test",
size="1024x1024",
output_format="jpeg",
image_urls=["http://example.invalid/reference.png"],
)
def test_body_rejects_sizes_outside_the_documented_matrix(self):
with self.assertRaisesRegex(ValueError, "图片尺寸无效"):
CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt="test",
size="1920x1080",
output_format="jpeg",
)
def test_body_omits_size_for_smart_resolution(self):
body = CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt="test",
size=None,
output_format="jpeg",
)
self.assertNotIn("size", body)
def test_reference_dimensions_match_current_volcengine_limits(self):
CLIENT.validate_seedream_reference_dimensions(15, 15)
CLIENT.validate_seedream_reference_dimensions(240, 15)
CLIENT.validate_seedream_reference_dimensions(15, 240)
with self.assertRaisesRegex(ValueError, "宽和高都必须大于 14px"):
CLIENT.validate_seedream_reference_dimensions(14, 240)
with self.assertRaisesRegex(ValueError, "宽高比必须在 1:1616:1"):
CLIENT.validate_seedream_reference_dimensions(241, 15)
with self.assertRaisesRegex(ValueError, "总像素不能超过"):
CLIENT.validate_seedream_reference_dimensions(6001, 6000)
def test_layer_reference_uses_its_documented_total_pixel_floor(self):
CLIENT.validate_seedream_reference_dimensions(
512,
512,
layer_decomposition=True,
)
with self.assertRaisesRegex(ValueError, "总像素必须在 512×512"):
CLIENT.validate_seedream_reference_dimensions(
511,
512,
layer_decomposition=True,
)
def test_reference_file_size_is_checked_against_exact_upload_payload(self):
class OversizedPayload:
def __len__(self):
return CLIENT.SEEDREAM_REFERENCE_MAX_BYTES + 1
source = Image.new("RGB", (15, 15), "red")
with (
patch.object(
CLIENT,
"image_to_upload_payload",
return_value=(OversizedPayload(), ".png", "image/png"),
),
self.assertRaisesRegex(ValueError, "文件不能超过 30MB"),
):
CLIENT.validate_seedream_reference_image(source)
def test_layer_decomposition_allows_blank_prompt_and_requires_one_png_reference(self):
body = CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt="",
size="1.5K",
output_format="png",
image_urls=["https://example.invalid/poster.png"],
layer_decomposition=True,
)
self.assertNotIn("prompt", body)
self.assertEqual(body["size"], "1.5K")
self.assertIs(body["layer_decomposition"], True)
with self.assertRaisesRegex(ValueError, "必须且只能提供1张"):
CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt="",
size="auto",
output_format="png",
layer_decomposition=True,
)
with self.assertRaisesRegex(ValueError, "仅支持 png"):
CLIENT.build_seedream_submit_body(
model=CLIENT.SEEDREAM_API_MODEL_ID,
prompt="",
size="2K",
output_format="jpeg",
image_urls=["https://example.invalid/poster.png"],
layer_decomposition=True,
)
def test_layer_metadata_is_sanitized_without_result_urls(self):
metadata = CLIENT.extract_seedream_layer_metadata({
"data": {
"images": [{
"url": "https://signed.invalid/secret.png",
"z_index": "1",
"size": "1408x1780",
"output_format": "png",
"bounding_box": {
"absolute": [320, 180, 1728, 1960],
"normalized": [156, 88, 844, 957],
},
"name": "主体",
"description": "画面中的主要人物",
}],
},
})
self.assertEqual(metadata[0]["z_index"], 1)
self.assertEqual(metadata[0]["bounding_box"]["absolute"], [320, 180, 1728, 1960])
self.assertNotIn("url", metadata[0])
class SeedreamLifecycleTests(unittest.IsolatedAsyncioTestCase):
async def test_upload_submit_poll_and_parse_use_the_shared_async_lifecycle(self):
source = Image.new("RGB", (15, 15), "red")
result = Image.new("RGB", (2, 2), "blue")
completed = {
"task_id": "task-seedream",
"status": "SUCCESS",
"data": {"images": [{"url": "https://example.invalid/result.png"}]},
}
metrics = {
"download_bytes": 123,
"download_seconds": 0.2,
"download_wall_seconds": 0.1,
"inline_images": 0,
}
with (
patch.object(
CLIENT,
"upload_images_to_temp_urls",
new=AsyncMock(return_value=["https://example.invalid/reference.png"]),
) as upload,
patch.object(
CLIENT,
"submit_async_image_task",
new=AsyncMock(return_value="task-seedream"),
) as submit,
patch.object(
CLIENT,
"poll_async_image_task",
new=AsyncMock(return_value=completed),
) as poll,
patch.object(
CLIENT,
"parse_completed_async_image_task",
new=AsyncMock(return_value=(completed, ([result], metrics))),
) as parse,
):
client = CLIENT.SeedreamImageClient(
base_url="https://cf-api.o1key.com/",
api_key="secret",
)
images, timing = await client.generate_async(
session=object(),
prompt="test",
model=CLIENT.SEEDREAM_API_MODEL_ID,
size="1024x1024",
output_format="jpeg",
images=[source],
)
self.assertEqual(images, [result])
upload.assert_awaited_once()
self.assertEqual(upload.await_args.kwargs["base_url"], "https://cf-api.o1key.com")
body = submit.await_args.args[3]
self.assertEqual(body["images"], ["https://example.invalid/reference.png"])
self.assertEqual(body["output_format"], "jpeg")
self.assertIs(body["watermark"], False)
poll.assert_awaited_once()
parse.assert_awaited_once()
self.assertEqual(timing["task_id"], "task-seedream")
self.assertEqual(timing["download_bytes"], 123)
async def test_layer_results_are_sorted_by_z_index_and_keep_safe_metadata(self):
top = Image.new("RGBA", (2, 2), (0, 0, 255, 64))
base = Image.new("RGB", (2, 2), "white")
completed = {
"status": "SUCCESS",
"data": {"images": [
{"url": "https://signed.invalid/top.png", "z_index": 2, "name": "文字"},
{"url": "https://signed.invalid/base.png", "z_index": 0, "name": "底图"},
]},
}
with (
patch.object(CLIENT, "upload_images_to_temp_urls", new=AsyncMock(
return_value=["https://example.invalid/reference.png"]
)),
patch.object(CLIENT, "submit_async_image_task", new=AsyncMock(return_value="task-layer")),
patch.object(CLIENT, "poll_async_image_task", new=AsyncMock(return_value=completed)),
patch.object(CLIENT, "parse_completed_async_image_task", new=AsyncMock(
return_value=(completed, ([top, base], {
"download_bytes": 1,
"download_seconds": 0.0,
"download_wall_seconds": 0.0,
"inline_images": 0,
}))
)),
):
images, timing = await CLIENT.SeedreamImageClient(
base_url="https://cf-api.o1key.com",
api_key="secret",
).generate_async(
session=object(),
prompt="",
model=CLIENT.SEEDREAM_API_MODEL_ID,
size="auto",
output_format="png",
images=[Image.new("RGB", (512, 512), "red")],
layer_decomposition=True,
)
self.assertEqual([item["z_index"] for item in timing["result_metadata"]], [0, 2])
self.assertEqual(getattr(images[0], "_o1key_seedream_layer")["name"], "底图")
async def test_invalid_reference_is_rejected_before_upload(self):
upload = AsyncMock(return_value=["https://example.invalid/reference.png"])
with patch.object(CLIENT, "upload_images_to_temp_urls", new=upload):
with self.assertRaisesRegex(ValueError, "宽和高都必须大于 14px"):
await CLIENT.SeedreamImageClient(
base_url="https://cf.invalid",
api_key="secret",
).generate_async(
session=object(),
prompt="test",
model=CLIENT.SEEDREAM_API_MODEL_ID,
size="1024x1024",
output_format="jpeg",
images=[Image.new("RGB", (14, 15), "red")],
)
upload.assert_not_awaited()
if __name__ == "__main__":
unittest.main(verbosity=2)
+154
View File
@@ -0,0 +1,154 @@
import importlib.util
import json
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import patch
import aiohttp
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
def _load_uploader():
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(ROOT)]
utils_package = types.ModuleType("comfyui_o1key.utils")
utils_package.__path__ = [str(ROOT / "utils")]
sys.modules[package.__name__] = package
sys.modules[utils_package.__name__] = utils_package
config = types.ModuleType("comfyui_o1key.utils.config")
config.get_api_key_or_raise = lambda *_args, **_kwargs: "secret"
config.get_api_base_url = lambda: "https://api.o1key.cn"
sys.modules[config.__name__] = config
video_task = types.ModuleType("comfyui_o1key.utils.video_task")
video_task.check_interrupt = lambda: None
async def run_with_interrupt(coro):
return await coro
video_task.run_with_interrupt = run_with_interrupt
sys.modules[video_task.__name__] = video_task
spec = importlib.util.spec_from_file_location(
"comfyui_o1key.utils.r2_uploader",
ROOT / "utils" / "r2_uploader.py",
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
UPLOADER = _load_uploader()
class _FakeResponse:
def __init__(self, status, payload, headers=None):
self.status = status
self.payload = payload
self.headers = headers or {}
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _tb):
return False
async def text(self):
return json.dumps(self.payload)
class _FakeSession:
def __init__(self, factory):
self.factory = factory
async def __aenter__(self):
return self
async def __aexit__(self, _exc_type, _exc, _tb):
return False
def post(self, url, **kwargs):
self.factory.calls.append((url, kwargs))
return self.factory.responses.pop(0)
class _SessionFactory:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def __call__(self, **_kwargs):
return _FakeSession(self)
class TempMediaUploadTests(unittest.IsolatedAsyncioTestCase):
async def test_upload_uses_new_multipart_endpoint(self):
expected_url = "https://cf-api.o1key.com/tmp/input/reference.png"
factory = _SessionFactory([
_FakeResponse(200, {
"url": expected_url,
"filename": "reference.png",
"content_type": "image/png",
"size": 12,
"expires_at": 1_787_495_062,
})
])
with (
patch.object(UPLOADER.aiohttp, "ClientSession", factory),
patch.object(UPLOADER.aiohttp, "TCPConnector", return_value=object()),
patch("builtins.print") as print_mock,
):
result = await UPLOADER._upload_file(
b"png-bytes",
"reference.png",
"image/png",
base_url="https://cf-api.o1key.com/",
)
self.assertEqual(result, expected_url)
rendered_log = "\n".join(
" ".join(str(arg) for arg in call.args)
for call in print_mock.call_args_list
)
self.assertNotIn(expected_url, rendered_log)
self.assertEqual(len(factory.calls), 1)
url, kwargs = factory.calls[0]
self.assertEqual(url, "https://cf-api.o1key.com/v1/o1key/uploads")
self.assertEqual(kwargs["headers"], {"Authorization": "Bearer secret"})
self.assertNotIn("Content-Type", kwargs["headers"])
self.assertIsInstance(kwargs["data"], aiohttp.FormData)
field_options, field_headers, field_value = kwargs["data"]._fields[0]
self.assertEqual(field_options["name"], "file")
self.assertEqual(field_options["filename"], "reference.png")
self.assertEqual(field_headers["Content-Type"], "image/png")
self.assertEqual(field_value, b"png-bytes")
async def test_non_retryable_upload_error_is_not_retried(self):
factory = _SessionFactory([
_FakeResponse(413, {"error": "attachment too large"})
])
with (
patch.object(UPLOADER.aiohttp, "ClientSession", factory),
patch.object(UPLOADER.aiohttp, "TCPConnector", return_value=object()),
):
with self.assertRaisesRegex(RuntimeError, "HTTP 413"):
await UPLOADER._upload_file(
b"data",
"video.mp4",
"video/mp4",
)
self.assertEqual(len(factory.calls), 1)
if __name__ == "__main__":
unittest.main(verbosity=2)
+40
View File
@@ -0,0 +1,40 @@
import sys
import types
import unittest
from pathlib import Path
PACKAGE_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PACKAGE_DIR.parent))
# Import the node module without running the root registry, whose unrelated
# ComfyUI imports initialize CUDA during package import.
package = types.ModuleType("comfyui_o1key")
package.__path__ = [str(PACKAGE_DIR)]
sys.modules.setdefault("comfyui_o1key", package)
nodes_package = types.ModuleType("comfyui_o1key.nodes")
nodes_package.__path__ = [str(PACKAGE_DIR / "nodes")]
sys.modules.setdefault("comfyui_o1key.nodes", nodes_package)
from comfyui_o1key.nodes.universal_llm import ( # noqa: E402
DEFAULT_MODEL,
SUPPORTED_MODELS,
UniversalLLMChat,
)
class UniversalLLMModelTests(unittest.TestCase):
def test_gpt_6_sol_is_supported_and_default(self):
schema = UniversalLLMChat.GET_SCHEMA()
schema.validate()
model_input = next(item for item in schema.inputs if item.id == "模型")
self.assertEqual(DEFAULT_MODEL, "gpt-6-sol")
self.assertEqual(SUPPORTED_MODELS[0], DEFAULT_MODEL)
self.assertIn("gpt-6-astra", SUPPORTED_MODELS)
self.assertEqual(model_input.options, SUPPORTED_MODELS)
self.assertEqual(model_input.default, DEFAULT_MODEL)
if __name__ == "__main__":
unittest.main()
+50 -10
View File
@@ -1,4 +1,4 @@
"""Git integration checks for the sidebar updater."""
"""Offline Git checks for the sidebar updater."""
import importlib.util
import subprocess
@@ -30,10 +30,17 @@ class UpdaterTests(unittest.TestCase):
(self.author / "version.txt").write_text("1\n", encoding="utf-8")
self.commit_and_push()
self.git(root, "clone", "--branch", "main", str(self.remote), str(self.install))
self.previous_dir = updater.PLUGIN_DIR
self.previous_url = updater.RELEASE_REPOSITORY_URL
updater.PLUGIN_DIR = self.install
updater.RELEASE_REPOSITORY_URL = str(self.remote)
self.addCleanup(setattr, updater, "PLUGIN_DIR", self.previous_dir)
self.addCleanup(setattr, updater, "RELEASE_REPOSITORY_URL", self.previous_url)
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip()
return subprocess.run(
["git", *args], cwd=cwd, check=True, capture_output=True, text=True,
).stdout.strip()
def commit_and_push(self):
self.git(self.author, "add", ".")
@@ -42,21 +49,23 @@ class UpdaterTests(unittest.TestCase):
def test_fast_forward_and_requirements_change(self):
self.assertFalse(updater.update_package()["updated"])
(self.author / "version.txt").write_text("2\n", encoding="utf-8")
# The release URL, not the user's origin, determines the update source.
self.git(self.install, "remote", "set-url", "origin", str(self.install / "unused-origin"))
(self.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8")
self.commit_and_push()
result = updater.update_package()
self.assertTrue(result["updated"])
self.assertTrue(result["requirements_changed"])
self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "2\n")
self.assertEqual((self.install / "requirements.txt").read_text(encoding="utf-8"), "requests>=3\n")
def test_local_changes_are_preserved(self):
(self.install / "version.txt").write_text("local\n", encoding="utf-8")
with self.assertRaisesRegex(updater.UpdateError, "本地修改"):
def test_local_modification_is_preserved(self):
(self.install / "version.txt").write_text("local work\n", encoding="utf-8")
with self.assertRaises(updater.UpdateError) as caught:
updater.update_package()
self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "local\n")
self.assertEqual(caught.exception.code, "local_changes")
self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "local work\n")
def test_diverged_branch_is_rejected(self):
def test_diverged_branch_is_preserved(self):
self.git(self.install, "config", "user.email", "[email protected]")
self.git(self.install, "config", "user.name", "Updater Test")
(self.install / "version.txt").write_text("local commit\n", encoding="utf-8")
@@ -64,10 +73,41 @@ class UpdaterTests(unittest.TestCase):
self.git(self.install, "commit", "-m", "local")
(self.author / "version.txt").write_text("remote commit\n", encoding="utf-8")
self.commit_and_push()
with self.assertRaisesRegex(updater.UpdateError, "已分叉"):
with self.assertRaises(updater.UpdateError) as caught:
updater.update_package()
self.assertEqual(caught.exception.code, "diverged")
self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "local commit\n")
def test_wrong_branch_is_reported(self):
self.git(self.install, "switch", "-c", "feature")
with self.assertRaises(updater.UpdateError) as caught:
updater.update_package()
self.assertEqual(caught.exception.code, "wrong_branch")
def test_zip_style_install_is_reported(self):
updater.PLUGIN_DIR = Path(self.temp.name) / "unpacked"
updater.PLUGIN_DIR.mkdir()
with self.assertRaises(updater.UpdateError) as caught:
updater.update_package()
self.assertEqual(caught.exception.code, "not_git")
self.assertIn("suggestion", caught.exception.as_dict())
def test_missing_release_repo_is_reported(self):
updater.RELEASE_REPOSITORY_URL = str(self.install / "missing-release.git")
with self.assertRaises(updater.UpdateError) as caught:
updater.update_package()
self.assertEqual(caught.exception.code, "fetch_failed")
self.assertEqual(caught.exception.status, 503)
def test_untracked_collision_is_preserved(self):
(self.install / "collision.txt").write_text("local file\n", encoding="utf-8")
(self.author / "collision.txt").write_text("release file\n", encoding="utf-8")
self.commit_and_push()
with self.assertRaises(updater.UpdateError) as caught:
updater.update_package()
self.assertEqual(caught.exception.code, "merge_blocked")
self.assertEqual((self.install / "collision.txt").read_text(encoding="utf-8"), "local file\n")
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = PLUGIN_ROOT / "nodes" / "video_trim.py"
def load_module():
spec = importlib.util.spec_from_file_location("o1key_video_trim_test", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class VideoTrimTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = load_module()
def test_schema_keeps_released_widget_order_and_internal_path_value(self):
required = self.module.O1keyVideoTrim.INPUT_TYPES()["required"]
self.assertEqual(
list(required),
["视频路径", "开始时间", "结束时间", "固定时长"],
)
self.assertEqual(required["视频路径"][0], "STRING")
self.assertIn("粘贴", required["视频路径"][1]["placeholder"])
def test_fixed_duration_is_clamped_to_the_end_of_the_source(self):
module = self.module
recorded_command = []
def fake_run(command, **_kwargs):
recorded_command[:] = command
Path(command[-1]).write_bytes(b"trimmed-video")
return SimpleNamespace(returncode=0, stderr="")
with tempfile.TemporaryDirectory() as temp_dir:
source = Path(temp_dir) / "source.mp4"
source.write_bytes(b"source-video")
video_factory = mock.Mock(side_effect=lambda path: SimpleNamespace(path=path))
fake_input_impl = SimpleNamespace(VideoFromFile=video_factory)
with (
mock.patch.object(module, "InputImpl", fake_input_impl),
mock.patch.object(module, "_probe_duration", return_value=10.0),
mock.patch.object(module, "_resolve_ffmpeg", return_value="ffmpeg"),
mock.patch.object(module.subprocess, "run", side_effect=fake_run),
):
video, duration = module.O1keyVideoTrim().trim(
视频路径=f' "{source}" ',
开始时间=9.0,
结束时间=0.0,
固定时长=4.0,
)
try:
self.assertEqual(duration, 4.0)
self.assertEqual(recorded_command[recorded_command.index("-ss") + 1], "6.000")
self.assertEqual(recorded_command[recorded_command.index("-t") + 1], "4.000")
self.assertEqual(video.path, recorded_command[-1])
finally:
Path(video.path).unlink(missing_ok=True)
def test_fixed_duration_longer_than_source_returns_the_full_video(self):
module = self.module
with tempfile.TemporaryDirectory() as temp_dir:
source = Path(temp_dir) / "short.mp4"
source.write_bytes(b"source-video")
video_factory = mock.Mock(side_effect=lambda path: SimpleNamespace(path=path))
fake_input_impl = SimpleNamespace(VideoFromFile=video_factory)
with (
mock.patch.object(module, "InputImpl", fake_input_impl),
mock.patch.object(module, "_probe_duration", return_value=3.0),
mock.patch.object(module, "_resolve_ffmpeg") as resolve_ffmpeg,
):
video, duration = module.O1keyVideoTrim().trim(
视频路径=str(source),
开始时间=2.0,
固定时长=5.0,
)
self.assertEqual(video.path, str(source))
self.assertEqual(duration, 3.0)
resolve_ffmpeg.assert_not_called()
if __name__ == "__main__":
unittest.main()
+151
View File
@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
const sourcePath = new URL("../web/js/videoTrim.js", import.meta.url);
const source = fs.readFileSync(sourcePath, "utf8").replace(/^import .*;\s*$/gm, "");
class Element {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.listeners = new Map();
this.style = {};
this.textContent = "";
this.className = "";
this.classList = { add() {}, remove() {} };
this.paused = true;
this.currentTime = 0;
}
append(...children) { this.children.push(...children); }
appendChild(child) { this.children.push(child); }
replaceChildren(...children) { this.children = [...children]; }
addEventListener(type, listener) { this.listeners.set(type, listener); }
removeAttribute(name) { delete this[name]; }
querySelector(selector) { return selector === "span" ? this._span ?? null : null; }
pause() { this.paused = true; }
load() {}
remove() { this.removed = true; }
set innerHTML(value) {
this._innerHTML = value;
if (this.tagName === "BUTTON" && value.includes("<span>")) {
this._span = new Element("span");
}
}
get innerHTML() { return this._innerHTML ?? ""; }
}
const createdElements = [];
const elementsById = new Map();
const document = {
head: {
appendChild(element) {
if (element.id) elementsById.set(element.id, element);
},
},
body: {
appendChild(element) { createdElements.push(element); },
},
createElement(tagName) {
const element = new Element(tagName);
createdElements.push(element);
return element;
},
getElementById(id) { return elementsById.get(id) ?? null; },
};
let registeredExtension;
const app = {
registerExtension(extension) { registeredExtension = extension; },
};
const api = {
apiURL(value) { return value; },
async fetchApi(path) {
assert.equal(path, "/o1key/input_dir");
return {
ok: true,
async json() { return { path: "F:/o1key_windows_portable/ComfyUI/input" }; },
};
},
};
const context = vm.createContext({
app,
api,
console,
document,
FormData,
URLSearchParams,
isFinite,
requestAnimationFrame: (callback) => callback(),
setTimeout,
});
vm.runInContext(source, context, { filename: sourcePath.pathname });
const callbackReceiver = { kind: "comfy-widget-component" };
let callbackArguments;
function originalFixedCallback(...args) {
assert.equal(this, callbackReceiver);
callbackArguments = args;
return "original-result";
}
class VideoTrimNode {
constructor() {
this.size = [300, 300];
this.widgets = [
{ name: "视频路径", value: "" },
{ name: "开始时间", value: 0 },
{ name: "结束时间", value: 0 },
{ name: "固定时长", value: 0, callback: originalFixedCallback },
];
}
addDOMWidget(name, type, element, options) {
const widget = { name, type, element, options };
this.widgets.push(widget);
return widget;
}
computeSize() { return [this.size[0], 480]; }
setSize(size) { this.size = size; }
setDirtyCanvas() {}
}
await registeredExtension.beforeRegisterNodeDef(VideoTrimNode, { name: "O1keyVideoTrim" });
const node = new VideoTrimNode();
node.onNodeCreated();
const pathWidget = node.widgets.find((widget) => widget.name === "视频路径");
const fixedWidget = node.widgets.find((widget) => widget.name === "固定时长");
assert.equal(pathWidget.hidden, true, "视频路径必须作为内部字段隐藏");
assert.equal(pathWidget.options.hidden, true, "Nodes 2.0 必须收到正式隐藏标记");
assert.equal(pathWidget.computeSize, undefined, "不得用负高度隐藏路径控件");
fixedWidget.value = 4;
const callbackResult = fixedWidget.callback.call(
callbackReceiver,
4,
"canvas",
node,
[10, 20],
{ type: "change" },
);
assert.equal(callbackResult, "original-result");
assert.deepEqual(callbackArguments, [4, "canvas", node, [10, 20], { type: "change" }]);
pathWidget.value = "F:/o1key_windows_portable/ComfyUI/input/clips/demo.mp4";
await node._o1vtRefreshPath(pathWidget.value);
const preview = createdElements.find((element) => element.tagName === "VIDEO");
assert.equal(preview.src, "/view?filename=demo.mp4&type=input&subfolder=clips");
assert.equal(preview.style.display, "block");
pathWidget.value = "D:/external/demo.mp4";
await node._o1vtRefreshPath(pathWidget.value);
assert.equal(preview.src, undefined, "input 目录外路径不能暴露为 /view 地址");
assert.equal(preview.style.display, "none");
node.onRemoved();
const fileInput = createdElements.find((element) => element.tagName === "INPUT");
assert.equal(fileInput.removed, true);
console.log("video trim frontend tests passed");