Files
video-gen/video-gen-api/app/services/generation/ai/task_create_service.py
T

618 lines
26 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.audio_reference import (
AUDIO_MAX_COUNT_LIMIT,
AUDIO_MAX_DURATION_SECONDS,
AUDIO_MAX_TOTAL_DURATION_SECONDS,
AUDIO_MIN_DURATION_SECONDS,
)
from app.enums.generation_task import CHAT_TOP_LEVEL_MODES, GenerationMode, GenerationType
from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User
from app.schemas.generation_ai import GenerationAITaskCreate
from app.services.generation.ai.engine_service import (
IMAGE_DEFAULT_PROPORTION,
IMAGE_DEFAULT_PX,
IMAGE_DEFAULT_SIZE,
VIDEO_DEFAULT_DURATION,
VIDEO_DEFAULT_RATIO,
VIDEO_DEFAULT_RESOLUTION,
build_image_snapshot,
build_video_snapshot,
get_image_engine,
get_video_engine,
image_supported_sizes,
normalize_generation_count,
normalize_px,
parse_json_list,
)
from app.services.generation.billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
from app.services.operation_log_service import log_operation_event
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
from app.utils.id_gen import generate_id
@dataclass(slots=True)
class GenerationTaskCreateResult:
top_level_task_id: str
enqueue_task_ids: list[str] = field(default_factory=list)
child_task_ids: list[str] = field(default_factory=list)
generation_count: int = 1
gen_type: str = GenerationType.IMAGE.value
created: bool = True
def _json(data: Any) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
async def find_existing_top_level_task(
db: AsyncSession,
*,
user_id: str,
idempotency_key: str | None,
) -> ChatGenerationTask | None:
if not idempotency_key:
return None
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.idempotency_key == idempotency_key,
ChatGenerationTask.generation_mode.in_(list(CHAT_TOP_LEVEL_MODES)),
ChatGenerationTask.deleted_at.is_(None),
)
.order_by(ChatGenerationTask.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
def _validate_video_references(refs: list[dict], *, max_audio_count: int) -> float:
input_video_duration = 0.0
for ref in refs:
if (ref.get("type") or "").lower() != GenerationType.VIDEO.value:
continue
try:
ref_duration = float(ref.get("duration") or 0)
except (TypeError, ValueError):
ref_duration = 0.0
if ref_duration < 2:
raise HTTPException(status_code=400, detail="视频素材最短不能少于 2 秒")
input_video_duration += ref_duration
if input_video_duration > 15:
raise HTTPException(status_code=400, detail=f"所有视频素材总时长不能超过 15 秒,当前 {input_video_duration:.1f} 秒")
audio_refs = [ref for ref in refs if (ref.get("type") or "").lower() == "audio"]
if audio_refs:
allowed_count = min(AUDIO_MAX_COUNT_LIMIT, max(0, int(max_audio_count or 0)))
if allowed_count <= 0:
raise HTTPException(status_code=400, detail="当前视频引擎不支持音频参考素材")
if len(audio_refs) > allowed_count:
raise HTTPException(status_code=400, detail=f"参考音频最多可传 {allowed_count} 段,当前 {len(audio_refs)} 段")
input_audio_duration = 0.0
for ref in audio_refs:
try:
ref_duration = float(ref.get("duration") or 0)
except (TypeError, ValueError):
ref_duration = 0.0
if ref_duration < AUDIO_MIN_DURATION_SECONDS or ref_duration > AUDIO_MAX_DURATION_SECONDS:
raise HTTPException(
status_code=400,
detail=f"单段参考音频时长必须在 {AUDIO_MIN_DURATION_SECONDS}-{AUDIO_MAX_DURATION_SECONDS} 秒之间",
)
input_audio_duration += ref_duration
if input_audio_duration > AUDIO_MAX_TOTAL_DURATION_SECONDS:
raise HTTPException(
status_code=400,
detail=f"所有参考音频总时长不能超过 {AUDIO_MAX_TOTAL_DURATION_SECONDS} 秒,当前 {input_audio_duration:.1f} 秒",
)
return input_video_duration
def _base_task_kwargs(
*,
task_id: str,
user_id: str,
req: GenerationAITaskCreate,
gen_type: str,
generation_mode: str,
generation_count: int,
engine_id: str,
engine_snapshot_json: str,
media_references_json: str | None,
deadline_at: datetime,
parent_task_id: str | None = None,
generation_index: int | None = None,
credits_cost: float = 0.0,
idempotency_key: str | None = None,
) -> dict[str, Any]:
resource_started_at = (
deadline_at - timedelta(minutes=int(settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES or 30))
if gen_type == GenerationType.IMAGE.value
else deadline_at - timedelta(hours=int(settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS or 24))
)
return {
"id": task_id,
"created_at": resource_started_at,
"resource_generation_started_at": resource_started_at,
"generation_attempt_no": 1,
"user_id": user_id,
"original_prompt": req.original_prompt,
"gen_type": gen_type,
"status": "generating",
"generation_mode": generation_mode,
"pipeline_stage": "queued",
"parent_task_id": parent_task_id,
"generation_count": generation_count,
"generation_index": generation_index,
"engine_id": engine_id,
"engine_snapshot_json": engine_snapshot_json,
"media_references": media_references_json,
"credits_cost": round(float(credits_cost or 0), 2),
"idempotency_key": idempotency_key,
"deadline_at": deadline_at,
}
async def create_generation_task_group(
db: AsyncSession,
current_user: User,
req: GenerationAITaskCreate,
) -> GenerationTaskCreateResult:
"""创建单份 chatapi_async 或多份 chatapi_main/chatapi_child 任务组。
本函数只 flush,不主动 commit。调用方提交成功后才能投递 Celery。
"""
gen_type = (req.gen_type or "").lower().strip()
if gen_type not in (GenerationType.IMAGE.value, GenerationType.VIDEO.value):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
existing = await find_existing_top_level_task(
db,
user_id=current_user.id,
idempotency_key=req.idempotency_key,
)
if existing:
return GenerationTaskCreateResult(
top_level_task_id=existing.id,
generation_count=int(existing.generation_count or 1),
gen_type=existing.gen_type,
created=False,
)
refs = [item.model_dump(exclude_none=True) for item in (req.media_references or [])]
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
media_references_json = _json(refs) if refs else None
await assert_user_resource_capacity_available(db, current_user.id)
now = datetime.now(timezone.utc)
main_id = generate_id()
child_ids: list[str] = []
enqueue_ids: list[str] = []
total_billed_credits = 0.0
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_CREATE_START",
event_status="started",
source="service",
user_id=current_user.id,
group_id=main_id,
detail={
"gen_type": gen_type,
"requested_generation_count": normalize_generation_count(req.generation_count),
"idempotency_key_present": bool(req.idempotency_key),
},
)
if gen_type == GenerationType.IMAGE.value:
if any((ref.get("type") or "").lower() == "audio" for ref in refs):
raise HTTPException(status_code=400, detail="图片生成不支持音频参考素材")
engine = await get_image_engine(db, req.engine_id)
generation_count = normalize_generation_count(req.generation_count)
multi_generation_enabled = bool(getattr(engine, "multi_generation_enabled", False))
max_generation_count = normalize_generation_count(getattr(engine, "max_generation_count", 1))
if generation_count > 1 and not multi_generation_enabled:
raise HTTPException(status_code=400, detail="当前图片引擎未开启多份生成,本次生成数量只能为 1")
if generation_count > max_generation_count:
raise HTTPException(
status_code=400,
detail=f"当前图片引擎本次最多允许生成 {max_generation_count} 份",
)
reference_image_count = sum(
1 for ref in refs if (ref.get("type") or "").lower() == GenerationType.IMAGE.value
)
max_reference_count = max(0, int(getattr(engine, "max_reference_image_count", 14) or 0))
multi_image_max_images = max(1, int(getattr(engine, "multi_image_max_images", 15) or 15))
if reference_image_count > max_reference_count:
raise HTTPException(
status_code=400,
detail=f"当前图片引擎最多支持 {max_reference_count} 张参考图,当前 {reference_image_count} 张",
)
if generation_count > 1 and reference_image_count + generation_count > multi_image_max_images:
raise HTTPException(
status_code=400,
detail=(
f"参考图数量与生成数量合计不能超过 {multi_image_max_images} 张,"
f"当前参考图 {reference_image_count} 张、生成 {generation_count} 张"
),
)
sizes = image_supported_sizes(engine)
size = req.image_size or engine.default_size or IMAGE_DEFAULT_SIZE
proportion = req.image_proportion or IMAGE_DEFAULT_PROPORTION
px = normalize_px(req.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
mode = GenerationMode.CHATAPI_ASYNC.value if generation_count == 1 else GenerationMode.CHATAPI_MAIN.value
billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=main_id,
gen_type=GenerationType.IMAGE.value,
image_size=size,
engine_id=engine.id,
input_image_count=reference_image_count if reference_image_count > 0 else None,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
quantity=generation_count,
)
image_snapshot = build_image_snapshot(engine, size, proportion, px)
image_snapshot["generation_count"] = generation_count
snapshot_json = _json(image_snapshot) or "{}"
total_billed_credits = round(float(billing.total_charged or 0), 2)
task = ChatGenerationTask(
**_base_task_kwargs(
task_id=main_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.IMAGE.value,
generation_mode=mode,
generation_count=generation_count,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES),
credits_cost=billing.total_charged,
idempotency_key=req.idempotency_key,
),
image_size=size,
image_proportion=proportion,
image_px=px,
)
db.add(task)
enqueue_ids.append(task.id)
else:
engine = await get_video_engine(db, req.engine_id)
generation_count = normalize_generation_count(req.generation_count)
multi_generation_enabled = bool(getattr(engine, "multi_generation_enabled", False))
max_generation_count = normalize_generation_count(getattr(engine, "max_generation_count", 1))
if generation_count > 1 and not multi_generation_enabled:
raise HTTPException(status_code=400, detail="当前视频引擎未开启多份生成,本次生成数量只能为 1")
if generation_count > max_generation_count:
raise HTTPException(
status_code=400,
detail=f"当前视频引擎本次最多允许生成 {max_generation_count} 份",
)
ratio = req.aspect_ratio or VIDEO_DEFAULT_RATIO
resolution = req.resolution or VIDEO_DEFAULT_RESOLUTION
duration = req.duration or VIDEO_DEFAULT_DURATION
ratios = parse_json_list(engine.supported_ratios, [])
resolutions = parse_json_list(engine.supported_resolutions, [])
durations = parse_json_list(engine.supported_durations, [])
if ratios and ratio not in ratios:
raise HTTPException(status_code=400, detail=f"视频比例不支持: {ratio}")
if resolutions and resolution not in resolutions:
raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {resolution}")
if durations and duration not in durations:
raise HTTPException(status_code=400, detail=f"视频时长不支持: {duration}")
if engine.max_duration and duration > engine.max_duration:
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒")
input_video_duration = _validate_video_references(refs, max_audio_count=engine.max_audio_count)
reference_image_count = sum(
1 for ref in refs if (ref.get("type") or "").lower() == GenerationType.IMAGE.value
)
provider_generation_resolution, upscale_enabled_snapshot, upscale_snapshot_json = await build_video_upscale_snapshot(
db,
target_resolution=resolution,
aspect_ratio=ratio,
supported_provider_resolutions=resolutions,
)
video_snapshot = build_video_snapshot(engine, ratio, resolution, duration)
video_snapshot["provider_generation_resolution"] = provider_generation_resolution
video_snapshot["video_upscale_enabled_snapshot"] = upscale_enabled_snapshot
video_snapshot["generation_count"] = generation_count
snapshot_json = _json(video_snapshot) or "{}"
deadline_at = now + timedelta(hours=settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS)
if generation_count == 1:
billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=main_id,
gen_type=GenerationType.VIDEO.value,
duration=duration,
resolution=resolution,
engine_id=engine.id,
input_video_duration=input_video_duration if input_video_duration > 0 else None,
input_image_count=reference_image_count if reference_image_count > 0 else None,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
)
total_billed_credits = round(float(billing.total_charged or 0), 2)
task = ChatGenerationTask(
**_base_task_kwargs(
task_id=main_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.VIDEO.value,
generation_mode=GenerationMode.CHATAPI_ASYNC.value,
generation_count=1,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=deadline_at,
credits_cost=billing.total_charged,
idempotency_key=req.idempotency_key,
),
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
video_upscale_enabled_snapshot=upscale_enabled_snapshot,
video_upscale_snapshot_json=upscale_snapshot_json,
image_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
)
db.add(task)
enqueue_ids.append(task.id)
else:
main_task = ChatGenerationTask(
**_base_task_kwargs(
task_id=main_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.VIDEO.value,
generation_mode=GenerationMode.CHATAPI_MAIN.value,
generation_count=generation_count,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=deadline_at,
idempotency_key=req.idempotency_key,
),
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
video_upscale_enabled_snapshot=upscale_enabled_snapshot,
video_upscale_snapshot_json=upscale_snapshot_json,
image_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
)
db.add(main_task)
await db.flush()
total_credits = 0.0
children: list[ChatGenerationTask] = []
for generation_index in range(1, generation_count + 1):
child_id = generate_id()
billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=child_id,
gen_type=GenerationType.VIDEO.value,
duration=duration,
resolution=resolution,
engine_id=engine.id,
input_video_duration=input_video_duration if input_video_duration > 0 else None,
input_image_count=reference_image_count if reference_image_count > 0 else None,
project_name="AI生成任务",
description_prefix=f"AI创作-第{generation_index}份-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
)
child = ChatGenerationTask(
**_base_task_kwargs(
task_id=child_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.VIDEO.value,
generation_mode=GenerationMode.CHATAPI_CHILD.value,
generation_count=generation_count,
generation_index=generation_index,
parent_task_id=main_id,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=deadline_at,
credits_cost=billing.total_charged,
),
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
video_upscale_enabled_snapshot=upscale_enabled_snapshot,
video_upscale_snapshot_json=upscale_snapshot_json,
image_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
)
children.append(child)
child_ids.append(child_id)
enqueue_ids.append(child_id)
total_credits = round(total_credits + billing.total_charged, 2)
db.add_all(children)
main_task.credits_cost = total_credits
total_billed_credits = total_credits
await db.flush()
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_BILLING_SUCCESS",
event_status="success",
source="service",
user_id=current_user.id,
group_id=main_id,
detail={
"gen_type": gen_type,
"generation_count": generation_count,
"total_billed_credits": total_billed_credits,
},
)
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_CHILDREN_CREATED" if child_ids else "BATCH_MAIN_CREATED",
event_status="success",
source="service",
user_id=current_user.id,
group_id=main_id,
detail={
"gen_type": gen_type,
"generation_count": generation_count,
"child_task_ids": child_ids,
"enqueue_task_ids": enqueue_ids,
"video_upscale_enabled_snapshot": bool(locals().get("upscale_enabled_snapshot", False)),
"provider_generation_resolution": locals().get("provider_generation_resolution"),
},
)
return GenerationTaskCreateResult(
top_level_task_id=main_id,
enqueue_task_ids=enqueue_ids,
child_task_ids=child_ids,
generation_count=generation_count,
gen_type=gen_type,
created=True,
)
async def enqueue_created_generation_tasks(
db: AsyncSession,
*,
task_ids: list[str],
) -> list[str]:
"""在业务事务提交后投递任务;返回投递失败的任务ID。
投递失败时保留 queued 状态和已提交的计费/快照,交由 generation recovery
补投,避免 broker 短暂故障被误判为业务生成失败并提前退款。
"""
from app.services.generation.log_service import log_task_event
from app.tasks.generation_create_tasks import chatapi_create_generation_task
normalized_ids = list(dict.fromkeys(str(item) for item in task_ids if item))
meta_result = await db.execute(
select(
ChatGenerationTask.id,
ChatGenerationTask.user_id,
ChatGenerationTask.parent_task_id,
ChatGenerationTask.generation_index,
ChatGenerationTask.generation_attempt_no,
).where(ChatGenerationTask.id.in_(normalized_ids))
) if normalized_ids else None
task_meta = {
str(row.id): {
"user_id": str(row.user_id),
"parent_task_id": str(row.parent_task_id) if row.parent_task_id else None,
"generation_index": row.generation_index,
"generation_attempt_no": int(row.generation_attempt_no or 1),
}
for row in (meta_result.all() if meta_result is not None else [])
}
failed_ids: list[str] = []
for task_id in normalized_ids:
meta = task_meta.get(task_id, {})
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_ENQUEUE_START",
event_status="started",
source="api",
user_id=meta.get("user_id"),
group_id=meta.get("parent_task_id") or task_id,
task_id=task_id,
detail={"generation_index": meta.get("generation_index")},
)
try:
chatapi_create_generation_task.apply_async(
args=[task_id],
kwargs={"owner_type": "chat_generation_task", "generation_attempt_no": int(meta.get("generation_attempt_no") or 1)},
queue="gen_chatapi_create",
)
await log_task_event(
task_id=task_id,
event_type="CHILD_ENQUEUE_SUCCESS",
to_status="generating",
to_stage="queued",
detail={"task_id": task_id},
)
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_ENQUEUE_SUCCESS",
event_status="success",
source="api",
user_id=meta.get("user_id"),
group_id=meta.get("parent_task_id") or task_id,
task_id=task_id,
detail={"generation_index": meta.get("generation_index")},
)
except Exception as exc:
failed_ids.append(task_id)
await log_task_event(
task_id=task_id,
generation_attempt_no=int(meta.get("generation_attempt_no") or 1),
event_type="CHILD_ENQUEUE_FAILED",
to_status="generating",
to_stage="queued",
message=str(exc),
detail={"task_id": task_id, "recoverable": True},
)
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_ENQUEUE_FAILED",
event_status="failed",
source="api",
user_id=meta.get("user_id"),
group_id=meta.get("parent_task_id") or task_id,
task_id=task_id,
message=str(exc),
detail={"recoverable": True, "physical_files_deleted": False},
error=str(exc),
)
return failed_ids