1263 lines
49 KiB
Python
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 []),
|
|
}
|