from __future__ import annotations import os import subprocess import tempfile import gradio as gr import torch from PIL import Image from shared.utils.hdr import iter_hdr_gbrpf32_frames, tonemap_hdr_tensor_to_uint8 from shared.utils.utils import get_video_info_details from shared.utils.video_decode import decode_video_frames_ffmpeg, resolve_media_binary from shared.utils.virtual_media import build_virtual_media_path, clear_virtual_media_source, get_virtual_video, store_virtual_video PROCESS_FULL_VIDEO_VSOURCE = "process_full_video" PROCESS_FULL_VIDEO_VFILE = "last_frames.mp4" def frame_to_image(frame_tensor: torch.Tensor) -> Image.Image: return Image.fromarray(frame_tensor.permute(1, 2, 0).cpu().numpy()) def clear_process_full_video_source() -> None: clear_virtual_media_source(PROCESS_FULL_VIDEO_VSOURCE) def build_process_full_video_source_path(*, hdr: bool = False) -> str: extras = {"vsource": PROCESS_FULL_VIDEO_VSOURCE} if hdr: extras["hdr"] = "1" return build_virtual_media_path(PROCESS_FULL_VIDEO_VFILE, extras=extras) def set_process_full_video_overlap_buffer(overlap_tensor: torch.Tensor | None, fps_float: float, *, hdr: bool = False) -> None: if overlap_tensor is None or not torch.is_tensor(overlap_tensor) or int(overlap_tensor.shape[1]) <= 0: clear_virtual_media_source(PROCESS_FULL_VIDEO_VSOURCE) return store_virtual_video(PROCESS_FULL_VIDEO_VSOURCE, PROCESS_FULL_VIDEO_VFILE, overlap_tensor.contiguous(), fps_float, hdr=hdr) def load_process_full_video_overlap_buffer(video_path: str, overlap_frames: int, actual_frame_count: int) -> torch.Tensor | None: if overlap_frames <= 0 or actual_frame_count <= 0 or not os.path.isfile(video_path): return None if overlap_frames > actual_frame_count: return None frames = decode_video_frames_ffmpeg(build_virtual_media_path(video_path, start_frame=-overlap_frames, end_frame=-1, extras={"frame_count": actual_frame_count}), 0, overlap_frames, target_fps=None, bridge="torch") return None if not torch.is_tensor(frames) or int(frames.shape[0]) <= 0 else frames.permute(3, 0, 1, 2).float().div_(127.5).sub_(1.0).contiguous() def update_process_full_video_overlap_buffer(committed_tensor: torch.Tensor, overlap_frames: int, fps_float: float, *, hdr: bool = False) -> torch.Tensor | None: if overlap_frames <= 0 or not torch.is_tensor(committed_tensor) or int(committed_tensor.shape[1]) <= 0: clear_virtual_media_source(PROCESS_FULL_VIDEO_VSOURCE) return None overlap_tensor = committed_tensor.detach().cpu().to(torch.float32).contiguous() if not hdr: overlap_tensor = overlap_tensor.div_(127.5).sub_(1.0) previous_overlap = get_virtual_video(PROCESS_FULL_VIDEO_VSOURCE, PROCESS_FULL_VIDEO_VFILE) if previous_overlap is not None and int(previous_overlap.shape[1]) > 0: overlap_tensor = torch.cat([previous_overlap, overlap_tensor], dim=1) available_frames = int(overlap_tensor.shape[1]) kept_frames = overlap_frames if overlap_frames < available_frames else available_frames overlap_tensor = overlap_tensor[:, -kept_frames:].contiguous() set_process_full_video_overlap_buffer(overlap_tensor, fps_float, hdr=hdr) return overlap_tensor def load_process_full_video_hdr_overlap_buffer(output_path: str, overlap_frames: int, actual_frame_count: int) -> torch.Tensor | None: if overlap_frames <= 0 or actual_frame_count <= 0: return None if overlap_frames > actual_frame_count: return None frames = decode_video_frames_ffmpeg(build_virtual_media_path(output_path, start_frame=-overlap_frames, end_frame=-1, extras={"frame_count": actual_frame_count}), 0, overlap_frames, target_fps=None, bridge="torch", hdr_linear=True) return None if not torch.is_tensor(frames) or int(frames.shape[0]) <= 0 else frames.permute(3, 0, 1, 2).contiguous() def _extract_exact_frame_image(video_path: str, frame_no: int) -> Image.Image: ffmpeg_path = resolve_media_binary("ffmpeg") if ffmpeg_path is None or not os.path.isfile(video_path) or int(frame_no) < 0: raise gr.Error(f"Unable to decode frame {frame_no} from {video_path}") with tempfile.TemporaryDirectory(prefix="wangp_tail_frame_") as temp_dir: output_path = os.path.join(temp_dir, "frame.png") command = [ffmpeg_path, "-v", "error", "-y", "-i", video_path, "-an", "-sn", "-vf", f"select=eq(n\\,{int(frame_no)})", "-frames:v", "1", output_path] result = subprocess.run(command, capture_output=True, text=True) if result.returncode != 0 or not os.path.isfile(output_path): raise gr.Error(f"Unable to decode frame {frame_no} from {video_path}") with Image.open(output_path) as frame_image: return frame_image.convert("RGB").copy() def resolve_resume_last_frame(video_path: str, reported_frame_count: int) -> tuple[int, Image.Image | None, str]: if reported_frame_count <= 0: return 0, None, "existing output contains no decodable frame" for backtrack in (0, 1, 2, 4, 8, 16, 32, 64, 128, 256): frame_no = reported_frame_count - 1 - backtrack if frame_no < 0: continue try: frame_image = _extract_exact_frame_image(video_path, frame_no) except gr.Error: continue actual_frame_count = frame_no + 1 message = "" if actual_frame_count == reported_frame_count else f"Adjusted continuation point to {actual_frame_count} decodable frame(s) from the existing output." return actual_frame_count, frame_image, message return 0, None, f"Unable to decode a valid tail frame from {video_path}" def probe_existing_output_resolution(output_path: str) -> tuple[str, int, int]: metadata = get_video_info_details(output_path) width = int(metadata.get("display_width") or metadata.get("width") or 0) height = int(metadata.get("display_height") or metadata.get("height") or 0) if width <= 0 or height <= 0: raise gr.Error(f"Unable to read the resolution of existing output: {output_path}") return f"{width}x{height}", width, height def get_video_tensor_resolution(video_tensor_uint8: torch.Tensor) -> tuple[int, int]: if not torch.is_tensor(video_tensor_uint8) or video_tensor_uint8.ndim != 4: raise gr.Error("WanGP API returned an invalid video tensor.") return int(video_tensor_uint8.shape[3]), int(video_tensor_uint8.shape[2]) def load_video_tensor_from_file(video_path: str) -> torch.Tensor: metadata = get_video_info_details(video_path) frame_count = int(metadata.get("frame_count") or 0) if frame_count <= 0: raise gr.Error(f"Unable to read the frame count of generated chunk: {video_path}") frames = decode_video_frames_ffmpeg(video_path, 0, frame_count, target_fps=None, bridge="torch") if frames.shape[0] <= 0: raise gr.Error(f"Unable to decode generated chunk: {video_path}") return frames.permute(3, 0, 1, 2).contiguous() def write_video_chunk(process, video_tensor_uint8: torch.Tensor, *, start_frame: int, frame_count: int) -> torch.Tensor: if frame_count <= 0: raise RuntimeError("No frames available to write.") end_frame = start_frame + frame_count batch_frames = 8 for batch_start in range(start_frame, end_frame, batch_frames): batch_end = batch_start + batch_frames if batch_end > end_frame: batch_end = end_frame batch = video_tensor_uint8[:, batch_start:batch_end].permute(1, 2, 3, 0).contiguous() try: process.stdin.write(batch.numpy().tobytes()) process.stdin.flush() except BrokenPipeError as exc: stderr = process.stderr.read().decode("utf-8", errors="ignore").strip() if process.stderr is not None and process.poll() is not None else "" raise RuntimeError(stderr or "ffmpeg stopped receiving video frames while streaming a chunk") from exc if process.poll() not in (None, 0): stderr = process.stderr.read().decode("utf-8", errors="ignore").strip() if process.stderr is not None else "" raise RuntimeError(stderr or "ffmpeg exited while streaming a chunk") return video_tensor_uint8[:, start_frame + frame_count - 1] def write_hdr_video_chunk(process, video_tensor_hdr: torch.Tensor, *, start_frame: int, frame_count: int) -> torch.Tensor: if frame_count <= 0: raise RuntimeError("No HDR frames available to write.") chunk = video_tensor_hdr[:, start_frame:start_frame + frame_count].detach().cpu() try: for frame_bytes in iter_hdr_gbrpf32_frames(chunk): process.stdin.write(frame_bytes) process.stdin.flush() except BrokenPipeError as exc: stderr = process.stderr.read().decode("utf-8", errors="ignore").strip() if process.stderr is not None and process.poll() is not None else "" raise RuntimeError(stderr or "ffmpeg stopped receiving HDR video frames while streaming a chunk") from exc if process.poll() not in (None, 0): stderr = process.stderr.read().decode("utf-8", errors="ignore").strip() if process.stderr is not None else "" raise RuntimeError(stderr or "ffmpeg exited while streaming HDR video") return tonemap_hdr_tensor_to_uint8(video_tensor_hdr[:, start_frame + frame_count - 1:start_frame + frame_count])[:, 0] def compute_selected_frame_range(metadata: dict, start_seconds: float | None, end_seconds: float | None) -> tuple[int, int, float, int]: fps_float = float(metadata.get("fps_float") or metadata.get("fps") or 0.0) total_frames = int(metadata.get("frame_count") or 0) if fps_float <= 0 or total_frames <= 0: raise gr.Error("Unable to read the source video FPS or frame count.") start_frame = int(round(float(start_seconds or 0.0) * fps_float)) if start_frame < 0: start_frame = 0 if start_frame >= total_frames: start_frame = total_frames - 1 if end_seconds in (None, ""): end_frame_exclusive = total_frames else: end_frame_exclusive = int(round(float(end_seconds) * fps_float)) if end_frame_exclusive <= start_frame: end_frame_exclusive = start_frame + 1 if end_frame_exclusive > total_frames: end_frame_exclusive = total_frames if end_frame_exclusive <= start_frame: raise gr.Error("End must be greater than Start.") return start_frame, end_frame_exclusive, fps_float, total_frames def get_processing_fps(fps_float: float) -> float: processing_fps = int(round(float(fps_float))) return float(processing_fps if processing_fps > 0 else 1)