""" app/services/fission_image_retry.py — 第11环裂变 失败分镜补图(中转站波动兜底) 实测发现 codeproxy 偶发不稳,单套中某张图重试3次仍失败会记 error 落库。 本模块对某一套(FissionNote)只补 images_json 里标了 error 的分镜:成功的不动, 失败的用引擎 target_role 单张重生(R2 零改动),merge 回 images_json。 🔴 生图引擎零改动;只补失败 role,避免整套重生浪费已成功的图与 token。 """ import logging from sqlalchemy.orm import Session logger = logging.getLogger(__name__) def _failed_roles(images: list[dict]) -> list[str]: """从 images_json 解析出标了 error 的分镜 role 列表。""" return [img.get("role", "") for img in images if img.get("error") and img.get("role")] def retry_fission_note_images( db: Session, clients, ft, product: dict, image_count: int, note_id: int, ) -> dict: """对单套 FissionNote 只补失败分镜。返回 {seq, retried, recovered, still_failed}。""" import asyncio import json as _json import os from app.models.fission import FissionNote from app.core.config import get_settings from app.services.ai_engine.image_gen import generate_storyboard_images from app.services.ai_engine.image_postprocessor import process_image from app.workers.pipeline_io import _resolve_image_path fn = db.query(FissionNote).filter(FissionNote.id == note_id).first() if not fn: return {"seq": None, "retried": 0, "recovered": 0, "still_failed": 0} try: images = _json.loads(fn.images_json) if fn.images_json else [] except (ValueError, TypeError): images = [] try: note = _json.loads(fn.note_json) if fn.note_json else {} except (ValueError, TypeError): note = {} failed = _failed_roles(images) if not failed: return {"seq": fn.seq, "retried": 0, "recovered": 0, "still_failed": 0} # 参考图沿用源产品同一张(与首轮生图一致) reference_images = None _img_path = _resolve_image_path(product.get("image_path", "")) if _img_path and os.path.isfile(_img_path): try: with open(_img_path, "rb") as f: reference_images = [f.read()] except Exception as e: # noqa: BLE001 logger.warning("裂变补图参考图读取失败,无参考图模式: %s", e) upload_base = get_settings().UPLOAD_ABS_ROOT img_dir = os.path.join(upload_base, str(ft.workspace_id), f"fission_{ft.id}", str(fn.seq)) os.makedirs(img_dir, exist_ok=True) recovered = 0 # role → 在 images 列表里的位置(保持原顺序/seq 不乱) role_idx = {img.get("role"): i for i, img in enumerate(images)} for role in failed: try: results = asyncio.run(generate_storyboard_images( client=clients, note=note, product=product, image_count=image_count, reference_images=reference_images, target_role=role, )) except Exception as exc: # noqa: BLE001 logger.error("裂变补图 seq=%s role=%s 仍失败: %s", fn.seq, role, exc) continue r = next((x for x in results if x.get("role") == role), None) if not r or r.get("error") or not r.get("image_bytes"): logger.warning("裂变补图 seq=%s role=%s 引擎返回空/error", fn.seq, role) continue try: processed = process_image(r["image_bytes"], aspect_ratio="3:4", resample_strength=1) except Exception: # noqa: BLE001 processed = r["image_bytes"] idx = role_idx.get(role, len(images)) seq_no = idx + 1 fname = f"{seq_no:02d}_{role}.jpg" with open(os.path.join(img_dir, fname), "wb") as f: f.write(processed) entry = {"role": role, "seq": seq_no, "url": f"/uploads/{ft.workspace_id}/fission_{ft.id}/{fn.seq}/{fname}"} if idx < len(images): images[idx] = entry else: images.append(entry) recovered += 1 fn.images_json = _json.dumps(images, ensure_ascii=False) still = len(_failed_roles(images)) fn.status = "image_done" if still == 0 else fn.status db.commit() return {"seq": fn.seq, "retried": len(failed), "recovered": recovered, "still_failed": still}