裂变补图:中转站波动导致单张失败时只重生失败分镜
实测发现 codeproxy 偶发不稳,单套中某张图重试3次仍失败会记 error。
本次加按需补图:只重生标 error 的分镜,成功的图与 token 不浪费。
- 019迁移+模型:fission_tasks 加 source_task_id(新架构不扇出GenerationTask,
补图需复刻首轮上下文:产品/operator key/张数,故落库回查)
- fission_image_retry:只补失败 role(引擎 target_role 单张重生,R2零改动),
merge 回 images_json,全恢复则 status=image_done
- tasks.py:retry_fission_images Celery task,key归属源任务operator(基石B),
幂等锁防重复补图
- api:POST /fission/{id}/notes/{seq}/retry-images,无失败分镜守卫 queued=false
- 前端:FissionNoteCard 失败提示改"重试补图"按钮+loading,父页轮询刷新
实测task5 seq1(hook图首轮codeproxy重试3次失败)补图跑通:只补hook(1024×1536
去AI化)、applied_proof原图不动、recovered=1 still_failed=0、status=image_done;
apiports503回落codeproxy gpt-5.5强档兜底;seq0无失败图守卫queued=false
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
105
backend/app/services/fission_image_retry.py
Normal file
105
backend/app/services/fission_image_retry.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
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}
|
||||
@@ -51,6 +51,7 @@ def create_fission(
|
||||
ft = FissionTask(
|
||||
workspace_id=current_user.workspace_id,
|
||||
source_note=json.dumps(source_note, ensure_ascii=False),
|
||||
source_task_id=source_task_id,
|
||||
reference_level=level, fanout_count=n, status="generating",
|
||||
)
|
||||
db.add(ft); db.commit(); db.refresh(ft)
|
||||
|
||||
Reference in New Issue
Block a user