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