爆款开头API开发完成
This commit is contained in:
+120
@@ -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 ###
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.enums.common import *
|
||||
from app.enums.hot_opening_replicate import *
|
||||
from app.enums.video_prompt_schema import *
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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视频生成提词结构"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 示例")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}"
|
||||
@@ -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"...<truncated:{len(value) - MAX_LOG_FIELD_LENGTH}>"
|
||||
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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user