Files
video-gen/video-gen-api/app/services/module_generation_v2/flow_service.py
T
2026-07-22 14:53:03 +08:00

1263 lines
49 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import (
LogEventStatusEnum,
LogSourceEnum,
ModuleEventTypeEnum,
ModuleGenerationFlowVersionEnum,
ModuleProjectStatusEnum,
ModuleStepStatusEnum,
)
from app.enums.generation_task import ChatGenerationTaskStatus
from app.enums.shot_replicate import (
ShotSegmentReplicateStatusEnum,
ShotSplitStatusEnum,
)
from app.enums.upload_resource import (
UploadResourceModuleEnum,
UploadResourceSourceModelEnum,
)
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.user import User
from app.schemas.module_generation_v2 import (
HotOpeningTaskCreateV2,
ModuleGenerationVideoConfigV2,
ModuleVideoPromptSchemaUpdateV2,
ShotReplicateProjectCreateV2,
)
from app.services.generation.ai.engine_service import (
build_video_snapshot,
get_video_engine,
parse_json_list,
)
from app.services.generation.billing_service import charge_module_prompt_usage
from app.services.generation.pipeline.db_lock_service import (
DatabaseRowLockBusy,
execute_with_lock_timeout,
)
from app.services.generation.task_factory_service import create_chat_generation_task_for_module
from app.services.hot_opening_video_prompt_service import (
build_final_video_prompt,
optimize_hot_opening_video_prompt,
patch_video_prompt_schema_from_client,
)
from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks,
create_module_step,
get_current_step_by_code,
soft_delete_steps_from_index,
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.services.operation_log_service import log_operation_event
from app.services.module_generation_step_common_service import (
build_step_output,
force_set_json,
step_payload,
utc_now,
)
from app.services.module_generation_step_update_service import update_module_video_prompt_schema
from app.services.module_generation_v2.config import (
CONFIG_BY_MODULE,
HOT_OPENING_V2,
MATERIAL_INPUT,
SHOT_REPLICATE_V2,
VIDEO_GENERATE,
VIDEO_PROMPT_OPTIMIZE,
ModuleGenerationV2Config,
)
from app.services.module_generation_v2.media_service import (
assert_v2_video_generation_references,
build_v2_video_generation_references,
)
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
from app.services.video_prompt_schema_config_service import (
get_runtime_schema_snapshot,
)
from app.utils.id_gen import generate_id
@dataclass(frozen=True)
class ModuleProjectCreateV2Result:
project: ModuleGenerationProject
prompt_step: ModuleGenerationStep
created_new: bool
def is_project_idempotency_conflict(exc: Exception) -> bool:
"""只把模块项目幂等唯一索引冲突视为可回查的并发重放。"""
original = getattr(exc, "orig", None)
constraint_name = getattr(getattr(original, "diag", None), "constraint_name", None)
if constraint_name == "uq_module_generation_projects_user_module_idempotency":
return True
return "uq_module_generation_projects_user_module_idempotency" in str(original or exc)
def flow_version_of(project: ModuleGenerationProject) -> str:
return str(getattr(project, "flow_version", None) or ModuleGenerationFlowVersionEnum.V1.value)
def assert_v2_project(project: ModuleGenerationProject) -> None:
if flow_version_of(project) != ModuleGenerationFlowVersionEnum.V2.value:
raise HTTPException(
status_code=409,
detail={
"message": "项目流程版本与 V2 接口不匹配",
"project_id": project.id,
"flow_version": flow_version_of(project),
},
)
def assert_v1_project(project: ModuleGenerationProject) -> None:
if flow_version_of(project) != ModuleGenerationFlowVersionEnum.V1.value:
raise HTTPException(
status_code=409,
detail={
"message": "项目流程版本与 V1 接口不匹配",
"project_id": project.id,
"flow_version": flow_version_of(project),
},
)
async def log_v2_event(
db: AsyncSession,
*,
project: ModuleGenerationProject,
event_type: str,
step: ModuleGenerationStep | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
) -> None:
_ = db
event_detail = {
"flow_version": ModuleGenerationFlowVersionEnum.V2.value,
"step_index": step.step_index if step else None,
"step_code": step.step_code if step else None,
"step_version": step.version if step else None,
**(detail or {}),
}
log_module_event_file(
module=project.module,
event_type=event_type,
project_id=project.id,
step_id=step.id if step else None,
user_id=project.user_id,
event_status=LogEventStatusEnum.SUCCESS.value,
source=LogSourceEnum.SERVICE.value,
message=message,
detail=event_detail,
)
log_operation_event(
domain="module_generation",
module=project.module,
event_type=event_type,
event_status=LogEventStatusEnum.SUCCESS.value,
source=LogSourceEnum.SERVICE.value,
user_id=project.user_id,
project_id=project.id,
step_id=step.id if step else None,
message=message,
detail=event_detail,
)
async def get_v2_project_for_user(
db: AsyncSession,
*,
config: ModuleGenerationV2Config,
project_id: str,
current_user: User | SimpleNamespace,
for_update: bool = False,
) -> ModuleGenerationProject:
stmt = select(ModuleGenerationProject).where(
ModuleGenerationProject.id == project_id,
ModuleGenerationProject.module == config.module,
ModuleGenerationProject.deleted_at.is_(None),
)
if not bool(getattr(current_user, "is_admin", False)):
stmt = stmt.where(ModuleGenerationProject.user_id == str(current_user.id))
if for_update:
stmt = stmt.with_for_update()
result = await execute_with_lock_timeout(db, stmt.limit(1))
else:
result = await db.execute(stmt.limit(1))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail=config.project_not_found_message)
assert_v2_project(project)
return project
def _validate_video_config(
engine: Any,
video_config: dict[str, Any],
*,
has_reference_image: bool,
) -> dict[str, Any]:
duration = int(video_config.get("duration") or 0)
aspect_ratio = str(video_config.get("aspect_ratio") or "").strip()
resolution = str(video_config.get("resolution") or "").strip()
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
durations: list[int] = []
for item in parse_json_list(engine.supported_durations, []):
try:
durations.append(int(item))
except (TypeError, ValueError):
continue
if ratios and aspect_ratio not in ratios:
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选比例")
if resolutions and resolution not in resolutions:
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选分辨率")
if durations and duration not in durations:
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选时长")
if duration <= 0 or (int(engine.max_duration or 0) > 0 and duration > int(engine.max_duration)):
raise HTTPException(status_code=400, detail="视频时长不合法或超过当前引擎上限")
if has_reference_image:
if not bool(getattr(engine, "supports_universal_reference", False)):
raise HTTPException(status_code=400, detail="当前视频引擎不支持参考图片生成")
if int(getattr(engine, "max_image_count", 0) or 0) < 1:
raise HTTPException(status_code=400, detail="当前视频引擎允许的参考图片数量为 0")
return {
"engine_id": engine.id,
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
"engine_snapshot": build_video_snapshot(engine, aspect_ratio, resolution, duration),
}
async def _load_video_config(
db: AsyncSession,
video_config: Any,
*,
has_reference_image: bool,
) -> dict[str, Any]:
raw = video_config.model_dump() if hasattr(video_config, "model_dump") else dict(video_config or {})
engine = await get_video_engine(db, str(raw.get("engine_id") or "") or None)
return _validate_video_config(engine, raw, has_reference_image=has_reference_image)
async def _find_idempotent_project(
db: AsyncSession,
*,
user_id: str,
config: ModuleGenerationV2Config,
idempotency_key: str | None,
) -> ModuleGenerationProject | None:
if not idempotency_key:
return None
result = await db.execute(
select(ModuleGenerationProject)
.where(
ModuleGenerationProject.user_id == user_id,
ModuleGenerationProject.module == config.module,
ModuleGenerationProject.idempotency_key == idempotency_key,
ModuleGenerationProject.deleted_at.is_(None),
)
.order_by(ModuleGenerationProject.created_at.desc())
.limit(1)
)
project = result.scalar_one_or_none()
if project:
assert_v2_project(project)
return project
async def _create_material_and_prompt_steps(
db: AsyncSession,
*,
project: ModuleGenerationProject,
config: ModuleGenerationV2Config,
material_payload: dict[str, Any],
video_config: dict[str, Any],
target_platform: str,
context: dict[str, Any] | None = None,
) -> tuple[ModuleGenerationStep, ModuleGenerationStep]:
material_step = await create_module_step(
db,
project=project,
step_code=MATERIAL_INPUT,
config=config.flow_config,
log_module_event=log_v2_event,
status=ModuleStepStatusEnum.COMPLETED.value,
input_data={**material_payload, "source_context": context or {}},
output_data={
"accepted": True,
"message": "V2 素材和项目信息已保存",
"next_step_code": VIDEO_PROMPT_OPTIMIZE,
},
)
prompt_step = await create_module_step(
db,
project=project,
step_code=VIDEO_PROMPT_OPTIMIZE,
config=config.flow_config,
log_module_event=log_v2_event,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=material_step.id,
source_step_id=material_step.id,
input_data={
"source_step_id": material_step.id,
"video_config": video_config,
"target_platform": target_platform,
"trigger": "project_create",
},
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = VIDEO_PROMPT_OPTIMIZE
project.error_message = None
await log_v2_event(
db,
project=project,
step=prompt_step,
event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_AUTO_CREATED.value,
message="创建 V2 项目后自动创建视频提词步骤",
detail={"material_step_id": material_step.id},
)
return material_step, prompt_step
async def create_hot_opening_project_v2(
db: AsyncSession,
*,
current_user: User,
req: HotOpeningTaskCreateV2,
) -> ModuleProjectCreateV2Result:
existing = await _find_idempotent_project(
db,
user_id=current_user.id,
config=HOT_OPENING_V2,
idempotency_key=req.idempotency_key,
)
if existing:
step = await get_current_step_by_code(
db,
project_id=existing.id,
step_code=VIDEO_PROMPT_OPTIMIZE,
config=HOT_OPENING_V2.flow_config,
)
if not step:
raise HTTPException(status_code=409, detail="幂等项目缺少当前视频提词步骤")
return ModuleProjectCreateV2Result(existing, step, False)
video_config = await _load_video_config(
db, req.video_config, has_reference_image=bool(req.material_image_url)
)
project = ModuleGenerationProject(
id=generate_id(),
user_id=current_user.id,
module=HOT_OPENING_V2.module,
flow_version=ModuleGenerationFlowVersionEnum.V2.value,
title=req.target_project_name or req.source_project_name or "爆款开头复刻",
status=ModuleProjectStatusEnum.PROCESSING.value,
current_step_code=VIDEO_PROMPT_OPTIMIZE,
idempotency_key=req.idempotency_key,
)
db.add(project)
await db.flush()
material_payload = {
"material_video_url": req.material_video_url,
"material_video_resource_id": req.material_video_resource_id,
"material_video_duration_seconds": req.material_video_duration_seconds,
"material_image_url": req.material_image_url,
"material_image_resource_id": req.material_image_resource_id,
"source_project_name": req.source_project_name or "参考素材",
"target_project_name": req.target_project_name or project.title,
"core_content_point": req.core_content_point or req.project_description or "按参考素材生成视频",
"project_description": req.project_description,
"material_video_locked": True,
}
_, prompt_step = await _create_material_and_prompt_steps(
db,
project=project,
config=HOT_OPENING_V2,
material_payload=material_payload,
video_config=video_config,
target_platform=req.target_platform or "抖音",
)
await bind_upload_resources(
db,
user_id=current_user.id,
module=UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_id=project.id,
resource_ids=[req.material_video_resource_id, req.material_image_resource_id],
urls=[req.material_video_url, req.material_image_url],
allow_common_migrate=True,
)
await log_v2_event(
db,
project=project,
event_type=ModuleEventTypeEnum.V2_PROJECT_CREATED.value,
message="创建爆款开头复刻 V2 三步骤项目",
detail={"engine_id": video_config["engine_id"]},
)
return ModuleProjectCreateV2Result(project, prompt_step, True)
async def create_shot_replicate_project_v2(
db: AsyncSession,
*,
current_user: User,
segment: ShotReplicateSegment,
req: ShotReplicateProjectCreateV2,
) -> ModuleProjectCreateV2Result:
if segment.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=404, detail="拆镜片段不存在")
if segment.split_status != ShotSplitStatusEnum.COMPLETED.value or not segment.segment_video_url:
raise HTTPException(status_code=400, detail="拆镜片段视频未完成,不能进入复刻流程")
if segment.module_project_id:
result = await db.execute(
select(ModuleGenerationProject)
.where(
ModuleGenerationProject.id == segment.module_project_id,
ModuleGenerationProject.deleted_at.is_(None),
)
.limit(1)
)
existing = result.scalar_one_or_none()
if existing:
if flow_version_of(existing) != ModuleGenerationFlowVersionEnum.V2.value:
raise HTTPException(
status_code=409,
detail={
"message": "该片段已关联历史 V1 复刻项目",
"existing_project_id": existing.id,
"existing_flow_version": flow_version_of(existing),
},
)
step = await get_current_step_by_code(
db,
project_id=existing.id,
step_code=VIDEO_PROMPT_OPTIMIZE,
config=SHOT_REPLICATE_V2.flow_config,
)
if not step:
raise HTTPException(status_code=409, detail="关联 V2 项目缺少当前视频提词步骤")
return ModuleProjectCreateV2Result(existing, step, False)
existing = await _find_idempotent_project(
db,
user_id=current_user.id,
config=SHOT_REPLICATE_V2,
idempotency_key=req.idempotency_key,
)
if existing:
assert_v2_project(existing)
material = await _current_material_step(
db, project=existing, config=SHOT_REPLICATE_V2, for_update=False
)
existing_payload = step_payload(
material.input_json, schema_version=SHOT_REPLICATE_V2.io_schema_version
)
if str(existing_payload.get("source_segment_id") or "") != str(segment.id):
raise HTTPException(status_code=409, detail="该幂等键已用于其他拆镜片段")
segment.module_project_id = existing.id
segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value
step = await get_current_step_by_code(
db,
project_id=existing.id,
step_code=VIDEO_PROMPT_OPTIMIZE,
config=SHOT_REPLICATE_V2.flow_config,
)
if not step:
raise HTTPException(status_code=409, detail="幂等项目缺少当前视频提词步骤")
return ModuleProjectCreateV2Result(existing, step, False)
video_config = await _load_video_config(
db, req.video_config, has_reference_image=bool(req.material_image_url)
)
project = ModuleGenerationProject(
id=generate_id(),
user_id=current_user.id,
module=SHOT_REPLICATE_V2.module,
flow_version=ModuleGenerationFlowVersionEnum.V2.value,
title=req.target_project_name or "拆镜复刻",
status=ModuleProjectStatusEnum.PROCESSING.value,
current_step_code=VIDEO_PROMPT_OPTIMIZE,
idempotency_key=req.idempotency_key,
)
db.add(project)
await db.flush()
material_payload = {
"material_video_url": segment.segment_video_url,
"material_video_locked": True,
"material_image_url": req.material_image_url,
"material_image_resource_id": req.material_image_resource_id,
"source_project_name": segment.segment_category or segment.original_video_category or "拆镜片段",
"target_project_name": req.target_project_name or project.title,
"core_content_point": req.core_content_point or req.project_description or segment.segment_content or "按拆镜片段生成视频",
"project_description": req.project_description,
"source_shot_task_set_id": segment.task_set_id,
"source_segment_id": segment.id,
"source_segment_index": segment.segment_index,
"segment_time_node": segment.time_node,
}
context = {
"shot_segment": {
"id": segment.id,
"task_set_id": segment.task_set_id,
"segment_index": segment.segment_index,
"time_node": segment.time_node,
"start_second": segment.start_second,
"end_second": segment.end_second,
"source_mode": segment.source_mode,
},
"analysis": {
"original_video_content": segment.original_video_content,
"original_video_category": segment.original_video_category,
"original_video_audience": segment.original_video_audience,
"segment_content": segment.segment_content,
"segment_category": segment.segment_category,
"segment_audience": segment.segment_audience,
},
}
_, prompt_step = await _create_material_and_prompt_steps(
db,
project=project,
config=SHOT_REPLICATE_V2,
material_payload=material_payload,
video_config=video_config,
target_platform=req.target_platform or "抖音",
context=context,
)
await bind_upload_resources(
db,
user_id=current_user.id,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_id=project.id,
resource_ids=[req.material_image_resource_id],
urls=[req.material_image_url],
allow_common_migrate=True,
)
segment.module_project_id = project.id
segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value
await log_v2_event(
db,
project=project,
event_type=ModuleEventTypeEnum.V2_PROJECT_CREATED.value,
message="从拆镜片段创建 V2 三步骤复刻项目",
detail={"segment_id": segment.id, "engine_id": video_config["engine_id"]},
)
return ModuleProjectCreateV2Result(project, prompt_step, True)
async def _current_material_step(
db: AsyncSession,
*,
project: ModuleGenerationProject,
config: ModuleGenerationV2Config,
for_update: bool = False,
) -> ModuleGenerationStep:
stmt = select(ModuleGenerationStep).where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.step_code == MATERIAL_INPUT,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
if for_update:
stmt = stmt.with_for_update()
result = await execute_with_lock_timeout(db, stmt.limit(1))
else:
result = await db.execute(stmt.limit(1))
step = result.scalar_one_or_none()
if not step:
raise HTTPException(status_code=409, detail="V2 项目缺少素材步骤")
return step
async def rebuild_video_prompt_step_v2(
db: AsyncSession,
*,
config: ModuleGenerationV2Config,
current_user: User,
project_id: str,
source_prompt_step_id: str,
video_config: ModuleGenerationVideoConfigV2,
trigger: str = "manual_retry",
) -> tuple[ModuleGenerationProject, ModuleGenerationStep]:
project = await get_v2_project_for_user(
db,
config=config,
project_id=project_id,
current_user=current_user,
for_update=True,
)
await assert_project_has_no_active_chat_tasks(
db,
project=project,
config=config.flow_config,
detail_message=f"当前{config.display_name}项目仍有视频生成任务处理中,暂不能重新生成视频提词",
)
material_step = await _current_material_step(db, project=project, config=config, for_update=True)
current_prompt = await get_current_step_by_code(
db,
project_id=project.id,
step_code=VIDEO_PROMPT_OPTIMIZE,
config=config.flow_config,
)
if not current_prompt:
raise HTTPException(status_code=404, detail="当前视频提词步骤不存在")
if str(current_prompt.id) != str(source_prompt_step_id):
raise HTTPException(status_code=409, detail="路径中的步骤不是当前有效视频提词步骤")
if current_prompt.status == ModuleStepStatusEnum.PROCESSING.value:
raise HTTPException(status_code=409, detail="视频提词正在生成,请等待完成或失败后再重试")
prompt_input = step_payload(current_prompt.input_json, schema_version=config.io_schema_version)
old_video_config = dict(prompt_input.get("video_config") or {})
material_input = step_payload(material_step.input_json, schema_version=config.io_schema_version)
validated_video_config = await _load_video_config(
db,
video_config,
has_reference_image=bool(material_input.get("material_image_url")),
)
target_platform = str(prompt_input.get("target_platform") or "抖音")
await soft_delete_steps_from_index(
db,
project=project,
start_index=2,
config=config.flow_config,
log_module_event=log_v2_event,
)
step = await create_module_step(
db,
project=project,
step_code=VIDEO_PROMPT_OPTIMIZE,
config=config.flow_config,
log_module_event=log_v2_event,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=material_step.id,
source_step_id=material_step.id,
input_data={
"source_step_id": material_step.id,
"video_config": validated_video_config,
"target_platform": target_platform,
"trigger": trigger,
},
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = VIDEO_PROMPT_OPTIMIZE
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await log_v2_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_REGENERATED.value,
message="重复生成 V2 视频提词",
detail={
"trigger": trigger,
"material_step_id": material_step.id,
"source_prompt_step_id": current_prompt.id,
"previous_video_config": old_video_config,
"video_config": validated_video_config,
},
)
return project, step
async def mark_video_prompt_dispatch_failed_v2(
db: AsyncSession,
*,
config: ModuleGenerationV2Config,
project_id: str,
step_id: str,
error_message: str,
) -> None:
result = await execute_with_lock_timeout(
db,
select(ModuleGenerationProject, ModuleGenerationStep)
.join(ModuleGenerationStep, ModuleGenerationStep.project_id == ModuleGenerationProject.id)
.where(
ModuleGenerationProject.id == project_id,
ModuleGenerationProject.module == config.module,
ModuleGenerationProject.flow_version == ModuleGenerationFlowVersionEnum.V2.value,
ModuleGenerationProject.deleted_at.is_(None),
ModuleGenerationStep.id == step_id,
ModuleGenerationStep.step_code == VIDEO_PROMPT_OPTIMIZE,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
.with_for_update()
.limit(1),
)
row = result.first()
if not row:
await db.rollback()
return
project, step = row
if step.status != ModuleStepStatusEnum.PROCESSING.value:
await db.rollback()
return
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = error_message
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.current_step_code = VIDEO_PROMPT_OPTIMIZE
project.error_message = error_message
await log_v2_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_DISPATCH_FAILED.value,
message=error_message,
detail={"dispatch_compensated": True},
)
await db.commit()
async def run_video_prompt_optimize_v2(
db: AsyncSession,
*,
project_id: str,
step_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> ModuleGenerationStep | None:
try:
meta_result = await execute_with_lock_timeout(
db,
select(ModuleGenerationProject, ModuleGenerationStep)
.join(ModuleGenerationStep, ModuleGenerationStep.project_id == ModuleGenerationProject.id)
.where(
ModuleGenerationProject.id == project_id,
ModuleGenerationProject.flow_version == ModuleGenerationFlowVersionEnum.V2.value,
ModuleGenerationProject.deleted_at.is_(None),
ModuleGenerationStep.id == step_id,
ModuleGenerationStep.project_id == project_id,
ModuleGenerationStep.step_code == VIDEO_PROMPT_OPTIMIZE,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
.with_for_update()
.limit(1),
)
row = meta_result.first()
if not row:
await db.rollback()
return None
project, step = row
config = CONFIG_BY_MODULE.get(project.module)
if not config or step.status != ModuleStepStatusEnum.PROCESSING.value:
await db.rollback()
return step
material_step = await _current_material_step(db, project=project, config=config, for_update=False)
material_payload = step_payload(material_step.input_json, schema_version=config.io_schema_version)
prompt_input = step_payload(step.input_json, schema_version=config.io_schema_version)
video_config = dict(prompt_input.get("video_config") or {})
if not all(video_config.get(key) for key in ("engine_id", "duration", "aspect_ratio", "resolution")):
raise RuntimeError("V2 视频提词步骤缺少完整视频参数")
expected_version = int(step.version)
expected_input = json.loads(json.dumps(step.input_json, ensure_ascii=False, default=str))
project_snapshot = {
"module": project.module,
"project_id": project.id,
"step_id": step.id,
"user_id": project.user_id,
"source_project_name": material_payload.get("source_project_name") or "参考素材",
"target_project_name": material_payload.get("target_project_name") or project.title or config.display_name,
"core_content_point": material_payload.get("core_content_point") or material_payload.get("project_description") or "按参考素材生成视频",
"material_video_url": material_payload.get("material_video_url"),
"material_image_url": material_payload.get("material_image_url"),
"target_platform": prompt_input.get("target_platform") or "抖音",
"video_config": video_config,
}
if not project_snapshot["material_video_url"]:
raise RuntimeError("V2 素材步骤缺少参考视频")
schema_config_snapshot = await get_runtime_schema_snapshot(db)
await db.commit()
prompt_schema, final_prompt, usage = await optimize_hot_opening_video_prompt(
db,
user_id=project_snapshot["user_id"],
source_project_name=project_snapshot["source_project_name"],
target_project_name=project_snapshot["target_project_name"],
core_content_point=project_snapshot["core_content_point"],
material_video_url=project_snapshot["material_video_url"],
generated_image_url=project_snapshot["material_image_url"],
video_config=project_snapshot["video_config"],
target_platform=project_snapshot["target_platform"],
schema_config_snapshot=schema_config_snapshot,
module=project_snapshot["module"],
project_id=project_snapshot["project_id"],
step_id=project_snapshot["step_id"],
)
if execution_guard is not None:
await execution_guard()
locked = await execute_with_lock_timeout(
db,
select(ModuleGenerationProject, ModuleGenerationStep)
.join(ModuleGenerationStep, ModuleGenerationStep.project_id == ModuleGenerationProject.id)
.where(
ModuleGenerationProject.id == project_snapshot["project_id"],
ModuleGenerationProject.deleted_at.is_(None),
ModuleGenerationProject.flow_version == ModuleGenerationFlowVersionEnum.V2.value,
ModuleGenerationStep.id == project_snapshot["step_id"],
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
.with_for_update()
.limit(1),
)
row = locked.first()
if not row:
await db.rollback()
return None
project, step = row
if int(step.version) != expected_version or step.input_json != expected_input or step.status != ModuleStepStatusEnum.PROCESSING.value:
await db.rollback()
log_module_event_file(
module=project_snapshot["module"],
event_type=ModuleEventTypeEnum.STALE_STEP_RESULT_DISCARDED.value,
project_id=project_snapshot["project_id"],
step_id=project_snapshot["step_id"],
user_id=project_snapshot["user_id"],
message="V2 旧视频提词结果已丢弃",
detail={"expected_version": expected_version},
)
return None
billing = await charge_module_prompt_usage(
db,
user_id=project.user_id,
step_id=step.id,
usage=usage,
description=f"{config.display_name}-视频提词优化",
attempt_no=1,
)
output_payload = {
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"params_used_for_prompt": project_snapshot["video_config"],
"target_platform": project_snapshot["target_platform"],
"schema_config_snapshot": schema_config_snapshot,
"schema_config_source": schema_config_snapshot.get("source"),
"schema_config_version": schema_config_snapshot.get("version"),
}
force_set_json(
step,
"output_json",
build_step_output(
step_code=VIDEO_PROMPT_OPTIMIZE,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_payload,
usage={
**dict(usage or {}),
"text_credits_cost": round(billing.total_charged, 2),
},
schema_version=config.io_schema_version,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = VIDEO_PROMPT_OPTIMIZE
project.error_message = None
await log_v2_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUCCESS.value,
message="V2 视频提词生成成功",
detail={"engine_id": project_snapshot["video_config"].get("engine_id")},
)
await db.commit()
return step
except DatabaseRowLockBusy:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
try:
if execution_guard is not None:
await execution_guard()
result = await execute_with_lock_timeout(
db,
select(ModuleGenerationProject, ModuleGenerationStep)
.join(ModuleGenerationStep, ModuleGenerationStep.project_id == ModuleGenerationProject.id)
.where(
ModuleGenerationProject.id == project_id,
ModuleGenerationProject.deleted_at.is_(None),
ModuleGenerationStep.id == step_id,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
.with_for_update()
.limit(1),
)
row = result.first()
if row:
project, step = row
if step.status == ModuleStepStatusEnum.PROCESSING.value:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc)
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = str(exc)
await db.commit()
log_module_error(
module=row[0].module if row else "module_generation_v2",
event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value,
project_id=project_id,
step_id=step_id,
message="V2 视频提词生成失败",
exc=exc,
)
except Exception as mark_exc:
await db.rollback()
log_module_error(
module="module_generation_v2",
event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value,
project_id=project_id,
step_id=step_id,
message="V2 视频提词失败状态落库失败",
detail={"origin_error": str(exc)},
exc=mark_exc,
)
return None
async def update_video_prompt_schema_v2(
db: AsyncSession,
*,
config: ModuleGenerationV2Config,
current_user: User,
project_id: str,
step_id: str,
req: ModuleVideoPromptSchemaUpdateV2,
) -> tuple[ModuleGenerationProject, ModuleGenerationStep]:
# 先做版本校验;公共更新服务负责短事务锁、白名单 patch 和下游软删。
await get_v2_project_for_user(
db,
config=config,
project_id=project_id,
current_user=current_user,
for_update=False,
)
return await update_module_video_prompt_schema(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
req=req,
config=config.flow_config,
log_module_event=log_v2_event,
patch_video_prompt_schema_from_client=patch_video_prompt_schema_from_client,
build_final_video_prompt=build_final_video_prompt,
)
async def generate_video_from_prompt_v2(
db: AsyncSession,
*,
config: ModuleGenerationV2Config,
current_user: User,
project_id: str,
prompt_step_id: str,
) -> tuple[ModuleGenerationProject, ModuleGenerationStep, ChatGenerationTask]:
project = await get_v2_project_for_user(
db,
config=config,
project_id=project_id,
current_user=current_user,
for_update=True,
)
await assert_project_has_no_active_chat_tasks(
db,
project=project,
config=config.flow_config,
detail_message=f"当前{config.display_name}项目已有视频生成任务处理中,请等待完成后再操作",
)
prompt_result = await execute_with_lock_timeout(
db,
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.id == prompt_step_id,
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.step_code == VIDEO_PROMPT_OPTIMIZE,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
.with_for_update()
.limit(1),
)
prompt_step = prompt_result.scalar_one_or_none()
if not prompt_step:
raise HTTPException(status_code=404, detail="当前视频提词步骤不存在")
if prompt_step.status != ModuleStepStatusEnum.COMPLETED.value:
raise HTTPException(status_code=400, detail="视频提词未完成,不能生成视频")
material_step = await _current_material_step(db, project=project, config=config, for_update=False)
material_payload = step_payload(material_step.input_json, schema_version=config.io_schema_version)
prompt_input = step_payload(prompt_step.input_json, schema_version=config.io_schema_version)
prompt_output = step_payload(prompt_step.output_json, schema_version=config.io_schema_version)
prompt_schema = prompt_output.get("prompt_schema")
final_prompt = str(prompt_output.get("final_prompt") or "").strip()
video_config = dict(prompt_input.get("video_config") or {})
if not isinstance(prompt_schema, dict) or not prompt_schema:
raise HTTPException(status_code=400, detail="视频提词缺少 prompt_schema")
if not final_prompt:
final_prompt = build_final_video_prompt(prompt_schema)
if not all(video_config.get(key) for key in ("engine_id", "duration", "aspect_ratio", "resolution")):
raise HTTPException(status_code=400, detail="视频提词缺少完整视频参数快照")
await soft_delete_steps_from_index(
db,
project=project,
start_index=3,
config=config.flow_config,
log_module_event=log_v2_event,
)
references = build_v2_video_generation_references(material_payload)
assert_v2_video_generation_references(references)
engine = await get_video_engine(db, str(video_config["engine_id"]))
video_config = _validate_video_config(
engine,
video_config,
has_reference_image=bool(references),
)
generate_step = await create_module_step(
db,
project=project,
step_code=VIDEO_GENERATE,
config=config.flow_config,
log_module_event=log_v2_event,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
input_data={
"engine_id": video_config["engine_id"],
"params": {
"duration": int(video_config["duration"]),
"aspect_ratio": str(video_config["aspect_ratio"]),
"resolution": str(video_config["resolution"]),
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": references,
"attachment_policy": "optional_image_only_no_video",
},
)
prompt_schema_text = json.dumps(prompt_schema, ensure_ascii=False)
task = await create_chat_generation_task_for_module(
db,
current_user=current_user,
generation_mode=config.generation_mode,
gen_type="video",
original_prompt=prompt_schema_text,
optimized_prompt=prompt_schema_text,
engine_id=str(video_config["engine_id"]),
media_references=references,
duration=int(video_config["duration"]),
aspect_ratio=str(video_config["aspect_ratio"]),
resolution=str(video_config["resolution"]),
billing_project_name=project.title or config.display_name,
billing_description_prefix=f"{config.display_name}视频生成-",
billing_source_module=project.module,
billing_source_project_id=project.id,
billing_source_step_id=generate_step.id,
billing_source_step_code=VIDEO_GENERATE,
)
generate_step.chat_task_id = task.id
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = VIDEO_GENERATE
project.error_message = None
await log_v2_event(
db,
project=project,
step=generate_step,
event_type=ModuleEventTypeEnum.VIDEO_GENERATE_SUBMITTED.value,
message="V2 视频生成任务已创建",
detail={
"chat_task_id": task.id,
"engine_id": task.engine_id,
"material_image_count": len(references),
"material_video_count": 0,
},
)
return project, generate_step, task
async def handle_chat_generation_task_finished_v2(
db: AsyncSession,
*,
task: ChatGenerationTask,
) -> bool:
meta = await db.execute(
select(ModuleGenerationStep.id, ModuleGenerationStep.project_id)
.where(
ModuleGenerationStep.chat_task_id == task.id,
ModuleGenerationStep.step_code == VIDEO_GENERATE,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
.limit(1)
)
row = meta.first()
if not row:
return False
result = await execute_with_lock_timeout(
db,
select(ModuleGenerationProject, ModuleGenerationStep)
.join(ModuleGenerationStep, ModuleGenerationStep.project_id == ModuleGenerationProject.id)
.where(
ModuleGenerationProject.id == str(row.project_id),
ModuleGenerationProject.flow_version == ModuleGenerationFlowVersionEnum.V2.value,
ModuleGenerationProject.deleted_at.is_(None),
ModuleGenerationStep.id == str(row.id),
ModuleGenerationStep.chat_task_id == task.id,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
.with_for_update()
.limit(1),
)
locked = result.first()
if not locked:
return False
project, step = locked
config = CONFIG_BY_MODULE.get(project.module)
if not config:
return False
if task.status == ChatGenerationTaskStatus.COMPLETED.value:
if step.status == ModuleStepStatusEnum.COMPLETED.value and project.status == ModuleProjectStatusEnum.COMPLETED.value:
return True
force_set_json(
step,
"output_json",
build_step_output(
step_code=VIDEO_GENERATE,
status=ModuleStepStatusEnum.COMPLETED.value,
result={
"result_video_url": task.video_url,
"result_video_cover_url": task.video_cover_url,
"chat_task_id": task.id,
},
schema_version=config.io_schema_version,
),
)
step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None
step.completed_at = utc_now()
project.final_video_url = task.video_url
project.final_video_cover_url = task.video_cover_url
project.status = ModuleProjectStatusEnum.COMPLETED.value
project.current_step_code = VIDEO_GENERATE
project.completed_at = utc_now()
project.error_message = None
await log_v2_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.VIDEO_GENERATE_SUCCESS.value,
message="V2 视频生成完成",
detail={"chat_task_id": task.id},
)
elif task.status == ChatGenerationTaskStatus.FAILED.value:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = task.error_message
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = task.error_message or "视频生成失败"
await log_v2_event(
db,
project=project,
step=step,
event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value,
message=project.error_message,
detail={"chat_task_id": task.id},
)
return True
async def delete_project_v2(
db: AsyncSession,
*,
config: ModuleGenerationV2Config,
current_user: User,
project_id: str,
) -> dict[str, Any]:
"""软删除 V2 项目;不沿步骤父子链查询,直接按项目批量处理。"""
project = await get_v2_project_for_user(
db,
config=config,
project_id=project_id,
current_user=current_user,
for_update=True,
)
await assert_project_has_no_active_chat_tasks(
db,
project=project,
config=config.flow_config,
detail_message=f"当前{config.display_name}项目仍有生成中任务,暂不能删除",
)
deleted_at = utc_now()
project.deleted_at = deleted_at
await soft_delete_steps_from_index(
db,
project=project,
start_index=1,
deleted_at=deleted_at,
config=config.flow_config,
log_module_event=log_v2_event,
)
if config.module == SHOT_REPLICATE_V2.module:
segment_result = await execute_with_lock_timeout(
db,
select(ShotReplicateSegment)
.where(ShotReplicateSegment.module_project_id == project.id)
.with_for_update(),
)
for segment in segment_result.scalars().all():
segment.module_project_id = None
segment.replicate_status = ShotSegmentReplicateStatusEnum.NOT_STARTED.value
upload_module = (
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value
if config.module == HOT_OPENING_V2.module
else UploadResourceModuleEnum.SHOT_REPLICATE.value
)
release = await release_upload_resources_by_source(
db,
source_model=UploadResourceSourceModelEnum.MODULE_GENERATION_PROJECT.value,
source_ids=[project.id],
module=upload_module,
)
await log_v2_event(
db,
project=project,
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
message=f"软删除{config.display_name} V2 项目",
detail={
"upload_resource_release": {
key: value for key, value in release.items() if key != "released_resource_ids"
}
},
)
return {
"message": "项目已删除",
"project_id": project.id,
"deleted": True,
"released_size_bytes": int(release.get("released_size_bytes") or 0),
"upload_resource_released": int(release.get("released") or 0),
"pending_delete_resource_ids": list(release.get("released_resource_ids") or []),
}