247 lines
9.1 KiB
Python
247 lines
9.1 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from volcenginesdkarkruntime import AsyncArk
|
|
|
|
from app.config import settings
|
|
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
|
|
from app.models.video_engine import VideoEngine
|
|
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
|
|
from app.utils.id_gen import generate_id
|
|
from app.types.generation.provider import (
|
|
ProviderGenerationRecordLike,
|
|
ProviderVideoEngineLike,
|
|
)
|
|
|
|
logger = logging.getLogger("videogen")
|
|
|
|
|
|
|
|
def _provider_log_context(engine, record, *, call_id: str, step_code: str) -> dict:
|
|
generation_mode = str(getattr(record, "generation_mode", "") or "generation_record")
|
|
owner_type = "chat_generation_task" if generation_mode != "generation_record" else "generation_record"
|
|
return {
|
|
"module": generation_mode,
|
|
"step_code": step_code,
|
|
"call_id": call_id,
|
|
"source": "app.services.video_gen",
|
|
"user_id": str(getattr(record, "user_id", "") or "") or None,
|
|
"project_id": str(getattr(record, "project_id", "") or "") or None,
|
|
"task_id": str(getattr(record, "id", "") or "") or None,
|
|
"owner_type": owner_type,
|
|
"owner_id": str(getattr(record, "id", "") or "") or None,
|
|
"generation_attempt_no": int(getattr(record, "generation_attempt_no", 1) or 1),
|
|
"model_config_id": str(getattr(engine, "id", "") or "") or None,
|
|
"model_config_name": str(getattr(engine, "name", "") or "") or None,
|
|
"model_name": str(getattr(engine, "model_name", "") or "") or None,
|
|
"provider": str(getattr(engine, "provider", "") or "") or None,
|
|
"api_base": str(getattr(engine, "api_base", "") or "") or None,
|
|
}
|
|
|
|
|
|
async def get_active_engine(db: AsyncSession) -> VideoEngine:
|
|
"""Get the active video engine with highest priority."""
|
|
result = await db.execute(
|
|
select(VideoEngine)
|
|
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
|
.order_by(VideoEngine.priority.desc())
|
|
.limit(1)
|
|
)
|
|
engine = result.scalar_one_or_none()
|
|
if not engine:
|
|
raise ValueError("没有可用的视频引擎,请联系管理员配置")
|
|
return engine
|
|
|
|
|
|
def _resolve_url(url: str) -> str:
|
|
"""Convert local path to base64 data URI, pass through remote URLs."""
|
|
# if url.startswith("http"):
|
|
# return url
|
|
# # Local file: read and encode as base64 data URI
|
|
# file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
|
|
# if not os.path.exists(file_path):
|
|
# return url
|
|
# mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
|
# with open(file_path, "rb") as f:
|
|
# b64 = base64.b64encode(f.read()).decode()
|
|
# return f"data:{mime};base64,{b64}"
|
|
if url.startswith(("http://", "https://", "data:", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
|
|
return url
|
|
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
|
|
|
|
|
async def submit_video_task(
|
|
db: AsyncSession,
|
|
engine: ProviderVideoEngineLike,
|
|
record: ProviderGenerationRecordLike,
|
|
*,
|
|
include_media_references: bool,
|
|
) -> str:
|
|
"""Submit a video generation task via Ark SDK. Returns task_id."""
|
|
client = AsyncArk(
|
|
base_url=engine.api_base,
|
|
api_key=engine.api_key,
|
|
)
|
|
|
|
content = [{"type": "text", "text": record.optimized_prompt or record.original_prompt}]
|
|
|
|
# GenerationRecord 的附件已在 API 提词优化阶段参与过 optimized_prompt 生成;
|
|
# ChatGenerationTask 不走 API 提词优化,所以创建供应商任务时仍需携带附件。
|
|
if include_media_references and record.media_references:
|
|
try:
|
|
refs = json.loads(record.media_references)
|
|
for ref in refs:
|
|
ref_type = ref.get("type")
|
|
ref_url = ref.get("url", "")
|
|
ref_role = ref.get("role")
|
|
if ref_type == "image" and ref_url:
|
|
resolved = _resolve_url(ref_url)
|
|
role = ref_role if ref_role in ("first_frame", "last_frame") else "reference_image"
|
|
content.append({"type": "image_url", "image_url": {"url": resolved}, "role": role})
|
|
elif ref_type == "video" and ref_url:
|
|
resolved = _resolve_url(ref_url)
|
|
role = ref_role if ref_role else "reference_video"
|
|
content.append({"type": "video_url", "video_url": {"url": resolved}, "role": role})
|
|
elif ref_type == "audio" and ref_url:
|
|
resolved = _resolve_url(ref_url)
|
|
role = ref_role if ref_role else "reference_audio"
|
|
content.append({"type": "audio_url", "audio_url": {"url": resolved}, "role": role})
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
request_payload = {
|
|
"model": engine.model_name,
|
|
"content": content,
|
|
"ratio": record.aspect_ratio,
|
|
"duration": record.duration,
|
|
"resolution": getattr(record, "provider_generation_resolution", None) or record.resolution,
|
|
"generate_audio": True,
|
|
"watermark": False,
|
|
}
|
|
|
|
call_id = generate_id()
|
|
started = time.perf_counter()
|
|
log_context = _provider_log_context(
|
|
engine,
|
|
record,
|
|
call_id=call_id,
|
|
step_code="video_create",
|
|
)
|
|
log_ai_model_event(
|
|
event_type="REQUEST",
|
|
event_phase="REQUEST",
|
|
event_status="started",
|
|
remote_action="video_create",
|
|
request={**request_payload, "include_media_references": include_media_references},
|
|
**log_context,
|
|
)
|
|
|
|
try:
|
|
result = await client.content_generation.tasks.create(**request_payload)
|
|
task_id = result.id
|
|
log_ai_model_event(
|
|
event_type="RESPONSE",
|
|
event_phase="RESPONSE",
|
|
event_status="success",
|
|
remote_action="video_create",
|
|
remote_request_id=task_id,
|
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
response={"task_id": task_id},
|
|
**log_context,
|
|
)
|
|
except Exception as exc:
|
|
log_ai_model_event(
|
|
event_type="ERROR",
|
|
event_phase="ERROR",
|
|
event_status="failed",
|
|
remote_action="video_create",
|
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
detail=build_exception_detail(exc),
|
|
error=str(exc),
|
|
**log_context,
|
|
)
|
|
raise
|
|
finally:
|
|
await client.close()
|
|
|
|
return task_id
|
|
|
|
|
|
async def poll_task_status(engine: VideoEngine, task_id: str) -> dict:
|
|
"""Query task status via Ark SDK. Returns {status, video_url, response_data}."""
|
|
client = AsyncArk(
|
|
base_url=engine.api_base,
|
|
api_key=engine.api_key,
|
|
)
|
|
|
|
result = await client.content_generation.tasks.get(task_id=task_id)
|
|
await client.close()
|
|
|
|
# Serialize response
|
|
response_dict = {
|
|
"id": result.id,
|
|
"model": result.model,
|
|
"status": result.status,
|
|
"created_at": result.created_at,
|
|
"updated_at": result.updated_at,
|
|
}
|
|
|
|
video_url = None
|
|
video_tokens = 0
|
|
if result.status == "succeeded" and result.content:
|
|
video_url = getattr(result.content, "video_url", None)
|
|
response_dict["video_url"] = video_url
|
|
response_dict["duration"] = getattr(result, "duration", None)
|
|
response_dict["ratio"] = getattr(result, "ratio", None)
|
|
response_dict["resolution"] = getattr(result, "resolution", None)
|
|
# Extract usage info if present
|
|
usage = getattr(result, "usage", None)
|
|
if usage:
|
|
response_dict["usage"] = {
|
|
"input_tokens": getattr(usage, "input_tokens", 0),
|
|
"output_tokens": getattr(usage, "output_tokens", 0),
|
|
"total_tokens": getattr(usage, "total_tokens", 0),
|
|
}
|
|
video_tokens = getattr(usage, "total_tokens", 0)
|
|
elif result.status == "failed":
|
|
response_dict["error"] = str(getattr(result, "error", "视频生成失败"))
|
|
|
|
return {
|
|
"status": result.status,
|
|
"video_url": video_url,
|
|
"video_tokens": video_tokens,
|
|
"response_data": json.dumps(response_dict, ensure_ascii=False, default=str),
|
|
"error": response_dict.get("error"),
|
|
}
|
|
|
|
|
|
async def download_video(
|
|
video_url: str,
|
|
dest_path: str,
|
|
*,
|
|
execution_guard=None,
|
|
) -> str:
|
|
"""Download video to local storage."""
|
|
import os
|
|
|
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
|
|
async with httpx.AsyncClient(timeout=300) as client:
|
|
async with client.stream("GET", video_url) as response:
|
|
response.raise_for_status()
|
|
with open(dest_path, "wb") as f:
|
|
chunk_no = 0
|
|
async for chunk in response.aiter_bytes(chunk_size=8192):
|
|
f.write(chunk)
|
|
chunk_no += 1
|
|
if execution_guard is not None and chunk_no % 32 == 0:
|
|
await execution_guard()
|
|
if execution_guard is not None:
|
|
await execution_guard()
|
|
return dest_path
|