diff --git a/src/services/ai_image_service.py b/src/services/ai_image_service.py index afacfb9..40a46b4 100644 --- a/src/services/ai_image_service.py +++ b/src/services/ai_image_service.py @@ -28,6 +28,16 @@ SUPPORTED_API_TYPES = { _BASE64_KEYS = {"image_base64", "base64", "b64_json", "data"} _IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} +# Default read timeout (seconds) per output resolution (docs/11-ai-outfit.md §8). +# Higher resolutions take longer to generate; a fixed timeout_seconds in the +# model config overrides this mapping. +RESOLUTION_TIMEOUTS = {"512": 180, "1K": 240, "2K": 360, "4K": 600} + + +def resolution_timeout(resolution, default=240): + """Return the default read timeout in seconds for *resolution*.""" + return RESOLUTION_TIMEOUTS.get(str(resolution).strip().upper(), default) + class AiImageServiceError(RuntimeError): """Base error for AI image generation service failures.""" @@ -45,7 +55,7 @@ class AiModelConfig: model: str api_key: str api_type: str = API_AUTO - timeout_seconds: int = 240 + timeout_seconds: int = 0 # 0 = 按分辨率自动(resolution_timeout) connect_timeout_seconds: int = 30 extra_body: Any = field(default_factory=dict) @@ -58,7 +68,7 @@ class AiModelConfig: model=str(data.get("model", "") or ""), api_key=str(data.get("api_key", "") or ""), api_type=str(data.get("api_type", API_AUTO) or API_AUTO), - timeout_seconds=_to_int(data.get("timeout_seconds", 240), 240), + timeout_seconds=_parse_optional_timeout(data.get("timeout_seconds")), connect_timeout_seconds=_to_int(data.get("connect_timeout_seconds", 30), 30), extra_body=data.get("extra_body") or {}, ) @@ -84,7 +94,7 @@ def api_config_errors(config): errors.append("缺少 api_key") if cfg.api_type not in SUPPORTED_API_TYPES: errors.append("api_type 不支持: {}".format(cfg.api_type)) - if cfg.timeout_seconds <= 0: + if cfg.timeout_seconds < 0: # 0 = 按分辨率自动;负数 = 解析失败/非法 errors.append("timeout_seconds 必须大于 0") if cfg.connect_timeout_seconds <= 0: errors.append("connect_timeout_seconds 必须大于 0") @@ -285,7 +295,12 @@ class ImageApiClient: url = url.replace("{model}", self.config.model) headers = {"Authorization": "Bearer {}".format(self.config.api_key)} - timeout = (self.config.connect_timeout_seconds, self.config.timeout_seconds) + read_timeout = ( + self.config.timeout_seconds + if self.config.timeout_seconds > 0 + else resolution_timeout(resolution) + ) + timeout = (self.config.connect_timeout_seconds, read_timeout) if api_type == API_IMAGES_EDITS: data, files = build_multipart_fields(self.config, prompt, image_path, resolution) @@ -310,7 +325,7 @@ class ImageApiClient: image_bytes = extract_image_from_response( payload, session=self.session, - timeout=self.config.timeout_seconds, + timeout=read_timeout, ) if not image_bytes: raise AiImageServiceError("AI 响应中未找到图片") @@ -327,7 +342,21 @@ def _to_int(value, default): try: return int(value or default) except (TypeError, ValueError): + return default + + +def _parse_optional_timeout(value): + """Parse an optional read timeout. + + Absent/blank -> 0 (auto by resolution); an explicit positive value overrides + the per-resolution default; an unparseable value -> -1 so validation flags it. + """ + if value is None or value == "": return 0 + try: + return int(value) + except (TypeError, ValueError): + return -1 def _join_url(base_url, suffix): diff --git a/tasks.md b/tasks.md index 52d5bf5..05539b1 100644 --- a/tasks.md +++ b/tasks.md @@ -1049,15 +1049,15 @@ - [x] `core/models.py` 新增 `OutfitTask` / `OutfitResult`(纯 dataclass,Python 3.7 兼容,不依赖 PySide6) - [x] `services/excel_service.py`:读行 → `List[OutfitTask]`、写回 D/E/F、占用检测、跳过「完成」/按设置重试「失败」/空字段安全跳过、每行即存 - [x] `services/ai_image_service.py`:移植旧项目 `ImageApiClient`(多模型、多请求格式 `chat/gemini/images/images_edits`、传图 data-url、递归取图、URL 归一化、字段校验);注意 PEP585 类型注解改 Python 3.7 写法 -- [x] `core/ai_outfit.py`:单行生成纯逻辑编排(提示词渲染 + 调用 + 保存 JPG + 产出 `OutfitResult`) -- [ ] 单测:Excel 读写、提示词渲染、取图、命名去重(API 用 mock);可在 Python 3.7 运行、不依赖 GUI +- [x] `core/ai_outfit.py`:单行生成纯逻辑编排(提示词渲染 + 调用 + 保存 JPG + 产出 `OutfitResult`);§8「超时按分辨率动态决定(512/1K/2K/4K→180/240/360/600,可被 `timeout_seconds` 覆盖)」由 `ai_image_service.resolution_timeout` 实现 +- [x] 单测:`test_excel_service` / `test_ai_image_service` / `test_ai_outfit` / `test_outfit_batch` 共 40+ 用例(Excel 读写、提示词渲染、取图、命名去重、超时映射;API 用 mock);Python 3.7 通过、不依赖 GUI ### 19.2 批量编排 — docs/11 §14 阶段 2 前置阅读:`docs/11-ai-outfit.md`(§8) -- [x] `Worker(QObject)`:`ThreadPoolExecutor(并发数)` + `RateLimiter(请求间隔)` + 单任务冷却 + 阶梯重试 + 温和停止 + >30s 心跳;纯逻辑尽量可测 -- [x] 子线程只经 signal 回主线程,不直接碰控件 +- [x] `core/outfit_batch.py` 纯逻辑 `OutfitBatchRunner`:`ThreadPoolExecutor(并发数)` + `RateLimiter(请求间隔)` + 单任务冷却 + 阶梯重试 + 温和停止 + >30s 心跳;全可测(`QObject` 包装放 §19.3 UI 接线) +- [x] 子线程只经回调/signal 回主线程,不直接碰控件(runner 用 callback,UI 层转 signal) ### 19.3 UI 页签 — docs/11 §14 阶段 3 diff --git a/tests/test_ai_image_service.py b/tests/test_ai_image_service.py index b1969b1..ced5a85 100644 --- a/tests/test_ai_image_service.py +++ b/tests/test_ai_image_service.py @@ -215,6 +215,59 @@ class TestImageApiClient(unittest.TestCase): self.assertEqual(session.last_headers["Authorization"], "Bearer secret") self.assertEqual(session.last_json["model"], "model-x") + def test_generate_uses_resolution_timeout_when_unset(self): + from services.ai_image_service import ImageApiClient + + session = _FakeSession() + config = {"url": "https://api.example.test", "model": "m", "api_key": "k", "api_type": "chat"} + + ImageApiClient(config, session=session).generate("p", self.image_path, resolution="4K") + + # timeout_seconds absent -> (connect 30, read 600 for 4K) + self.assertEqual(session.last_timeout, (30, 600)) + + def test_generate_explicit_timeout_overrides_resolution(self): + from services.ai_image_service import ImageApiClient + + session = _FakeSession() + config = { + "url": "https://api.example.test", + "model": "m", + "api_key": "k", + "api_type": "chat", + "timeout_seconds": 120, + } + + ImageApiClient(config, session=session).generate("p", self.image_path, resolution="4K") + + self.assertEqual(session.last_timeout, (30, 120)) + + +class TestTimeoutHelpers(unittest.TestCase): + def test_resolution_timeout_mapping(self): + from services.ai_image_service import resolution_timeout + + self.assertEqual(resolution_timeout("512"), 180) + self.assertEqual(resolution_timeout("1K"), 240) + self.assertEqual(resolution_timeout("2k"), 360) # case-insensitive + self.assertEqual(resolution_timeout("4K"), 600) + self.assertEqual(resolution_timeout("weird"), 240) # unknown -> default + + def test_blank_timeout_is_auto_and_valid(self): + from services.ai_image_service import AiModelConfig, api_config_errors + + cfg = AiModelConfig.from_dict({"url": "https://x", "model": "m", "api_key": "k"}) + self.assertEqual(cfg.timeout_seconds, 0) # 0 = auto by resolution + self.assertEqual(api_config_errors(cfg), []) + + def test_invalid_connect_timeout_falls_back_to_default(self): + from services.ai_image_service import AiModelConfig + + cfg = AiModelConfig.from_dict( + {"url": "https://x", "model": "m", "api_key": "k", "connect_timeout_seconds": "bad"} + ) + self.assertEqual(cfg.connect_timeout_seconds, 30) + class _FakeResponse: def __init__(self, payload=None, content=b"downloaded"): @@ -239,6 +292,7 @@ class _FakeSession: self.last_post_url = None self.last_headers = None self.last_json = None + self.last_timeout = None def get(self, url, timeout=60): self.last_url = url @@ -248,6 +302,7 @@ class _FakeSession: self.last_post_url = url self.last_headers = headers self.last_json = json + self.last_timeout = timeout return _FakeResponse()