From 2923b1d6e1daf85998995a8b03ef22d3f34094d7 Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Wed, 10 Jun 2026 15:17:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=88=86=E6=AC=BE=E5=BC=80=E5=A4=B4API?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._add_module_generation_project_and_hot_.py | 120 ++ .../ed3823a24b8e_merge_changes_from_remote.py | 25 + .../app/api/v1/hot_opening_replicate.py | 527 ++++++ video-gen-api/app/enums/__init__.py | 3 + video-gen-api/app/enums/common.py | 57 + .../app/enums/hot_opening_replicate.py | 32 + .../app/enums/video_prompt_schema.py | 15 + .../app/models/module_generation_project.py | 47 + .../app/models/module_generation_step.py | 74 + .../app/schemas/hot_opening_replicate.py | 485 ++++++ .../generation_module_hook_service.py | 25 + .../generation_task_factory_service.py | 185 ++ .../services/hot_opening_replicate_service.py | 1546 +++++++++++++++++ .../hot_opening_video_prompt_service.py | 707 ++++++++ .../services/module_generation_log_service.py | 140 ++ .../app/tasks/hot_opening_replicate_tasks.py | 46 + 16 files changed, 4034 insertions(+) create mode 100644 video-gen-api/alembic/versions/150fc6da855f_add_module_generation_project_and_hot_.py create mode 100644 video-gen-api/alembic/versions/ed3823a24b8e_merge_changes_from_remote.py create mode 100644 video-gen-api/app/api/v1/hot_opening_replicate.py create mode 100644 video-gen-api/app/enums/__init__.py create mode 100644 video-gen-api/app/enums/common.py create mode 100644 video-gen-api/app/enums/hot_opening_replicate.py create mode 100644 video-gen-api/app/enums/video_prompt_schema.py create mode 100644 video-gen-api/app/models/module_generation_project.py create mode 100644 video-gen-api/app/models/module_generation_step.py create mode 100644 video-gen-api/app/schemas/hot_opening_replicate.py create mode 100644 video-gen-api/app/services/generation_module_hook_service.py create mode 100644 video-gen-api/app/services/generation_task_factory_service.py create mode 100644 video-gen-api/app/services/hot_opening_replicate_service.py create mode 100644 video-gen-api/app/services/hot_opening_video_prompt_service.py create mode 100644 video-gen-api/app/services/module_generation_log_service.py create mode 100644 video-gen-api/app/tasks/hot_opening_replicate_tasks.py diff --git a/video-gen-api/alembic/versions/150fc6da855f_add_module_generation_project_and_hot_.py b/video-gen-api/alembic/versions/150fc6da855f_add_module_generation_project_and_hot_.py new file mode 100644 index 00000000..4c76a8ab --- /dev/null +++ b/video-gen-api/alembic/versions/150fc6da855f_add_module_generation_project_and_hot_.py @@ -0,0 +1,120 @@ +"""add module generation project and hot opening replicate + +Revision ID: 150fc6da855f +Revises: 476b259992de +Create Date: 2026-06-10 11:56:22.146017 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '150fc6da855f' +down_revision: Union[str, None] = '476b259992de' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('module_generation_projects', + sa.Column('id', sa.String(length=32), nullable=False), + sa.Column('user_id', sa.String(length=32), nullable=False), + sa.Column('module', sa.String(length=64), nullable=False), + sa.Column('title', sa.String(length=160), nullable=True), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('current_step_code', sa.String(length=64), nullable=True), + sa.Column('final_image_url', sa.String(length=512), nullable=True), + sa.Column('final_video_url', sa.String(length=512), nullable=True), + sa.Column('final_video_cover_url', sa.String(length=512), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('idempotency_key', sa.String(length=64), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_module_generation_projects_status', 'module_generation_projects', ['module', 'status'], unique=False) + op.create_index('idx_module_generation_projects_user_module', 'module_generation_projects', ['user_id', 'module'], unique=False) + op.create_index(op.f('ix_module_generation_projects_current_step_code'), 'module_generation_projects', ['current_step_code'], unique=False) + op.create_index(op.f('ix_module_generation_projects_deleted_at'), 'module_generation_projects', ['deleted_at'], unique=False) + op.create_index(op.f('ix_module_generation_projects_idempotency_key'), 'module_generation_projects', ['idempotency_key'], unique=False) + op.create_index(op.f('ix_module_generation_projects_module'), 'module_generation_projects', ['module'], unique=False) + op.create_index(op.f('ix_module_generation_projects_status'), 'module_generation_projects', ['status'], unique=False) + op.create_index(op.f('ix_module_generation_projects_user_id'), 'module_generation_projects', ['user_id'], unique=False) + op.create_index('uq_module_generation_projects_user_module_idempotency', 'module_generation_projects', ['user_id', 'module', 'idempotency_key'], unique=True, postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL')) + op.create_table('module_generation_steps', + sa.Column('id', sa.String(length=32), nullable=False), + sa.Column('project_id', sa.String(length=32), nullable=False), + sa.Column('user_id', sa.String(length=32), nullable=False), + sa.Column('module', sa.String(length=64), nullable=False), + sa.Column('step_index', sa.Integer(), nullable=False), + sa.Column('step_code', sa.String(length=64), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('is_current', sa.Boolean(), nullable=False), + sa.Column('parent_step_id', sa.String(length=32), nullable=True), + sa.Column('source_step_id', sa.String(length=32), nullable=True), + sa.Column('chat_task_id', sa.String(length=32), nullable=True), + sa.Column('input_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('output_json', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['chat_task_id'], ['chat_generation_tasks.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['module_generation_projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_module_generation_steps_chat_task', 'module_generation_steps', ['chat_task_id'], unique=False) + op.create_index('idx_module_generation_steps_project_code', 'module_generation_steps', ['project_id', 'step_code', 'is_current'], unique=False) + op.create_index('idx_module_generation_steps_project_current', 'module_generation_steps', ['project_id', 'is_current', 'deleted_at'], unique=False) + op.create_index(op.f('ix_module_generation_steps_chat_task_id'), 'module_generation_steps', ['chat_task_id'], unique=False) + op.create_index(op.f('ix_module_generation_steps_deleted_at'), 'module_generation_steps', ['deleted_at'], unique=False) + op.create_index(op.f('ix_module_generation_steps_is_current'), 'module_generation_steps', ['is_current'], unique=False) + op.create_index(op.f('ix_module_generation_steps_module'), 'module_generation_steps', ['module'], unique=False) + op.create_index(op.f('ix_module_generation_steps_parent_step_id'), 'module_generation_steps', ['parent_step_id'], unique=False) + op.create_index(op.f('ix_module_generation_steps_project_id'), 'module_generation_steps', ['project_id'], unique=False) + op.create_index(op.f('ix_module_generation_steps_source_step_id'), 'module_generation_steps', ['source_step_id'], unique=False) + op.create_index(op.f('ix_module_generation_steps_status'), 'module_generation_steps', ['status'], unique=False) + op.create_index(op.f('ix_module_generation_steps_step_code'), 'module_generation_steps', ['step_code'], unique=False) + op.create_index(op.f('ix_module_generation_steps_step_index'), 'module_generation_steps', ['step_index'], unique=False) + op.create_index(op.f('ix_module_generation_steps_user_id'), 'module_generation_steps', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_module_generation_steps_user_id'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_step_index'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_step_code'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_status'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_source_step_id'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_project_id'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_parent_step_id'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_module'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_is_current'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_deleted_at'), table_name='module_generation_steps') + op.drop_index(op.f('ix_module_generation_steps_chat_task_id'), table_name='module_generation_steps') + op.drop_index('idx_module_generation_steps_project_current', table_name='module_generation_steps') + op.drop_index('idx_module_generation_steps_project_code', table_name='module_generation_steps') + op.drop_index('idx_module_generation_steps_chat_task', table_name='module_generation_steps') + op.drop_table('module_generation_steps') + op.drop_index('uq_module_generation_projects_user_module_idempotency', table_name='module_generation_projects', postgresql_where=sa.text('deleted_at IS NULL AND idempotency_key IS NOT NULL')) + op.drop_index(op.f('ix_module_generation_projects_user_id'), table_name='module_generation_projects') + op.drop_index(op.f('ix_module_generation_projects_status'), table_name='module_generation_projects') + op.drop_index(op.f('ix_module_generation_projects_module'), table_name='module_generation_projects') + op.drop_index(op.f('ix_module_generation_projects_idempotency_key'), table_name='module_generation_projects') + op.drop_index(op.f('ix_module_generation_projects_deleted_at'), table_name='module_generation_projects') + op.drop_index(op.f('ix_module_generation_projects_current_step_code'), table_name='module_generation_projects') + op.drop_index('idx_module_generation_projects_user_module', table_name='module_generation_projects') + op.drop_index('idx_module_generation_projects_status', table_name='module_generation_projects') + op.drop_table('module_generation_projects') + # ### end Alembic commands ### diff --git a/video-gen-api/alembic/versions/ed3823a24b8e_merge_changes_from_remote.py b/video-gen-api/alembic/versions/ed3823a24b8e_merge_changes_from_remote.py new file mode 100644 index 00000000..6d314a5f --- /dev/null +++ b/video-gen-api/alembic/versions/ed3823a24b8e_merge_changes_from_remote.py @@ -0,0 +1,25 @@ +"""merge changes from remote + +Revision ID: ed3823a24b8e +Revises: 150fc6da855f, a1b2c3d4e5f6 +Create Date: 2026-06-10 15:12:49.885803 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ed3823a24b8e' +down_revision: Union[str, None] = ('150fc6da855f', 'a1b2c3d4e5f6') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/video-gen-api/app/api/v1/hot_opening_replicate.py b/video-gen-api/app/api/v1/hot_opening_replicate.py new file mode 100644 index 00000000..e53bc8d4 --- /dev/null +++ b/video-gen-api/app/api/v1/hot_opening_replicate.py @@ -0,0 +1,527 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_current_user, get_db +from app.models.user import User +from app.schemas.hot_opening_replicate import ( + HotOpeningActionOut, + HotOpeningDeleteOut, + HotOpeningGenerateImagePromptRequest, + HotOpeningGenerateImageRequest, + HotOpeningGenerateVideoPromptRequest, + HotOpeningGenerateVideoRequest, + HotOpeningImagePromptUpdateRequest, + HotOpeningMaterialUpdateRequest, + HotOpeningSpecOut, + HotOpeningTaskCreate, + HotOpeningTaskDetailOut, + HotOpeningTaskListOut, + HotOpeningVideoPromptSchemaUpdateRequest, +) +from app.services.hot_opening_replicate_service import ( + _get_project_for_user, + create_hot_opening_project, + delete_hot_opening_project, + generate_image_from_prompt, + generate_video_from_prompt, + list_hot_opening_projects, + mark_hot_opening_step_dispatch_failed, + project_to_detail_out, + submit_image_prompt_optimize, + submit_video_prompt_optimize, + update_hot_opening_image_prompt, + update_hot_opening_material_input, + update_hot_opening_video_prompt_schema, +) +from app.tasks.celery_app import celery_app + +router = APIRouter( + prefix="/hot-opening-replications", + tags=["hot-opening-replications"], +) + + +async def _reload_project_detail( + db: AsyncSession, + current_user: User, + project_id: str, +) -> HotOpeningTaskDetailOut: + """提交事务后统一重新查询详情,避免继续访问 commit 前 ORM 对象。""" + project = await _get_project_for_user( + db, + project_id=project_id, + user=current_user, + for_update=False, + populate_existing=True, + ) + return await project_to_detail_out(db, project) + + +async def _mark_dispatch_failed_and_raise( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str | None, + message: str, +) -> None: + """Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。""" + if step_id: + try: + await mark_hot_opening_step_dispatch_failed( + db, + current_user=current_user, + project_id=project_id, + step_id=step_id, + error_message=message, + ) + await db.commit() + except Exception: + await db.rollback() + raise HTTPException(status_code=503, detail=message) + + +@router.get( + "/spec", + response_model=HotOpeningSpecOut, + summary="查询爆款开头复刻模块状态枚举和步骤 JSON 结构说明", + description="返回总任务状态、子任务状态、5个固定步骤编码以及每个步骤 input_json/output_json 的统一结构示例,方便前端和排查人员对照。", +) +async def get_spec(): + return HotOpeningSpecOut() + + +@router.post( + "/tasks", + response_model=HotOpeningTaskDetailOut, + summary="创建爆款开头复刻总任务项目", + description=( + "创建爆款开头复刻总任务项目。总任务表 id 就是项目ID,不再传 project_id。" + "接口只同步创建第1个素材输入子任务,保存素材视频链接、素材图片链接、视频素材内容项目名称、生成项目名称和50字核心内容点。" + "后端不开发上传接口,也不校验素材文件时长、大小、格式,直接使用前端已有上传接口返回的链接。" + "创建后不会自动生成第2步图片 AI 提词,需要前端手动调用 generate-image-prompt。" + ), +) +async def create_task( + req: HotOpeningTaskCreate = Body(..., description="爆款开头复刻创建参数,只包含素材链接和项目描述,不包含图片/视频引擎参数"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project = await create_hot_opening_project(db, current_user, req) + project_id_value = str(project.id) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"创建爆款开头复刻项目失败: {exc}") + + return await _reload_project_detail(db, current_user, project_id_value) + + +@router.get( + "/tasks", + response_model=HotOpeningTaskListOut, + summary="查询爆款开头复刻总任务项目列表", + description="分页查询爆款开头复刻总任务项目列表。普通用户只能查看自己的项目,管理员可查看全部。", +) +async def list_tasks( + status: str | None = Query(None, description="总任务状态筛选,例如 waiting_user、processing、completed、failed;为空不过滤"), + page: int = Query(1, ge=1, description="分页页码,从1开始"), + page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await list_hot_opening_projects(db, current_user=current_user, status=status, page=page, page_size=page_size) + + +@router.get( + "/tasks/{project_id}", + response_model=HotOpeningTaskDetailOut, + summary="获取爆款开头复刻总任务项目详情", + description=( + "获取爆款开头复刻总任务详情。详情会聚合返回第1步素材信息、第2步图片提词、第3步图片引擎和参数、" + "第4步视频提词 JSON schema、第5步视频引擎和参数、最终图片、最终视频和完整子任务列表。" + ), +) +async def get_task( + project_id: str = Path(..., description="总任务项目ID,即 module_generation_projects.id"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return await _reload_project_detail(db, current_user, project_id) + + +@router.put( + "/tasks/{project_id}/material", + response_model=HotOpeningActionOut, + summary="修改第1步素材输入并重建第1步新版本", + description=( + "反复修改爆款开头复刻第1步素材输入。" + "接口会软删除旧第1步以及第2、3、4、5步当前有效子任务,联动软删除关联 ChatGenerationTask," + "然后新建第1步 material_input 的 version+1,项目回到 waiting_user 状态。" + ), +) +async def update_material( + project_id: str = Path(..., description="总任务项目ID"), + req: HotOpeningMaterialUpdateRequest = Body(..., description="第1步素材输入修改参数,至少传一个字段"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project_id_value, step_id_value = await update_hot_opening_material_input( + db, + current_user=current_user, + project_id=project_id, + req=req, + ) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}") + + return HotOpeningActionOut( + message="素材输入已修改,旧步骤已软删除,请重新生成图片 AI 提词", + project_id=project_id_value, + step_id=step_id_value, + detail=await _reload_project_detail(db, current_user, project_id_value), + ) + + +@router.put( + "/tasks/{project_id}/steps/{step_id}/image-prompt", + response_model=HotOpeningActionOut, + summary="直接修改第2步图片 AI 优化提词", + description=( + "直接修改第2步图片 AI 优化提词,不调用 AI、不扣积分。" + "保存后会软删除第3、4、5步当前有效任务和关联 ChatGenerationTask," + "清空旧图片/视频结果,让用户从图片生成开始重新执行。" + ), +) +async def update_image_prompt( + project_id: str = Path(..., description="总任务项目ID"), + step_id: str = Path(..., description="第2步图片 AI 提词子任务ID"), + req: HotOpeningImagePromptUpdateRequest = Body(..., description="图片 AI 优化提词修改参数"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step = await update_hot_opening_image_prompt( + db, + current_user=current_user, + project_id=project_id, + step_id=step_id, + req=req, + ) + project_id_value = str(project.id) + step_id_value = str(step.id) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}") + + return HotOpeningActionOut( + message="图片 AI 提词已修改,后续步骤已软删除,请重新生成图片", + project_id=project_id_value, + step_id=step_id_value, + detail=await _reload_project_detail(db, current_user, project_id_value), + ) + + +@router.put( + "/tasks/{project_id}/steps/{step_id}/video-prompt-schema", + response_model=HotOpeningActionOut, + summary="修改第4步视频 AI 提词 JSON schema", + description=( + "修改第4步视频 AI 提词 JSON schema,不调用 AI、不扣积分。" + "前端提交的 schema 只作为 patch,服务端会锁定视频时长、比例、清晰度、帧率、推荐分辨率、" + "动作/镜头/动态时间规划数组长度和时间段、输出规格、质量控制、合规控制、schema_version、schema_usage。" + "最终提示词允许修改,但会清洗秒数、比例、分辨率、帧率等视频参数。保存后软删除第5步视频生成任务。" + ), +) +async def update_video_prompt_schema( + project_id: str = Path(..., description="总任务项目ID"), + step_id: str = Path(..., description="第4步视频 AI 提词子任务ID"), + req: HotOpeningVideoPromptSchemaUpdateRequest = Body(..., description="视频 AI 提词 schema 修改参数"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + project, step = await update_hot_opening_video_prompt_schema( + db, + current_user=current_user, + project_id=project_id, + step_id=step_id, + req=req, + ) + project_id_value = str(project.id) + step_id_value = str(step.id) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"修改视频 AI 提词 schema 失败: {exc}") + + return HotOpeningActionOut( + message="视频 AI 提词 schema 已修改,第5步视频生成任务已软删除,请重新生成视频", + project_id=project_id_value, + step_id=step_id_value, + detail=await _reload_project_detail(db, current_user, project_id_value), + ) + + +@router.post( + "/tasks/{project_id}/steps/{step_id}/generate-image-prompt", + response_model=HotOpeningActionOut, + summary="基于素材输入手动生成图片 AI 提词", + description=( + "基于第1步素材输入子任务手动生成第2步图片 AI 提词。" + "如果已存在旧的第2、3、4、5步,会先软删除旧步骤,再创建新的第2步。" + ), +) +async def generate_image_prompt( + project_id: str = Path(..., description="总任务项目ID"), + step_id: str = Path(..., description="第1步素材输入子任务ID"), + req: HotOpeningGenerateImagePromptRequest = Body(default_factory=HotOpeningGenerateImagePromptRequest, description="图片提词生成参数,当前无需传参,额外字段会忽略"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + _ = req + if celery_app is None: + raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") + + try: + project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id) + project_id_value = str(project.id) + step_id_value = str(step.id) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"图片提词任务创建失败: {exc}") + + from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize + + try: + start_image_prompt_optimize.delay(project_id_value, step_id_value) + except Exception as exc: + await _mark_dispatch_failed_and_raise( + db, + current_user=current_user, + project_id=project_id_value, + step_id=step_id_value, + message=f"图片提词任务投递失败: {exc}", + ) + + return HotOpeningActionOut( + message="图片 AI 提词任务已提交", + project_id=project_id_value, + step_id=step_id_value, + detail=await _reload_project_detail(db, current_user, project_id_value), + ) + + +@router.post( + "/tasks/{project_id}/steps/{step_id}/generate-image", + response_model=HotOpeningActionOut, + summary="基于图片 AI 提词生成新项目图片", + description=( + "基于第2步图片 AI 提词生成新项目图片。调用时传入图片生成引擎和图片生成参数。" + "后端会创建第3步图片生成子任务,ChatGenerationTask 幂等键由后端按任务ID自动生成,不再使用前端幂等键。" + "图片生成媒体积分在创建 ChatGenerationTask 时扣除,生成失败走媒体积分退款。" + "如果已存在旧的第3、4、5步,会先软删除旧步骤,再创建新的第3步。" + ), +) +async def generate_image( + project_id: str = Path(..., description="总任务项目ID"), + step_id: str = Path(..., description="第2步图片 AI 提词子任务ID"), + req: HotOpeningGenerateImageRequest = Body(..., description="图片生成引擎和参数"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if celery_app is None: + raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") + + try: + project, step = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req) + project_id_value = str(project.id) + step_id_value = str(step.id) + chat_task_id_value = step.chat_task_id + if not chat_task_id_value: + raise HTTPException(status_code=500, detail="图片生成任务创建失败:chat_task_id为空") + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"图片生成任务创建失败: {exc}") + + from app.tasks.generation_create_tasks import chatapi_create_generation_task + + try: + chatapi_create_generation_task.delay(chat_task_id_value) + except Exception as exc: + await _mark_dispatch_failed_and_raise( + db, + current_user=current_user, + project_id=project_id_value, + step_id=step_id_value, + message=f"图片生成任务投递失败: {exc}", + ) + + return HotOpeningActionOut( + message="图片生成任务已提交", + project_id=project_id_value, + step_id=step_id_value, + detail=await _reload_project_detail(db, current_user, project_id_value), + ) + + +@router.post( + "/tasks/{project_id}/steps/{step_id}/generate-video-prompt", + response_model=HotOpeningActionOut, + summary="基于图片结果手动生成视频 AI 提词", + description=( + "基于第3步图片生成子任务手动生成第4步视频 AI 提词 JSON schema。" + "视频时长、比例、分辨率集中在本步骤确定并写入 step.output_json.payload.params_used_for_prompt。" + "视频 AI 提词成功后按文本 token 扣积分;该文本积分不参与后续视频生成失败退款。" + "如果已存在旧的第4、5步,会先软删除旧步骤,再创建新的第4步。" + ), +) +async def generate_video_prompt( + project_id: str = Path(..., description="总任务项目ID"), + step_id: str = Path(..., description="第3步图片生成子任务ID"), + req: HotOpeningGenerateVideoPromptRequest = Body(..., description="视频提词生成参数,用于读取接口配置并规划视频时长、比例、分辨率"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if celery_app is None: + raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") + + try: + project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req) + project_id_value = str(project.id) + step_id_value = str(step.id) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"视频提词任务创建失败: {exc}") + + from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize + + try: + start_video_prompt_optimize.delay(project_id_value, step_id_value) + except Exception as exc: + await _mark_dispatch_failed_and_raise( + db, + current_user=current_user, + project_id=project_id_value, + step_id=step_id_value, + message=f"视频提词任务投递失败: {exc}", + ) + + return HotOpeningActionOut( + message="视频 AI 提词任务已提交", + project_id=project_id_value, + step_id=step_id_value, + detail=await _reload_project_detail(db, current_user, project_id_value), + ) + + +@router.post( + "/tasks/{project_id}/steps/{step_id}/generate-video", + response_model=HotOpeningActionOut, + summary="基于视频 AI 提词生成最终视频", + description=( + "基于第4步视频 AI 提词生成最终视频。请求体只需要选择视频生成引擎 engine_id。" + "视频时长、比例、分辨率从第4步视频提词优化结果读取,不再由本接口动态传入。" + "关联 ChatGenerationTask 的 original_prompt 和 optimized_prompt 都使用第4步生成的 prompt_schema JSON 字符串。" + "ChatGenerationTask 幂等键由后端按任务ID自动生成;视频生成媒体积分失败时走退款。" + "如果已存在旧的第5步,会先软删除旧步骤,再创建新的第5步。" + ), +) +async def generate_video( + project_id: str = Path(..., description="总任务项目ID"), + step_id: str = Path(..., description="第4步视频 AI 提词子任务ID"), + req: HotOpeningGenerateVideoRequest = Body(..., description="视频生成参数:只传 engine_id,其它视频参数继承第4步视频提词结果"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if celery_app is None: + raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") + + try: + project, step = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req) + project_id_value = str(project.id) + step_id_value = str(step.id) + chat_task_id_value = step.chat_task_id + if not chat_task_id_value: + raise HTTPException(status_code=500, detail="视频生成任务创建失败:chat_task_id为空") + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"视频生成任务创建失败: {exc}") + + from app.tasks.generation_create_tasks import chatapi_create_generation_task + + try: + chatapi_create_generation_task.delay(chat_task_id_value) + except Exception as exc: + await _mark_dispatch_failed_and_raise( + db, + current_user=current_user, + project_id=project_id_value, + step_id=step_id_value, + message=f"视频生成任务投递失败: {exc}", + ) + + return HotOpeningActionOut( + message="视频生成任务已提交", + project_id=project_id_value, + step_id=step_id_value, + detail=await _reload_project_detail(db, current_user, project_id_value), + ) + + +@router.delete( + "/tasks/{project_id}", + response_model=HotOpeningDeleteOut, + summary="删除爆款开头复刻总任务项目", + description="软删除爆款开头复刻总任务项目,并联动软删除当前有效子任务和关联的 ChatGenerationTask。", +) +async def delete_task( + project_id: str = Path(..., description="总任务项目ID"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + result = await delete_hot_opening_project(db, current_user=current_user, project_id=project_id) + await db.commit() + return result + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"删除爆款开头复刻项目失败: {exc}") diff --git a/video-gen-api/app/enums/__init__.py b/video-gen-api/app/enums/__init__.py new file mode 100644 index 00000000..a27058d0 --- /dev/null +++ b/video-gen-api/app/enums/__init__.py @@ -0,0 +1,3 @@ +from app.enums.common import * +from app.enums.hot_opening_replicate import * +from app.enums.video_prompt_schema import * diff --git a/video-gen-api/app/enums/common.py b/video-gen-api/app/enums/common.py new file mode 100644 index 00000000..fbde775e --- /dev/null +++ b/video-gen-api/app/enums/common.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from enum import StrEnum + + +class ModuleProjectStatusEnum(StrEnum): + """通用模块项目状态。""" + + PENDING = "pending" + WAITING_USER = "waiting_user" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ModuleStepStatusEnum(StrEnum): + """通用模块子任务状态。""" + + PENDING = "pending" + WAITING_USER = "waiting_user" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ModuleEventTypeEnum(StrEnum): + """通用模块事件类型。""" + + PROJECT_CREATED = "PROJECT_CREATED" + PROJECT_DELETED = "PROJECT_DELETED" + STEP_CREATED = "STEP_CREATED" + STEP_UPDATED = "STEP_UPDATED" + SOFT_DELETE_STEPS = "SOFT_DELETE_STEPS" + IMAGE_PROMPT_SUBMITTED = "IMAGE_PROMPT_SUBMITTED" + IMAGE_PROMPT_SUCCESS = "IMAGE_PROMPT_SUCCESS" + IMAGE_PROMPT_FAILED = "IMAGE_PROMPT_FAILED" + IMAGE_GENERATE_SUBMITTED = "IMAGE_GENERATE_SUBMITTED" + IMAGE_GENERATE_SUCCESS = "IMAGE_GENERATE_SUCCESS" + VIDEO_PROMPT_SUBMITTED = "VIDEO_PROMPT_SUBMITTED" + VIDEO_PROMPT_SUCCESS = "VIDEO_PROMPT_SUCCESS" + VIDEO_PROMPT_FAILED = "VIDEO_PROMPT_FAILED" + VIDEO_GENERATE_SUBMITTED = "VIDEO_GENERATE_SUBMITTED" + VIDEO_GENERATE_SUCCESS = "VIDEO_GENERATE_SUCCESS" + CHAT_TASK_FAILED = "CHAT_TASK_FAILED" + CHAT_TASK_CANCELLED = "CHAT_TASK_CANCELLED" + MEDIA_REFUND = "MEDIA_REFUND" + PROMPT_BILLING_SUCCESS = "PROMPT_BILLING_SUCCESS" + PROMPT_BILLING_FAILED = "PROMPT_BILLING_FAILED" + + +class ModulePromptTypeEnum(StrEnum): + """通用模块提词结果类型。""" + + IMAGE_PROMPT = "image_prompt" + VIDEO_PROMPT = "video_prompt" diff --git a/video-gen-api/app/enums/hot_opening_replicate.py b/video-gen-api/app/enums/hot_opening_replicate.py new file mode 100644 index 00000000..a43d11ba --- /dev/null +++ b/video-gen-api/app/enums/hot_opening_replicate.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from enum import StrEnum + + +class ModuleCodeEnum(StrEnum): + """可复用模块编码。""" + + HOT_OPENING_REPLICATE = "hot_opening_replicate" + + +class HotOpeningStepCodeEnum(StrEnum): + """爆款开头复刻子任务步骤编码。""" + + MATERIAL_INPUT = "material_input" + IMAGE_PROMPT_OPTIMIZE = "image_prompt_optimize" + IMAGE_GENERATE = "image_generate" + VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize" + VIDEO_GENERATE = "video_generate" + + +class HotOpeningGenerationModeEnum(StrEnum): + """复用 ChatGenerationTask 时使用的 generation_mode。""" + + HOT_OPENING_REPLICATE = "hot_opening_replicate" + + + +class HotOpeningStepIOSchemaVersionEnum(StrEnum): + """爆款开头复刻子任务 input_json/output_json 结构版本。""" + + V1 = "hot_opening_step_io_v1" diff --git a/video-gen-api/app/enums/video_prompt_schema.py b/video-gen-api/app/enums/video_prompt_schema.py new file mode 100644 index 00000000..a9541ad0 --- /dev/null +++ b/video-gen-api/app/enums/video_prompt_schema.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from enum import StrEnum + + +class PromptSchemaVersionEnum(StrEnum): + """视频提词 schema 版本。""" + + CLIENT_V1 = "video_prompt_schema_client_v1" + + +class VideoPromptSchemaUsageEnum(StrEnum): + """视频提词 schema 用途。""" + + CLIENT_DISPLAY = "客户端可展示的AI视频生成提词结构" diff --git a/video-gen-api/app/models/module_generation_project.py b/video-gen-api/app/models/module_generation_project.py new file mode 100644 index 00000000..8aed2b53 --- /dev/null +++ b/video-gen-api/app/models/module_generation_project.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, SoftDeleteMixin, TimestampMixin + + +class ModuleGenerationProject(Base, TimestampMixin, SoftDeleteMixin): + """通用模块生成项目/总任务表。 + + 说明: + - 本表的 id 就是前端理解的“项目ID/总任务ID”,不再额外保存 project_id。 + - 通过 module 区分业务模块,后续其它功能也可以复用这张总任务项目表。 + - 爆款开头复刻使用 module=hot_opening_replicate。 + """ + + __tablename__ = "module_generation_projects" + __table_args__ = ( + Index("idx_module_generation_projects_user_module", "user_id", "module"), + Index("idx_module_generation_projects_status", "module", "status"), + Index( + "uq_module_generation_projects_user_module_idempotency", + "user_id", + "module", + "idempotency_key", + unique=True, + postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"), + ), + ) + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + module: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + title: Mapped[str | None] = mapped_column(String(160), nullable=True) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + current_step_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + final_image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + final_video_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + final_video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + idempotency_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/video-gen-api/app/models/module_generation_step.py b/video-gen-api/app/models/module_generation_step.py new file mode 100644 index 00000000..81156be1 --- /dev/null +++ b/video-gen-api/app/models/module_generation_step.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, SoftDeleteMixin, TimestampMixin + +_STEP_JSON_TYPE = JSON().with_variant(JSONB, "postgresql") + + +class ModuleGenerationStep(Base, TimestampMixin, SoftDeleteMixin): + """通用模块生成步骤表。 + + 爆款开头复刻固定步骤: + 1 material_input + 2 image_prompt_optimize + 3 image_generate + 4 video_prompt_optimize + 5 video_generate + + input_json / output_json 使用 JSON/JSONB 存储。 + 建议结构: + input_json = { + "schema_version": "hot_opening_step_io_v1", + "step_code": "...", + "source": {...}, + "payload": {...}, + "context": {...} + } + output_json = { + "schema_version": "hot_opening_step_io_v1", + "step_code": "...", + "status": "completed|failed|...", + "payload": {...}, + "result": {...}, + "usage": {...}, + "error": {...} + } + """ + + __tablename__ = "module_generation_steps" + __table_args__ = ( + Index("idx_module_generation_steps_project_current", "project_id", "is_current", "deleted_at"), + Index("idx_module_generation_steps_project_code", "project_id", "step_code", "is_current"), + Index("idx_module_generation_steps_chat_task", "chat_task_id"), + ) + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + project_id: Mapped[str] = mapped_column( + String(32), ForeignKey("module_generation_projects.id", ondelete="CASCADE"), index=True, nullable=False + ) + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False + ) + module: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + step_index: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + step_code: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + is_current: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + parent_step_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + source_step_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + chat_task_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("chat_generation_tasks.id", ondelete="SET NULL"), nullable=True, index=True + ) + input_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_STEP_JSON_TYPE, nullable=True) + output_json: Mapped[dict[str, Any] | list[Any] | None] = mapped_column(_STEP_JSON_TYPE, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/video-gen-api/app/schemas/hot_opening_replicate.py b/video-gen-api/app/schemas/hot_opening_replicate.py new file mode 100644 index 00000000..f06d5e5d --- /dev/null +++ b/video-gen-api/app/schemas/hot_opening_replicate.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.schemas.common import NaiveDatetimeOptional + +HOT_OPENING_PROJECT_STATUS_DESCRIPTIONS: dict[str, str] = { + "pending": "已创建但未进入流程", + "waiting_user": "等待用户手动触发下一步", + "processing": "当前有步骤处理中", + "completed": "总任务完成", + "failed": "总任务失败", + "cancelled": "总任务取消", +} + +HOT_OPENING_STEP_STATUS_DESCRIPTIONS: dict[str, str] = { + "pending": "子任务待处理", + "waiting_user": "等待用户确认或触发", + "processing": "子任务处理中", + "completed": "子任务完成", + "failed": "子任务失败", + "cancelled": "子任务取消", +} + +HOT_OPENING_STEP_DESCRIPTIONS: list[dict[str, Any]] = [ + {"step_index": 1, "step_code": "material_input", "name": "素材输入"}, + {"step_index": 2, "step_code": "image_prompt_optimize", "name": "图片 AI 提词"}, + {"step_index": 3, "step_code": "image_generate", "name": "图片生成"}, + {"step_index": 4, "step_code": "video_prompt_optimize", "name": "视频 AI 提词 JSON schema"}, + {"step_index": 5, "step_code": "video_generate", "name": "视频生成"}, +] + +HOT_OPENING_STEP_IO_SCHEMA_VERSION = "hot_opening_step_io_v1" + +HOT_OPENING_STEP_IO_EXAMPLES: dict[str, dict[str, Any]] = { + "material_input": { + "input_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "material_input", + "source": {"source_step_id": None, "parent_step_id": None}, + "payload": { + "material_video_url": "https://example.com/source.mp4", + "material_image_url": "https://example.com/product.png", + "source_project_name": "参考素材项目名称", + "target_project_name": "新项目名称", + "core_content_point": "50字以内核心内容点", + }, + "context": {}, + }, + "output_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "material_input", + "status": "completed", + "payload": {}, + "result": {"accepted": True, "message": "素材输入已提交", "next_step_code": "image_prompt_optimize"}, + "usage": {}, + "error": {}, + }, + }, + "image_prompt_optimize": { + "input_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "image_prompt_optimize", + "source": {"source_step_id": "第1步素材输入ID", "parent_step_id": "第1步素材输入ID"}, + "payload": {"source_step_id": "第1步素材输入ID"}, + "context": {}, + }, + "output_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "image_prompt_optimize", + "status": "completed", + "payload": { + "optimized_prompt": "图片生成提示词", + "prompt": "兼容字段,同 optimized_prompt", + "original_prompt": "后端拼接的图片提词原始需求", + "references": [{"type": "video|image", "url": "...", "name": "..."}], + }, + "result": {}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "text_credits_cost": 0, + "credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge", + }, + "error": {}, + }, + }, + "image_generate": { + "input_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "image_generate", + "source": {"source_step_id": "第2步图片提词ID", "parent_step_id": "第2步图片提词ID"}, + "payload": { + "engine_id": "图片引擎ID", + "params": {"image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"}, + "prompt": "图片生成提示词", + "media_references": [{"type": "image", "url": "新产品图片", "name": "新产品图片"}], + }, + "context": {}, + }, + "output_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "image_generate", + "status": "completed", + "payload": {}, + "result": {"result_image_url": "/generate/images/xxx.png", "chat_task_id": "ChatGenerationTask ID"}, + "usage": {}, + "error": {}, + }, + }, + "video_prompt_optimize": { + "input_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "video_prompt_optimize", + "source": {"source_step_id": "第3步图片生成ID", "parent_step_id": "第3步图片生成ID"}, + "payload": { + "source_step_id": "第3步图片生成ID", + "video_config": {"engine_id": "视频引擎ID", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"}, + "target_platform": "抖音", + }, + "context": {}, + }, + "output_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "video_prompt_optimize", + "status": "completed", + "payload": { + "prompt_schema": {"任务基础信息": {}, "最终提示词": {}}, + "final_prompt": "展示用最终视频提示词", + "params_used_for_prompt": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"}, + "target_platform": "抖音", + }, + "result": {}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "text_credits_cost": 0, + "credit_biz_key": "module_generation_step:{step_id}:attempt:1:text_prompt:charge", + }, + "error": {}, + }, + }, + "video_generate": { + "input_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "video_generate", + "source": {"source_step_id": "第4步视频提词ID", "parent_step_id": "第4步视频提词ID"}, + "payload": { + "engine_id": "视频引擎ID", + "params": {"duration": 8, "aspect_ratio": "9:16", "resolution": "1080p"}, + "prompt_schema": {"任务基础信息": {}, "最终提示词": {}}, + "final_prompt": "展示用最终提示词", + "media_references": [{"type": "image", "url": "第3步生成图片", "name": "新项目图片"}], + }, + "context": {}, + }, + "output_json": { + "schema_version": HOT_OPENING_STEP_IO_SCHEMA_VERSION, + "step_code": "video_generate", + "status": "completed", + "payload": {}, + "result": { + "result_video_url": "/generate/videos/xxx.mp4", + "result_video_cover_url": "/generate/covers/xxx.jpg", + "chat_task_id": "ChatGenerationTask ID", + }, + "usage": {}, + "error": {}, + }, + }, +} + + +class HotOpeningTaskCreate(BaseModel): + """创建爆款开头复刻总任务项目请求体。""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "material_video_url": "https://example.com/source.mp4", + "material_image_url": "https://example.com/product.png", + "source_project_name": "参考素材项目名称", + "target_project_name": "新项目名称", + "core_content_point": "突出产品能帮助用户认识附近新朋友", + "idempotency_key": "frontend-submit-uuid-001", + } + } + ) + + material_video_url: str = Field(..., min_length=1, description="素材视频链接,参考素材,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验") + material_image_url: str = Field(..., min_length=1, description="素材图片链接,新产品图片,1份。由项目已有上传接口返回,本接口不负责上传,不做后端素材校验") + source_project_name: str = Field(..., min_length=1, max_length=120, description="视频素材内容项目名称") + target_project_name: str = Field(..., min_length=1, max_length=120, description="生成项目名称") + core_content_point: str = Field(..., min_length=1, max_length=50, description="生成的项目核心内容点,最多50字") + idempotency_key: str | None = Field(None, max_length=64, description="创建总任务幂等键。只用于 module_generation_projects,不用于 ChatGenerationTask") + + @field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point") + @classmethod + def _strip_required(cls, value: str) -> str: + value = str(value or "").strip() + if not value: + raise ValueError("字段不能为空") + return value + + +class HotOpeningMaterialUpdateRequest(BaseModel): + """修改爆款开头复刻第1步素材输入请求体。""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "material_video_url": "https://example.com/new-source.mp4", + "material_image_url": "https://example.com/new-product.png", + "source_project_name": "新的参考素材项目名称", + "target_project_name": "新的生成项目名称", + "core_content_point": "新的50字以内核心内容点", + } + } + ) + + material_video_url: str | None = Field(None, min_length=1, description="素材视频链接,未传则沿用旧值") + material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值") + source_project_name: str | None = Field(None, min_length=1, max_length=120, description="视频素材内容项目名称,未传则沿用旧值") + target_project_name: str | None = Field(None, min_length=1, max_length=120, description="生成项目名称,未传则沿用旧值") + core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值") + + @field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before") + @classmethod + def _strip_optional(cls, value: str | None) -> str | None: + if value is None: + return None + value = str(value).strip() + if not value: + raise ValueError("字段不能为空字符串") + return value + + @model_validator(mode="after") + def _require_at_least_one(self) -> "HotOpeningMaterialUpdateRequest": + if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")): + raise ValueError("至少需要传入一个需要修改的字段") + return self + + +class HotOpeningStepUpdate(BaseModel): + """修改爆款开头复刻子任务请求体。""" + + material_video_url: str | None = Field(None, description="修改第1步素材视频链接") + material_image_url: str | None = Field(None, description="修改第1步素材图片链接") + source_project_name: str | None = Field(None, max_length=120, description="修改第1步视频素材内容项目名称") + target_project_name: str | None = Field(None, max_length=120, description="修改第1步生成项目名称") + core_content_point: str | None = Field(None, max_length=50, description="修改第1步生成项目核心内容点,最多50字") + prompt: str | None = Field(None, description="修改第2步图片提词或第4步视频最终提词") + prompt_schema: dict[str, Any] | None = Field(None, description="修改第4步视频提词 JSON schema。只对视频提词步骤有意义") + input_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 input_json.payload") + output_json: dict[str, Any] | None = Field(None, description="高级用法:合并修改当前步骤 output_json.payload") + + +class HotOpeningImagePromptUpdateRequest(BaseModel): + """直接修改第2步图片 AI 优化提词请求体。 + + 本接口不调用 AI、不扣积分;保存后会软删除第3、4、5步当前有效任务。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={"example": {"prompt": "用户手动修改后的图片生成提示词"}}, + ) + + prompt: str = Field(..., min_length=1, description="用户手动修改后的图片生成提示词,不能为空") + + @field_validator("prompt", mode="before") + @classmethod + def _strip_prompt(cls, value: str) -> str: + value = str(value or "").strip() + if not value: + raise ValueError("图片提示词不能为空") + return value + + +class HotOpeningVideoPromptSchemaUpdateRequest(BaseModel): + """修改第4步视频 AI 提词 JSON schema 请求体。 + + 前端提交的 prompt_schema 只作为 patch:服务端会锁定视频时长、比例、清晰度、帧率、推荐分辨率、 + 动作/镜头/动态时间规划数组长度和时间段、输出规格限制、质量控制、合规控制、schema_version、schema_usage。 + 最终提示词允许修改,但保存前会清洗视频参数。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={ + "example": { + "prompt_schema": { + "业务属性": {"产品名称": "脱单交友APP", "行动引导": "立即下载"}, + "最终提示词": {"主提示词": "脱单交友APP推广短视频,突出认识附近新朋友和高效匹配"}, + } + } + }, + ) + + prompt_schema: dict[str, Any] = Field(..., description="前端修改后的视频提词 JSON schema。后端只按白名单回填允许修改字段") + + @field_validator("prompt_schema") + @classmethod + def _validate_schema(cls, value: dict[str, Any]) -> dict[str, Any]: + if not isinstance(value, dict) or not value: + raise ValueError("prompt_schema 必须是非空 JSON 对象") + return value + + +class HotOpeningGenerateImagePromptRequest(BaseModel): + """手动生成第2步图片 AI 提词请求体。当前无需请求参数。""" + + model_config = ConfigDict(extra="ignore") + + +class HotOpeningGenerateImageRequest(BaseModel): + """根据图片提词生成新项目图片请求体。 + + ChatGenerationTask.idempotency_key 由后端自动生成,接口不再接收前端幂等键。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={"example": {"engine_id": "image_engine_xxx", "image_size": "2K", "image_proportion": "1:1", "image_px": "2048x2048"}}, + ) + + engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎") + image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。为空使用引擎默认值") + image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。为空使用默认值") + image_px: str | None = Field(None, description="图片像素尺寸,例如 2048x2048。为空时按引擎支持尺寸自动匹配") + + +class HotOpeningGenerateVideoPromptRequest(BaseModel): + """手动生成第4步视频 AI 提词请求体。 + + 视频时长、比例、分辨率集中在本步骤确定;第5步生成视频只选择视频引擎。 + """ + + model_config = ConfigDict( + extra="ignore", + json_schema_extra={"example": {"engine_id": "video_engine_xxx", "duration": 8, "aspect_ratio": "9:16", "resolution": "1080p", "target_platform": "抖音"}}, + ) + + engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎") + duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_DURATION") + aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例。为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RATIO") + resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率。为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RESOLUTION") + target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 HOT_OPENING_DEFAULT_TARGET_PLATFORM") + + +class HotOpeningGenerateVideoRequest(BaseModel): + """根据视频提词生成最终视频请求体。 + + 只选择视频生成引擎。duration / aspect_ratio / resolution 从第4步视频提词优化结果读取。 + ChatGenerationTask.original_prompt / optimized_prompt 都写入第4步生成的 prompt_schema JSON 字符串。 + """ + + model_config = ConfigDict(extra="ignore", json_schema_extra={"example": {"engine_id": "video_engine_xxx"}}) + + engine_id: str | None = Field(None, description="视频生成引擎ID。为空优先使用第4步视频提词时选择的 engine_id,再为空使用最高优先级启用视频引擎") + + +class HotOpeningStepOut(BaseModel): + id: str = Field(..., description="子任务ID") + project_id: str = Field(..., description="总任务项目ID,即 module_generation_projects.id") + module: str = Field(..., description="模块标识,例如 hot_opening_replicate") + step_index: int = Field(..., description="步骤序号:1素材输入、2图片提词、3图片生成、4视频提词、5视频生成") + step_code: str = Field(..., description="步骤编码:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate") + status: str = Field(..., description="步骤状态:pending/waiting_user/processing/completed/failed/cancelled") + version: int = Field(..., description="步骤版本号。重新生成或修改上游步骤后 version+1") + is_current: bool = Field(..., description="是否当前有效步骤。旧步骤会软删除且 is_current=false") + parent_step_id: str | None = Field(None, description="上一个步骤ID") + source_step_id: str | None = Field(None, description="当前步骤基于哪个上游步骤生成") + chat_task_id: str | None = Field(None, description="关联的 ChatGenerationTask ID。第3步图片生成、第5步视频生成有值") + input: dict[str, Any] | None = Field(None, description=f"步骤输入 JSON,统一 schema_version={HOT_OPENING_STEP_IO_SCHEMA_VERSION}") + output: dict[str, Any] | None = Field(None, description=f"步骤输出 JSON,统一 schema_version={HOT_OPENING_STEP_IO_SCHEMA_VERSION}") + error_message: str | None = Field(None, description="步骤错误信息") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") + completed_at: NaiveDatetimeOptional = Field(None, description="完成时间") + + +class HotOpeningMaterialOut(BaseModel): + material_step_id: str | None = Field(None, description="第1步素材输入子任务ID") + material_video_url: str | None = Field(None, description="素材视频链接") + material_image_url: str | None = Field(None, description="素材图片链接") + source_project_name: str | None = Field(None, description="视频素材内容项目名称") + target_project_name: str | None = Field(None, description="生成项目名称") + core_content_point: str | None = Field(None, description="生成项目核心内容点") + + +class HotOpeningImageGenerationOut(BaseModel): + prompt_step_id: str | None = Field(None, description="第2步图片 AI 提词子任务ID") + generate_step_id: str | None = Field(None, description="第3步图片生成子任务ID") + prompt: str | None = Field(None, description="图片优化提词") + engine_id: str | None = Field(None, description="图片生成引擎ID") + engine_name: str | None = Field(None, description="图片生成引擎名称") + params: dict[str, Any] | None = Field(None, description="图片生成参数") + chat_task_id: str | None = Field(None, description="图片生成 ChatGenerationTask ID") + status: str | None = Field(None, description="图片生成状态") + result_image_url: str | None = Field(None, description="新项目图片 URL") + error_message: str | None = Field(None, description="图片生成错误信息") + + +class HotOpeningVideoGenerationOut(BaseModel): + prompt_step_id: str | None = Field(None, description="第4步视频 AI 提词子任务ID") + generate_step_id: str | None = Field(None, description="第5步视频生成子任务ID") + prompt_schema: dict[str, Any] | None = Field(None, description="视频提词 JSON schema。第5步 ChatGenerationTask 原始提词会使用该 JSON 字符串") + final_prompt: str | None = Field(None, description="视频最终提词,仅用于前端展示") + prompt_params: dict[str, Any] | None = Field(None, description="第4步生成视频提词时使用的视频配置,例如 duration、aspect_ratio、resolution") + engine_id: str | None = Field(None, description="视频生成引擎ID") + engine_name: str | None = Field(None, description="视频生成引擎名称") + params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步") + chat_task_id: str | None = Field(None, description="视频生成 ChatGenerationTask ID") + status: str | None = Field(None, description="视频生成状态") + result_video_url: str | None = Field(None, description="最终视频 URL") + result_video_cover_url: str | None = Field(None, description="最终视频封面 URL") + error_message: str | None = Field(None, description="视频生成错误信息") + + +class HotOpeningTaskDetailOut(BaseModel): + id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID") + project_id: str = Field(..., description="兼容前端命名,等同于 id") + module: str = Field(..., description="模块标识,爆款开头复刻固定为 hot_opening_replicate") + title: str | None = Field(None, description="项目标题,默认取生成项目名称") + status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled") + current_step_code: str | None = Field(None, description="当前所处步骤编码") + final_image_url: str | None = Field(None, description="最终新项目图片 URL") + final_video_url: str | None = Field(None, description="最终视频 URL") + final_video_cover_url: str | None = Field(None, description="最终视频封面 URL") + error_message: str | None = Field(None, description="总任务错误信息") + material: HotOpeningMaterialOut = Field(default_factory=HotOpeningMaterialOut, description="素材和项目描述信息") + image_generation: HotOpeningImageGenerationOut = Field(default_factory=HotOpeningImageGenerationOut, description="图片提词、图片引擎参数和图片结果") + video_generation: HotOpeningVideoGenerationOut = Field(default_factory=HotOpeningVideoGenerationOut, description="视频提词、视频引擎参数和视频结果") + steps: list[HotOpeningStepOut] = Field(default_factory=list, description="当前有效子任务列表") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") + completed_at: NaiveDatetimeOptional = Field(None, description="完成时间") + + +class HotOpeningTaskListItemOut(BaseModel): + id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID") + project_id: str = Field(..., description="兼容前端命名,等同于 id") + module: str = Field(..., description="模块标识") + title: str | None = Field(None, description="项目标题") + status: str = Field(..., description="总任务状态") + current_step_code: str | None = Field(None, description="当前步骤") + target_project_name: str | None = Field(None, description="生成项目名称,来源于第1步素材输入") + final_image_url: str | None = Field(None, description="最终图片 URL") + final_video_url: str | None = Field(None, description="最终视频 URL") + error_message: str | None = Field(None, description="错误信息") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") + completed_at: NaiveDatetimeOptional = Field(None, description="完成时间") + + +class HotOpeningTaskListOut(BaseModel): + total: int = Field(..., description="总数量") + items: list[HotOpeningTaskListItemOut] = Field(default_factory=list, description="列表数据") + + +class HotOpeningActionOut(BaseModel): + message: str = Field(..., description="操作结果提示") + project_id: str = Field(..., description="总任务项目ID") + step_id: str | None = Field(None, description="本次创建或修改的子任务ID") + next_step_id: str | None = Field(None, description="兼容字段:当前接口不自动生成下下个任务,一般为空") + detail: HotOpeningTaskDetailOut | None = Field(None, description="操作后的总任务详情") + + +class HotOpeningDeleteOut(BaseModel): + message: str = Field(..., description="删除结果提示") + project_id: str = Field(..., description="被软删除的总任务项目ID") + deleted: bool = Field(..., description="是否已软删除") + + +class HotOpeningSpecOut(BaseModel): + project_statuses: dict[str, str] = Field(default_factory=lambda: HOT_OPENING_PROJECT_STATUS_DESCRIPTIONS, description="总任务状态说明") + step_statuses: dict[str, str] = Field(default_factory=lambda: HOT_OPENING_STEP_STATUS_DESCRIPTIONS, description="子任务状态说明") + steps: list[dict[str, Any]] = Field(default_factory=lambda: HOT_OPENING_STEP_DESCRIPTIONS, description="5个固定步骤说明") + step_io_schema_version: str = Field(default=HOT_OPENING_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本") + step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: HOT_OPENING_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例") diff --git a/video-gen-api/app/services/generation_module_hook_service.py b/video-gen-api/app/services/generation_module_hook_service.py new file mode 100644 index 00000000..ebf3f03c --- /dev/null +++ b/video-gen-api/app/services/generation_module_hook_service.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.chat_generation_task import ChatGenerationTask + + +async def notify_chat_generation_task_finished(db: AsyncSession, task: ChatGenerationTask) -> None: + """通知业务模块 ChatGenerationTask 已进入终态。 + + 当前用于爆款开头复刻: + - image_generate 完成后自动进入 video_prompt_optimize + - video_generate 完成后总任务完成 + """ + if not task: + return + if task.generation_mode == "hot_opening_replicate": + from app.services.hot_opening_replicate_service import ( + handle_chat_generation_task_completed, + handle_chat_generation_task_failed, + ) + if task.status == "completed": + await handle_chat_generation_task_completed(db, task) + elif task.status == "failed": + await handle_chat_generation_task_failed(db, task) diff --git a/video-gen-api/app/services/generation_task_factory_service.py b/video-gen-api/app/services/generation_task_factory_service.py new file mode 100644 index 00000000..21381157 --- /dev/null +++ b/video-gen-api/app/services/generation_task_factory_service.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.chat_generation_task import ChatGenerationTask +from app.models.user import User +from app.schemas.generation_ai import GenerationAIReference, GenerationAITaskCreate +from app.services.generation_ai_service import ( + IMAGE_DEFAULT_PROPORTION, + IMAGE_DEFAULT_PX, + IMAGE_DEFAULT_SIZE, + VIDEO_DEFAULT_RATIO, + VIDEO_DEFAULT_RESOLUTION, + _build_image_snapshot, + _build_video_snapshot, + _get_image_engine, + _get_video_engine, + _image_supported_sizes, + _parse_list, + normalize_px, +) +from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params +from app.utils.id_gen import generate_id + + +def _json(data: Any) -> str | None: + if data is None: + return None + return json.dumps(data, ensure_ascii=False, default=str) + + +def _build_backend_idempotency_key(*, generation_mode: str, gen_type: str, task_id: str) -> str: + """模块生成关联 ChatGenerationTask 的幂等键由后端生成。 + + 不再接收前端透传,避免 user_id + generation_mode + idempotency_key + 唯一索引被前端固定 key 或重复 key 拦截。 + """ + return f"{generation_mode}:{gen_type}:{task_id}"[:64] + + +async def create_chat_generation_task_for_module( + db: AsyncSession, + *, + current_user: User, + generation_mode: str, + gen_type: str, + original_prompt: str, + optimized_prompt: str | None = None, + engine_id: str | None = None, + media_references: list[dict[str, Any]] | None = None, + idempotency_key: str | None = None, + image_size: str | None = None, + image_proportion: str | None = None, + image_px: str | None = None, + duration: int | None = None, + aspect_ratio: str | None = None, + resolution: str | None = None, + billing_project_name: str = "模块生成任务", + billing_description_prefix: str = "模块生成-", +) -> ChatGenerationTask: + """创建可复用的 ChatGenerationTask 子任务。 + + 和 /generation-ai 普通任务不同,generation_mode 由业务模块传入, + 但仍复用同一套引擎校验、扣费、Celery 创建/轮询/下载逻辑。 + """ + gen_type = gen_type.lower().strip() + if gen_type not in ("image", "video"): + raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video") + + task_id = generate_id() + now = datetime.now(timezone.utc) + refs = media_references or [] + backend_idempotency_key = _build_backend_idempotency_key( + generation_mode=generation_mode, + gen_type=gen_type, + task_id=task_id, + ) + + if gen_type == "image": + engine = await _get_image_engine(db, engine_id) + sizes = _image_supported_sizes(engine) + size = image_size or engine.default_size or IMAGE_DEFAULT_SIZE + proportion = image_proportion or IMAGE_DEFAULT_PROPORTION + px = normalize_px(image_px) + if sizes: + if size not in sizes: + raise HTTPException(status_code=400, detail=f"图片分辨率档位不支持: {size}") + if proportion not in sizes.get(size, {}): + raise HTTPException(status_code=400, detail=f"图片比例不支持: {proportion}") + px = px or normalize_px((sizes.get(size) or {}).get(proportion)) + px = px or IMAGE_DEFAULT_PX + media_billing = await charge_generation_media_by_params( + db, + user_id=current_user.id, + record_id=task_id, + gen_type="image", + image_size=size, + engine_id=engine.id, + project_name=billing_project_name, + description_prefix=billing_description_prefix, + owner_type=OWNER_CHAT_GENERATION_TASK, + attempt_no=1, + ) + snapshot = _build_image_snapshot(engine, size, proportion, px) + task = ChatGenerationTask( + id=task_id, + user_id=current_user.id, + original_prompt=original_prompt, + optimized_prompt=optimized_prompt, + gen_type="image", + image_size=size, + image_proportion=proportion, + image_px=px, + status="generating", + generation_mode=generation_mode, + pipeline_stage="queued", + engine_id=engine.id, + engine_snapshot_json=_json(snapshot), + media_references=_json(refs) if refs else None, + credits_cost=round(media_billing.total_charged, 2), + idempotency_key=backend_idempotency_key, + deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES), + ) + else: + engine = await _get_video_engine(db, engine_id) + ratio = aspect_ratio or VIDEO_DEFAULT_RATIO + selected_resolution = resolution or VIDEO_DEFAULT_RESOLUTION + selected_duration = duration or 4 + ratios = _parse_list(engine.supported_ratios, []) + resolutions = _parse_list(engine.supported_resolutions, []) + durations = _parse_list(engine.supported_durations, []) + if ratios and ratio not in ratios: + raise HTTPException(status_code=400, detail=f"视频比例不支持: {ratio}") + if resolutions and selected_resolution not in resolutions: + raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {selected_resolution}") + if durations and selected_duration not in durations: + raise HTTPException(status_code=400, detail=f"视频时长不支持: {selected_duration}") + if engine.max_duration and selected_duration > engine.max_duration: + raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒") + media_billing = await charge_generation_media_by_params( + db, + user_id=current_user.id, + record_id=task_id, + gen_type="video", + duration=selected_duration, + resolution=selected_resolution, + engine_id=engine.id, + project_name=billing_project_name, + description_prefix=billing_description_prefix, + owner_type=OWNER_CHAT_GENERATION_TASK, + attempt_no=1, + ) + snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration) + task = ChatGenerationTask( + id=task_id, + user_id=current_user.id, + original_prompt=original_prompt, + optimized_prompt=optimized_prompt, + gen_type="video", + duration=selected_duration, + aspect_ratio=ratio, + resolution=selected_resolution, + image_size=image_size or IMAGE_DEFAULT_SIZE, + image_proportion=image_proportion or IMAGE_DEFAULT_PROPORTION, + image_px=normalize_px(image_px) or IMAGE_DEFAULT_PX, + status="generating", + generation_mode=generation_mode, + pipeline_stage="queued", + engine_id=engine.id, + engine_snapshot_json=_json(snapshot), + media_references=_json(refs) if refs else None, + credits_cost=round(media_billing.total_charged, 2), + idempotency_key=backend_idempotency_key, + deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_VIDEO_DEADLINE_MINUTES), + ) + + db.add(task) + await db.flush() + return task diff --git a/video-gen-api/app/services/hot_opening_replicate_service.py b/video-gen-api/app/services/hot_opening_replicate_service.py new file mode 100644 index 00000000..040dea7b --- /dev/null +++ b/video-gen-api/app/services/hot_opening_replicate_service.py @@ -0,0 +1,1546 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum +from app.enums.hot_opening_replicate import HotOpeningGenerationModeEnum, HotOpeningStepCodeEnum, ModuleCodeEnum +from app.models.chat_generation_task import ChatGenerationTask +from app.models.module_generation_project import ModuleGenerationProject +from app.models.module_generation_step import ModuleGenerationStep +from app.models.user import User +from app.schemas.hot_opening_replicate import ( + HotOpeningDeleteOut, + HotOpeningGenerateImageRequest, + HotOpeningGenerateVideoPromptRequest, + HotOpeningGenerateVideoRequest, + HotOpeningImageGenerationOut, + HotOpeningImagePromptUpdateRequest, + HotOpeningMaterialOut, + HotOpeningMaterialUpdateRequest, + HotOpeningStepOut, + HotOpeningStepUpdate, + HotOpeningTaskCreate, + HotOpeningTaskDetailOut, + HotOpeningTaskListItemOut, + HotOpeningTaskListOut, + HotOpeningVideoGenerationOut, + HotOpeningVideoPromptSchemaUpdateRequest, +) +from app.services.generation_ai_service import ( + VIDEO_DEFAULT_DURATION, + VIDEO_DEFAULT_RATIO, + VIDEO_DEFAULT_RESOLUTION, + _get_video_engine, + _parse_list, +) +from app.services.generation_billing_service import charge_module_prompt_usage +from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once +from app.services.generation_task_factory_service import create_chat_generation_task_for_module +from app.services.hot_opening_video_prompt_service import build_final_video_prompt, optimize_hot_opening_video_prompt, patch_video_prompt_schema_from_client +from app.services.module_generation_log_service import log_module_event_file, log_module_prompt_event +from app.services.llm import optimize_prompt +from app.services.resource_accounting_service import soft_delete_chat_task_resources +from app.services.resource_signed_url_service import build_resource_signed_url +from app.utils.id_gen import generate_id + +MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value +GENERATION_MODE = HotOpeningGenerationModeEnum.HOT_OPENING_REPLICATE.value + +STEP_INDEX_MAP = { + HotOpeningStepCodeEnum.MATERIAL_INPUT.value: 1, + HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: 2, + HotOpeningStepCodeEnum.IMAGE_GENERATE.value: 3, + HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: 4, + HotOpeningStepCodeEnum.VIDEO_GENERATE.value: 5, +} + +STEP_IO_SCHEMA_VERSION = "hot_opening_step_io_v1" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _json(data: Any) -> str | None: + if data is None: + return None + return json.dumps(data, ensure_ascii=False, default=str) + + +def _parse_json(value: Any, fallback: Any = None) -> Any: + if value is None or value == "": + return fallback + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except Exception: + return fallback + return fallback + + +def _step_input( + *, + step_code: str, + payload: dict[str, Any] | None = None, + source_step_id: str | None = None, + parent_step_id: str | None = None, + context: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "schema_version": STEP_IO_SCHEMA_VERSION, + "step_code": step_code, + "source": { + "source_step_id": source_step_id, + "parent_step_id": parent_step_id, + }, + "payload": payload or {}, + "context": context or {}, + } + + +def _step_output( + *, + step_code: str, + status: str, + payload: dict[str, Any] | None = None, + result: dict[str, Any] | None = None, + usage: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "schema_version": STEP_IO_SCHEMA_VERSION, + "step_code": step_code, + "status": status, + "payload": payload or {}, + "result": result or {}, + "usage": usage or {}, + "error": error or {}, + } + + +def _is_wrapped_step_io(value: Any) -> bool: + return isinstance(value, dict) and value.get("schema_version") == STEP_IO_SCHEMA_VERSION + + +def _step_payload(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if _is_wrapped_step_io(data): + payload = data.get("payload") + return payload if isinstance(payload, dict) else {} + return data if isinstance(data, dict) else {} + + +def _step_result(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if _is_wrapped_step_io(data): + result = data.get("result") + if isinstance(result, dict) and result: + return result + payload = data.get("payload") + return payload if isinstance(payload, dict) else {} + return data if isinstance(data, dict) else {} + + +def _step_usage(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if _is_wrapped_step_io(data): + usage = data.get("usage") + return usage if isinstance(usage, dict) else {} + usage = data.get("token_usage") if isinstance(data, dict) else {} + return usage if isinstance(usage, dict) else {} + + +def _unwrap_step_output(value: Any) -> dict[str, Any]: + data = _parse_json(value, {}) or {} + if not _is_wrapped_step_io(data): + return data if isinstance(data, dict) else {} + merged: dict[str, Any] = {} + payload = data.get("payload") + result = data.get("result") + usage = data.get("usage") + if isinstance(payload, dict): + merged.update(payload) + if isinstance(result, dict): + merged.update(result) + if isinstance(usage, dict) and usage: + merged["token_usage"] = usage + return merged + + +def _merge_dict(old: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[str, Any]: + merged = dict(old or {}) + for key, value in (new or {}).items(): + if value is not None: + merged[key] = value + return merged + + +async def log_module_event( + db: AsyncSession, + *, + project: ModuleGenerationProject, + event_type: str, + step: ModuleGenerationStep | None = None, + message: str | None = None, + detail: dict[str, Any] | None = None, +) -> None: + """模块事件日志只落盘,不再写 module_generation_events 表。""" + _ = db + log_module_event_file( + module=project.module, + event_type=event_type, + project_id=project.id, + step_id=step.id if step else None, + user_id=project.user_id, + message=message, + detail=detail, + ) + + +async def _get_project_for_user( + db: AsyncSession, + *, + project_id: str, + user: User, + for_update: bool = False, + populate_existing: bool = False, +) -> ModuleGenerationProject: + query = select(ModuleGenerationProject).where( + ModuleGenerationProject.id == project_id, + ModuleGenerationProject.module == MODULE, + ModuleGenerationProject.deleted_at.is_(None), + ) + if not user.is_admin: + query = query.where(ModuleGenerationProject.user_id == user.id) + if populate_existing: + query = query.execution_options(populate_existing=True) + if for_update: + query = query.with_for_update() + result = await db.execute(query.limit(1)) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="爆款开头复刻项目不存在") + return project + + +async def _get_step_for_user( + db: AsyncSession, + *, + project_id: str, + step_id: str, + user: User, + for_update: bool = False, +) -> ModuleGenerationStep: + await _get_project_for_user(db, project_id=project_id, user=user, for_update=for_update) + query = select(ModuleGenerationStep).where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + if not user.is_admin: + query = query.where(ModuleGenerationStep.user_id == user.id) + if for_update: + query = query.with_for_update() + result = await db.execute(query.limit(1)) + step = result.scalar_one_or_none() + if not step: + raise HTTPException(status_code=404, detail="子任务不存在") + return step + + +async def _get_current_steps(db: AsyncSession, project_id: str) -> list[ModuleGenerationStep]: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .order_by(ModuleGenerationStep.step_index.asc(), ModuleGenerationStep.created_at.asc()) + ) + return list(result.scalars().all()) + + +async def _get_current_step_by_code(db: AsyncSession, project_id: str, step_code: str) -> ModuleGenerationStep | None: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.step_code == step_code, + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.deleted_at.is_(None), + ) + .order_by(ModuleGenerationStep.version.desc(), ModuleGenerationStep.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def _next_version(db: AsyncSession, project_id: str, step_code: str) -> int: + result = await db.execute( + select(func.max(ModuleGenerationStep.version)).where( + ModuleGenerationStep.project_id == project_id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.step_code == step_code, + ) + ) + return int(result.scalar_one_or_none() or 0) + 1 + + +async def _create_step( + db: AsyncSession, + *, + project: ModuleGenerationProject, + step_code: str, + status: str = ModuleStepStatusEnum.PENDING.value, + parent_step_id: str | None = None, + source_step_id: str | None = None, + chat_task_id: str | None = None, + input_data: dict[str, Any] | None = None, + output_data: dict[str, Any] | None = None, +) -> ModuleGenerationStep: + version = await _next_version(db, project.id, step_code) + step = ModuleGenerationStep( + id=generate_id(), + project_id=project.id, + user_id=project.user_id, + module=project.module, + step_index=STEP_INDEX_MAP[step_code], + step_code=step_code, + status=status, + version=version, + is_current=True, + parent_step_id=parent_step_id, + source_step_id=source_step_id, + chat_task_id=chat_task_id, + input_json=_step_input( + step_code=step_code, + payload=input_data, + source_step_id=source_step_id, + parent_step_id=parent_step_id, + ) if input_data is not None else None, + output_json=_step_output( + step_code=step_code, + status=status, + result=output_data, + ) if output_data is not None else None, + started_at=_now() if status == ModuleStepStatusEnum.PROCESSING.value else None, + completed_at=_now() if status == ModuleStepStatusEnum.COMPLETED.value else None, + ) + db.add(step) + project.current_step_code = step_code + await db.flush() + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_CREATED.value, detail={"step_code": step_code, "version": version}) + return step + + +async def _soft_delete_steps_from_index( + db: AsyncSession, + *, + project: ModuleGenerationProject, + start_index: int, + deleted_at: datetime | None = None, +) -> None: + deleted_at = deleted_at or _now() + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.step_index >= start_index, + ) + .with_for_update() + ) + steps = list(result.scalars().all()) + for step in steps: + step.is_current = False + step.deleted_at = deleted_at + if step.chat_task_id: + chat_result = await db.execute( + select(ChatGenerationTask) + .where(ChatGenerationTask.id == step.chat_task_id, ChatGenerationTask.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + chat_task = chat_result.scalar_one_or_none() + if chat_task: + if chat_task.status == "completed": + await soft_delete_chat_task_resources(db, chat_task.id, deleted_at=deleted_at) + elif chat_task.status != "failed": + await mark_chat_generation_task_failed_and_refund_once( + db, + task=chat_task, + error_message="爆款开头复刻步骤被重新生成或删除,旧生成任务已取消", + pipeline_stage="failed", + ) + chat_task.deleted_at = deleted_at + if steps: + await log_module_event( + db, + project=project, + event_type=ModuleEventTypeEnum.SOFT_DELETE_STEPS.value, + message=f"软删除第 {start_index} 步及之后的旧子任务", + detail={"step_ids": [step.id for step in steps]}, + ) + + +def _step_to_out(step: ModuleGenerationStep) -> HotOpeningStepOut: + return HotOpeningStepOut( + id=step.id, + project_id=step.project_id, + module=step.module, + step_index=step.step_index, + step_code=step.step_code, + status=step.status, + version=step.version, + is_current=step.is_current, + parent_step_id=step.parent_step_id, + source_step_id=step.source_step_id, + chat_task_id=step.chat_task_id, + input=_parse_json(step.input_json, {}), + output=_parse_json(step.output_json, {}), + error_message=step.error_message, + created_at=step.created_at, + updated_at=step.updated_at, + completed_at=step.completed_at, + ) + + +def _snapshot_from_chat(chat_task: ChatGenerationTask | None) -> dict[str, Any]: + if not chat_task: + return {} + return _parse_json(chat_task.engine_snapshot_json, {}) or {} + + +async def _chat_tasks_by_id(db: AsyncSession, steps: list[ModuleGenerationStep]) -> dict[str, ChatGenerationTask]: + ids = [step.chat_task_id for step in steps if step.chat_task_id] + if not ids: + return {} + result = await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(ids))) + return {task.id: task for task in result.scalars().all()} + + +async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProject) -> HotOpeningTaskDetailOut: + steps = await _get_current_steps(db, project.id) + by_code = {step.step_code: step for step in steps} + chats = await _chat_tasks_by_id(db, steps) + + material_step = by_code.get(HotOpeningStepCodeEnum.MATERIAL_INPUT.value) + image_prompt_step = by_code.get(HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value) + image_generate_step = by_code.get(HotOpeningStepCodeEnum.IMAGE_GENERATE.value) + video_prompt_step = by_code.get(HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value) + video_generate_step = by_code.get(HotOpeningStepCodeEnum.VIDEO_GENERATE.value) + + material_input = _step_payload(material_step.input_json if material_step else None) + image_prompt_output = _unwrap_step_output(image_prompt_step.output_json if image_prompt_step else None) + image_generate_input = _step_payload(image_generate_step.input_json if image_generate_step else None) + image_generate_output = _unwrap_step_output(image_generate_step.output_json if image_generate_step else None) + video_prompt_input = _step_payload(video_prompt_step.input_json if video_prompt_step else None) + video_prompt_output = _unwrap_step_output(video_prompt_step.output_json if video_prompt_step else None) + video_generate_input = _step_payload(video_generate_step.input_json if video_generate_step else None) + video_generate_output = _unwrap_step_output(video_generate_step.output_json if video_generate_step else None) + + image_chat = chats.get(image_generate_step.chat_task_id) if image_generate_step and image_generate_step.chat_task_id else None + video_chat = chats.get(video_generate_step.chat_task_id) if video_generate_step and video_generate_step.chat_task_id else None + image_snapshot = _snapshot_from_chat(image_chat) + video_snapshot = _snapshot_from_chat(video_chat) + + image_url = image_generate_output.get("result_image_url") or (image_chat.image_url if image_chat else None) or project.final_image_url + video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url + cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url + + return HotOpeningTaskDetailOut( + id=project.id, + project_id=project.id, + module=project.module, + title=project.title, + status=project.status, + current_step_code=project.current_step_code, + final_image_url=build_resource_signed_url(project.final_image_url) if project.final_image_url else None, + final_video_url=build_resource_signed_url(project.final_video_url) if project.final_video_url else None, + final_video_cover_url=build_resource_signed_url(project.final_video_cover_url) if project.final_video_cover_url else None, + error_message=project.error_message, + material=HotOpeningMaterialOut( + material_step_id=material_step.id if material_step else None, + material_video_url=material_input.get("material_video_url"), + material_image_url=material_input.get("material_image_url"), + source_project_name=material_input.get("source_project_name"), + target_project_name=material_input.get("target_project_name"), + core_content_point=material_input.get("core_content_point"), + ), + image_generation=HotOpeningImageGenerationOut( + prompt_step_id=image_prompt_step.id if image_prompt_step else None, + generate_step_id=image_generate_step.id if image_generate_step else None, + prompt=image_prompt_output.get("optimized_prompt") or image_prompt_output.get("prompt"), + engine_id=image_snapshot.get("id") or image_generate_input.get("engine_id"), + engine_name=image_snapshot.get("name") or image_generate_input.get("engine_name"), + params=image_generate_input.get("params") or image_generate_input, + chat_task_id=image_generate_step.chat_task_id if image_generate_step else None, + status=image_chat.status if image_chat else (image_generate_step.status if image_generate_step else None), + result_image_url=build_resource_signed_url(image_url) if image_url else None, + error_message=image_chat.error_message if image_chat else (image_generate_step.error_message if image_generate_step else None), + ), + video_generation=HotOpeningVideoGenerationOut( + prompt_step_id=video_prompt_step.id if video_prompt_step else None, + generate_step_id=video_generate_step.id if video_generate_step else None, + prompt_schema=video_prompt_output.get("prompt_schema"), + final_prompt=video_prompt_output.get("final_prompt"), + prompt_params=video_prompt_output.get("params_used_for_prompt") or video_prompt_input.get("video_config"), + engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"), + engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"), + params=video_generate_input.get("params") or video_generate_input, + chat_task_id=video_generate_step.chat_task_id if video_generate_step else None, + status=video_chat.status if video_chat else (video_generate_step.status if video_generate_step else None), + result_video_url=build_resource_signed_url(video_url) if video_url else None, + result_video_cover_url=build_resource_signed_url(cover_url) if cover_url else None, + error_message=video_chat.error_message if video_chat else (video_generate_step.error_message if video_generate_step else None), + ), + steps=[_step_to_out(step) for step in steps], + created_at=project.created_at, + updated_at=project.updated_at, + completed_at=project.completed_at, + ) + + +async def create_hot_opening_project(db: AsyncSession, current_user: User, req: HotOpeningTaskCreate) -> ModuleGenerationProject: + if req.idempotency_key: + result = await db.execute( + select(ModuleGenerationProject) + .where( + ModuleGenerationProject.user_id == current_user.id, + ModuleGenerationProject.module == MODULE, + ModuleGenerationProject.idempotency_key == req.idempotency_key, + ModuleGenerationProject.deleted_at.is_(None), + ) + .order_by(ModuleGenerationProject.created_at.desc()) + .limit(1) + ) + existing = result.scalar_one_or_none() + if existing: + return existing + + project = ModuleGenerationProject( + id=generate_id(), + user_id=current_user.id, + module=MODULE, + title=req.target_project_name, + status=ModuleProjectStatusEnum.WAITING_USER.value, + current_step_code=HotOpeningStepCodeEnum.MATERIAL_INPUT.value, + idempotency_key=req.idempotency_key, + ) + db.add(project) + await db.flush() + + await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.MATERIAL_INPUT.value, + status=ModuleStepStatusEnum.COMPLETED.value, + input_data={ + "material_video_url": req.material_video_url, + "material_image_url": req.material_image_url, + "source_project_name": req.source_project_name, + "target_project_name": req.target_project_name, + "core_content_point": req.core_content_point, + }, + output_data={"message": "素材输入已提交,后端不做素材文件校验。下一步请手动生成图片AI提词。"}, + ) + await log_module_event(db, project=project, event_type=ModuleEventTypeEnum.PROJECT_CREATED.value, message="创建爆款开头复刻项目") + return project + + +async def list_hot_opening_projects( + db: AsyncSession, + *, + current_user: User, + status: str | None, + page: int, + page_size: int, +) -> HotOpeningTaskListOut: + query = select(ModuleGenerationProject).where( + ModuleGenerationProject.module == MODULE, + ModuleGenerationProject.deleted_at.is_(None), + ) + if not current_user.is_admin: + query = query.where(ModuleGenerationProject.user_id == current_user.id) + if status: + query = query.where(ModuleGenerationProject.status == status) + + total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one() + result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size)) + projects = list(result.scalars().all()) + + items: list[HotOpeningTaskListItemOut] = [] + for project in projects: + material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value) + material = _step_payload(material_step.input_json if material_step else None) + items.append( + HotOpeningTaskListItemOut( + id=project.id, + project_id=project.id, + module=project.module, + title=project.title, + status=project.status, + current_step_code=project.current_step_code, + target_project_name=material.get("target_project_name"), + final_image_url=build_resource_signed_url(project.final_image_url) if project.final_image_url else None, + final_video_url=build_resource_signed_url(project.final_video_url) if project.final_video_url else None, + error_message=project.error_message, + created_at=project.created_at, + updated_at=project.updated_at, + completed_at=project.completed_at, + ) + ) + return HotOpeningTaskListOut(total=total, items=items) + + +async def update_hot_opening_step( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + req: HotOpeningStepUpdate, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True) + if step.status == ModuleStepStatusEnum.PROCESSING.value: + raise HTTPException(status_code=400, detail="当前子任务正在处理中,暂不能修改") + + input_data = _step_payload(step.input_json) + output_data = _unwrap_step_output(step.output_json) + + if step.step_code == HotOpeningStepCodeEnum.MATERIAL_INPUT.value: + input_data = _merge_dict( + input_data, + { + "material_video_url": req.material_video_url, + "material_image_url": req.material_image_url, + "source_project_name": req.source_project_name, + "target_project_name": req.target_project_name, + "core_content_point": req.core_content_point, + }, + ) + if req.target_project_name: + project.title = req.target_project_name + elif step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: + if req.prompt is not None: + output_data["optimized_prompt"] = req.prompt + output_data["prompt"] = req.prompt + elif step.step_code == HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: + if req.prompt_schema is not None: + output_data["prompt_schema"] = req.prompt_schema + if req.prompt is not None: + output_data["final_prompt"] = req.prompt + else: + if req.input_json: + input_data = _merge_dict(input_data, req.input_json) + if req.output_json: + output_data = _merge_dict(output_data, req.output_json) + + if req.input_json: + input_data = _merge_dict(input_data, req.input_json) + if req.output_json: + output_data = _merge_dict(output_data, req.output_json) + + step.input_json = _step_input(step_code=step.step_code, payload=input_data, source_step_id=step.source_step_id, parent_step_id=step.parent_step_id) + step.output_json = _step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.error_message = None + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = step.step_code + project.error_message = None + + await _soft_delete_steps_from_index(db, project=project, start_index=step.step_index + 1) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容") + return project, step + + +async def update_hot_opening_material_input( + db: AsyncSession, + *, + current_user: User, + project_id: str, + req: HotOpeningMaterialUpdateRequest, +) -> tuple[str, str]: + """修改第1步素材输入。 + + 采用方案 B:软删除旧第1步及之后的当前有效步骤,然后新建第1步 version+1。 + 未传字段沿用旧第1步素材输入,避免前端只改一个字段时丢失其它素材信息。 + """ + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + old_material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value) + old_material = _step_payload(old_material_step.input_json if old_material_step else None) + + material = { + "material_video_url": req.material_video_url if req.material_video_url is not None else old_material.get("material_video_url"), + "material_image_url": req.material_image_url if req.material_image_url is not None else old_material.get("material_image_url"), + "source_project_name": req.source_project_name if req.source_project_name is not None else old_material.get("source_project_name"), + "target_project_name": req.target_project_name if req.target_project_name is not None else old_material.get("target_project_name"), + "core_content_point": req.core_content_point if req.core_content_point is not None else old_material.get("core_content_point"), + } + + missing_fields = [key for key, value in material.items() if value is None or str(value).strip() == ""] + if missing_fields: + raise HTTPException(status_code=400, detail=f"素材输入缺少必要字段: {', '.join(missing_fields)}") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.MATERIAL_INPUT.value]) + + project.title = str(material["target_project_name"]) + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = HotOpeningStepCodeEnum.MATERIAL_INPUT.value + project.final_image_url = None + project.final_video_url = None + project.final_video_cover_url = None + project.error_message = None + project.completed_at = None + + new_step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.MATERIAL_INPUT.value, + status=ModuleStepStatusEnum.COMPLETED.value, + input_data=material, + output_data={"message": "素材输入已修改,旧步骤已软删除。下一步请重新生成图片AI提词。"}, + ) + await log_module_event( + db, + project=project, + step=new_step, + event_type=ModuleEventTypeEnum.STEP_UPDATED.value, + message="用户修改素材输入并重建第1步新版本", + detail={ + "old_material_step_id": old_material_step.id if old_material_step else None, + "new_material_step_id": new_step.id, + "version": new_step.version, + }, + ) + return project.id, new_step.id + + +async def update_hot_opening_image_prompt( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + req: HotOpeningImagePromptUpdateRequest, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + """直接修改第2步图片 AI 优化提词,不调用 AI、不扣积分。 + + 修改后软删除第3、4、5步当前有效任务,让用户从图片生成开始重新执行。 + """ + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True) + if step.step_code != HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="只能修改第2步图片 AI 提词子任务") + if step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="图片 AI 提词未完成,不能直接修改") + + output_data = _step_payload(step.output_json) + usage = _step_usage(step.output_json) + new_prompt = req.prompt.strip() + output_data["optimized_prompt"] = new_prompt + output_data["prompt"] = new_prompt + output_data["manual_edited"] = True + output_data["manual_edited_at"] = _now().isoformat() + + step.output_json = _step_output( + step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload=output_data, + usage=usage, + ) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.error_message = None + step.completed_at = _now() + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_GENERATE.value]) + + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + project.final_image_url = None + project.final_video_url = None + project.final_video_cover_url = None + project.completed_at = None + project.error_message = None + + await log_module_event( + db, + project=project, + step=step, + event_type=ModuleEventTypeEnum.STEP_UPDATED.value, + message="用户直接修改图片 AI 优化提词,已软删除后续步骤", + detail={"start_deleted_step_index": STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_GENERATE.value]}, + ) + return project, step + + +async def update_hot_opening_video_prompt_schema( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + req: HotOpeningVideoPromptSchemaUpdateRequest, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + """以前端 schema 为 patch 修改第4步视频 AI 提词,不调用 AI、不扣积分。 + + 服务端已有 schema 为基准:视频规格、数组长度、时间段、合规控制、质量控制、协议字段均锁定。 + 最终提示词允许修改,但保存前会清洗视频时长、比例、分辨率、帧率等参数。 + """ + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + step = await _get_step_for_user(db, project_id=project_id, step_id=step_id, user=current_user, for_update=True) + if step.step_code != HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="只能修改第4步视频 AI 提词 JSON schema 子任务") + if step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="视频 AI 提词未完成,不能直接修改") + + output_data = _step_payload(step.output_json) + usage = _step_usage(step.output_json) + input_data = _step_payload(step.input_json) + server_schema = output_data.get("prompt_schema") if isinstance(output_data.get("prompt_schema"), dict) else {} + video_config = output_data.get("params_used_for_prompt") or input_data.get("video_config") or {} + if not isinstance(video_config, dict) or not video_config.get("duration") or not video_config.get("aspect_ratio") or not video_config.get("resolution"): + raise HTTPException(status_code=400, detail="缺少第4步视频参数快照,不能安全修改视频 schema") + + patched_schema = patch_video_prompt_schema_from_client( + server_schema=server_schema, + client_schema=req.prompt_schema, + video_config=video_config, + ) + final_prompt = build_final_video_prompt(patched_schema) + + output_data["prompt_schema"] = patched_schema + output_data["final_prompt"] = final_prompt + output_data["params_used_for_prompt"] = video_config + output_data["manual_edited"] = True + output_data["manual_edited_at"] = _now().isoformat() + + step.output_json = _step_output( + step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload=output_data, + usage=usage, + ) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.error_message = None + step.completed_at = _now() + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.VIDEO_GENERATE.value]) + + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value + project.final_video_url = None + project.final_video_cover_url = None + project.completed_at = None + project.error_message = None + + await log_module_event( + db, + project=project, + step=step, + event_type=ModuleEventTypeEnum.STEP_UPDATED.value, + message="用户修改视频 AI 提词 schema,已软删除视频生成步骤", + detail={ + "start_deleted_step_index": STEP_INDEX_MAP[HotOpeningStepCodeEnum.VIDEO_GENERATE.value], + "locked_fields": [ + "schema_version", + "schema_usage", + "画面属性.视频时长", + "画面属性.视频比例", + "画面属性.清晰度", + "画面属性.帧率", + "画面属性.推荐分辨率", + "动作流程[*].时间段", + "镜头流程[*].时间段", + "动态时间规划", + "输出规格限制", + "质量控制", + "合规控制", + ], + }, + ) + return project, step + + +async def submit_image_prompt_optimize( + db: AsyncSession, + *, + current_user: User, + project_id: str, + material_step_id: str, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + material_step = await _get_step_for_user(db, project_id=project_id, step_id=material_step_id, user=current_user, for_update=True) + if material_step.step_code != HotOpeningStepCodeEnum.MATERIAL_INPUT.value: + raise HTTPException(status_code=400, detail="请基于第1步素材输入子任务生成图片 AI 提词") + if material_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="素材输入子任务未完成,不能生成图片 AI 提词") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value]) + step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=material_step.id, + source_step_id=material_step.id, + input_data={"source_step_id": material_step.id}, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交") + return project, step + + +async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None: + project_result = await db.execute( + select(ModuleGenerationProject) + .where(ModuleGenerationProject.id == project_id, ModuleGenerationProject.module == MODULE, ModuleGenerationProject.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + project = project_result.scalar_one_or_none() + if not project: + return None + + material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value) + if not material_step: + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = "缺少素材输入子任务" + return None + + if step_id: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + else: + step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value) + if not step: + if step_id: + # 用户重复提交后,旧 Celery 消息对应的 step 可能已被软删。 + # 指定 step_id 查不到时必须静默忽略,不能重新创建步骤导致旧任务复活。 + return None + step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=material_step.id, + source_step_id=material_step.id, + input_data={"source_step_id": material_step.id}, + ) + else: + step.status = ModuleStepStatusEnum.PROCESSING.value + step.started_at = _now() + step.error_message = None + + material = _step_payload(material_step.input_json) + prompt_text = ( + "请基于参考素材复刻爆款开头视觉风格,用于生成新项目图片。\n" + f"视频素材内容项目名称:{material.get('source_project_name')}\n" + f"生成项目名称:{material.get('target_project_name')}\n" + f"生成项目核心内容点:{material.get('core_content_point')}\n" + "要求:参考素材视频的开头构图、主体位置、节奏和风格;结合新产品图片生成新项目推广图片;不要照抄原素材品牌、文字、水印;适合作为后续图生视频首帧。" + ) + references = [ + {"type": "video", "url": material.get("material_video_url"), "name": "参考素材视频"}, + {"type": "image", "url": material.get("material_image_url"), "name": "新产品图片"}, + ] + try: + request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"} + log_module_prompt_event( + event_type="module_prompt_request", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.IMAGE_PROMPT.value, + request=request_log, + ) + optimized, token_usage = await optimize_prompt( + db, + original_prompt=prompt_text, + user_id=project.user_id, + references=references, + gen_type="image", + ) + billing = await charge_module_prompt_usage( + db, + user_id=project.user_id, + step_id=step.id, + usage=token_usage, + description="爆款开头复刻-图片AI提词优化", + ) + usage = dict(token_usage or {}) + usage.update({ + "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), + "credit_biz_key": billing.items[0].biz_key if billing.items else None, + }) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload={ + "optimized_prompt": optimized, + "prompt": optimized, + "original_prompt": prompt_text, + "references": references, + }, + usage=usage, + ) + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + project.error_message = None + log_module_prompt_event( + event_type="module_prompt_response", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.IMAGE_PROMPT.value, + request=request_log, + response={"optimized_prompt": optimized}, + token_usage=usage, + ) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUCCESS.value, message="图片 AI 提词生成成功") + except Exception as exc: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = str(exc) + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = f"图片 AI 提词生成失败: {exc}" + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message) + return step + + +async def generate_image_from_prompt( + db: AsyncSession, + *, + current_user: User, + project_id: str, + prompt_step_id: str, + req: HotOpeningGenerateImageRequest, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + prompt_step = await _get_step_for_user(db, project_id=project_id, step_id=prompt_step_id, user=current_user, for_update=True) + if prompt_step.step_code != HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="请基于第2步图片 AI 提词子任务生成图片") + if prompt_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="图片 AI 提词未完成,不能生成图片") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_GENERATE.value]) + + material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value) + material = _step_payload(material_step.input_json if material_step else None) + prompt_output = _unwrap_step_output(prompt_step.output_json) + optimized_prompt = prompt_output.get("optimized_prompt") or prompt_output.get("prompt") or "" + refs = [ + {"type": "image", "url": material.get("material_image_url"), "name": "新产品图片"}, + ] + + chat_task = await create_chat_generation_task_for_module( + db, + current_user=current_user, + generation_mode=GENERATION_MODE, + gen_type="image", + original_prompt=prompt_output.get("original_prompt") or optimized_prompt, + optimized_prompt=optimized_prompt, + engine_id=req.engine_id, + media_references=refs, + image_size=req.image_size, + image_proportion=req.image_proportion, + image_px=req.image_px, + billing_project_name=project.title or "爆款开头复刻", + billing_description_prefix="爆款开头复刻图片生成", + ) + step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + chat_task_id=chat_task.id, + input_data={ + "engine_id": chat_task.engine_id, + "params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px}, + "prompt": optimized_prompt, + "media_references": refs, + }, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = HotOpeningStepCodeEnum.IMAGE_GENERATE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_GENERATE_SUBMITTED.value, message="图片生成任务已提交", detail={"chat_task_id": chat_task.id}) + return project, step + + +async def _resolve_video_prompt_config(db: AsyncSession, req: HotOpeningGenerateVideoPromptRequest) -> dict[str, Any]: + engine = await _get_video_engine(db, req.engine_id) + supported_ratios = _parse_list(engine.supported_ratios, []) + supported_resolutions = _parse_list(engine.supported_resolutions, []) + supported_durations = _parse_list(engine.supported_durations, []) + + default_ratio = getattr(settings, "HOT_OPENING_DEFAULT_VIDEO_RATIO", None) or VIDEO_DEFAULT_RATIO + default_resolution = getattr(settings, "HOT_OPENING_DEFAULT_VIDEO_RESOLUTION", None) or VIDEO_DEFAULT_RESOLUTION + default_duration = int(getattr(settings, "HOT_OPENING_DEFAULT_VIDEO_DURATION", None) or VIDEO_DEFAULT_DURATION) + + selected_ratio = req.aspect_ratio or (default_ratio if not supported_ratios or default_ratio in supported_ratios else supported_ratios[0]) + selected_resolution = req.resolution or (default_resolution if not supported_resolutions or default_resolution in supported_resolutions else supported_resolutions[0]) + selected_duration = req.duration or (default_duration if not supported_durations or default_duration in supported_durations else supported_durations[0]) + + if supported_ratios and selected_ratio not in supported_ratios: + raise HTTPException(status_code=400, detail=f"视频比例不支持: {selected_ratio}") + if supported_resolutions and selected_resolution not in supported_resolutions: + raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {selected_resolution}") + if supported_durations and selected_duration not in supported_durations: + raise HTTPException(status_code=400, detail=f"视频时长不支持: {selected_duration}") + if engine.max_duration and int(selected_duration) > int(engine.max_duration): + raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒") + + return { + "engine_id": engine.id, + "engine_name": engine.name, + "duration": int(selected_duration), + "aspect_ratio": selected_ratio, + "resolution": selected_resolution, + "supported_ratios": supported_ratios, + "supported_resolutions": supported_resolutions, + "supported_durations": supported_durations, + "max_duration": engine.max_duration, + "frame_rate": "30fps", + "reference_video_fps": max(1, int(settings.CHATAPI_VIDEO_FPS or 1)), + } + + +async def submit_video_prompt_optimize( + db: AsyncSession, + *, + current_user: User, + project_id: str, + image_step_id: str, + req: HotOpeningGenerateVideoPromptRequest, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + image_step = await _get_step_for_user(db, project_id=project_id, step_id=image_step_id, user=current_user, for_update=True) + if image_step.step_code != HotOpeningStepCodeEnum.IMAGE_GENERATE.value: + raise HTTPException(status_code=400, detail="请基于第3步图片生成子任务生成视频 AI 提词") + if image_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="图片生成子任务未完成,不能生成视频 AI 提词") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value]) + video_config = await _resolve_video_prompt_config(db, req) + step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=image_step.id, + source_step_id=image_step.id, + input_data={ + "source_step_id": image_step.id, + "video_config": video_config, + "target_platform": req.target_platform or getattr(settings, "HOT_OPENING_DEFAULT_TARGET_PLATFORM", "抖音") or "抖音", + }, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交") + return project, step + + +async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None: + project_result = await db.execute( + select(ModuleGenerationProject) + .where(ModuleGenerationProject.id == project_id, ModuleGenerationProject.module == MODULE, ModuleGenerationProject.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + project = project_result.scalar_one_or_none() + if not project: + return None + + material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value) + image_prompt_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value) + image_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.IMAGE_GENERATE.value) + if not material_step or not image_step: + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = "生成视频提词失败:缺少素材输入或图片生成结果" + return None + + if step_id: + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.step_code == HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + else: + step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value) + if not step: + if step_id: + # 用户重复提交后,旧 Celery 消息对应的 step 可能已被软删。 + # 指定 step_id 查不到时必须静默忽略,不能把当前项目标记失败。 + return None + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = "缺少视频 AI 提词子任务,请先手动提交视频提词生成" + return None + + step.status = ModuleStepStatusEnum.PROCESSING.value + step.started_at = _now() + step.error_message = None + + material = _step_payload(material_step.input_json) + image_output = _unwrap_step_output(image_step.output_json) + step_input = _step_payload(step.input_json) + video_config = step_input.get("video_config") or {} + target_platform = step_input.get("target_platform") or getattr(settings, "HOT_OPENING_DEFAULT_TARGET_PLATFORM", "抖音") or "抖音" + generated_image_url = image_output.get("result_image_url") or project.final_image_url + if not generated_image_url: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = "缺少新项目图片结果,不能生成视频提词" + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + return step + + try: + request_log = { + "source_project_name": material.get("source_project_name") or "无", + "target_project_name": material.get("target_project_name") or "无", + "core_content_point": material.get("core_content_point") or "无", + "material_video_url": material.get("material_video_url") or "", + "generated_image_url": generated_image_url, + "video_config": video_config, + "target_platform": target_platform, + } + log_module_prompt_event( + event_type="module_prompt_request", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value, + request=request_log, + ) + prompt_schema, final_prompt, token_usage = await optimize_hot_opening_video_prompt( + db, + user_id=project.user_id, + source_project_name=request_log["source_project_name"], + target_project_name=request_log["target_project_name"], + core_content_point=request_log["core_content_point"], + material_video_url=request_log["material_video_url"], + generated_image_url=generated_image_url, + video_config=video_config, + target_platform=target_platform, + ) + billing = await charge_module_prompt_usage( + db, + user_id=project.user_id, + step_id=step.id, + usage=token_usage, + description="爆款开头复刻-视频AI提词优化", + ) + usage = dict(token_usage or {}) + usage.update({ + "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), + "credit_biz_key": billing.items[0].biz_key if billing.items else None, + }) + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + payload={ + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "params_used_for_prompt": video_config, + "target_platform": target_platform, + }, + usage=usage, + ) + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value + project.error_message = None + log_module_prompt_event( + event_type="module_prompt_response", + project_id=project.id, + step_id=step.id, + user_id=project.user_id, + module=project.module, + prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value, + request=request_log, + response={"prompt_schema": prompt_schema, "final_prompt": final_prompt}, + token_usage=usage, + ) + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUCCESS.value, message="视频 AI 提词生成成功") + except Exception as exc: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = str(exc) + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = f"视频 AI 提词生成失败: {exc}" + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message) + return step + + +async def generate_video_from_prompt( + db: AsyncSession, + *, + current_user: User, + project_id: str, + prompt_step_id: str, + req: HotOpeningGenerateVideoRequest, +) -> tuple[ModuleGenerationProject, ModuleGenerationStep]: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + prompt_step = await _get_step_for_user(db, project_id=project_id, step_id=prompt_step_id, user=current_user, for_update=True) + if prompt_step.step_code != HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: + raise HTTPException(status_code=400, detail="请基于第4步视频 AI 提词子任务生成视频") + if prompt_step.status != ModuleStepStatusEnum.COMPLETED.value: + raise HTTPException(status_code=400, detail="视频 AI 提词未完成,不能生成视频") + + await _soft_delete_steps_from_index(db, project=project, start_index=STEP_INDEX_MAP[HotOpeningStepCodeEnum.VIDEO_GENERATE.value]) + + image_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.IMAGE_GENERATE.value) + image_output = _unwrap_step_output(image_step.output_json if image_step else None) + prompt_output = _unwrap_step_output(prompt_step.output_json) + final_prompt = prompt_output.get("final_prompt") or "" + prompt_schema = prompt_output.get("prompt_schema") or {} + prompt_schema_str = json.dumps(prompt_schema, ensure_ascii=False, default=str) if prompt_schema else "" + prompt_input = _step_payload(prompt_step.input_json) + prompt_params = prompt_output.get("params_used_for_prompt") or prompt_input.get("video_config") or {} + duration = int(prompt_params.get("duration") or settings.HOT_OPENING_DEFAULT_VIDEO_DURATION or 4) + aspect_ratio = prompt_params.get("aspect_ratio") or settings.HOT_OPENING_DEFAULT_VIDEO_RATIO or "9:16" + resolution = prompt_params.get("resolution") or settings.HOT_OPENING_DEFAULT_VIDEO_RESOLUTION or "480p" + generated_image_url = image_output.get("result_image_url") or project.final_image_url + if not generated_image_url: + raise HTTPException(status_code=400, detail="缺少新项目图片结果,不能生成视频") + + refs = [ + {"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"}, + ] + + chat_task = await create_chat_generation_task_for_module( + db, + current_user=current_user, + generation_mode=GENERATION_MODE, + gen_type="video", + original_prompt=prompt_schema_str or final_prompt, + optimized_prompt=prompt_schema_str or final_prompt, + engine_id=req.engine_id or prompt_params.get("engine_id"), + media_references=refs, + duration=duration, + aspect_ratio=aspect_ratio, + resolution=resolution, + billing_project_name=project.title or "爆款开头复刻", + billing_description_prefix="爆款开头复刻视频生成", + ) + step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + chat_task_id=chat_task.id, + input_data={ + "engine_id": chat_task.engine_id, + "params": { + "duration": chat_task.duration, + "aspect_ratio": chat_task.aspect_ratio, + "resolution": chat_task.resolution, + "image_size": chat_task.image_size, + "image_proportion": chat_task.image_proportion, + "image_px": chat_task.image_px, + }, + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "media_references": refs, + }, + ) + project.status = ModuleProjectStatusEnum.PROCESSING.value + project.current_step_code = HotOpeningStepCodeEnum.VIDEO_GENERATE.value + project.error_message = None + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_GENERATE_SUBMITTED.value, message="视频生成任务已提交", detail={"chat_task_id": chat_task.id}) + return project, step + + +async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGenerationTask) -> None: + if not task or task.generation_mode != GENERATION_MODE: + return + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.chat_task_id == task.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.deleted_at.is_(None), + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + if not step: + return + project_result = await db.execute( + select(ModuleGenerationProject) + .where(ModuleGenerationProject.id == step.project_id, ModuleGenerationProject.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + project = project_result.scalar_one_or_none() + if not project: + return + + if step.step_code == HotOpeningStepCodeEnum.IMAGE_GENERATE.value: + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + result={"result_image_url": task.image_url, "chat_task_id": task.id}, + ) + project.final_image_url = task.image_url + project.status = ModuleProjectStatusEnum.WAITING_USER.value + project.current_step_code = HotOpeningStepCodeEnum.IMAGE_GENERATE.value + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_GENERATE_SUCCESS.value, message="图片生成完成,等待用户手动生成视频 AI 提词") + elif step.step_code == HotOpeningStepCodeEnum.VIDEO_GENERATE.value: + step.status = ModuleStepStatusEnum.COMPLETED.value + step.completed_at = _now() + step.output_json = _step_output( + step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value, + status=ModuleStepStatusEnum.COMPLETED.value, + result={"result_video_url": task.video_url, "result_video_cover_url": task.video_cover_url, "chat_task_id": task.id}, + ) + project.final_video_url = task.video_url + project.final_video_cover_url = task.video_cover_url + project.status = ModuleProjectStatusEnum.COMPLETED.value + project.current_step_code = HotOpeningStepCodeEnum.VIDEO_GENERATE.value + project.completed_at = _now() + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_GENERATE_SUCCESS.value, message="视频生成完成,总任务完成") + + +async def handle_chat_generation_task_failed(db: AsyncSession, task: ChatGenerationTask) -> None: + if not task or task.generation_mode != GENERATION_MODE: + return + result = await db.execute( + select(ModuleGenerationStep) + .where(ModuleGenerationStep.chat_task_id == task.id, ModuleGenerationStep.module == MODULE, ModuleGenerationStep.is_current == True, ModuleGenerationStep.deleted_at.is_(None)) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + if not step: + return + project_result = await db.execute(select(ModuleGenerationProject).where(ModuleGenerationProject.id == step.project_id).with_for_update().limit(1)) + project = project_result.scalar_one_or_none() + if not project: + return + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = task.error_message + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = task.error_message or "生成失败" + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=project.error_message, detail={"chat_task_id": task.id}) + + +async def mark_hot_opening_step_dispatch_failed( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + error_message: str, +) -> None: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + result = await db.execute( + select(ModuleGenerationStep) + .where( + ModuleGenerationStep.id == step_id, + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ) + .with_for_update() + .limit(1) + ) + step = result.scalar_one_or_none() + if not step: + return + if step.chat_task_id: + await mark_chat_generation_task_failed_and_refund_once( + db, + task_id=step.chat_task_id, + error_message=error_message, + pipeline_stage="failed", + ) + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = error_message + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = error_message + await log_module_event( + db, + project=project, + step=step, + event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, + message=error_message, + detail={"reason": "celery_dispatch_failed"}, + ) + + +async def delete_hot_opening_project(db: AsyncSession, *, current_user: User, project_id: str) -> HotOpeningDeleteOut: + project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + deleted_at = _now() + project.deleted_at = deleted_at + await _soft_delete_steps_from_index(db, project=project, start_index=1, deleted_at=deleted_at) + await log_module_event(db, project=project, event_type=ModuleEventTypeEnum.PROJECT_DELETED.value, message="软删除爆款开头复刻项目") + return HotOpeningDeleteOut(message="项目已删除", project_id=project.id, deleted=True) + +def _build_file_url_or_data_uri(file_url: str) -> str: + """ + Convert local upload path to base64 data URI. + Keep remote http/https/data URLs as-is. + """ + if file_url.startswith(("http://", "https://", "data:")): + return file_url + file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400) + return f"{settings.BASE_URL}{file_url_sign}" \ No newline at end of file diff --git a/video-gen-api/app/services/hot_opening_video_prompt_service.py b/video-gen-api/app/services/hot_opening_video_prompt_service.py new file mode 100644 index 00000000..d6b2858f --- /dev/null +++ b/video-gen-api/app/services/hot_opening_video_prompt_service.py @@ -0,0 +1,707 @@ +from __future__ import annotations + +import copy +import json +import re +from typing import Any + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSchemaUsageEnum +from app.models.model_config import ModelConfig +from app.models.token_usage import TokenUsage +from app.utils.id_gen import generate_id +from app.services.resource_signed_url_service import build_resource_signed_url + +DEFAULT_FRAME_RATE = "30fps" +DEFAULT_REFERENCE_VIDEO_FPS = 1 + +CLIENT_SCHEMA_V1: dict[str, Any] = { + "schema_version": PromptSchemaVersionEnum.CLIENT_V1.value, + "schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value, + "基础分类": { + "生成类型": "文生视频/图生视频/视频生视频/数字人视频/无", + "视频大类": "产品广告视频/电商带货视频/口播讲解视频/剧情视频/教程视频/风景旅行视频/美食视频/宠物视频/动漫卡通视频/游戏视频/企业宣传视频/新闻资讯视频/直播切片视频/图文快闪视频/音乐舞蹈视频/运动健身视频/无", + "视频子类": "产品推广短视频/口播讲解/电商带货/剧情演绎/操作教程/旅行风景/美食展示/宠物互动/二次元动画/游戏宣传/企业介绍/资讯播报/直播高光/图文快闪/无", + "视频用途": "广告投放/社媒发布/产品展示/课程教学/品牌宣传/娱乐内容/信息科普/无", + "目标平台": "抖音/快手/小红书/微信视频号/B站/TikTok/YouTube Shorts/Instagram Reels/无", + }, + "素材理解": { + "是否有参考图片": "是/否", + "是否有参考视频": "是/否", + "参考视频用途": "动作参考/镜头参考/风格参考/运镜参考/节奏参考/无", + "需要保留": [], + "允许改动": [], + "禁止改动": [], + }, + "业务属性": { + "产品类型": "APP/实物商品/食品/服饰/美妆/电子产品/汽车/房产/课程/服务/无", + "产品名称": "无", + "品牌名称": "无", + "核心卖点": [], + "目标受众": "无", + "核心表达目标": "无", + "内容风格": "无", + "行动引导": "立即体验/立即下载/立即购买/点击了解/预约咨询/无", + }, + "画面属性": { + "视频时长": "无", + "视频比例": "无", + "清晰度": "无", + "帧率": "无", + "主体描述": "无", + "主体数量": "无", + "主体位置": "无", + "主体占比": "无", + "场景描述": "无", + "构图方式": "无", + "画面风格": "无", + "光影色彩": "无", + }, + "动作流程": [], + "镜头流程": [], + "字幕与口播": { + "是否需要字幕": "是/否", + "字幕内容": [], + "字幕位置": "无", + "字幕样式": "无", + "是否口播": "是/否", + "口播内容": "无", + "口播语气": "无", + "口播语速": "无", + "是否需要口型同步": "是/否/无", + }, + "音频与节奏": { + "背景音乐": "无", + "音乐风格": "无", + "音乐节奏": "无", + "环境音": "无", + "动作音效": "无", + "整体节奏": "慢节奏/中等节奏/快节奏/卡点节奏/无", + }, + "合规控制": { + "是否广告": "是/否", + "风险等级": "低/中/高", + "安全表达": "无", + "禁用词": [], + "合规说明": "无", + }, + "质量控制": { + "主体一致性": "低/中/高/无", + "产品一致性": "低/中/高/无", + "动作自然度": "低/中/高/无", + "镜头稳定性": "低/中/高/无", + "字幕准确性": "低/中/高/无", + }, + "最终提示词": { + "主提示词": "无", + "动作提示词": "无", + "镜头提示词": "无", + "字幕提示词": "无", + "音频提示词": "无", + "风格提示词": "无", + "负面提示词": "无", + }, +} + + +def _safe_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _format_options(options: list[Any], fallback: str = "无") -> str: + values = [str(item) for item in _safe_list(options) if str(item).strip()] + return "/".join(values) if values else fallback + + +def get_recommended_resolution(video_ratio: str, resolution: str) -> str: + """按比例和清晰度粗略计算推荐像素,不在服务内维护固定比例/分辨率白名单。""" + try: + width_ratio, height_ratio = [float(x) for x in str(video_ratio).split(":", 1)] + short_edge = int(str(resolution).lower().replace("p", "")) + if width_ratio >= height_ratio: + height = short_edge + width = round(short_edge * width_ratio / height_ratio) + else: + width = short_edge + height = round(short_edge * height_ratio / width_ratio) + return f"{width}x{height}" + except Exception: + return "无" + + +def _build_scaled_bounds(duration: int, ratios: list[float]) -> list[int]: + duration = max(1, int(duration)) + raw = [0] + acc = 0.0 + for ratio in ratios[:-1]: + acc += ratio + raw.append(max(raw[-1] + 1, min(duration - 1, round(duration * acc)))) + raw.append(duration) + for index in range(1, len(raw)): + if raw[index] <= raw[index - 1]: + raw[index] = min(duration, raw[index - 1] + 1) + raw[-1] = duration + return raw + + +def _bounds_to_plan(bounds: list[int], stages: list[tuple[str, str]]) -> list[dict[str, str]]: + plan: list[dict[str, str]] = [] + for idx, (stage, desc) in enumerate(stages): + start = bounds[idx] + end = bounds[idx + 1] + plan.append({"时间段": f"{start}-{end}秒", "阶段": stage, "说明": desc}) + return plan + + +def build_time_plan(duration: int) -> list[dict[str, str]]: + duration = max(1, int(duration)) + if duration <= 5: + return _bounds_to_plan( + _build_scaled_bounds(duration, [0.2, 0.4, 0.4]), + [ + ("开场吸引", "快速建立主体、产品和画面风格"), + ("核心展示", "展示主体动作、核心卖点或主要视觉内容"), + ("行动引导", "强化记忆点并给出转化引导"), + ], + ) + if duration <= 8: + return _bounds_to_plan( + _build_scaled_bounds(duration, [0.15, 0.3, 0.35, 0.2]), + [ + ("开场吸引", "快速吸引注意力"), + ("主体展示", "展示主体和产品关系"), + ("核心卖点", "突出新项目核心内容点"), + ("收尾引导", "给出行动引导并稳定落版"), + ], + ) + return _bounds_to_plan( + _build_scaled_bounds(duration, [0.13, 0.2, 0.27, 0.25, 0.15]), + [ + ("爆款开头", "复刻参考素材开头节奏和视觉吸引点"), + ("主体建立", "明确新项目主体和产品信息"), + ("卖点放大", "围绕核心内容点展开动作和镜头"), + ("情绪推进", "用动作、字幕或镜头变化强化记忆"), + ("转化收尾", "给出清晰行动引导"), + ], + ) + + +def build_dynamic_schema(video_config: dict[str, Any]) -> dict[str, Any]: + schema = copy.deepcopy(CLIENT_SCHEMA_V1) + duration = int(video_config["duration"]) + video_ratio = str(video_config["aspect_ratio"]) + resolution = str(video_config["resolution"]) + frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE) + recommended_resolution = get_recommended_resolution(video_ratio, resolution) + + schema["画面属性"].update( + { + "视频时长": f"{duration}秒", + "视频比例": video_ratio, + "清晰度": resolution, + "推荐分辨率": recommended_resolution, + "帧率": frame_rate, + } + ) + schema["动态时间规划"] = build_time_plan(duration) + schema["输出规格限制"] = { + "支持时长": _safe_list(video_config.get("supported_durations")), + "支持比例": _safe_list(video_config.get("supported_ratios")), + "支持分辨率": _safe_list(video_config.get("supported_resolutions")), + "当前推荐分辨率": recommended_resolution, + } + return schema + + +def infer_generation_type(references: list[dict[str, str]] | None) -> str: + has_image = any(item.get("type") == "image" for item in references or []) + has_video = any(item.get("type") == "video" for item in references or []) + if has_image and has_video: + return "图生视频/视频生视频" + if has_image: + return "图生视频" + if has_video: + return "视频生视频" + return "文生视频" + + +def infer_video_category(text: str) -> tuple[str, str, list[str]]: + text = text or "" + if any(key in text for key in ["APP", "应用", "下载", "社交", "脱单", "附近"]): + return "产品广告视频", "产品推广短视频", ["产品推广", "用户转化", "核心卖点展示"] + return "产品广告视频", "产品推广短视频", ["产品展示", "视觉吸引", "行动引导"] + + +def build_system_prompt() -> str: + return ( + "你是专业短视频广告导演和AI视频提示词工程师。" + "你必须只输出一个合法 JSON 对象,不能输出 Markdown。" + "输出必须严格遵循用户提供的 schema 顶层结构。" + "所有未知、无法判断或不适用的字段填写'无',数组字段可填写 []。" + "必须根据参考视频复刻爆款开头的节奏、构图、动作和镜头语言,但不能照抄品牌、水印、字幕或侵权元素。" + ) + + +def build_user_text( + *, + source_project_name: str, + target_project_name: str, + core_content_point: str, + target_platform: str, + references: list[dict[str, str]], + video_config: dict[str, Any], + client_schema: dict[str, Any], +) -> str: + duration = int(video_config["duration"]) + video_ratio = str(video_config["aspect_ratio"]) + resolution = str(video_config["resolution"]) + frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE) + category, sub_category, default_points = infer_video_category(" ".join([source_project_name, target_project_name, core_content_point])) + return json.dumps( + { + "任务": "基于参考素材视频和新项目图片,生成可用于AI视频生成的中文结构化提示词JSON", + "业务输入": { + "视频素材内容项目名称": source_project_name, + "生成项目名称": target_project_name, + "生成项目核心内容点": core_content_point, + "目标平台": target_platform, + "默认视频大类": category, + "默认视频子类": sub_category, + "建议核心卖点": default_points, + }, + "视频规格": { + "视频时长": f"{duration}秒", + "视频比例": video_ratio, + "清晰度": resolution, + "帧率": frame_rate, + "支持时长": _safe_list(video_config.get("supported_durations")), + "支持比例": _safe_list(video_config.get("supported_ratios")), + "支持分辨率": _safe_list(video_config.get("supported_resolutions")), + "推荐分辨率": get_recommended_resolution(video_ratio, resolution), + }, + "参考素材": references, + "输出要求": { + "生成类型": infer_generation_type(references), + "必须填充动态时间规划": build_time_plan(duration), + "必须填充动作流程": "动作流程时间段必须覆盖完整视频时长", + "必须填充镜头流程": "镜头流程时间段必须覆盖完整视频时长", + "最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。", + "禁止": ["输出 Markdown", "输出 schema 之外的解释文字", "照抄参考素材品牌水印", "生成违法违规内容", "在最终提示词中写入秒数/比例/分辨率/帧率"], + }, + "必须按此schema输出": client_schema, + }, + ensure_ascii=False, + ) + + +def build_user_message(user_content: str, references: list[dict[str, str]], reference_video_fps: int) -> tuple[dict[str, Any], dict[str, Any]]: + content_parts: list[dict[str, Any]] = [{"type": "text", "text": user_content}] + log_content_parts: list[dict[str, Any]] = [{"type": "text", "text": user_content}] + for ref in references: + ref_type = ref.get("type") + ref_url = ref.get("url") + if not ref_url: + continue + if ref_type == "image": + content_parts.append({"type": "image_url", "image_url": {"url": ref_url}}) + log_content_parts.append({"type": "image_url", "image_url": {"url": ref_url}}) + elif ref_type == "video": + content_parts.append({"type": "video_url", "video_url": {"url": ref_url, "fps": reference_video_fps}}) + log_content_parts.append({"type": "video_url", "video_url": {"url": ref_url, "fps": reference_video_fps}}) + return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts} + + +def strip_json_code_fence(text: str) -> str: + text = (text or "").strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) + text = re.sub(r"\s*```$", "", text) + return text.strip() + + +def parse_model_json(content: str) -> dict[str, Any]: + content = strip_json_code_fence(content) + data = json.loads(content) + if not isinstance(data, dict): + raise ValueError("视频提词优化返回值不是 JSON 对象") + return data + + +def fill_none_with_wu(value: Any) -> Any: + if value is None or value == "": + return "无" + if isinstance(value, dict): + return {k: fill_none_with_wu(v) for k, v in value.items()} + if isinstance(value, list): + return [fill_none_with_wu(v) for v in value] + return value + + +def ensure_top_keys(result: dict[str, Any]) -> dict[str, Any]: + schema = copy.deepcopy(CLIENT_SCHEMA_V1) + for key, default_value in schema.items(): + if key not in result: + result[key] = default_value + elif isinstance(default_value, dict) and isinstance(result.get(key), dict): + merged = copy.deepcopy(default_value) + merged.update(result[key]) + result[key] = merged + return result + + +def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int) -> dict[str, Any]: + plan = build_time_plan(duration) + if not isinstance(result.get("动作流程"), list) or not result["动作流程"]: + result["动作流程"] = [ + {"时间段": item["时间段"], "动作内容": item["说明"]} + for item in plan + ] + if not isinstance(result.get("镜头流程"), list) or not result["镜头流程"]: + result["镜头流程"] = [ + {"时间段": item["时间段"], "镜头内容": item["说明"]} + for item in plan + ] + result["动作流程"] = _align_flow_time_ranges(result["动作流程"], plan, "动作内容") + result["镜头流程"] = _align_flow_time_ranges(result["镜头流程"], plan, "镜头内容") + result["动态时间规划"] = plan + return result + + +def ensure_negative_prompt(result: dict[str, Any]) -> dict[str, Any]: + final = result.setdefault("最终提示词", {}) + if not isinstance(final, dict): + final = {} + result["最终提示词"] = final + if not final.get("负面提示词") or final.get("负面提示词") == "无": + final["负面提示词"] = "画面模糊、主体畸变、手指畸形、脸部崩坏、字幕乱码、产品变形、镜头抖动、画面闪烁" + return result + + +def build_final_video_prompt(result: dict[str, Any]) -> str: + final = result.get("最终提示词", {}) if isinstance(result.get("最终提示词"), dict) else {} + parts = [ + final.get("主提示词"), + final.get("动作提示词"), + final.get("镜头提示词"), + final.get("字幕提示词"), + final.get("音频提示词"), + final.get("风格提示词"), + ] + return "\n".join(str(item).strip() for item in parts if item and str(item).strip() != "无") + + +VIDEO_SPEC_PROMPT_KEYS = ("主提示词", "动作提示词", "镜头提示词", "字幕提示词", "音频提示词", "风格提示词", "负面提示词") + + +def _normalize_prompt_text(text: str) -> str: + text = re.sub(r"[,、,;;::]\s*([,、,;;::])", r"\1", text) + text = re.sub(r"\s{2,}", " ", text) + text = re.sub(r"^[,、,;;::\s]+", "", text) + text = re.sub(r"[,、,;;::\s]+$", "", text) + return text.strip() or "无" + + +def clean_video_spec_from_prompt_text(text: Any, video_config: dict[str, Any] | None = None) -> str: + """清洗最终提示词里的视频规格参数。 + + 视频时长、比例、清晰度、分辨率、帧率属于接口参数和锁定字段, + 不能混入最终提示词,避免用户绕过扣费参数或与第5步视频生成参数冲突。 + """ + if text is None: + return "无" + value = str(text).strip() + if not value or value == "无": + return "无" + + cfg = video_config or {} + exact_values = { + str(cfg.get("aspect_ratio") or "").strip(), + str(cfg.get("resolution") or "").strip(), + str(cfg.get("frame_rate") or "").strip(), + } + try: + if cfg.get("duration") is not None: + exact_values.add(f"{int(cfg.get('duration'))}秒") + except Exception: + pass + try: + if cfg.get("aspect_ratio") and cfg.get("resolution"): + exact_values.add(get_recommended_resolution(str(cfg.get("aspect_ratio")), str(cfg.get("resolution")))) + except Exception: + pass + + for item in sorted((v for v in exact_values if v and v != "无"), key=len, reverse=True): + value = value.replace(item, "") + + patterns = [ + r"\d+\s*秒", + r"\b\d+\s*[sS]\b", + r"\d+\s*[::]\s*\d+", + r"\d{3,4}\s*[pP]", + r"\d{2,4}\s*[xX×]\s*\d{2,4}", + r"\d+\s*(?:fps|FPS|帧)", + r"(?:竖屏|横屏|方屏|超清|高清|标清|蓝光|4K|8K)", + r"(?:视频时长|时长|视频比例|画面比例|比例|分辨率|清晰度|帧率|推荐分辨率)\s*[::]?\s*", + ] + for pattern in patterns: + value = re.sub(pattern, "", value, flags=re.I) + return _normalize_prompt_text(value) + + +def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any] | None = None) -> dict[str, Any]: + final = schema.setdefault("最终提示词", {}) + if not isinstance(final, dict): + final = {} + schema["最终提示词"] = final + for key in VIDEO_SPEC_PROMPT_KEYS: + final[key] = clean_video_spec_from_prompt_text(final.get(key), video_config) + return schema + + +def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, Any]]: + source = flow if isinstance(flow, list) else [] + aligned: list[dict[str, Any]] = [] + for index, plan_item in enumerate(plan): + old_item = source[index] if index < len(source) and isinstance(source[index], dict) else {} + item = dict(old_item) + item["时间段"] = plan_item["时间段"] + if not any(k in item and str(item.get(k)).strip() for k in (default_content_key, "动作", "镜头", "说明", "内容")): + item[default_content_key] = plan_item["说明"] + aligned.append(item) + return aligned + + +def _merge_editable_dict_fields(base: dict[str, Any], patch: dict[str, Any], allowed_keys: set[str]) -> None: + for key in allowed_keys: + if key in patch: + base[key] = fill_none_with_wu(patch.get(key)) + + +def _merge_flow_patch(base_flow: Any, patch_flow: Any) -> list[dict[str, Any]]: + base = [dict(item) for item in base_flow] if isinstance(base_flow, list) else [] + patch = patch_flow if isinstance(patch_flow, list) else [] + result: list[dict[str, Any]] = [] + for index, base_item in enumerate(base): + merged = dict(base_item) + patch_item = patch[index] if index < len(patch) and isinstance(patch[index], dict) else {} + original_time_range = merged.get("时间段") + for key, value in patch_item.items(): + if key == "时间段": + continue + merged[key] = fill_none_with_wu(value) + merged["时间段"] = original_time_range + result.append(merged) + return result + + +def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]: + duration = int(video_config["duration"]) + video_ratio = str(video_config["aspect_ratio"]) + resolution = str(video_config["resolution"]) + frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE) + recommended_resolution = get_recommended_resolution(video_ratio, resolution) + dynamic_schema = build_dynamic_schema(video_config) + plan = build_time_plan(duration) + + schema["schema_version"] = PromptSchemaVersionEnum.CLIENT_V1.value + schema["schema_usage"] = VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value + + frame = schema.setdefault("画面属性", {}) + if not isinstance(frame, dict): + frame = {} + schema["画面属性"] = frame + frame.update( + { + "视频时长": f"{duration}秒", + "视频比例": video_ratio, + "清晰度": resolution, + "帧率": frame_rate, + "推荐分辨率": recommended_resolution, + } + ) + + schema["动态时间规划"] = plan + schema["输出规格限制"] = dynamic_schema.get("输出规格限制", {}) + + schema["动作流程"] = _align_flow_time_ranges(schema.get("动作流程"), plan, "动作内容") + schema["镜头流程"] = _align_flow_time_ranges(schema.get("镜头流程"), plan, "镜头内容") + + # 合规和质量控制不能被前端降低;AI 返回缺失时使用服务端默认结构补齐。 + default_schema = copy.deepcopy(CLIENT_SCHEMA_V1) + if not isinstance(schema.get("合规控制"), dict): + schema["合规控制"] = default_schema["合规控制"] + if not isinstance(schema.get("质量控制"), dict): + schema["质量控制"] = default_schema["质量控制"] + + return clean_final_prompt_specs(schema, video_config) + + +def normalize_video_prompt_schema_from_ai(result: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]: + duration = int(video_config["duration"]) + normalized = ensure_top_keys(fill_none_with_wu(result if isinstance(result, dict) else {})) + normalized = ensure_flow_matches_time_plan(normalized, duration) + normalized = ensure_negative_prompt(normalized) + return apply_locked_video_schema_fields(normalized, video_config) + + +def patch_video_prompt_schema_from_client( + *, + server_schema: dict[str, Any], + client_schema: dict[str, Any], + video_config: dict[str, Any], +) -> dict[str, Any]: + """以前端 JSON 作为 patch,回填到服务端已有 schema。 + + 禁止整包覆盖:数组长度、时间段、视频规格、输出规格、质量控制、合规控制、schema 协议字段均以服务端为准。 + """ + base = ensure_top_keys(fill_none_with_wu(copy.deepcopy(server_schema if isinstance(server_schema, dict) else {}))) + patch = client_schema if isinstance(client_schema, dict) else {} + + for key in ("基础分类", "素材理解", "业务属性", "字幕与口播", "音频与节奏"): + if isinstance(base.get(key), dict) and isinstance(patch.get(key), dict): + base[key].update(fill_none_with_wu(patch[key])) + + if isinstance(base.get("画面属性"), dict) and isinstance(patch.get("画面属性"), dict): + _merge_editable_dict_fields( + base["画面属性"], + patch["画面属性"], + {"主体描述", "主体数量", "主体位置", "主体占比", "场景描述", "构图方式", "画面风格", "光影色彩"}, + ) + + if isinstance(patch.get("动作流程"), list): + base["动作流程"] = _merge_flow_patch(base.get("动作流程"), patch.get("动作流程")) + if isinstance(patch.get("镜头流程"), list): + base["镜头流程"] = _merge_flow_patch(base.get("镜头流程"), patch.get("镜头流程")) + + # 动态时间规划保持服务端数组长度和时间段,只允许保留原值;不接受客户端 patch。 + + if isinstance(base.get("最终提示词"), dict) and isinstance(patch.get("最终提示词"), dict): + for key in VIDEO_SPEC_PROMPT_KEYS: + if key in patch["最终提示词"]: + base["最终提示词"][key] = fill_none_with_wu(patch["最终提示词"].get(key)) + + return apply_locked_video_schema_fields(base, video_config) + + +def _mock_result(video_config: dict[str, Any], target_platform: str) -> dict[str, Any]: + duration = int(video_config["duration"]) + video_ratio = str(video_config["aspect_ratio"]) + resolution = str(video_config["resolution"]) + schema = build_dynamic_schema(video_config) + schema["基础分类"].update({"生成类型": "图生视频/视频生视频", "视频大类": "产品广告视频", "视频子类": "产品推广短视频", "视频用途": "社媒发布", "目标平台": target_platform}) + schema["素材理解"].update({"是否有参考图片": "是", "是否有参考视频": "是", "参考视频用途": "动作参考/镜头参考/风格参考/节奏参考"}) + schema["业务属性"].update({"产品类型": "APP", "核心表达目标": "突出新项目核心内容点", "内容风格": "轻快、活泼、广告感适中", "行动引导": "立即体验"}) + schema["动作流程"] = [{"时间段": item["时间段"], "动作": item["阶段"], "说明": item["说明"]} for item in build_time_plan(duration)] + schema["镜头流程"] = [{"时间段": item["时间段"], "镜头": item["阶段"], "说明": item["说明"]} for item in build_time_plan(duration)] + schema["最终提示词"] = { + "主提示词": f"生成一段{duration}秒、{video_ratio}、{resolution}的产品推广短视频,参考素材视频的爆款开头节奏,结合新项目图片进行自然展示。", + "动作提示词": "主体动作自然,产品展示稳定,节奏轻快。", + "镜头提示词": "镜头稳定,开头快速吸引注意,后续平滑推进。", + "字幕提示词": "字幕简洁清晰,突出核心内容点。", + "音频提示词": "轻快背景音乐,节奏自然。", + "风格提示词": "年轻化、明亮、真实、广告感适中。", + "负面提示词": "画面模糊、主体畸变、手指畸形、脸部崩坏、字幕乱码、产品变形、镜头抖动、画面闪烁", + } + return schema + + +async def _select_model_config(db: AsyncSession) -> ModelConfig | None: + result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True).order_by(ModelConfig.priority.desc()).limit(1)) + return result.scalar_one_or_none() + + +async def optimize_hot_opening_video_prompt( + db: AsyncSession, + *, + user_id: str, + source_project_name: str, + target_project_name: str, + core_content_point: str, + material_video_url: str, + generated_image_url: str, + video_config: dict[str, Any], + target_platform: str = "抖音", +) -> tuple[dict[str, Any], str, dict[str, Any]]: + duration = int(video_config["duration"]) + references = [ + {"type": "video", "url": material_video_url}, + {"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)}, + ] + client_schema = build_dynamic_schema(video_config) + reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS) + + # if settings.LLM_MOCK: + # result = _mock_result(video_config, target_platform) + # result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration)) + # return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + + config = await _select_model_config(db) + if not config: + result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config) + return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + + user_text = build_user_text( + source_project_name=source_project_name, + target_project_name=target_project_name, + core_content_point=core_content_point, + target_platform=target_platform, + references=references, + video_config=video_config, + client_schema=client_schema, + ) + user_message, log_user_message = build_user_message(user_text, references, reference_video_fps) + request_data = { + "model": config.model_name, + "messages": [{"role": "system", "content": build_system_prompt()}, user_message], + "max_tokens": 6000, + "temperature": 0.15, + "response_format": {"type": "json_object"}, + } + + async with httpx.AsyncClient(timeout=int(settings.CHATAPI_REQUEST_TIMEOUT_SECONDS or 180)) as client: + response = await client.post( + f"{config.api_base.rstrip('/')}/chat/completions", + headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"}, + json=request_data, + ) + if response.status_code >= 400: + raise RuntimeError(f"视频提词优化失败 HTTP {response.status_code}: {response.text}") + + data = response.json() + content = data["choices"][0]["message"]["content"].strip() + usage = data.get("usage", {}) or {} + token_usage = { + "input_tokens": int(usage.get("prompt_tokens") or 0), + "output_tokens": int(usage.get("completion_tokens") or 0), + "total_tokens": int(usage.get("total_tokens") or 0), + "log_user_message": log_user_message, + } + db.add( + TokenUsage( + id=generate_id(), + model_config_id=config.id, + user_id=user_id, + input_tokens=token_usage["input_tokens"], + output_tokens=token_usage["output_tokens"], + total_tokens=token_usage["total_tokens"], + ) + ) + await db.flush() + + result = parse_model_json(content) + result = normalize_video_prompt_schema_from_ai(result, video_config) + return result, build_final_video_prompt(result), token_usage + +def _build_file_url_or_data_uri(file_url: str) -> str: + """ + Convert local upload path to base64 data URI. + Keep remote http/https/data URLs as-is. + """ + if file_url.startswith(("http://", "https://", "data:")): + return file_url + file_url_sign = build_resource_signed_url(resource_url=file_url, expire_seconds=86400) + return f"{settings.BASE_URL}{file_url_sign}" \ No newline at end of file diff --git a/video-gen-api/app/services/module_generation_log_service.py b/video-gen-api/app/services/module_generation_log_service.py new file mode 100644 index 00000000..1acb3b00 --- /dev/null +++ b/video-gen-api/app/services/module_generation_log_service.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import json +import os +import re +from datetime import datetime +from typing import Any + +from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, is_enabled + +MAX_LOG_FIELD_LENGTH = 20000 +MODULE_LOG_ROOT = os.path.join(os.path.dirname(LOG_DIR), "ModuleGeneration") + + +def _safe_module_name(module: str | None) -> str: + value = str(module or "unknown_module").strip() or "unknown_module" + value = re.sub(r"[^a-zA-Z0-9_.-]+", "_", value) + return value[:120] or "unknown_module" + + +def _safe_dump_value(value: Any) -> Any: + """限制单字段长度,避免超长 base64 / 响应体把日志打爆。""" + if value is None: + return None + if isinstance(value, str): + if len(value) > MAX_LOG_FIELD_LENGTH: + return value[:MAX_LOG_FIELD_LENGTH] + f"..." + return value + if isinstance(value, dict): + return {str(k): _safe_dump_value(v) for k, v in value.items()} + if isinstance(value, list): + return [_safe_dump_value(v) for v in value] + return value + + +def _append_module_log(module: str, entry: dict[str, Any]) -> None: + if not is_enabled(): + return + try: + module_dir = os.path.join(MODULE_LOG_ROOT, _safe_module_name(module)) + os.makedirs(module_dir, exist_ok=True) + today = datetime.now().strftime(LOG_DATE_FORMAT) + log_file = os.path.join(module_dir, f"{today}.log") + with open(log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n") + except Exception: + # 日志失败绝不能影响业务主流程。 + pass + + +def log_module_event_file( + *, + module: str, + event_type: str, + project_id: str | None = None, + step_id: str | None = None, + user_id: str | None = None, + message: str | None = None, + detail: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + """记录模块流程事件到 JSONL 文件。 + + 统一落盘目录:log/ModuleGeneration/{module}/YYYY-MM-DD.log + 不再写 module_generation_events 表。 + """ + entry = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "log_type": "module_event", + "module": module, + "event_type": event_type, + "project_id": project_id, + "step_id": step_id, + "user_id": user_id, + "message": message, + "detail": _safe_dump_value(detail or {}), + "error": error, + } + _append_module_log(module, entry) + + +def log_module_prompt_event( + *, + event_type: str, + project_id: str, + step_id: str, + user_id: str, + module: str, + prompt_type: str, + request: dict[str, Any] | None = None, + response: dict[str, Any] | None = None, + token_usage: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + """记录模块 AI 提词请求/响应到 JSONL 文件。 + + 与模块事件共用同一个服务,但按 module 分目录,方便按模块排查。 + """ + entry = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "log_type": "module_prompt", + "module": module, + "event_type": event_type, + "prompt_type": prompt_type, + "project_id": project_id, + "step_id": step_id, + "user_id": user_id, + "request": _safe_dump_value(request or {}), + "response": _safe_dump_value(response or {}), + "token_usage": _safe_dump_value(token_usage or {}), + "error": error, + } + _append_module_log(module, entry) + + +def log_module_error( + *, + module: str, + event_type: str, + project_id: str | None = None, + step_id: str | None = None, + user_id: str | None = None, + message: str | None = None, + detail: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + """记录模块异常日志。""" + entry = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "log_type": "module_error", + "module": module, + "event_type": event_type, + "project_id": project_id, + "step_id": step_id, + "user_id": user_id, + "message": message, + "detail": _safe_dump_value(detail or {}), + "error": error, + } + _append_module_log(module, entry) diff --git a/video-gen-api/app/tasks/hot_opening_replicate_tasks.py b/video-gen-api/app/tasks/hot_opening_replicate_tasks.py new file mode 100644 index 00000000..a0228467 --- /dev/null +++ b/video-gen-api/app/tasks/hot_opening_replicate_tasks.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from app.models.base import async_session +from app.services.hot_opening_replicate_service import run_image_prompt_optimize, run_video_prompt_optimize +from app.tasks.async_runner import run_async +from app.tasks.celery_app import celery_app + + +async def _run_image_prompt(project_id: str, step_id: str | None = None): + async with async_session() as db: + await run_image_prompt_optimize(db, project_id=project_id, step_id=step_id) + await db.commit() + + +async def _run_video_prompt(project_id: str, step_id: str | None = None): + async with async_session() as db: + await run_video_prompt_optimize(db, project_id=project_id, step_id=step_id) + await db.commit() + + +if celery_app: + @celery_app.task(name="hot_opening.start_image_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) + def start_image_prompt_optimize(self, project_id: str, step_id: str | None = None): + """手动触发后的图片 AI 提词任务。 + + 该任务路由到现有 gen_chatapi_create 队列,不需要新增 hot_opening worker。 + """ + return run_async(_run_image_prompt(project_id, step_id)) + + @celery_app.task(name="hot_opening.start_video_prompt_optimize", bind=True, max_retries=3, default_retry_delay=30) + def start_video_prompt_optimize(self, project_id: str, step_id: str | None = None): + """手动触发后的视频 AI 提词任务。 + + 该任务路由到现有 gen_chatapi_create 队列,不需要新增 hot_opening worker。 + """ + return run_async(_run_video_prompt(project_id, step_id)) +else: + class _DisabledTask: + def delay(self, *args, **kwargs): + raise RuntimeError("Celery is disabled") + + def apply_async(self, *args, **kwargs): + raise RuntimeError("Celery is disabled") + + start_image_prompt_optimize = _DisabledTask() + start_video_prompt_optimize = _DisabledTask()