diff --git a/backend/alembic/versions/019_fission_source_task_id.py b/backend/alembic/versions/019_fission_source_task_id.py new file mode 100644 index 0000000..26fe6f4 --- /dev/null +++ b/backend/alembic/versions/019_fission_source_task_id.py @@ -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") diff --git a/backend/app/api/v1/fission.py b/backend/app/api/v1/fission.py index fe26f4f..7e6d086 100644 --- a/backend/app/api/v1/fission.py +++ b/backend/app/api/v1/fission.py @@ -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}) diff --git a/backend/app/models/fission.py b/backend/app/models/fission.py index 716ba02..4dde122 100644 --- a/backend/app/models/fission.py +++ b/backend/app/models/fission.py @@ -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, diff --git a/backend/app/services/fission_image_retry.py b/backend/app/services/fission_image_retry.py new file mode 100644 index 0000000..a0fd44e --- /dev/null +++ b/backend/app/services/fission_image_retry.py @@ -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} diff --git a/backend/app/services/fission_service.py b/backend/app/services/fission_service.py index bae6116..fd2353f 100644 --- a/backend/app/services/fission_service.py +++ b/backend/app/services/fission_service.py @@ -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) diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 5b968d6..35ef8d0 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -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() diff --git a/frontend/src/app/fission/[id]/notes/page.tsx b/frontend/src/app/fission/[id]/notes/page.tsx index b69c00d..f1bd3fd 100644 --- a/frontend/src/app/fission/[id]/notes/page.tsx +++ b/frontend/src/app/fission/[id]/notes/page.tsx @@ -69,6 +69,23 @@ export default function FissionNotesPage() { useEffect(() => { if (fissionId) fetchNotes(); }, [fissionId, fetchNotes]); + // 补图:对某套只重生失败分镜,丢 Celery 后轮询刷新几次看 images 更新 + const retryImages = useCallback(async (seq: number) => { + try { + await api.post(`/api/v1/fission/${fissionId}/notes/${seq}/retry-images`, {}); + } catch { + setError('补图请求失败,请稍后再试'); + return; + } + let n = 0; + const tick = () => { + n += 1; + fetchNotes(); + if (n < 6) setTimeout(tick, 8000); + }; + setTimeout(tick, 8000); + }, [fissionId, fetchNotes]); + return (
- {failedImages.length} 张配图生成失败,可在交付前重试生图。 -
+