"""阶段 2:从 Trellis combined_video 抽帧 + 切左右半得到 color / normal 帧。 Trellis (firtoz/trellis) 部署版 `e8f6c452...` 实测只返回 `combined_video` (color 和 normal 左右并排 mp4,约 5 秒 360° 水平环绕),不再单独返回 `color_video` / `normal_video`(详见 PF-20260420-1600)。 本模块职责: 1. 对给定 combined_video 做一次全帧抽取(imageio-ffmpeg 自带二进制,无需系统 ffmpeg) 2. 按 azimuth / total_rotation_deg 映射到帧索引并挑出对应帧 3. 用 PIL 切左半 → color png、右半 → normal png,落盘到 GENERATED_DIR 4. 返回 [{az, el, frame_idx, color_png, normal_png}, ...] 一期仅支持 elevation=0(Trellis 的 combined_video 就是平视一圈,本身没有仰俯)。 需要自由 elevation 时走 Phase 5 的 mesh_renderer 路径(pyrender / Blender headless)。 """ from __future__ import annotations import asyncio import logging import subprocess import uuid from pathlib import Path from typing import Any import imageio_ffmpeg # 提供静态 ffmpeg 二进制,避免依赖系统 ffmpeg from PIL import Image from app.services.image_gen import GENERATED_DIR logger = logging.getLogger(__name__) FFMPEG_EXE = imageio_ffmpeg.get_ffmpeg_exe() def _resolve_local_path(path_or_url: str) -> Path: """把 mesh_generator 返回的 '/generated/xxx.mp4' 解析为真实磁盘路径。 兼容三种输入: - '/generated/xxx.mp4' → GENERATED_DIR / xxx.mp4 - 'D:/abs/path/xxx.mp4' → Path 原样 - 'xxx.mp4' → Path 原样(调用方自行保证) """ if not path_or_url: raise ValueError("combined_video_path 为空") if path_or_url.startswith("/generated/"): return GENERATED_DIR / Path(path_or_url).name return Path(path_or_url) def _extract_all_frames(video_path: Path, temp_dir: Path) -> list[Path]: """把 video 的所有帧抽到 temp_dir,返回按文件名排序的帧列表。 对 Trellis ~5s 的 combined_video(~100-150 帧)这种量级是 OK 的, 比起"用 -ss 逐帧抽取"更省心、角度映射也更稳。 """ temp_dir.mkdir(parents=True, exist_ok=True) out_pattern = str(temp_dir / "f%05d.png") cmd = [ FFMPEG_EXE, "-y", "-hide_banner", "-loglevel", "error", "-i", str(video_path), "-vsync", "0", out_pattern, ] logger.info("ffmpeg extract-all: %s", " ".join(cmd)) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError( f"ffmpeg 抽帧失败 (code {proc.returncode}): stderr={proc.stderr[-500:]}" ) frames = sorted(temp_dir.glob("f*.png")) if not frames: raise RuntimeError( f"ffmpeg 执行成功但没产出帧:{video_path};stderr={proc.stderr[-500:]}" ) return frames def _split_combined_frame( frame_path: Path, out_prefix: str, az: int, el: int, ) -> tuple[Path, Path]: """把 combined 帧按中线切成 color(左)+ normal(右)两张图。""" img = Image.open(frame_path) w, h = img.size mid = w // 2 color = img.crop((0, 0, mid, h)) normal = img.crop((mid, 0, w, h)) color_name = f"view_{out_prefix}_az{az}_el{el}_color.png" normal_name = f"view_{out_prefix}_az{az}_el{el}_normal.png" color_path = GENERATED_DIR / color_name normal_path = GENERATED_DIR / normal_name color.save(color_path, "PNG") normal.save(normal_path, "PNG") return color_path, normal_path def _cleanup_dir(dir_path: Path) -> None: """静默删除临时目录及其所有文件,失败不抛。""" if not dir_path.exists(): return for f in dir_path.glob("*"): try: f.unlink() except OSError: pass try: dir_path.rmdir() except OSError: pass def _do_extract( video: Path, azimuths: list[int], elevations: list[int], total_rotation_deg: int, ) -> list[dict[str, Any]]: """同步版抽帧逻辑,供 async 版本用 run_in_executor 包起来跑。""" batch_id = uuid.uuid4().hex[:8] temp_dir = GENERATED_DIR / f"_frames_{batch_id}" try: frames = _extract_all_frames(video, temp_dir) total = len(frames) logger.info("combined_video %s 抽出 %d 帧,总时长~%.2fs (按 24fps 估)", video.name, total, total / 24.0) results: list[dict[str, Any]] = [] for az, el in zip(azimuths, elevations): # az 归一化到 [0, total_rotation_deg),再映射到 [0, total) 整数索引 az_mod = az % total_rotation_deg if total_rotation_deg > 0 else 0 frame_idx = int(round(az_mod / total_rotation_deg * total)) % total if total > 0 else 0 src_frame = frames[frame_idx] color_path, normal_path = _split_combined_frame(src_frame, batch_id, az, el) results.append({ "az": az, "el": el, "frame_idx": frame_idx, "color_png": f"/generated/{color_path.name}", "normal_png": f"/generated/{normal_path.name}", }) return results finally: _cleanup_dir(temp_dir) async def extract_by_azimuth( combined_video_path: str, azimuths: list[int], elevations: list[int] | None = None, total_rotation_deg: int = 360, ) -> list[dict[str, Any]]: """按 azimuth 列表从 combined_video 抽帧 + 切左右半。 Args: combined_video_path: '/generated/trellis_combined_xxx.mp4' 或本地磁盘路径 azimuths: 方位角数组,单位度(0-360,允许大于 360 会自动取模) elevations: 仰角数组;长度须等于 azimuths;一期所有值被忽略,保留字段以便后续扩展 total_rotation_deg: combined_video 覆盖的水平旋转范围;Trellis 默认 360 Returns: [{"az": 60, "el": 0, "frame_idx": 20, "color_png": "/generated/view_xxx_az60_el0_color.png", "normal_png": "/generated/view_xxx_az60_el0_normal.png"}, ...] """ video = _resolve_local_path(combined_video_path) if not video.exists(): raise FileNotFoundError(f"combined_video 文件不存在:{video}") if not azimuths: return [] if elevations is None: elevations = [0] * len(azimuths) if len(elevations) != len(azimuths): raise ValueError( f"azimuths / elevations 长度不一致:{len(azimuths)} vs {len(elevations)}" ) # ffmpeg + PIL 是 CPU/IO 阻塞的,丢到线程池里跑避免堵死事件循环 loop = asyncio.get_running_loop() return await loop.run_in_executor( None, _do_extract, video, azimuths, elevations, total_rotation_deg, )