裂变补图:中转站波动导致单张失败时只重生失败分镜

实测发现 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:
yangqianqian
2026-06-18 11:30:41 +08:00
parent 25f86b2f4a
commit 285791c12f
8 changed files with 296 additions and 5 deletions

View File

@@ -0,0 +1,29 @@
"""add source_task_id to fission_tasks
Revision ID: 019
Revises: 018
Create Date: 2026-06-18
裂变补图/重生需复刻首轮上下文(产品+operator key+张数),故在 fission_tasks
存下源 GenerationTask id。新架构不再扇出 GenerationTask旧的 source_fission_id
反查链断裂,本列补齐回查能力。
"""
from alembic import op
import sqlalchemy as sa
revision = "019"
down_revision = "018"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"fission_tasks",
sa.Column("source_task_id", sa.BigInteger, nullable=True,
comment="源 GenerationTask id补图复刻上下文用"),
)
def downgrade():
op.drop_column("fission_tasks", "source_task_id")

View File

@@ -115,3 +115,44 @@ def get_fission_notes(
return ok({"fission_id": ft.id, "status": ft.status,
"fanout_count": ft.fanout_count, "notes": notes})
@router.post("/fission/{fission_id}/notes/{seq}/retry-images")
def retry_fission_note_images_endpoint(
fission_id: int,
seq: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""补图:对某一套只重生标了 error 的失败分镜(中转站波动兜底)。
异步丢 Celery前端轮询 GET /fission/{id}/notes 看 images 更新。
"""
ft = db.query(FissionTask).filter(
FissionTask.id == fission_id,
FissionTask.workspace_id == current_user.workspace_id,
).first()
if not ft:
raise_not_found("裂变任务不存在")
fn = db.query(FissionNote).filter(
FissionNote.fission_id == fission_id,
FissionNote.workspace_id == current_user.workspace_id,
FissionNote.seq == seq,
).first()
if not fn:
raise_not_found("该套笔记不存在")
try:
images = json.loads(fn.images_json) if fn.images_json else []
except (ValueError, TypeError):
images = []
failed = [img.get("role") for img in images if img.get("error")]
if not failed:
return ok({"fission_id": fission_id, "seq": seq,
"queued": False, "message": "该套无失败分镜,无需补图"})
from app.workers.tasks import retry_fission_images
retry_fission_images.delay(fission_id, fn.id)
return ok({"fission_id": fission_id, "seq": seq, "note_id": fn.id,
"queued": True, "failed_roles": failed})

View File

@@ -29,6 +29,10 @@ class FissionTask(Base):
source_note: Mapped[str | None] = mapped_column(
Text, comment="爆款源笔记内容(文案+图描述JSON存储"
)
# 源生成任务ID补图/重生需复刻首轮上下文(产品+operator key+张数)时回查
source_task_id: Mapped[int | None] = mapped_column(
BigInteger, nullable=True, comment="源 GenerationTask id补图复刻上下文用"
)
# 参考程度low/mid/high
reference_level: Mapped[str] = mapped_column(
String(10), default="mid", nullable=False,

View 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}

View File

@@ -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)

View File

@@ -286,3 +286,72 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
finally:
_r.delete(lock_key)
db.close()
@celery_app.task(
bind=True,
name="app.workers.tasks.retry_fission_images",
max_retries=1,
default_retry_delay=20,
queue="generation",
)
def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
"""裂变补图:对某一套只重生标了 error 的失败分镜(中转站波动兜底)。
只接收 id基石B不接收 key。复用源产品参考图与 image_count。
解密 key→构建 clients→委托 fission_image_retry.retry_fission_note_images。
"""
logger.info("retry_fission_images start: fission_id=%s note_id=%s", fission_id, note_id)
lock_key = f"fission:retry:lock:{note_id}"
import redis as _redis
from app.core.config import get_settings as _gs
_r = _redis.from_url(_gs().REDIS_URL, decode_responses=True)
if not _r.set(lock_key, "1", nx=True, ex=1800):
logger.warning("retry_fission_images 跳过(已在跑): note_id=%s", note_id)
return {"note_id": note_id, "status": "skipped_locked"}
db = _get_db()
try:
from app.models.fission import FissionTask
from app.models.task import GenerationTask
from app.models.product import Product
from app.workers.pipeline_steps import (
decrypt_user_key, build_clients_and_clear_key, build_product_dict,
)
from app.services.fission_image_retry import retry_fission_note_images
ft = db.query(FissionTask).filter(FissionTask.id == fission_id).first()
if not ft:
return {"note_id": note_id, "status": "not_found"}
if not ft.source_task_id:
return {"note_id": note_id, "status": "no_source_task"}
src = db.query(GenerationTask).filter(
GenerationTask.id == ft.source_task_id,
).first()
if not src:
return {"note_id": note_id, "status": "source_task_gone"}
product_row = db.query(Product).filter(Product.id == src.product_id).first()
if not product_row:
return {"note_id": note_id, "status": "no_product"}
# key 归属源任务 operator与首轮生图一致基石B不接收明文 key
plain_key = decrypt_user_key(db, src.operator_id, ft.workspace_id)
clients = build_clients_and_clear_key(plain_key)
plain_key = None # 用完即清基石B
product = build_product_dict(product_row)
image_count = max(1, (src.image_count or 3))
result = retry_fission_note_images(
db, clients, ft, product, image_count, note_id,
)
result["status"] = "completed"
return result
except Exception as exc: # noqa: BLE001
logger.error("retry_fission_images failed: note_id=%s err=%s", note_id, exc)
exhausted = self.request.retries >= self.max_retries
if exhausted:
return {"note_id": note_id, "status": "failed", "error": str(exc)}
raise self.retry(exc=exc)
finally:
_r.delete(lock_key)
db.close()