爆款开头API开发完成

This commit is contained in:
2026-06-10 15:17:51 +08:00
parent 949bb794cc
commit 2923b1d6e1
16 changed files with 4034 additions and 0 deletions
@@ -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}")
+3
View File
@@ -0,0 +1,3 @@
from app.enums.common import *
from app.enums.hot_opening_replicate import *
from app.enums.video_prompt_schema import *
+57
View File
@@ -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()