Files
video-gen/video-gen-api/app/services/video_upscale/snapshot_service.py
T
2026-07-16 15:02:03 +08:00

177 lines
7.0 KiB
Python

from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any, Iterable
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.video_upscale import (
ALL_PROCESSOR_KEYS,
VIDEO_UPSCALE_RESOLUTIONS,
VideoUpscaleProcessorKey,
normalize_video_upscale_resolution,
video_upscale_short_edge_pixels,
)
from app.services.video_upscale.config_service import get_runtime_video_upscale_config
from app.services.video_upscale.log_service import log_video_upscale_event
def normalize_resolution(value: str | None) -> str:
return normalize_video_upscale_resolution(value)
def _even(value: float) -> int:
rounded = int(round(value))
if rounded < 2:
rounded = 2
return rounded if rounded % 2 == 0 else rounded + 1
def parse_aspect_ratio(value: str | None) -> tuple[int, int]:
text = str(value or "").strip()
try:
left, right = text.split(":", 1)
width = int(left)
height = int(right)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"视频比例格式不合法: {text}") from exc
if width <= 0 or height <= 0:
raise HTTPException(status_code=400, detail=f"视频比例格式不合法: {text}")
return width, height
def calculate_target_dimensions(*, aspect_ratio: str, target_resolution: str) -> tuple[int, int]:
ratio_w, ratio_h = parse_aspect_ratio(aspect_ratio)
try:
pixels = video_upscale_short_edge_pixels(target_resolution)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ratio_w >= ratio_h:
height = _even(pixels)
width = _even(height * ratio_w / ratio_h)
else:
width = _even(pixels)
height = _even(width * ratio_h / ratio_w)
return width, height
def _runtime_processor_snapshot(processor_key: str) -> dict[str, Any]:
if processor_key not in ALL_PROCESSOR_KEYS:
raise HTTPException(status_code=500, detail=f"未注册的超分处理器: {processor_key}")
is_local = processor_key == VideoUpscaleProcessorKey.LOCAL_FFMPEG_CROP_V1.value
return {
"max_attempts": max(1, int(settings.VIDEO_UPSCALE_MAX_ATTEMPTS or 3)),
"timeout_seconds": int(
settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS
if is_local
else settings.VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS
),
"request_timeout_seconds": max(3, int(settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS or 30)),
"poll_timeout_seconds": max(60, int(settings.VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS or 7200)),
"poll_interval_seconds": max(5, int(settings.VIDEO_UPSCALE_REMOTE_POLL_INTERVAL_SECONDS or 30)),
"source_url_expire_seconds": max(600, int(settings.VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS or 7200)),
"bitrate_level": "medium",
"scene": "aigc",
"fps": None,
"queue_id": None,
}
async def build_video_upscale_snapshot(
db: AsyncSession,
*,
target_resolution: str,
aspect_ratio: str,
supported_provider_resolutions: Iterable[str] | None = None,
) -> tuple[str, bool, str | None]:
config = await get_runtime_video_upscale_config(db)
original_resolution = normalize_resolution(target_resolution)
if original_resolution not in VIDEO_UPSCALE_RESOLUTIONS:
return str(target_resolution).strip(), False, None
if not config.get("enabled"):
log_video_upscale_event(
event_type="upscale_snapshot_bypassed",
event_status="bypassed",
detail={"reason": "global_disabled", "target_resolution": original_resolution, "aspect_ratio": aspect_ratio},
)
return original_resolution, False, None
matched: dict[str, Any] | None = None
for rule in config.get("rules") or []:
if not rule.get("enabled"):
continue
if normalize_resolution(rule.get("target_resolution")) == original_resolution:
matched = dict(rule)
break
if matched is None:
log_video_upscale_event(
event_type="upscale_snapshot_bypassed",
event_status="bypassed",
detail={"reason": "rule_not_matched", "target_resolution": original_resolution, "aspect_ratio": aspect_ratio},
)
return original_resolution, False, None
provider_resolution = normalize_resolution(matched.get("provider_generation_resolution"))
processor_key = str(matched.get("processor_key") or "").strip()
processor = _runtime_processor_snapshot(processor_key)
supported = {normalize_resolution(item) for item in (supported_provider_resolutions or []) if item}
if supported and provider_resolution not in supported:
raise HTTPException(
status_code=400,
detail=f"超分规则要求实际生成 {provider_resolution},但当前视频引擎不支持该分辨率",
)
target_width, target_height = calculate_target_dimensions(
aspect_ratio=aspect_ratio,
target_resolution=original_resolution,
)
target_short_edge_pixels = video_upscale_short_edge_pixels(original_resolution)
snapshot: dict[str, Any] = {
"config_version": int(config.get("version") or 1),
"target_resolution": original_resolution,
"provider_generation_resolution": provider_resolution,
"processor_key": processor_key,
"processor": processor,
"aspect_ratio": aspect_ratio,
"target_short_edge_pixels": target_short_edge_pixels,
"target_width": target_width,
"target_height": target_height,
"delete_source_after_success": bool(config.get("delete_source_after_success", True)),
"snapshot_created_at": datetime.now(timezone.utc).isoformat(),
}
canonical = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
snapshot["snapshot_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
log_video_upscale_event(
event_type="upscale_snapshot_matched",
detail={
"target_resolution": original_resolution,
"provider_generation_resolution": provider_resolution,
"processor_key": processor_key,
"aspect_ratio": aspect_ratio,
"target_width": target_width,
"target_height": target_height,
"delete_source_after_success": snapshot["delete_source_after_success"],
"config_version": snapshot["config_version"],
"snapshot_hash": snapshot["snapshot_hash"],
},
)
return provider_resolution, True, json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"))
def parse_video_upscale_snapshot(value: str | None) -> dict[str, Any]:
if not value:
return {}
try:
data = json.loads(value)
except Exception as exc:
raise RuntimeError(f"超分快照不是合法 JSON: {exc}") from exc
if not isinstance(data, dict):
raise RuntimeError("超分快照必须是 JSON 对象")
return data