Files
video-gen/video-gen-api/app/services/generation_task_factory_service.py
T

219 lines
8.8 KiB
Python

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.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
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 = "模块生成-",
billing_source_module: str | None = None,
billing_source_project_id: str | None = None,
billing_source_step_id: str | None = None,
billing_source_step_code: str | None = None,
billing_scene: str | None = None,
) -> 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 []
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
backend_idempotency_key = _build_backend_idempotency_key(
generation_mode=generation_mode,
gen_type=gen_type,
task_id=task_id,
)
await assert_user_resource_capacity_available(db, current_user.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,
image_px=px,
aspect_ratio=proportion,
engine_id=engine.id,
project_name=billing_project_name,
description_prefix=billing_description_prefix,
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
source_module=billing_source_module,
source_project_id=billing_source_project_id,
source_step_id=billing_source_step_id,
source_step_code=billing_source_step_code,
billing_scene=billing_scene,
media_references=refs,
)
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),
current_billing_attempt_no=1,
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,
aspect_ratio=ratio,
fps=24,
engine_id=engine.id,
project_name=billing_project_name,
description_prefix=billing_description_prefix,
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
source_module=billing_source_module,
source_project_id=billing_source_project_id,
source_step_id=billing_source_step_id,
source_step_code=billing_source_step_code,
billing_scene=billing_scene,
media_references=refs,
)
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),
current_billing_attempt_no=1,
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(hours=settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS),
)
db.add(task)
await db.flush()
return task