From 5d5e6485ee2f1ae8ed309771d175a347644d9158 Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Wed, 17 Jun 2026 11:25:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=86=E9=95=9C=E5=A4=8D?= =?UTF-8?q?=E5=88=BB=E8=B7=AF=E7=94=B1=EF=BC=8C=E4=BF=AE=E8=A1=A5API?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=AF=B4=E6=98=8E=EF=BC=8C=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=BC=82=E5=B8=B8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...测表pre_test_template和素材表resources_.py | 356 ++++++++++++------ .../app/api/v1/hot_opening_replicate.py | 7 +- video-gen-api/app/api/v1/shot_replicate.py | 195 ++++++---- .../app/schemas/hot_opening_replicate.py | 18 +- video-gen-api/app/schemas/shot_replicate.py | 204 +++++----- .../services/shot_replicate_flow_service.py | 4 +- 6 files changed, 493 insertions(+), 291 deletions(-) diff --git a/video-gen-api/alembic/versions/287c6c064c5d_新增素材前测表pre_test_template和素材表resources_.py b/video-gen-api/alembic/versions/287c6c064c5d_新增素材前测表pre_test_template和素材表resources_.py index 7562bf5e..959cd2b7 100644 --- a/video-gen-api/alembic/versions/287c6c064c5d_新增素材前测表pre_test_template和素材表resources_.py +++ b/video-gen-api/alembic/versions/287c6c064c5d_新增素材前测表pre_test_template和素材表resources_.py @@ -8,122 +8,262 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. -revision: str = '287c6c064c5d' -down_revision: Union[str, None] = '9216bca75ccf' +revision: str = "287c6c064c5d" +down_revision: Union[str, None] = "9216bca75ccf" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def _inspector(): + return inspect(op.get_bind()) + + +def _table_exists(table_name: str) -> bool: + return table_name in _inspector().get_table_names(schema="public") + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + columns = _inspector().get_columns(table_name, schema="public") + return any(column["name"] == column_name for column in columns) + + +def _index_exists(table_name: str, index_name: str) -> bool: + if not _table_exists(table_name): + return False + indexes = _inspector().get_indexes(table_name, schema="public") + return any(index["name"] == index_name for index in indexes) + + +def _create_pre_test_template_table() -> None: + op.create_table( + "pre_test_template", + sa.Column("id", sa.String(length=32), nullable=False, comment="主键"), + sa.Column("name", sa.String(length=128), nullable=False, comment="模板名称"), + sa.Column("user_id", sa.String(length=32), nullable=False, comment="用户id"), + sa.Column("note", sa.Text(), nullable=True, comment="模板备注"), + sa.Column("platform", sa.String(length=32), nullable=True, comment="投放平台(AD/QIANCHUAN/LOCAL)"), + sa.Column("external_action", sa.String(length=64), nullable=True, comment="转化目标"), + sa.Column("cpa_bid", sa.Float(), nullable=True, comment="目标转化成本:[1, 10000]"), + sa.Column("audience_gender", sa.String(length=16), nullable=True, comment="性别(ALL/MALE/FEMALE)"), + sa.Column("audience_age", sa.Text(), nullable=True, comment="受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]"), + sa.Column("audience_region", sa.Text(), nullable=True, comment="受众地区,JSON数组(二级行政区域code)"), + sa.Column("audience_network", sa.Text(), nullable=True, comment="网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]"), + sa.Column("cus_name", sa.String(length=256), nullable=True, comment="客户主体名称"), + sa.Column("pricing_type", sa.String(length=16), nullable=True, comment="出价类型(OCPC/CPA/OCPM)"), + sa.Column("cost_cap", sa.Boolean(), nullable=True, comment="是否最优成本出价(仅AD支持)"), + sa.Column("target_cost", sa.Boolean(), nullable=True, comment="是否稳定成本出价(仅AD支持)"), + sa.Column("nobid", sa.Boolean(), nullable=True, comment="是否最大转化出价(仅AD支持)"), + sa.Column("cpc_bid", sa.Float(), nullable=True, comment="目标点击成本:[1, 10000]"), + sa.Column("budget", sa.Float(), nullable=True, comment="预算金额:[1, 10000]"), + sa.Column("is_default", sa.Boolean(), nullable=True, comment="是否默认模板"), + 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.PrimaryKeyConstraint("id", name="pre_test_template_pkey"), + ) + + if not _index_exists("pre_test_template", "ix_pre_test_template_user_id"): + op.create_index( + "ix_pre_test_template_user_id", + "pre_test_template", + ["user_id"], + unique=False, + ) + + +def _upgrade_existing_pre_test_template_table() -> None: + if not _column_exists("pre_test_template", "note"): + op.add_column( + "pre_test_template", + sa.Column("note", sa.Text(), nullable=True, comment="模板备注"), + ) + + if _column_exists("pre_test_template", "platform"): + op.alter_column( + "pre_test_template", + "platform", + existing_type=sa.VARCHAR(length=32), + nullable=True, + existing_comment="投放平台(AD/QIANCHUAN/LOCAL)", + ) + + if _column_exists("pre_test_template", "external_action"): + op.alter_column( + "pre_test_template", + "external_action", + existing_type=sa.VARCHAR(length=64), + nullable=True, + existing_comment="转化目标", + ) + + if _column_exists("pre_test_template", "audience_gender"): + op.alter_column( + "pre_test_template", + "audience_gender", + existing_type=sa.VARCHAR(length=16), + nullable=True, + existing_comment="性别(ALL/MALE/FEMALE)", + ) + + if _column_exists("pre_test_template", "audience_age"): + op.alter_column( + "pre_test_template", + "audience_age", + existing_type=sa.TEXT(), + comment="受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]", + existing_comment="受众年龄,JSON数组", + existing_nullable=True, + ) + + if _column_exists("pre_test_template", "audience_network"): + op.alter_column( + "pre_test_template", + "audience_network", + existing_type=sa.TEXT(), + comment="网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]", + existing_comment="网络类型,JSON数组", + existing_nullable=True, + ) + + if _column_exists("pre_test_template", "pricing_type"): + op.alter_column( + "pre_test_template", + "pricing_type", + existing_type=sa.VARCHAR(length=16), + nullable=True, + existing_comment="出价类型(OCPC/CPA/OCPM)", + ) + + if _column_exists("pre_test_template", "cost_cap"): + op.alter_column( + "pre_test_template", + "cost_cap", + existing_type=sa.BOOLEAN(), + nullable=True, + existing_comment="是否最优成本出价(仅AD支持)", + ) + + if _column_exists("pre_test_template", "target_cost"): + op.alter_column( + "pre_test_template", + "target_cost", + existing_type=sa.BOOLEAN(), + nullable=True, + existing_comment="是否稳定成本出价(仅AD支持)", + ) + + if _column_exists("pre_test_template", "nobid"): + op.alter_column( + "pre_test_template", + "nobid", + existing_type=sa.BOOLEAN(), + nullable=True, + existing_comment="是否最大转化出价(仅AD支持)", + ) + + if _column_exists("pre_test_template", "is_default"): + op.alter_column( + "pre_test_template", + "is_default", + existing_type=sa.BOOLEAN(), + nullable=True, + existing_comment="是否默认模板", + ) + + if _column_exists("pre_test_template", "status"): + op.drop_column("pre_test_template", "status") + + if _column_exists("pre_test_template", "description"): + op.drop_column("pre_test_template", "description") + + if not _index_exists("pre_test_template", "ix_pre_test_template_user_id"): + op.create_index( + "ix_pre_test_template_user_id", + "pre_test_template", + ["user_id"], + unique=False, + ) + + def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('pre_test_template', sa.Column('note', sa.Text(), nullable=True, comment='模板备注')) - op.alter_column('pre_test_template', 'platform', - existing_type=sa.VARCHAR(length=32), - nullable=True, - existing_comment='投放平台(AD/QIANCHUAN/LOCAL)') - op.alter_column('pre_test_template', 'external_action', - existing_type=sa.VARCHAR(length=64), - nullable=True, - existing_comment='转化目标') - op.alter_column('pre_test_template', 'audience_gender', - existing_type=sa.VARCHAR(length=16), - nullable=True, - existing_comment='性别(ALL/MALE/FEMALE)') - op.alter_column('pre_test_template', 'audience_age', - existing_type=sa.TEXT(), - comment='受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]', - existing_comment='受众年龄,JSON数组', - existing_nullable=True) - op.alter_column('pre_test_template', 'audience_network', - existing_type=sa.TEXT(), - comment='网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]', - existing_comment='网络类型,JSON数组', - existing_nullable=True) - op.alter_column('pre_test_template', 'pricing_type', - existing_type=sa.VARCHAR(length=16), - nullable=True, - existing_comment='出价类型(OCPC/CPA/OCPM)') - op.alter_column('pre_test_template', 'cost_cap', - existing_type=sa.BOOLEAN(), - nullable=True, - existing_comment='是否最优成本出价(仅AD支持)') - op.alter_column('pre_test_template', 'target_cost', - existing_type=sa.BOOLEAN(), - nullable=True, - existing_comment='是否稳定成本出价(仅AD支持)') - op.alter_column('pre_test_template', 'nobid', - existing_type=sa.BOOLEAN(), - nullable=True, - existing_comment='是否最大转化出价(仅AD支持)') - op.alter_column('pre_test_template', 'is_default', - existing_type=sa.BOOLEAN(), - nullable=True, - existing_comment='是否默认模板') - op.drop_column('pre_test_template', 'status') - op.drop_column('pre_test_template', 'description') - op.add_column('resources_material', sa.Column('task_id', sa.String(length=32), nullable=True, comment='前测任务id')) - op.add_column('resources_material', sa.Column('note', sa.Text(), nullable=True, comment='前测失败备注或者其他备注')) - op.add_column('resources_material', sa.Column('status', sa.String(length=16), nullable=True, comment='前测状态(FAILED/PENDING/SUCCESS)')) - op.add_column('resources_material', sa.Column('pre_result', sa.Text(), nullable=True, comment='前测结果,JSON数组对象')) - op.add_column('resources_material', sa.Column('pre_test_template_id', sa.String(length=32), nullable=True, comment='前测模板id')) - op.create_index(op.f('ix_resources_material_task_id'), 'resources_material', ['task_id'], unique=False) - # ### end Alembic commands ### + # pre_test_template 是这版迁移缺失的核心表。 + # 如果不存在,直接按当前模型源码创建完整表。 + # 如果已存在,则按原迁移逻辑补 note、调整 nullable/comment、删除旧字段。 + if not _table_exists("pre_test_template"): + _create_pre_test_template_table() + else: + _upgrade_existing_pre_test_template_table() + + # resources_material 追加字段,按源码迁移逻辑保留,同时增加字段存在判断,避免重复执行报错。 + if _table_exists("resources_material"): + if not _column_exists("resources_material", "task_id"): + op.add_column( + "resources_material", + sa.Column("task_id", sa.String(length=32), nullable=True, comment="前测任务id"), + ) + + if not _column_exists("resources_material", "note"): + op.add_column( + "resources_material", + sa.Column("note", sa.Text(), nullable=True, comment="前测失败备注或者其他备注"), + ) + + if not _column_exists("resources_material", "status"): + op.add_column( + "resources_material", + sa.Column("status", sa.String(length=16), nullable=True, comment="前测状态(FAILED/PENDING/SUCCESS)"), + ) + + if not _column_exists("resources_material", "pre_result"): + op.add_column( + "resources_material", + sa.Column("pre_result", sa.Text(), nullable=True, comment="前测结果,JSON数组对象"), + ) + + if not _column_exists("resources_material", "pre_test_template_id"): + op.add_column( + "resources_material", + sa.Column("pre_test_template_id", sa.String(length=32), nullable=True, comment="前测模板id"), + ) + + if not _index_exists("resources_material", "ix_resources_material_task_id"): + op.create_index( + "ix_resources_material_task_id", + "resources_material", + ["task_id"], + unique=False, + ) def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_resources_material_task_id'), table_name='resources_material') - op.drop_column('resources_material', 'pre_test_template_id') - op.drop_column('resources_material', 'pre_result') - op.drop_column('resources_material', 'status') - op.drop_column('resources_material', 'note') - op.drop_column('resources_material', 'task_id') - op.add_column('pre_test_template', sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True, comment='模板描述')) - op.add_column('pre_test_template', sa.Column('status', sa.INTEGER(), autoincrement=False, nullable=False, comment='状态(0=禁用,1=启用)')) - op.alter_column('pre_test_template', 'is_default', - existing_type=sa.BOOLEAN(), - nullable=False, - existing_comment='是否默认模板') - op.alter_column('pre_test_template', 'nobid', - existing_type=sa.BOOLEAN(), - nullable=False, - existing_comment='是否最大转化出价(仅AD支持)') - op.alter_column('pre_test_template', 'target_cost', - existing_type=sa.BOOLEAN(), - nullable=False, - existing_comment='是否稳定成本出价(仅AD支持)') - op.alter_column('pre_test_template', 'cost_cap', - existing_type=sa.BOOLEAN(), - nullable=False, - existing_comment='是否最优成本出价(仅AD支持)') - op.alter_column('pre_test_template', 'pricing_type', - existing_type=sa.VARCHAR(length=16), - nullable=False, - existing_comment='出价类型(OCPC/CPA/OCPM)') - op.alter_column('pre_test_template', 'audience_network', - existing_type=sa.TEXT(), - comment='网络类型,JSON数组', - existing_comment='网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]', - existing_nullable=True) - op.alter_column('pre_test_template', 'audience_age', - existing_type=sa.TEXT(), - comment='受众年龄,JSON数组', - existing_comment='受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]', - existing_nullable=True) - op.alter_column('pre_test_template', 'audience_gender', - existing_type=sa.VARCHAR(length=16), - nullable=False, - existing_comment='性别(ALL/MALE/FEMALE)') - op.alter_column('pre_test_template', 'external_action', - existing_type=sa.VARCHAR(length=64), - nullable=False, - existing_comment='转化目标') - op.alter_column('pre_test_template', 'platform', - existing_type=sa.VARCHAR(length=32), - nullable=False, - existing_comment='投放平台(AD/QIANCHUAN/LOCAL)') - op.drop_column('pre_test_template', 'note') - # ### end Alembic commands ### + if _table_exists("resources_material"): + if _index_exists("resources_material", "ix_resources_material_task_id"): + op.drop_index("ix_resources_material_task_id", table_name="resources_material") + + if _column_exists("resources_material", "pre_test_template_id"): + op.drop_column("resources_material", "pre_test_template_id") + + if _column_exists("resources_material", "pre_result"): + op.drop_column("resources_material", "pre_result") + + if _column_exists("resources_material", "status"): + op.drop_column("resources_material", "status") + + if _column_exists("resources_material", "note"): + op.drop_column("resources_material", "note") + + if _column_exists("resources_material", "task_id"): + op.drop_column("resources_material", "task_id") + + # 这一版迁移负责新增 pre_test_template 表,所以回滚时删除该表。 + if _table_exists("pre_test_template"): + if _index_exists("pre_test_template", "ix_pre_test_template_user_id"): + op.drop_index("ix_pre_test_template_user_id", table_name="pre_test_template") + + op.drop_table("pre_test_template") \ No newline at end of file diff --git a/video-gen-api/app/api/v1/hot_opening_replicate.py b/video-gen-api/app/api/v1/hot_opening_replicate.py index c7c9b493..2eab4212 100644 --- a/video-gen-api/app/api/v1/hot_opening_replicate.py +++ b/video-gen-api/app/api/v1/hot_opening_replicate.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.models.user import User +from app.enums.common import ModuleProjectStatusEnum from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum from app.schemas.hot_opening_replicate import ( HotOpeningActionOut, @@ -195,7 +196,7 @@ async def _mark_dispatch_failed_and_raise( "/spec", response_model=HotOpeningSpecOut, summary="查询爆款开头复刻模块状态枚举和步骤 JSON 结构说明", - description="返回总任务状态、子任务状态、5个固定步骤编码以及每个步骤 input_json/output_json 的统一结构示例,方便前端和排查人员对照。", + description="保留给调试和前端兜底读取。原业务接口已经在 Path、Query、Body 和响应模型字段上直接展示参数说明与枚举值。", ) async def get_spec(): return HotOpeningSpecOut() @@ -239,13 +240,13 @@ async def create_task( description="分页查询爆款开头复刻总任务项目列表。普通用户只能查看自己的项目,管理员可查看全部。", ) async def list_tasks( - status: str | None = Query(None, description="总任务状态筛选,例如 waiting_user、processing、completed、failed;为空不过滤"), + status: ModuleProjectStatusEnum | None = Query(None, description="总任务状态筛选:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消;为空不过滤"), 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) + return await list_hot_opening_projects(db, current_user=current_user, status=status.value if status else None, page=page, page_size=page_size) @router.get( diff --git a/video-gen-api/app/api/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py index d620f104..22b16923 100644 --- a/video-gen-api/app/api/v1/shot_replicate.py +++ b/video-gen-api/app/api/v1/shot_replicate.py @@ -8,7 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.models.user import User -from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateStepCodeEnum +from app.enums.shot_replicate import ( + ModuleCodeEnum, + ShotAnalysisStatusEnum, + ShotReplicateStepCodeEnum, + ShotSegmentAnalysisStatusEnum, + ShotSegmentReplicateStatusEnum, + ShotSegmentSourceModeEnum, + ShotSplitStatusEnum, + ShotTaskSetStatusEnum, +) from app.schemas.shot_replicate import ( ShotReplicateActionOut, ShotReplicateDeleteOut, @@ -222,6 +231,7 @@ async def _mark_dispatch_failed_and_raise( "/spec", response_model=ShotReplicateSpecOut, summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明", + description="保留给调试和前端兜底读取。原业务接口已经在 Path、Query、Body 和响应模型字段上直接展示参数说明与枚举值。", ) async def get_spec(): return ShotReplicateSpecOut() @@ -231,9 +241,14 @@ async def get_spec(): "/task-sets", response_model=ShotTaskSetDetailOut, summary="创建拆镜总任务集并异步分析原视频", + description=( + "创建拆镜总任务集,保存原视频地址和时长,随后异步投递原视频 AI 分析任务。" + "分析完成后会写入原视频内容、分类、受众和 AI 建议拆镜时间段。" + "状态枚举直接见本接口响应字段:status、analysis_status、split_status。" + ), ) async def create_shot_task_set( - req: ShotTaskSetCreate = Body(...), + req: ShotTaskSetCreate = Body(..., description="创建拆镜总任务集参数:原视频 URL、视频时长、标题和可选幂等键"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -275,21 +290,21 @@ async def create_shot_task_set( summary="查询拆镜总任务集列表", ) async def list_shot_task_sets( - status: str | None = Query(None, description="总任务状态,见 ShotTaskSetStatusEnum"), - analysis_status: str | None = Query(None, description="分析状态,见 ShotAnalysisStatusEnum"), - split_status: str | None = Query(None, description="拆镜状态,见 ShotSplitStatusEnum"), - keyword: str | None = Query(None, description="标题/内容关键词"), - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), + status: ShotTaskSetStatusEnum | None = Query(None, description="总任务状态筛选:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删"), + analysis_status: ShotAnalysisStatusEnum | None = Query(None, description="原视频分析状态筛选:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败"), + split_status: ShotSplitStatusEnum | None = Query(None, description="拆镜状态筛选:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"), + keyword: str | None = Query(None, description="标题/原视频内容/分类/受众关键词,模糊搜索;为空不过滤"), + 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_task_sets( db, current_user=current_user, - status=status, - analysis_status=analysis_status, - split_status=split_status, + status=status.value if status else None, + analysis_status=analysis_status.value if analysis_status else None, + split_status=split_status.value if split_status else None, keyword=keyword, page=page, page_size=page_size, @@ -300,9 +315,10 @@ async def list_shot_task_sets( "/task-sets/{task_set_id}", response_model=ShotTaskSetDetailOut, summary="获取拆镜总任务集详情", + description="根据拆镜总任务集ID查询详情,包含原视频分析结果、AI 建议拆镜列表和当前总任务状态。", ) async def get_shot_task_set( - task_set_id: str = Path(...), + task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -313,10 +329,14 @@ async def get_shot_task_set( "/task-sets/{task_set_id}/split-by-ai", response_model=ShotSplitByAIOut, summary="按 AI 建议方案异步拆镜", + description=( + "基于原视频 AI 分析生成的建议时间段创建拆镜片段,并异步投递 ffmpeg 切割任务。" + "selected_indices 不传时默认按全部 AI 建议拆镜;replace_existing=true 时会软删旧 AI 建议片段后重新创建。" + ), ) async def split_by_ai( - task_set_id: str = Path(...), - req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest), + task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), + req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest, description="AI 建议拆镜参数:可选择建议序号,也可选择是否覆盖旧 AI 片段"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -345,10 +365,11 @@ async def split_by_ai( "/task-sets/{task_set_id}/split-custom", response_model=ShotSplitCustomOut, summary="按用户自定义开始/结束秒异步拆单条片段", + description="按用户传入的 start_second/end_second 创建 custom 来源片段,并异步投递 ffmpeg 切割任务;切割完成后可进入复刻项目。", ) async def split_custom( - task_set_id: str = Path(...), - req: ShotSplitCustomRequest = Body(...), + task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), + req: ShotSplitCustomRequest = Body(..., description="自定义拆镜时间段参数,end_second 必须大于 start_second"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -376,15 +397,16 @@ async def split_custom( "/task-sets/{task_set_id}/segments", response_model=ShotSegmentListOut, summary="查询拆镜片段列表", + description="分页查询指定拆镜总任务集下的片段列表,可按来源、切割状态、片段分析状态和复刻状态筛选。", ) async def list_task_set_segments( - task_set_id: str = Path(...), - source_mode: str | None = Query(None, description="ai_suggestion/custom"), - split_status: str | None = Query(None, description="拆镜状态"), - analysis_status: str | None = Query(None, description="片段分析状态"), - replicate_status: str | None = Query(None, description="复刻状态"), - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), + task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"), + source_mode: ShotSegmentSourceModeEnum | None = Query(None, description="片段来源:ai_suggestion=AI 建议拆镜,custom=用户自定义拆镜"), + split_status: ShotSplitStatusEnum | None = Query(None, description="切割状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"), + analysis_status: ShotSegmentAnalysisStatusEnum | None = Query(None, description="片段分析状态:not_required=无需单独分析,pending=等待分析,processing=分析中,completed=分析完成,failed=分析失败"), + replicate_status: ShotSegmentReplicateStatusEnum | None = Query(None, description="片段复刻状态:not_started=未复刻,project_created=已创建复刻项目,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), ): @@ -392,10 +414,10 @@ async def list_task_set_segments( db, current_user=current_user, task_set_id=task_set_id, - source_mode=source_mode, - split_status=split_status, - analysis_status=analysis_status, - replicate_status=replicate_status, + source_mode=source_mode.value if source_mode else None, + split_status=split_status.value if split_status else None, + analysis_status=analysis_status.value if analysis_status else None, + replicate_status=replicate_status.value if replicate_status else None, page=page, page_size=page_size, ) @@ -405,9 +427,10 @@ async def list_task_set_segments( "/segments/{segment_id}", response_model=ShotSegmentDetailOut, summary="获取拆镜片段详情", + description="根据拆镜片段ID查询单个片段详情,包含切割结果、片段分析结果、复刻项目ID和状态。", ) async def get_segment( - segment_id: str = Path(...), + segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -418,10 +441,15 @@ async def get_segment( "/segments/{segment_id}/replication-projects", response_model=ShotReplicateActionOut, summary="将拆镜片段创建为拆镜复刻项目", + description=( + "以拆镜片段作为锁定素材视频创建 ModuleGenerationProject 复刻项目。" + "创建后只同步生成第1步 material_input,后续第2-5步需要调用 /projects/{project_id}/steps/{step_id}/... 系列接口手动推进。" + "素材视频来自片段 segment_video_url,不允许前端传入或后续修改。" + ), ) async def create_replication_project_from_segment( - segment_id: str = Path(...), - req: ShotSegmentReplicationCreateRequest = Body(...), + segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"), + req: ShotSegmentReplicationCreateRequest = Body(..., description="从拆镜片段创建复刻项目参数:生成项目名称、核心内容点、新产品图片和可选幂等键"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -450,9 +478,10 @@ async def create_replication_project_from_segment( "/projects/{project_id}", response_model=ShotReplicateTaskDetailOut, summary="获取拆镜复刻项目详情", + description="获取拆镜复刻 ModuleGenerationProject 项目详情,聚合返回素材、图片提词、图片生成、视频提词、视频生成和当前有效步骤列表。", ) async def get_project( - project_id: str = Path(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -463,10 +492,15 @@ async def get_project( "/projects/{project_id}/material", response_model=ShotReplicateActionOut, summary="修改拆镜复刻素材信息,素材视频不允许修改", + description=( + "修改拆镜复刻第1步素材输入,并重建第1步 material_input 新版本。" + "素材视频 material_video_url 锁定为拆镜片段视频,不允许修改;可修改新产品图片、参考素材项目名、生成项目名和核心内容点。" + "修改后会软删除第2、3、4、5步当前有效任务,并清空旧图片/视频结果。" + ), ) async def update_material( - project_id: str = Path(...), - req: ShotReplicateMaterialUpdateRequest = Body(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), + req: ShotReplicateMaterialUpdateRequest = Body(..., description="第1步素材输入修改参数;不接收 material_video_url,至少传一个允许修改字段"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -486,12 +520,13 @@ async def update_material( @router.put( "/projects/{project_id}/steps/{step_id}/image-prompt", response_model=ShotReplicateActionOut, - summary="直接修改图片 AI 优化提词", + summary="直接修改第2步图片 AI 优化提词", + description="直接修改第2步 image_prompt_optimize 的图片提示词,不调用 AI、不扣积分;保存后软删除第3、4、5步当前有效任务。", ) async def update_image_prompt( - project_id: str = Path(...), - step_id: str = Path(...), - req: ShotReplicateImagePromptUpdateRequest = Body(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), + step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.id,step_code=image_prompt_optimize"), + req: ShotReplicateImagePromptUpdateRequest = Body(..., description="图片 AI 优化提词修改参数"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -512,12 +547,16 @@ async def update_image_prompt( @router.put( "/projects/{project_id}/steps/{step_id}/video-prompt-schema", response_model=ShotReplicateActionOut, - summary="修改视频 AI 提词 JSON schema", + summary="修改第4步视频 AI 提词 JSON schema", + description=( + "修改第4步 video_prompt_optimize 的视频提词 JSON schema,不调用 AI、不扣积分。" + "服务端会锁定视频时长、比例、分辨率、帧率、动态时间规划等关键结构;保存后软删除第5步视频生成任务。" + ), ) async def update_video_prompt_schema( - project_id: str = Path(...), - step_id: str = Path(...), - req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), + step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.id,step_code=video_prompt_optimize"), + req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(..., description="视频 AI 提词 JSON schema 修改参数"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -536,19 +575,24 @@ async def update_video_prompt_schema( @router.post( - "/projects/{project_id}/generate-image-prompt", + "/projects/{project_id}/steps/{step_id}/generate-image-prompt", response_model=ShotReplicateActionOut, - summary="生成图片 AI 提词", + summary="基于第1步素材输入生成图片 AI 提词", + description=( + "基于第1步 material_input 子任务手动生成第2步 image_prompt_optimize。" + "如果已存在旧的第2、3、4、5步,会先软删除旧步骤,再创建新的第2步并投递 Celery 文本提词任务。" + ), ) async def generate_image_prompt( - project_id: str = Path(...), - req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), + step_id: str = Path(..., description="第1步素材输入子任务ID,即 module_generation_steps.id,step_code=material_input"), + req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest, description="图片提词生成参数,当前无需传参,额外字段会忽略"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) + _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: - project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req) + project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req) project_id_value, step_id_value = project.id, step.id await db.commit() except HTTPException: @@ -577,19 +621,25 @@ async def generate_image_prompt( @router.post( - "/projects/{project_id}/generate-image", + "/projects/{project_id}/steps/{step_id}/generate-image", response_model=ShotReplicateActionOut, - summary="根据图片 AI 提词生成图片", + summary="基于第2步图片 AI 提词生成图片", + description=( + "基于第2步 image_prompt_optimize 子任务生成第3步 image_generate。" + "请求体传入图片引擎和图片参数;ChatGenerationTask 幂等键由后端自动生成。" + "如果已存在旧的第3、4、5步,会先软删除旧步骤,再创建新的第3步。" + ), ) async def generate_image( - project_id: str = Path(...), - req: ShotReplicateGenerateImageRequest = Body(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), + step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.id,step_code=image_prompt_optimize"), + req: ShotReplicateGenerateImageRequest = Body(..., description="图片生成引擎和参数;可选值来自图片引擎配置接口"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) + _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: - project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, req=req) + project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req) project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id await db.commit() except HTTPException: @@ -611,19 +661,25 @@ async def generate_image( @router.post( - "/projects/{project_id}/generate-video-prompt", + "/projects/{project_id}/steps/{step_id}/generate-video-prompt", response_model=ShotReplicateActionOut, - summary="生成视频 AI 提词 JSON schema", + summary="基于第3步图片结果生成视频 AI 提词 JSON schema", + description=( + "基于第3步 image_generate 子任务生成第4步 video_prompt_optimize。" + "视频时长、比例、分辨率在本步骤确定并写入第4步 output_json;第5步视频生成只选择视频引擎。" + "如果已存在旧的第4、5步,会先软删除旧步骤,再创建新的第4步并投递 Celery 文本提词任务。" + ), ) async def generate_video_prompt( - project_id: str = Path(...), - req: ShotReplicateGenerateVideoPromptRequest = Body(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), + step_id: str = Path(..., description="第3步图片生成子任务ID,即 module_generation_steps.id,step_code=image_generate"), + req: ShotReplicateGenerateVideoPromptRequest = Body(..., description="视频提词生成参数:视频引擎、时长、比例、分辨率和目标平台;可选值来自视频引擎配置接口"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) + _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: - project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req) + 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, step_id_value = project.id, step.id await db.commit() except HTTPException: @@ -652,19 +708,25 @@ async def generate_video_prompt( @router.post( - "/projects/{project_id}/generate-video", + "/projects/{project_id}/steps/{step_id}/generate-video", response_model=ShotReplicateActionOut, - summary="根据视频 AI 提词生成视频", + summary="基于第4步视频 AI 提词生成最终视频", + description=( + "基于第4步 video_prompt_optimize 子任务生成第5步 video_generate。" + "请求体只需要选择视频生成引擎 engine_id;duration、aspect_ratio、resolution 从第4步视频提词结果继承。" + "如果已存在旧的第5步,会先软删除旧步骤,再创建新的第5步。" + ), ) async def generate_video( - project_id: str = Path(...), - req: ShotReplicateGenerateVideoRequest = Body(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), + step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.id,step_code=video_prompt_optimize"), + req: ShotReplicateGenerateVideoRequest = Body(..., description="视频生成参数:只传 engine_id,其它视频参数继承第4步视频提词结果"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) + _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: - project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, req=req) + project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req) project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id await db.commit() except HTTPException: @@ -689,9 +751,10 @@ async def generate_video( "/projects/{project_id}", response_model=ShotReplicateDeleteOut, summary="软删除拆镜复刻项目", + description="软删除拆镜复刻 ModuleGenerationProject,并联动软删除当前有效步骤和关联的 ChatGenerationTask。", ) async def delete_project( - project_id: str = Path(...), + project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): diff --git a/video-gen-api/app/schemas/hot_opening_replicate.py b/video-gen-api/app/schemas/hot_opening_replicate.py index de85b793..0cc51459 100644 --- a/video-gen-api/app/schemas/hot_opening_replicate.py +++ b/video-gen-api/app/schemas/hot_opening_replicate.py @@ -329,9 +329,9 @@ class HotOpeningGenerateImageRequest(BaseModel): ) 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。为空时按引擎支持尺寸自动匹配") + 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="图片像素尺寸,例如 1024x1024、2048x2048。具体可选值来自图片引擎配置接口;为空时按引擎支持尺寸自动匹配") class HotOpeningGenerateVideoPromptRequest(BaseModel): @@ -346,9 +346,9 @@ class HotOpeningGenerateVideoPromptRequest(BaseModel): ) 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") + duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。具体可选值来自视频引擎 supported_durations;为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_DURATION") + aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例,例如 9:16、16:9、1:1。具体可选值来自视频引擎 supported_ratios;为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RATIO") + resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率,例如 480p、720p、1080p。具体可选值来自视频引擎 supported_resolutions;为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RESOLUTION") target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 HOT_OPENING_DEFAULT_TARGET_PLATFORM") @@ -370,7 +370,7 @@ class HotOpeningStepOut(BaseModel): 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") + 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") @@ -427,7 +427,7 @@ class HotOpeningTaskDetailOut(BaseModel): 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") + 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") @@ -447,7 +447,7 @@ class HotOpeningTaskListItemOut(BaseModel): project_id: str = Field(..., description="兼容前端命名,等同于 id") module: str = Field(..., description="模块标识") title: str | None = Field(None, description="项目标题") - status: str = Field(..., description="总任务状态") + status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消") 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") diff --git a/video-gen-api/app/schemas/shot_replicate.py b/video-gen-api/app/schemas/shot_replicate.py index a018d673..403e7cef 100644 --- a/video-gen-api/app/schemas/shot_replicate.py +++ b/video-gen-api/app/schemas/shot_replicate.py @@ -208,27 +208,30 @@ class ShotReplicateTaskCreate(BaseModel): class ShotReplicateMaterialUpdateRequest(BaseModel): - """修改拆镜复刻第1步素材输入请求体。""" + """修改拆镜复刻第1步素材输入请求体。 + + 拆镜复刻的素材视频来自拆镜片段 segment_video_url,与片段强绑定,不允许修改。 + 本接口只允许修改新产品图片、参考素材项目名、生成项目名和核心内容点。 + """ model_config = ConfigDict( + extra="forbid", 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="素材图片链接,未传则沿用旧值") + material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值。用于新产品/目标素材图片,来自已有上传接口") source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值") target_project_name: str | None = Field(None, min_length=1, max_length=20, 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") + @field_validator("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: @@ -240,7 +243,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel): @model_validator(mode="after") def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest": - 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")): + if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point")): raise ValueError("至少需要传入一个需要修改的字段") return self @@ -248,7 +251,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel): class ShotReplicateStepUpdate(BaseModel): """修改拆镜复刻子任务请求体。""" - material_video_url: str | None = Field(None, description="修改第1步素材视频链接") + material_video_url: str | None = Field(None, description="历史兼容字段:拆镜复刻项目素材视频与片段绑定,正式接口不允许修改") material_image_url: str | None = Field(None, description="修改第1步素材图片链接") source_project_name: str | None = Field(None, max_length=20, description="修改第1步视频素材内容项目名称") target_project_name: str | None = Field(None, max_length=20, description="修改第1步生成项目名称") @@ -329,9 +332,9 @@ class ShotReplicateGenerateImageRequest(BaseModel): ) 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。为空时按引擎支持尺寸自动匹配") + 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="图片像素尺寸,例如 1024x1024、2048x2048。具体可选值来自图片引擎配置接口;为空时按引擎支持尺寸自动匹配") class ShotReplicateGenerateVideoPromptRequest(BaseModel): @@ -346,9 +349,9 @@ class ShotReplicateGenerateVideoPromptRequest(BaseModel): ) engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎") - duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_DURATION") - aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RATIO") - resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION") + duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。具体可选值来自视频引擎 supported_durations;为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_DURATION") + aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例,例如 9:16、16:9、1:1。具体可选值来自视频引擎 supported_ratios;为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RATIO") + resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率,例如 480p、720p、1080p。具体可选值来自视频引擎 supported_resolutions;为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION") target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM") @@ -477,13 +480,6 @@ class ShotReplicateDeleteOut(BaseModel): deleted: bool = Field(..., description="是否已软删除") -class ShotReplicateSpecOut(BaseModel): - project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS, description="总任务状态说明") - step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS, description="子任务状态说明") - steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS, description="5个固定步骤说明") - step_io_schema_version: str = Field(default=SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本") - step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例") - # ======================== # 拆镜总任务集 / 片段 API Schema @@ -579,10 +575,10 @@ class ShotTaskSetCreate(BaseModel): class ShotTaskSetListQuery(BaseModel): - status: str | None = Field(None, description="总任务状态筛选,见 ShotTaskSetStatusEnum") - analysis_status: str | None = Field(None, description="分析状态筛选,见 ShotAnalysisStatusEnum") - split_status: str | None = Field(None, description="拆镜状态筛选,见 ShotSplitStatusEnum") - keyword: str | None = Field(None, description="标题/内容关键词") + status: str | None = Field(None, description="总任务状态筛选:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted") + analysis_status: str | None = Field(None, description="分析状态筛选:pending/processing/completed/failed") + split_status: str | None = Field(None, description="拆镜状态筛选:none/pending/processing/completed/failed/retry_waiting") + keyword: str | None = Field(None, description="标题/内容关键词,模糊搜索") page: int = Field(1, ge=1, description="页码") page_size: int = Field(20, ge=1, le=100, description="每页数量") @@ -590,44 +586,44 @@ class ShotTaskSetListQuery(BaseModel): class ShotTaskSetOut(BaseModel): model_config = ConfigDict(from_attributes=True) - id: str - title: str | None = None - video_url: str - video_duration_seconds: float - status: str - analysis_status: str - split_status: str - original_video_content: str | None = None - original_video_category: str | None = None - original_video_audience: str | None = None - segment_count: int = 0 - completed_segment_count: int = 0 - failed_segment_count: int = 0 - analysis_error_message: str | None = None - split_error_message: str | None = None - created_at: NaiveDatetimeOptional = None - updated_at: NaiveDatetimeOptional = None + id: str = Field(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id") + title: str | None = Field(None, description="拆镜总任务标题,可为空") + video_url: str = Field(..., description="原视频 URL,来自已有上传接口") + video_duration_seconds: float = Field(..., description="原视频时长,单位秒,允许浮点") + status: str = Field(..., description="总任务状态:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删") + analysis_status: str = Field(..., description="原视频分析状态:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败") + split_status: str = Field(..., description="拆镜状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试") + original_video_content: str | None = Field(None, description="AI 分析出的原视频整体内容描述") + original_video_category: str | None = Field(None, description="AI 分析出的原视频分类,例如游戏视频、产品广告、教程等") + original_video_audience: str | None = Field(None, description="AI 分析出的原视频受众人群") + segment_count: int = Field(0, description="当前有效拆镜片段总数") + completed_segment_count: int = Field(0, description="切割完成的片段数量") + failed_segment_count: int = Field(0, description="切割失败的片段数量") + analysis_error_message: str | None = Field(None, description="原视频 AI 分析失败原因") + split_error_message: str | None = Field(None, description="总任务级拆镜失败原因") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") class ShotTaskSetListOut(BaseModel): - total: int - page: int - page_size: int - items: list[ShotTaskSetOut] + total: int = Field(..., description="符合筛选条件的总任务集总数") + page: int = Field(..., description="当前页码") + page_size: int = Field(..., description="每页数量") + items: list[ShotTaskSetOut] = Field(default_factory=list, description="拆镜总任务集列表") class ShotTaskSetDetailOut(ShotTaskSetOut): - ai_suggestions: list[ShotAISuggestionOut] = Field(default_factory=list) - analysis_result_json: dict[str, Any] | list[Any] | None = None + ai_suggestions: list[ShotAISuggestionOut] = Field(default_factory=list, description="AI 建议拆镜时间段列表,split-by-ai 接口可按 index 选择") + analysis_result_json: dict[str, Any] | list[Any] | None = Field(None, description="原视频 AI 分析完整 JSON 结果,结构由模型响应决定") class ShotSplitByAIRequest(BaseModel): - selected_indices: list[int] | None = Field(None, description="指定 AI 建议序号。不传则全部拆") - replace_existing: bool = Field(False, description="是否软删旧 AI 建议片段后重新拆") + selected_indices: list[int] | None = Field(None, description="指定 AI 建议序号列表,序号来自 ai_suggestions[].index;不传则按全部 AI 建议拆镜") + replace_existing: bool = Field(False, description="是否软删旧 AI 建议片段后重新拆;false 时保留旧片段并新增未存在片段") class ShotSplitCustomRequest(BaseModel): - start_second: float = Field(..., ge=0, description="自定义拆镜开始秒,允许浮点") + start_second: float = Field(..., ge=0, description="自定义拆镜开始秒,允许浮点,必须大于等于0") end_second: float = Field(..., gt=0, description="自定义拆镜结束秒,允许浮点,必须大于 start_second") @model_validator(mode="after") @@ -640,56 +636,56 @@ class ShotSplitCustomRequest(BaseModel): class ShotSegmentOut(BaseModel): model_config = ConfigDict(from_attributes=True) - id: str - task_set_id: str - segment_index: int - segment_name: str | None = None - source_mode: str - start_second: float - end_second: float - duration_seconds: float - time_node: str - split_status: str - analysis_status: str - replicate_status: str - segment_video_url: str | None = None - original_video_content: str | None = None - original_video_category: str | None = None - original_video_audience: str | None = None - segment_content: str | None = None - segment_category: str | None = None - segment_audience: str | None = None - split_retry_count: int = 0 - split_last_error: str | None = None - analysis_error_message: str | None = None - module_project_id: str | None = None - created_at: NaiveDatetimeOptional = None - updated_at: NaiveDatetimeOptional = None + id: str = Field(..., description="拆镜片段ID,即 shot_replicate_segments.id") + task_set_id: str = Field(..., description="所属拆镜总任务集ID,即 shot_replicate_task_sets.id") + segment_index: int = Field(..., description="片段序号,从1开始") + segment_name: str | None = Field(None, description="片段名称,可为空") + source_mode: str = Field(..., description="片段来源:ai_suggestion=AI 建议拆镜,custom=用户自定义拆镜") + start_second: float = Field(..., description="片段开始秒") + end_second: float = Field(..., description="片段结束秒") + duration_seconds: float = Field(..., description="片段时长,单位秒") + time_node: str = Field(..., description="片段时间节点展示文案,例如 0-5秒") + split_status: str = Field(..., description="切割状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试") + analysis_status: str = Field(..., description="片段分析状态:not_required=无需单独分析,pending=等待分析,processing=分析中,completed=分析完成,failed=分析失败") + replicate_status: str = Field(..., description="片段复刻状态:not_started=未复刻,project_created=已创建复刻项目,processing=复刻处理中,completed=复刻完成,failed=复刻失败") + segment_video_url: str | None = Field(None, description="切割后的片段视频 URL。切割完成后有值,用作复刻项目锁定素材视频") + original_video_content: str | None = Field(None, description="原视频整体内容描述,来自总任务 AI 分析") + original_video_category: str | None = Field(None, description="原视频分类,来自总任务 AI 分析") + original_video_audience: str | None = Field(None, description="原视频受众,来自总任务 AI 分析") + segment_content: str | None = Field(None, description="当前片段内容描述") + segment_category: str | None = Field(None, description="当前片段分类") + segment_audience: str | None = Field(None, description="当前片段受众人群") + split_retry_count: int = Field(0, description="切割失败后的恢复重试次数") + split_last_error: str | None = Field(None, description="最近一次切割失败原因") + analysis_error_message: str | None = Field(None, description="片段分析失败原因") + module_project_id: str | None = Field(None, description="由该片段创建的拆镜复刻项目ID,即 module_generation_projects.id") + created_at: NaiveDatetimeOptional = Field(None, description="创建时间") + updated_at: NaiveDatetimeOptional = Field(None, description="更新时间") class ShotSegmentDetailOut(ShotSegmentOut): - analysis_json: dict[str, Any] | list[Any] | None = None - ai_suggestion_json: dict[str, Any] | list[Any] | None = None + analysis_json: dict[str, Any] | list[Any] | None = Field(None, description="片段 AI 分析完整 JSON,custom 片段可能有值;AI 建议片段通常复用 ai_suggestion_json") + ai_suggestion_json: dict[str, Any] | list[Any] | None = Field(None, description="AI 建议拆镜原始 JSON,source_mode=ai_suggestion 时通常有值") class ShotSegmentListOut(BaseModel): - total: int - page: int - page_size: int - items: list[ShotSegmentOut] + total: int = Field(..., description="符合筛选条件的片段总数") + page: int = Field(..., description="当前页码") + page_size: int = Field(..., description="每页数量") + items: list[ShotSegmentOut] = Field(default_factory=list, description="拆镜片段列表") class ShotSplitByAIOut(BaseModel): - task_set_id: str - status: str - split_status: str - created_segment_count: int - segments: list[ShotSegmentOut] + task_set_id: str = Field(..., description="拆镜总任务集ID") + status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted") + split_status: str = Field(..., description="拆镜状态:none/pending/processing/completed/failed/retry_waiting") + created_segment_count: int = Field(..., description="本次创建的拆镜片段数量") + segments: list[ShotSegmentOut] = Field(default_factory=list, description="本次创建或返回的拆镜片段列表") class ShotSplitCustomOut(BaseModel): - task_set_id: str - segment: ShotSegmentOut + task_set_id: str = Field(..., description="拆镜总任务集ID") + segment: ShotSegmentOut = Field(..., description="本次创建的自定义拆镜片段") class ShotSegmentReplicationCreateRequest(BaseModel): @@ -704,10 +700,10 @@ class ShotSegmentReplicationCreateRequest(BaseModel): } ) - target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称") - core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字") - material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口") - idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键") + target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称,最多20字;会写入 module_generation_projects.title 和第1步 material_input") + core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字;用于后续图片 AI 提词和视频 AI 提词") + material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口;作为图片生成参考素材") + idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键;为空时后端可按业务生成或不使用") @field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before") @classmethod @@ -721,14 +717,14 @@ class ShotSegmentReplicationCreateRequest(BaseModel): class ShotReplicateSpecOut(BaseModel): - project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS) - step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS) - steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS) - step_io_schema_version: str = SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION - step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES) - task_set_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_TASK_SET_STATUS_DESCRIPTIONS) - analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_ANALYSIS_STATUS_DESCRIPTIONS) - split_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SPLIT_STATUS_DESCRIPTIONS) - segment_source_modes: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS) - segment_analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS) - segment_replicate_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS) + project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS, description="ModuleGenerationProject 总任务状态说明:pending/waiting_user/processing/completed/failed/cancelled") + step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS, description="ModuleGenerationStep 子任务状态说明:pending/waiting_user/processing/completed/failed/cancelled") + steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS, description="5个固定步骤说明:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate") + step_io_schema_version: str = Field(default=SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本") + step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例") + task_set_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_TASK_SET_STATUS_DESCRIPTIONS, description="拆镜总任务集状态说明") + analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_ANALYSIS_STATUS_DESCRIPTIONS, description="原视频/片段视频分析状态说明") + split_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SPLIT_STATUS_DESCRIPTIONS, description="ffmpeg 拆镜状态说明") + segment_source_modes: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS, description="拆镜片段来源说明") + segment_analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS, description="拆镜片段分析状态说明") + segment_replicate_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS, description="拆镜片段进入复刻流程后的状态说明") diff --git a/video-gen-api/app/services/shot_replicate_flow_service.py b/video-gen-api/app/services/shot_replicate_flow_service.py index 22343d57..20a27745 100644 --- a/video-gen-api/app/services/shot_replicate_flow_service.py +++ b/video-gen-api/app/services/shot_replicate_flow_service.py @@ -717,8 +717,10 @@ async def update_shot_replicate_material_input( old_material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) old_material = _step_payload(old_material_step.input_json if old_material_step else None) + # 拆镜复刻的素材视频来自 shot_replicate_segments.segment_video_url, + # 与片段强绑定,不允许前端在项目素材修改接口中覆盖。 material = { - "material_video_url": req.material_video_url if req.material_video_url is not None else old_material.get("material_video_url"), + "material_video_url": 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"),