From 8a6302f6861dbf907736545f70fc54730c82214b Mon Sep 17 00:00:00 2001 From: iOrange <1606894729@qq.com> Date: Thu, 7 May 2026 15:45:14 +0800 Subject: [PATCH 1/3] Add student install scripts --- deployment/Deploy_Install.bat | 76 +++++++++++++++++++++++++++++++++++ deployment/Uninstall.bat | 43 ++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 deployment/Deploy_Install.bat create mode 100644 deployment/Uninstall.bat diff --git a/deployment/Deploy_Install.bat b/deployment/Deploy_Install.bat new file mode 100644 index 0000000..02858c2 --- /dev/null +++ b/deployment/Deploy_Install.bat @@ -0,0 +1,76 @@ +@echo off + +echo ====================================== +echo ComfyUI Portable - Deployment +echo ====================================== +echo. + +rem ===== Auto-detect NAS Drive ===== +echo Searching for NAS drive... + +set NAS_FOLDER=ComfyUI_Master\ComfyUI_windows_portable +set NAS_DRIVE= + +for %%d in (Z P N M L K J I H G F E D X W) do ( + if exist "%%d:\%NAS_FOLDER%\" ( + set NAS_DRIVE=%%d: + goto :found + ) +) + +echo ERROR: Cannot find NAS! +echo Searched for: %NAS_FOLDER% +echo Please ensure NAS is mapped and contains the ComfyUI_Master folder. +pause +goto :eof + +:found +echo Found NAS at: %NAS_DRIVE% + +rem ===== Path Configuration ===== +set NAS_SOURCE=%NAS_DRIVE%\ComfyUI_Master\ComfyUI_windows_portable +set NAS_SCRIPTS=%NAS_DRIVE%\ComfyUI_Master +set LOCAL_DIR=C:\ComfyUI_Portable + +rem ===== Copy Files ===== +echo. +echo Copying ComfyUI to local drive... +echo This may take a few minutes... +echo. + +robocopy "%NAS_SOURCE%" "%LOCAL_DIR%" /E /XO /FFT /R:3 /W:1 /NJH /NJS /XD "output" "temp" + +if %ERRORLEVEL% GEQ 8 ( + echo ERROR: Copy failed! + pause + goto :eof +) +echo Copy complete! + +rem ===== Copy Start Script ===== +echo. +echo Setting up launcher script... +copy /Y "%NAS_SCRIPTS%\Start_Client.bat" "%LOCAL_DIR%\Start_Client.bat" >nul + +rem ===== Create Desktop Shortcut ===== +echo Creating desktop shortcut... + +set DESKTOP_LNK=%USERPROFILE%\Desktop\ComfyUI.lnk +set TARGET_BAT=%LOCAL_DIR%\Start_Client.bat +set ICON_EXE=%LOCAL_DIR%\ComfyUI.exe + +if exist "%ICON_EXE%" ( + powershell -Command "$s=(New-Object -COM WScript.Shell).CreateShortcut('%DESKTOP_LNK%');$s.TargetPath='%TARGET_BAT%';$s.WorkingDirectory='%LOCAL_DIR%';$s.IconLocation='%ICON_EXE%,0';$s.Save()" +) else ( + powershell -Command "$s=(New-Object -COM WScript.Shell).CreateShortcut('%DESKTOP_LNK%');$s.TargetPath='%TARGET_BAT%';$s.WorkingDirectory='%LOCAL_DIR%';$s.Save()" +) + +echo. +echo ====================================== +echo Deployment Complete! +echo ====================================== +echo. +echo NAS Drive: %NAS_DRIVE% +echo Local folder: %LOCAL_DIR% +echo. +pause diff --git a/deployment/Uninstall.bat b/deployment/Uninstall.bat new file mode 100644 index 0000000..c378638 --- /dev/null +++ b/deployment/Uninstall.bat @@ -0,0 +1,43 @@ +@echo off + +echo ====================================== +echo ComfyUI Portable - Uninstall +echo ====================================== +echo. + +set LOCAL_DIR=C:\ComfyUI_Portable +set DESKTOP_LNK=%USERPROFILE%\Desktop\ComfyUI.lnk + +echo WARNING: This will delete the following: +echo - Folder: %LOCAL_DIR% +echo - Shortcut: %DESKTOP_LNK% +echo. +echo Press any key to continue, or close this window to cancel... +pause >nul + +echo. +echo Removing desktop shortcut... +if exist "%DESKTOP_LNK%" ( + del "%DESKTOP_LNK%" + echo Shortcut removed. +) else ( + echo Shortcut not found, skipping. +) + +echo. +echo Removing ComfyUI folder... +if exist "%LOCAL_DIR%" ( + rmdir /s /q "%LOCAL_DIR%" + echo Folder removed. +) else ( + echo Folder not found, skipping. +) + +echo. +echo ====================================== +echo Uninstall Complete! +echo ====================================== +echo. +echo You can now run Deploy_Install.bat to reinstall. +echo. +pause From c1b464385cf6f8084078ab0bb3eb53e159a01122 Mon Sep 17 00:00:00 2001 From: iOrange <1606894729@qq.com> Date: Thu, 7 May 2026 15:49:01 +0800 Subject: [PATCH 2/3] Document student deployment scripts --- deployment/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 deployment/README.md diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 0000000..05a9c21 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,31 @@ +# Student Deployment Scripts + +This directory stores the student-side deployment scripts that are copied to the NAS root: + +- `Start_Client.bat`: launcher used by student desktop shortcuts. It checks the NAS, self-updates from the NAS root, verifies `ComfyUI-Custom-Batchbox` against the NAS copy, mirrors the plugin when needed, then starts ComfyUI. +- `Deploy_Install.bat`: first-time or repair install script. It copies the portable ComfyUI bundle from NAS to `C:\ComfyUI_Portable`, installs the local launcher, and creates the desktop shortcut. +- `Uninstall.bat`: local cleanup script for removing `C:\ComfyUI_Portable` and the desktop shortcut before a reinstall. + +## NAS Placement + +Copy these files to: + +```text +Z:\ComfyUI_Master\Start_Client.bat +Z:\ComfyUI_Master\Deploy_Install.bat +Z:\ComfyUI_Master\Uninstall.bat +``` + +Student machines may map the same NAS as another drive letter, such as `P:`. Both scripts auto-detect mapped drives by checking for: + +```text +ComfyUI_Master\ComfyUI_windows_portable +``` + +## BatchBox Sync Policy + +`Start_Client.bat` does not try to kill running Python or ComfyUI processes. It compares the local BatchBox plugin against the NAS copy first: + +- If every file matches by relative path, size, and modified time, it starts ComfyUI immediately. +- If anything differs, it runs `robocopy /MIR` for `ComfyUI-Custom-Batchbox`, which also removes local extra files. +- If `.pyd` files are locked and cannot be mirrored, startup stops and the user should reboot, then run the launcher again. From 33d7d9992286c8cd2228518a304ba739440fab5c Mon Sep 17 00:00:00 2001 From: iOrange <1606894729@qq.com> Date: Thu, 7 May 2026 15:56:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20APIMart=20GPT?= =?UTF-8?q?=20Image=202=20=E5=AE=98=E6=96=B9=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 APIMart 官方配置,并补充参数过滤、异步轮询和生成耗时日志测试。 --- __init__.py | 6 + adapters/generic.py | 11 +- api_config.yaml | 179 +++++++++++++++++++++++---- secrets.yaml.example | 7 ++ tests/test_adapters.py | 235 ++++++++++++++++++++++++++++++++++++ tests/test_api_endpoints.py | 6 +- 6 files changed, 420 insertions(+), 24 deletions(-) diff --git a/__init__.py b/__init__.py index ca7eaad..5d64308 100644 --- a/__init__.py +++ b/__init__.py @@ -1222,6 +1222,12 @@ async def on_batch_complete(batch_idx, total, batch_previews): ) _usage_duration = _time.monotonic() - _usage_t0 + result["duration_seconds"] = _usage_duration + _preview_count = len(result.get("preview_images", [])) + print( + f"[Independent] ✅ 完成: {model} | {_preview_count} 张 | 耗时 {_usage_duration:.1f}s", + flush=True, + ) # --- Usage Tracking --- try: diff --git a/adapters/generic.py b/adapters/generic.py index 4f856d5..bc7f336 100644 --- a/adapters/generic.py +++ b/adapters/generic.py @@ -191,9 +191,13 @@ def _build_openai_request(self, params: Dict, mode: str = "text2img") -> Dict: # Frontend handles api_name mapping, so params already have correct keys # When payload has "messages" (Chat API), skip prompt/seed — they're inside messages chat_mode = "messages" in payload + excluded_params = set(self.endpoint.get("exclude_params") or []) + excluded_params.update(self.mode_config.get("exclude_params") or []) for param_name, value in params.items(): if param_name.startswith("_"): continue + if param_name in excluded_params: + continue if chat_mode and param_name in ("prompt", "seed"): continue if param_name in ("endpoint_override",): @@ -1115,6 +1119,11 @@ def _poll_for_result(self, task_id: str, timeout: int = 600) -> APIResponse: status_path = self.mode_config.get("status_path", "data.status") success_value = self.mode_config.get("success_value", "SUCCESS") response_path = self.mode_config.get("response_path", "data.data.data[*].url") + poll_interval = self.mode_config.get("poll_interval", 2) + try: + poll_interval = max(float(poll_interval), 0) + except (TypeError, ValueError): + poll_interval = 2 poll_url = f"{self.base_url}{polling_endpoint}" @@ -1130,7 +1139,7 @@ def _poll_for_result(self, task_id: str, timeout: int = 600) -> APIResponse: start_time = time.time() while time.time() - start_time < timeout: - time.sleep(2) + time.sleep(poll_interval) try: resp = requests.get( diff --git a/api_config.yaml b/api_config.yaml index 62d5093..f25d870 100644 --- a/api_config.yaml +++ b/api_config.yaml @@ -106,7 +106,7 @@ models: display_name: 🖼️ GPT-Image-2 (旗舰·图像生成) category: image description: OpenAI 最新旗舰图像生成模型,文字渲染能力强(最大长边 3840px,2K以上为实验性功能) - show_seed_widget: true + show_seed_widget: false dynamic_inputs: image: max: 16 @@ -117,45 +117,116 @@ models: prompt: type: string default: '' + resolution: + type: select + default: 1k + options: + - value: 1k + label: 1K (1024 基准) + - value: 2k + label: 2K (高清海报) + - value: 4k + label: 4K (仅限部分比例) size: type: select default: auto options: - value: auto - label: 自动 (模型决定) - - value: 1024x1024 - label: 1024×1024 (1:1) - - value: 1536x1024 - label: 1536×1024 (3:2 横版) - - value: 1024x1536 - label: 1024×1536 (2:3 竖版) - - value: 1792x1024 - label: 1792×1024 (16:9 横版) - - value: 1024x1792 - label: 1024×1792 (9:16 竖版) - - value: 2048x2048 - label: 2048×2048 (2K 1:1) - - value: 2048x1152 - label: 2048×1152 (2K 横版) - - value: 3840x2160 - label: 3840×2160 (4K 横版) - - value: 2160x3840 - label: 2160×3840 (4K 竖版) + label: 自动 (服务端选择比例) + - value: '1:1' + label: 1:1 正方形 + - value: '3:2' + label: 3:2 横构图 + - value: '2:3' + label: 2:3 竖构图 + - value: '4:3' + label: 4:3 横构图 + - value: '3:4' + label: 3:4 竖构图 + - value: '5:4' + label: 5:4 横构图 + - value: '4:5' + label: 4:5 竖构图 + - value: '16:9' + label: 16:9 宽屏横版 + - value: '9:16' + label: 9:16 手机竖版 + - value: '2:1' + label: 2:1 横向 Banner + - value: '1:2' + label: 1:2 竖向长图 + - value: '21:9' + label: 21:9 电影超宽屏 + - value: '9:21' + label: 9:21 竖向超长图 quality: type: select - default: medium + default: auto options: + - value: auto + label: 自动 - value: low label: 低质量 (快速) - value: medium label: 中等 - value: high label: 高质量 - advanced: {} + output_format: + type: select + default: png + options: + - value: png + label: PNG + - value: jpeg + label: JPEG + - value: webp + label: WebP + n: + type: number + default: 1 + min: 1 + max: 4 + step: 1 + advanced: + background: + type: select + default: auto + options: + - value: auto + label: 自动 + - value: opaque + label: 不透明 + - value: transparent + label: 透明 (官方会降级为 auto) + moderation: + type: select + default: auto + options: + - value: auto + label: 默认审核 + - value: low + label: 宽松审核 + output_compression: + type: string + default: '' + mask_url: + type: string + default: '' api_endpoints: - provider: openrouter priority: 2 model_name: openai/gpt-5.4-image-2 + exclude_params: + - seed + - size + - quality + - resolution + - background + - moderation + - output_format + - output_compression + - n + - mask_url extra_params: modalities: - image @@ -187,6 +258,17 @@ models: - provider: 柏拉图 priority: 1 model_name: gpt-image-2 + exclude_params: + - seed + - size + - quality + - resolution + - background + - moderation + - output_format + - output_compression + - n + - mask_url modes: text2img: endpoint: /v1/images/generations @@ -204,6 +286,59 @@ models: model: gpt-image-2 prompt: '{{prompt}}' image: '{{_images_b64}}' + - provider: apimart + display_name: APIMart 官方 + priority: 3 + model_name: gpt-image-2-official + use_oss_cache: true + modes: + text2img: + endpoint: /v1/images/generations + method: POST + content_type: application/json + response_type: async + task_id_path: data.0.task_id + polling_endpoint: /v1/tasks/{{task_id}} + poll_interval: 5 + status_path: data.status + success_value: completed + response_path: data.result.images[*].url[0] + exclude_params: + - seed + payload_template: + model: gpt-image-2-official + prompt: '{{prompt}}' + size: '{{size}}' + resolution: '{{resolution}}' + quality: '{{quality}}' + background: '{{background}}' + moderation: '{{moderation}}' + output_format: '{{output_format}}' + n: '{{n}}' + img2img: + endpoint: /v1/images/generations + oss_endpoint: /v1/images/generations + method: POST + content_type: multipart/form-data + response_type: async + task_id_path: data.0.task_id + polling_endpoint: /v1/tasks/{{task_id}} + poll_interval: 5 + status_path: data.status + success_value: completed + response_path: data.result.images[*].url[0] + exclude_params: + - seed + payload_template: + model: gpt-image-2-official + prompt: '{{prompt}}' + size: '{{size}}' + resolution: '{{resolution}}' + quality: '{{quality}}' + background: '{{background}}' + moderation: '{{moderation}}' + output_format: '{{output_format}}' + n: '{{n}}' sora_image(文生图): display_name: 🖼️ Sora-Image (视觉先锋·文生图) category: image diff --git a/secrets.yaml.example b/secrets.yaml.example index d1322d9..84933c9 100644 --- a/secrets.yaml.example +++ b/secrets.yaml.example @@ -34,6 +34,13 @@ providers: rate_limit: 60 api_key: "" # 填入你的 Google API Key + # APIMart 官方 GPT-Image-2 通道(需要自己的 APIMart API Key) + apimart: + display_name: APIMart 官方 + base_url: https://api.apimart.ai + rate_limit: 60 + api_key: "" # 填入你的 APIMart API Key + # ============================================================================= # 🔑 Account 服务配置 (acggit.com 代理) # ============================================================================= diff --git a/tests/test_adapters.py b/tests/test_adapters.py index bf7db56..26fdc9e 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -5,6 +5,8 @@ import pytest from unittest.mock import Mock, patch, MagicMock import requests +import yaml +from pathlib import Path # Import from parent directory import sys @@ -80,6 +82,51 @@ def test_build_request_includes_model(self, adapter): if "json" in request: assert request["json"].get("model") == "test-model" + def test_build_request_excludes_configured_params(self, provider_config, endpoint_config): + """Test that endpoint configs can prevent unsupported params from being sent.""" + mode_config = { + "endpoint": "/v1/images/generate", + "method": "POST", + "content_type": "application/json", + "exclude_params": ["seed"], + } + adapter = GenericAPIAdapter(provider_config, endpoint_config, mode_config) + + request = adapter.build_request( + {"prompt": "test", "seed": 123, "size": "1024x1024"}, + "text2img", + ) + + assert "seed" not in request["json"] + assert request["json"]["size"] == "1024x1024" + + def test_build_request_excludes_endpoint_level_params(self, provider_config, endpoint_config): + """Test that provider endpoints can exclude unsupported params for every mode.""" + endpoint_config = { + **endpoint_config, + "exclude_params": ["resolution", "background"], + } + mode_config = { + "endpoint": "/v1/images/generate", + "method": "POST", + "content_type": "application/json", + } + adapter = GenericAPIAdapter(provider_config, endpoint_config, mode_config) + + request = adapter.build_request( + { + "prompt": "test", + "resolution": "2k", + "background": "opaque", + "quality": "high", + }, + "text2img", + ) + + assert "resolution" not in request["json"] + assert "background" not in request["json"] + assert request["json"]["quality"] == "high" + @patch.object(GenericAPIAdapter, "_download_image", return_value=b"fake-image") @patch('requests.request') def test_execute_success(self, mock_request, _mock_download, adapter): @@ -225,6 +272,194 @@ def test_parse_multiple_image_urls(self, adapter): assert result.success is True assert len(result.image_urls) >= 1 # At least one parsed + @patch.object(GenericAPIAdapter, "_download_image", return_value=b"fake-image") + @patch("adapters.generic.time.sleep") + @patch("adapters.generic.requests.get") + @patch("adapters.generic.requests.request") + def test_execute_async_apimart_polling( + self, + mock_request, + mock_get, + mock_sleep, + _mock_download, + ): + """Test APIMart-style async image generation and task polling.""" + provider = { + "name": "apimart", + "base_url": "https://api.apimart.ai", + "api_key": "test-key", + } + endpoint = { + "provider": "apimart", + "model_name": "gpt-image-2-official", + } + mode = { + "endpoint": "/v1/images/generations", + "method": "POST", + "content_type": "application/json", + "response_type": "async", + "task_id_path": "data.0.task_id", + "polling_endpoint": "/v1/tasks/{{task_id}}", + "poll_interval": 5, + "status_path": "data.status", + "success_value": "completed", + "response_path": "data.result.images[*].url[0]", + "exclude_params": ["seed"], + "payload_template": { + "model": "gpt-image-2-official", + "prompt": "{{prompt}}", + "size": "{{size}}", + "resolution": "{{resolution}}", + "quality": "{{quality}}", + "background": "{{background}}", + "moderation": "{{moderation}}", + "output_format": "{{output_format}}", + "n": "{{n}}", + }, + } + adapter = GenericAPIAdapter(provider, endpoint, mode) + + submit_response = Mock() + submit_response.status_code = 200 + submit_response.json.return_value = { + "code": 200, + "data": [{"status": "submitted", "task_id": "task_123"}], + } + submit_response.text = '{"code": 200}' + mock_request.return_value = submit_response + + poll_response = Mock() + poll_response.status_code = 200 + poll_response.json.return_value = { + "code": 200, + "data": { + "id": "task_123", + "status": "completed", + "result": { + "images": [ + {"url": ["https://example.com/result.png"]} + ] + }, + }, + } + mock_get.return_value = poll_response + + result = adapter.execute( + { + "prompt": "星空下的古老城堡", + "size": "16:9", + "resolution": "2k", + "quality": "high", + "background": "auto", + "moderation": "auto", + "output_format": "png", + "n": 1, + "seed": 123, + }, + "text2img", + retry_config=RetryConfig(max_retries=0), + ) + + assert result.success is True + assert result.image_urls == ["https://example.com/result.png"] + assert result.images == [b"fake-image"] + assert mock_request.call_args.args[:2] == ( + "POST", + "https://api.apimart.ai/v1/images/generations", + ) + assert mock_request.call_args.kwargs["json"] == { + "model": "gpt-image-2-official", + "prompt": "星空下的古老城堡", + "size": "16:9", + "resolution": "2k", + "quality": "high", + "background": "auto", + "moderation": "auto", + "output_format": "png", + "n": 1, + } + mock_get.assert_called_once() + assert mock_get.call_args.args[0] == "https://api.apimart.ai/v1/tasks/task_123" + mock_sleep.assert_called_once_with(5.0) + +def test_gpt_image_2_apimart_official_schema_and_endpoint_config(): + """GPT-Image-2 exposes APIMart official params without leaking them to legacy endpoints.""" + project_root = Path(__file__).resolve().parents[1] + config = yaml.safe_load((project_root / "api_config.yaml").read_text(encoding="utf-8")) + model = config["models"]["gpt_image_2"] + assert model["show_seed_widget"] is False + basic = model["parameter_schema"]["basic"] + advanced = model["parameter_schema"]["advanced"] + + assert basic["resolution"]["default"] == "1k" + assert [opt["value"] for opt in basic["resolution"]["options"]] == ["1k", "2k", "4k"] + assert basic["size"]["default"] == "auto" + assert [opt["value"] for opt in basic["size"]["options"]] == [ + "auto", + "1:1", + "3:2", + "2:3", + "4:3", + "3:4", + "5:4", + "4:5", + "16:9", + "9:16", + "2:1", + "1:2", + "21:9", + "9:21", + ] + assert basic["quality"]["default"] == "auto" + assert [opt["value"] for opt in basic["quality"]["options"]] == [ + "auto", + "low", + "medium", + "high", + ] + assert basic["output_format"]["default"] == "png" + assert basic["n"]["default"] == 1 + assert basic["n"]["min"] == 1 + assert basic["n"]["max"] == 4 + + assert advanced["background"]["default"] == "auto" + assert advanced["moderation"]["default"] == "auto" + assert advanced["output_compression"]["default"] == "" + assert advanced["mask_url"]["default"] == "" + + apimart = next(ep for ep in model["api_endpoints"] if ep["provider"] == "apimart") + apimart_fields = { + "resolution", + "background", + "moderation", + "output_format", + "output_compression", + "n", + "mask_url", + } + for mode_config in apimart["modes"].values(): + assert mode_config["response_type"] == "async" + assert mode_config["task_id_path"] == "data.0.task_id" + assert mode_config["polling_endpoint"] == "/v1/tasks/{{task_id}}" + assert mode_config["payload_template"] == { + "model": "gpt-image-2-official", + "prompt": "{{prompt}}", + "size": "{{size}}", + "resolution": "{{resolution}}", + "quality": "{{quality}}", + "background": "{{background}}", + "moderation": "{{moderation}}", + "output_format": "{{output_format}}", + "n": "{{n}}", + } + assert "seed" in mode_config["exclude_params"] + + legacy_excluded = apimart_fields | {"size", "quality"} + for ep in model["api_endpoints"]: + if ep["provider"] == "apimart": + continue + assert legacy_excluded.issubset(set(ep["exclude_params"])) + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index b9ab345..e80c6ea 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -417,7 +417,7 @@ def test_apply_blur_rejects_oversized_body(self, api_module): assert payload["success"] is False assert "200MB" in payload["error"] - def test_generate_independent_reads_chunked_body_and_writes_history(self, api_module, monkeypatch): + def test_generate_independent_reads_chunked_body_and_writes_history(self, api_module, monkeypatch, capsys): generator_instance = Mock() async def fake_generate(**kwargs): @@ -462,6 +462,10 @@ async def fake_generate(**kwargs): payload = _response_json(response) assert response.status == 200 assert payload["success"] is True + assert "duration_seconds" in payload + assert payload["duration_seconds"] >= 0 + console_output = capsys.readouterr().out + assert "[Independent] ✅ 完成: model-a | 2 张 | 耗时 " in console_output generator_instance.generate.assert_awaited_once() call_kwargs = generator_instance.generate.await_args.kwargs assert call_kwargs["model"] == "model-a"