| from __future__ import annotations |
|
|
| import json |
| import os |
| import shutil |
| import subprocess |
| import tempfile |
| import uuid |
| from pathlib import Path |
|
|
| import gradio as gr |
|
|
| from shared.utils.audio_video import get_hdr_video_encode_args, get_video_encode_args |
| from shared.utils.video_codecs import normalize_video_container, validate_video_output_settings |
| from shared.utils.utils import get_video_info_details |
| from shared.utils.video_decode import resolve_media_binary |
| from shared.utils.video_metadata import DEFAULT_RESERVED_VIDEO_METADATA_BYTES, write_reserved_video_ffmetadata |
|
|
| from . import constants |
|
|
| class ContinuationMergeOutputLockedError(PermissionError): |
| def __init__(self, output_path: str) -> None: |
| self.output_path = str(output_path or "") |
| super().__init__(f"Unable to replace locked output file: {self.output_path}") |
|
|
|
|
| def probe_resume_frame_count(ffprobe_path: str, output_path: str, fps_float: float) -> tuple[int, str]: |
| probe = subprocess.run([ffprobe_path, "-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "json", output_path], capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if probe.returncode == 0: |
| try: |
| frame_count = int((((json.loads(probe.stdout).get("streams") or [{}])[0]).get("nb_read_packets")) or 0) |
| except (TypeError, ValueError, json.JSONDecodeError, IndexError): |
| frame_count = 0 |
| if frame_count > 0: |
| return frame_count, "" |
| probe = subprocess.run([ffprobe_path, "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=nb_read_frames", "-of", "json", output_path], capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if probe.returncode == 0: |
| try: |
| frame_count = int((((json.loads(probe.stdout).get("streams") or [{}])[0]).get("nb_read_frames")) or 0) |
| except (TypeError, ValueError, json.JSONDecodeError, IndexError): |
| frame_count = 0 |
| if frame_count > 0: |
| return frame_count, "" |
| metadata = get_video_info_details(output_path) |
| frame_count = int(metadata.get("frame_count") or 0) |
| if frame_count > 0: |
| return frame_count, "" |
| duration = float(metadata.get("duration") or 0.0) |
| if duration > 0 and fps_float > 0: |
| return int(round(duration * fps_float)), "" |
| stderr = (probe.stderr or "").strip() |
| return 0, stderr or "existing output contains no readable frame count or duration metadata" |
|
|
|
|
| def normalize_container_name(video_container: str | None) -> str: |
| return normalize_video_container(video_container) |
|
|
|
|
| SUPPORTED_OUTPUT_CONTAINERS = constants.SUPPORTED_OUTPUT_CONTAINERS |
| LIVE_AUDIO_LOOP_PADDING_SECONDS = 5.0 |
|
|
|
|
| def is_supported_output_container(video_container: str | None) -> bool: |
| return normalize_container_name(video_container) in SUPPORTED_OUTPUT_CONTAINERS |
|
|
|
|
| def is_quicktime_output_container(video_container: str | None) -> bool: |
| return normalize_container_name(video_container) in {"mp4", "mov"} |
|
|
|
|
| def _get_live_mux_output_args(video_container: str | None) -> list[str]: |
| video_container = normalize_container_name(video_container) |
| if video_container == "mkv": |
| return ["-fflags", "+flush_packets", "-flush_packets", "1", "-f", "matroska", "-live", "1"] |
| if is_quicktime_output_container(video_container): |
| return ["-movflags", "+frag_keyframe+empty_moov+default_base_moof"] |
| return [] |
|
|
|
|
| def _probe_media_duration(ffprobe_path: str, media_path: str) -> float: |
| result = subprocess.run([ffprobe_path, "-v", "error", "-show_entries", "format=duration", "-of", "json", media_path], capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if result.returncode != 0: |
| return 0.0 |
| try: |
| return max(0.0, float((((json.loads(result.stdout) or {}).get("format") or {}).get("duration")) or 0.0)) |
| except (TypeError, ValueError, json.JSONDecodeError): |
| return 0.0 |
|
|
|
|
| def _probe_audio_end_time(ffprobe_path: str, media_path: str, audio_index: int) -> float: |
| stream_selector = f"a:{max(0, int(audio_index))}" |
| result = subprocess.run([ffprobe_path, "-v", "error", "-select_streams", stream_selector, "-show_entries", "stream=start_time,duration", "-of", "json", media_path], capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if result.returncode == 0: |
| try: |
| stream = (((json.loads(result.stdout) or {}).get("streams") or [{}])[0]) |
| start_time = 0.0 if stream.get("start_time") in (None, "", "N/A") else max(0.0, float(stream.get("start_time"))) |
| duration_time = 0.0 if stream.get("duration") in (None, "", "N/A") else max(0.0, float(stream.get("duration"))) |
| if duration_time > 0.0: |
| return start_time + duration_time |
| except (TypeError, ValueError, json.JSONDecodeError, IndexError): |
| pass |
| approximate_end = _probe_media_duration(ffprobe_path, media_path) |
| if approximate_end <= 0.0: |
| return 0.0 |
| for window_seconds in (2.0, 8.0, 32.0, 128.0): |
| probe_start = max(0.0, approximate_end - window_seconds) |
| probe_span = max(0.25, approximate_end - probe_start + 0.25) |
| result = subprocess.run( |
| [ffprobe_path, "-v", "error", "-select_streams", stream_selector, "-read_intervals", f"{probe_start:.6f}%+{probe_span:.6f}", "-show_packets", "-show_entries", "packet=pts_time,duration_time", "-of", "csv=p=0", media_path], |
| capture_output=True, |
| text=True, |
| encoding="utf-8", |
| errors="ignore", |
| check=False, |
| ) |
| if result.returncode != 0: |
| continue |
| last_end = 0.0 |
| for raw_line in result.stdout.splitlines(): |
| fields = [field.strip() for field in str(raw_line or "").strip().split(",")] |
| if len(fields) <= 0 or len(fields[0]) == 0: |
| continue |
| try: |
| pts_time = float(fields[0]) |
| except (TypeError, ValueError): |
| continue |
| try: |
| duration_time = float(fields[1]) if len(fields) > 1 and len(fields[1]) > 0 else 0.0 |
| except (TypeError, ValueError): |
| duration_time = 0.0 |
| last_end = max(last_end, pts_time, pts_time + max(0.0, duration_time)) |
| if last_end > 0.0: |
| return last_end |
| return approximate_end |
|
|
|
|
| def _probe_selected_audio_end_time(ffprobe_path: str, media_path: str, audio_track_no: int | None) -> float: |
| _, audio_stream_count = _probe_media_stream_layout(ffprobe_path, media_path) |
| if audio_stream_count <= 0: |
| return 0.0 |
| if audio_track_no is None: |
| audio_indices = range(audio_stream_count) |
| else: |
| audio_indices = [max(0, min(audio_stream_count - 1, int(audio_track_no) - 1))] |
| return max((_probe_audio_end_time(ffprobe_path, media_path, audio_index) for audio_index in audio_indices), default=0.0) |
|
|
|
|
| def probe_selected_audio_overhang(ffprobe_path: str, media_path: str, audio_track_no: int | None, visible_duration_seconds: float) -> float: |
| visible_duration_seconds = max(0.0, float(visible_duration_seconds or 0.0)) |
| _, audio_stream_count = _probe_media_stream_layout(ffprobe_path, media_path) |
| if audio_stream_count <= 0: |
| return 0.0 |
| if audio_track_no is None: |
| audio_indices = range(audio_stream_count) |
| else: |
| audio_indices = [max(0, min(audio_stream_count - 1, int(audio_track_no) - 1))] |
| video_end_seconds = _probe_primary_video_start_time(ffprobe_path, media_path) + visible_duration_seconds |
| seam_start = max(0.0, video_end_seconds - 1.0) |
| for probe_span in (2.0, 8.0, 32.0, 128.0): |
| last_end = 0.0 |
| for audio_index in audio_indices: |
| result = subprocess.run( |
| [ |
| ffprobe_path, |
| "-v", |
| "error", |
| "-select_streams", |
| f"a:{int(audio_index)}", |
| "-read_intervals", |
| f"{seam_start:.6f}%+{probe_span:.6f}", |
| "-show_packets", |
| "-show_entries", |
| "packet=pts_time,duration_time", |
| "-of", |
| "csv=p=0", |
| media_path, |
| ], |
| capture_output=True, |
| text=True, |
| encoding="utf-8", |
| errors="ignore", |
| check=False, |
| ) |
| if result.returncode != 0: |
| continue |
| for raw_line in result.stdout.splitlines(): |
| fields = [field.strip() for field in str(raw_line or "").strip().split(",")] |
| if len(fields) <= 0 or len(fields[0]) == 0: |
| continue |
| try: |
| pts_time = float(fields[0]) |
| except (TypeError, ValueError): |
| continue |
| try: |
| duration_time = float(fields[1]) if len(fields) > 1 and len(fields[1]) > 0 else 0.0 |
| except (TypeError, ValueError): |
| duration_time = 0.0 |
| last_end = max(last_end, pts_time, pts_time + max(0.0, duration_time)) |
| if last_end > video_end_seconds: |
| return max(0.0, last_end - video_end_seconds) |
| return max(0.0, _probe_selected_audio_end_time(ffprobe_path, media_path, audio_track_no) - video_end_seconds) |
|
|
|
|
| def _probe_audio_stream_codecs(ffprobe_path: str, media_path: str) -> list[str]: |
| result = subprocess.run([ffprobe_path, "-v", "error", "-select_streams", "a", "-show_entries", "stream=index,codec_name", "-of", "json", media_path], capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if result.returncode != 0: |
| raise gr.Error((result.stderr or result.stdout or f"ffprobe failed for {media_path}").strip()) |
| try: |
| streams = list((json.loads(result.stdout) or {}).get("streams") or []) |
| except json.JSONDecodeError as exc: |
| raise gr.Error(f"Unable to read audio codecs for {media_path}") from exc |
| return [str(stream.get("codec_name") or "").strip().lower() for stream in streams] |
|
|
|
|
| def validate_audio_copy_container(ffprobe_path: str, source_path: str, video_container: str, audio_track_no: int | None) -> None: |
| video_container = normalize_container_name(video_container) |
| if video_container not in {"mp4", "mov"}: |
| return |
| supported_codecs = {"aac", "ac3", "alac", "eac3", "mp3", "opus"} if video_container == "mp4" else {"aac", "ac3", "alac", "eac3", "mp3", "pcm_s16le", "pcm_s24le", "pcm_s32le"} |
| audio_codecs = _probe_audio_stream_codecs(ffprobe_path, source_path) |
| if audio_track_no is None: |
| selected_codecs = [codec for codec in audio_codecs if len(codec) > 0] |
| else: |
| selected_index = max(0, int(audio_track_no) - 1) |
| selected_codecs = [audio_codecs[selected_index]] if selected_index < len(audio_codecs) and len(audio_codecs[selected_index]) > 0 else [] |
| incompatible_codecs = [codec for codec in selected_codecs if codec not in supported_codecs] |
| if len(incompatible_codecs) > 0: |
| track_label = f"audio track {int(audio_track_no)}" if audio_track_no is not None else "the selected audio tracks" |
| codecs_text = ", ".join(sorted(set(incompatible_codecs))) |
| raise gr.Error(f"{video_container.upper()} output cannot packet-copy {track_label} with codec(s): {codecs_text}. Use an .mkv output path or choose a compatible track.") |
|
|
|
|
| def validate_output_codec_container(video_codec: str, video_container: str, audio_codec: str | None = None, width: int | None = None, height: int | None = None, output_path: str | None = None) -> None: |
| error = validate_video_output_settings(video_codec, video_container, audio_codec=audio_codec, width=width, height=height, allowed_containers=SUPPORTED_OUTPUT_CONTAINERS) |
| if error is None: |
| return |
| suffix = Path(output_path).suffix if output_path else f".{normalize_container_name(video_container)}" |
| raise gr.Error(f"Output file extension '{suffix}' is incompatible with the current video codec/container settings. {error} Choose a compatible output extension or update the Configuration plugin settings.") |
|
|
|
|
| def start_video_mux_process(ffmpeg_path: str, output_path: str, width: int, height: int, fps_float: float, video_codec: str, video_container: str, reserved_metadata_path: str | None = None) -> subprocess.Popen: |
| validate_output_codec_container(video_codec, video_container, width=width, height=height, output_path=output_path) |
| command = [ |
| ffmpeg_path, |
| "-y", |
| "-v", |
| "error", |
| "-f", |
| "rawvideo", |
| "-pix_fmt", |
| "rgb24", |
| "-video_size", |
| f"{width}x{height}", |
| "-framerate", |
| f"{float(fps_float):.12g}", |
| "-i", |
| "pipe:0", |
| ] |
| metadata_input_index = None |
| if reserved_metadata_path and os.path.isfile(reserved_metadata_path): |
| command += ["-f", "ffmetadata", "-i", reserved_metadata_path] |
| metadata_input_index = 1 |
| if metadata_input_index is not None: |
| command += ["-map_metadata", str(metadata_input_index)] |
| command += ["-map", "0:v:0"] |
| command += get_video_encode_args(video_codec, video_container) |
| command += _get_live_mux_output_args(video_container) |
| command += [output_path] |
| return subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=0) |
|
|
|
|
| def start_av_mux_process(ffmpeg_path: str, output_path: str, width: int, height: int, fps_float: float, video_codec: str, video_container: str, source_path: str, start_seconds: float, audio_track_no: int | None, reserved_metadata_path: str | None = None, source_audio_duration_seconds: float | None = None) -> subprocess.Popen: |
| validate_output_codec_container(video_codec, video_container, width=width, height=height, output_path=output_path) |
| command = [ |
| ffmpeg_path, |
| "-y", |
| "-v", |
| "error", |
| "-f", |
| "rawvideo", |
| "-pix_fmt", |
| "rgb24", |
| "-video_size", |
| f"{width}x{height}", |
| "-framerate", |
| f"{float(fps_float):.12g}", |
| "-i", |
| "pipe:0", |
| "-stream_loop", |
| "-1", |
| "-ss", |
| f"{max(0.0, float(start_seconds)):.12g}", |
| ] |
| if source_audio_duration_seconds is not None and source_audio_duration_seconds > 0.0: |
| command += ["-t", f"{max(0.0, float(source_audio_duration_seconds) + LIVE_AUDIO_LOOP_PADDING_SECONDS):.12g}"] |
| command += ["-i", source_path] |
| metadata_input_index = None |
| if reserved_metadata_path and os.path.isfile(reserved_metadata_path): |
| command += ["-f", "ffmetadata", "-i", reserved_metadata_path] |
| metadata_input_index = 2 |
| else: |
| metadata_input_index = 1 |
| command += ["-map_metadata", str(metadata_input_index), "-map", "0:v:0"] |
| if audio_track_no is None: |
| command += ["-map", "1:a?"] |
| else: |
| command += ["-map", f"1:a:{max(0, int(audio_track_no) - 1)}?"] |
| command += get_video_encode_args(video_codec, video_container) + ["-c:a", "copy", "-shortest"] |
| command += _get_live_mux_output_args(video_container) |
| command += [output_path] |
| return subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=0) |
|
|
|
|
| def start_hdr_video_mux_process(ffmpeg_path: str, output_path: str, width: int, height: int, fps_float: float, video_codec: str, video_container: str, reserved_metadata_path: str | None = None) -> subprocess.Popen: |
| command = [ |
| ffmpeg_path, |
| "-y", |
| "-v", |
| "error", |
| "-f", |
| "rawvideo", |
| "-pix_fmt", |
| "gbrpf32le", |
| "-video_size", |
| f"{width}x{height}", |
| "-framerate", |
| f"{float(fps_float):.12g}", |
| "-i", |
| "pipe:0", |
| ] |
| metadata_input_index = None |
| if reserved_metadata_path and os.path.isfile(reserved_metadata_path): |
| command += ["-f", "ffmetadata", "-i", reserved_metadata_path] |
| metadata_input_index = 1 |
| if metadata_input_index is not None: |
| command += ["-map_metadata", str(metadata_input_index)] |
| command += ["-map", "0:v:0"] |
| command += get_hdr_video_encode_args(video_codec, video_container) |
| command += _get_live_mux_output_args(video_container) |
| command += [output_path] |
| return subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=0) |
|
|
|
|
| def start_hdr_av_mux_process(ffmpeg_path: str, output_path: str, width: int, height: int, fps_float: float, video_codec: str, video_container: str, source_path: str, start_seconds: float, audio_track_no: int | None, reserved_metadata_path: str | None = None, source_audio_duration_seconds: float | None = None) -> subprocess.Popen: |
| command = [ |
| ffmpeg_path, |
| "-y", |
| "-v", |
| "error", |
| "-f", |
| "rawvideo", |
| "-pix_fmt", |
| "gbrpf32le", |
| "-video_size", |
| f"{width}x{height}", |
| "-framerate", |
| f"{float(fps_float):.12g}", |
| "-i", |
| "pipe:0", |
| "-stream_loop", |
| "-1", |
| "-ss", |
| f"{max(0.0, float(start_seconds)):.12g}", |
| ] |
| if source_audio_duration_seconds is not None and source_audio_duration_seconds > 0.0: |
| command += ["-t", f"{max(0.0, float(source_audio_duration_seconds) + LIVE_AUDIO_LOOP_PADDING_SECONDS):.12g}"] |
| command += ["-i", source_path] |
| if reserved_metadata_path and os.path.isfile(reserved_metadata_path): |
| command += ["-f", "ffmetadata", "-i", reserved_metadata_path, "-map_metadata", "2"] |
| else: |
| command += ["-map_metadata", "1"] |
| command += ["-map", "0:v:0"] |
| if audio_track_no is None: |
| command += ["-map", "1:a?"] |
| else: |
| command += ["-map", f"1:a:{max(0, int(audio_track_no) - 1)}?"] |
| command += get_hdr_video_encode_args(video_codec, video_container) + ["-c:a", "copy", "-shortest"] |
| command += _get_live_mux_output_args(video_container) |
| command += [output_path] |
| return subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=0) |
|
|
|
|
| def finalize_mux_process(process: subprocess.Popen, *, timeout_seconds: float = 30.0) -> tuple[int, str, bool]: |
| if process.stdin is not None and not process.stdin.closed: |
| try: |
| process.stdin.close() |
| except OSError: |
| pass |
| forced_termination = False |
| try: |
| return_code = process.wait(timeout=timeout_seconds) |
| except subprocess.TimeoutExpired: |
| forced_termination = True |
| process.kill() |
| return_code = process.wait(timeout=5) |
| stderr = process.stderr.read().decode("utf-8", errors="ignore").strip() if process.stderr is not None else "" |
| return return_code, stderr, forced_termination |
|
|
|
|
| def _reserve_sidecar_path(preferred_path: Path, fallback_path_factory) -> str: |
| for attempt in range(1000): |
| candidate = preferred_path if attempt == 0 else fallback_path_factory(uuid.uuid4().hex[:8]) |
| try: |
| handle = os.open(str(candidate), os.O_CREAT | os.O_EXCL | os.O_WRONLY) |
| except FileExistsError: |
| continue |
| try: |
| os.close(handle) |
| except OSError: |
| pass |
| return str(candidate) |
| raise gr.Error(f"Unable to reserve a scratch filename near {preferred_path.parent}") |
|
|
|
|
| def reserve_video_only_output_path(output_path: str) -> str: |
| output = Path(output_path).resolve() |
| preferred = output.with_name(f"{output.stem}_videoonly{output.suffix}") |
| return _reserve_sidecar_path(preferred, lambda token: output.with_name(f"{output.stem}_videoonly_{token}{output.suffix}")) |
|
|
|
|
| def create_reserved_metadata_file(output_path: str) -> str: |
| output = Path(output_path).resolve() |
| preferred = output.with_name(f"{output.name}.ffmeta") |
| reserved_metadata_path = _reserve_sidecar_path(preferred, lambda token: output.with_name(f"{output.name}.{token}.ffmeta")) |
| write_reserved_video_ffmetadata(reserved_metadata_path, DEFAULT_RESERVED_VIDEO_METADATA_BYTES) |
| return reserved_metadata_path |
|
|
|
|
| def delete_file_if_exists(file_path: str | None, *, label: str) -> None: |
| if not isinstance(file_path, str) or not os.path.isfile(file_path): |
| return |
| try: |
| os.remove(file_path) |
| except OSError as exc: |
| print(f"[Process Full Video] Warning: failed to delete {label} {file_path}: {exc}") |
|
|
|
|
| def delete_released_chunk_outputs(state: dict, chunk_output_paths: list[str], *, preserve_paths: list[str] | None = None) -> list[str]: |
| if not isinstance(state, dict): |
| return chunk_output_paths |
| gen = state.get("gen", {}) |
| if not isinstance(gen, dict): |
| return chunk_output_paths |
| referenced_paths = { |
| str(Path(path).resolve()) |
| for path in list(gen.get("file_list", []) or []) + list(gen.get("audio_file_list", []) or []) |
| if isinstance(path, str) and len(path.strip()) > 0 |
| } |
| referenced_paths.update( |
| str(Path(path).resolve()) |
| for path in list(preserve_paths or []) |
| if isinstance(path, str) and len(path.strip()) > 0 |
| ) |
| kept_paths: list[str] = [] |
| for path in chunk_output_paths: |
| resolved = str(Path(path).resolve()) |
| if resolved in referenced_paths: |
| kept_paths.append(resolved) |
| continue |
| if os.path.isfile(resolved): |
| try: |
| os.remove(resolved) |
| except OSError: |
| kept_paths.append(resolved) |
| return kept_paths |
|
|
|
|
| def get_last_generated_video_path(paths: list[str]) -> str | None: |
| video_paths = [str(Path(path).resolve()) for path in paths if isinstance(path, str) and os.path.isfile(path) and str(Path(path).suffix).lower() in {".mp4", ".mkv", ".mov"}] |
| return video_paths[-1] if len(video_paths) > 0 else None |
|
|
|
|
| def _make_output_temp_dir(output_path: str, prefix: str) -> str: |
| return tempfile.mkdtemp(prefix=prefix, dir=str(Path(output_path).resolve().parent)) |
|
|
|
|
| def _probe_media_stream_layout(ffprobe_path: str, media_path: str) -> tuple[int, int]: |
| result = subprocess.run([ffprobe_path, "-v", "error", "-show_entries", "stream=codec_type", "-of", "json", media_path], capture_output=True, text=True) |
| if result.returncode != 0: |
| raise gr.Error((result.stderr or result.stdout or f"ffprobe failed for {media_path}").strip()) |
| try: |
| streams = list((json.loads(result.stdout) or {}).get("streams") or []) |
| except json.JSONDecodeError as exc: |
| raise gr.Error(f"Unable to read media stream layout for {media_path}") from exc |
| video_count = sum(1 for stream in streams if str(stream.get("codec_type") or "").lower() == "video") |
| audio_count = sum(1 for stream in streams if str(stream.get("codec_type") or "").lower() == "audio") |
| return video_count, audio_count |
|
|
|
|
| def _probe_primary_video_codec(ffprobe_path: str, media_path: str) -> str: |
| result = subprocess.run([ffprobe_path, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "json", media_path], capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if result.returncode != 0: |
| raise gr.Error((result.stderr or result.stdout or f"ffprobe failed for {media_path}").strip()) |
| try: |
| codec_name = str((((json.loads(result.stdout) or {}).get("streams") or [{}])[0]).get("codec_name") or "").strip().lower() |
| except (TypeError, ValueError, json.JSONDecodeError, IndexError): |
| codec_name = "" |
| if len(codec_name) == 0: |
| raise gr.Error(f"Unable to detect the video codec of {media_path}") |
| return codec_name |
|
|
|
|
| def _probe_primary_video_start_time(ffprobe_path: str, media_path: str) -> float: |
| result = subprocess.run([ffprobe_path, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=start_time", "-of", "json", media_path], capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if result.returncode == 0: |
| try: |
| stream = (((json.loads(result.stdout) or {}).get("streams") or [{}])[0]) |
| start_time = stream.get("start_time") |
| if start_time not in (None, "", "N/A"): |
| return max(0.0, float(start_time)) |
| except (TypeError, ValueError, json.JSONDecodeError, IndexError): |
| pass |
| packet_times = _probe_video_packet_times(ffprobe_path, media_path, start_seconds=0.0, duration_seconds=1.0) |
| return max(0.0, min(packet_times)) if len(packet_times) > 0 else 0.0 |
|
|
|
|
| def _probe_video_packet_times(ffprobe_path: str, media_path: str, *, start_seconds: float | None = None, duration_seconds: float | None = None) -> list[float]: |
| command = [ffprobe_path, "-v", "error", "-select_streams", "v:0"] |
| if start_seconds is not None and duration_seconds is not None and float(duration_seconds) > 0.0: |
| command += ["-read_intervals", f"{max(0.0, float(start_seconds)):.6f}%+{max(0.05, float(duration_seconds)):.6f}"] |
| command += ["-show_packets", "-show_entries", "packet=dts_time,pts_time", "-of", "json", media_path] |
| result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="ignore", check=False) |
| if result.returncode != 0: |
| raise gr.Error((result.stderr or result.stdout or f"ffprobe failed for {media_path}").strip()) |
| try: |
| return sorted( |
| float(packet.get("dts_time") if packet.get("dts_time") is not None else packet.get("pts_time")) |
| for packet in ((json.loads(result.stdout) or {}).get("packets") or []) |
| if packet.get("dts_time") is not None or packet.get("pts_time") is not None |
| ) |
| except (TypeError, ValueError, json.JSONDecodeError): |
| return [] |
|
|
|
|
| def _probe_video_frame_gap(ffprobe_path: str, media_path: str, fps_float: float, *, near_time: float | None = None, window_seconds: float = 4.0) -> tuple[float, float, float] | None: |
| fps_value = float(fps_float or 0.0) |
| if fps_value <= 0.0 or not os.path.isfile(media_path): |
| return None |
| max_delta = max(1.0 / fps_value * 1.5, 0.05) |
| if near_time is None: |
| packet_times = _probe_video_packet_times(ffprobe_path, media_path) |
| if len(packet_times) < 2: |
| return None |
| for current_pts, next_pts in zip(packet_times, packet_times[1:]): |
| delta = float(next_pts) - float(current_pts) |
| if delta > max_delta: |
| return float(current_pts), float(next_pts), float(delta) |
| return None |
| seam_time = max(0.0, float(near_time)) |
| local_window = max(1.0, float(window_seconds)) |
| seam_margin = max_delta * 2.0 |
| total_duration = max(seam_time, _probe_media_duration(ffprobe_path, media_path)) |
| probe_start = max(0.0, seam_time - max(0.25, seam_margin)) |
| probe_duration = min(max(local_window, 4.0), max(0.05, total_duration - probe_start)) |
| while probe_duration > 0.0: |
| packet_times = _probe_video_packet_times(ffprobe_path, media_path, start_seconds=probe_start, duration_seconds=probe_duration) |
| prev_candidates = [packet_time for packet_time in packet_times if packet_time <= seam_time + seam_margin] |
| if len(prev_candidates) > 0: |
| prev_time = prev_candidates[-1] |
| next_candidates = [packet_time for packet_time in packet_times if packet_time > prev_time + 1e-9] |
| if len(next_candidates) > 0: |
| next_time = next_candidates[0] |
| delta = float(next_time) - float(prev_time) |
| return None if delta <= max_delta else (float(prev_time), float(next_time), float(delta)) |
| if probe_start + probe_duration >= total_duration - 1e-6: |
| return None |
| probe_duration = min(max(2.0 * probe_duration, local_window), max(0.05, total_duration - probe_start)) |
| return None |
|
|
|
|
| def _write_concat_list(list_path: str, media_paths: list[str]) -> None: |
| with open(list_path, "w", encoding="utf-8") as handle: |
| for media_path in media_paths: |
| escaped_path = str(media_path).replace("'", "'\\''") |
| handle.write(f"file '{escaped_path}'\n") |
|
|
|
|
| def _split_sparse_fine_seek(seconds: float) -> tuple[float, float]: |
| seconds = max(0.0, float(seconds or 0.0)) |
| coarse_seconds = max(0.0, seconds - 1.0) |
| return coarse_seconds, seconds - coarse_seconds |
|
|
|
|
| def _build_video_reconstruct_bsf(fps_float: float) -> str: |
| fps_value = max(float(fps_float or 0.0), 1.0) |
| frame_duration_expr = f"1/({fps_value:.15g}*TB)" |
| return ( |
| "setts=" |
| f"pts='if(eq(N,0),PTS,PREV_OUTPTS+(PTS-PREV_INPTS)-(PREV_INDURATION-DURATION))':" |
| f"dts='if(eq(N,0),DTS,PREV_OUTDTS+(DTS-PREV_INDTS)-(PREV_INDURATION-DURATION))':" |
| f"duration='if(eq(N,0),{frame_duration_expr},DURATION)'" |
| ) |
|
|
|
|
| def _concat_audio_segments(ffmpeg_path: str, segment_paths: list[str], output_path: str, work_dir: str, *, segment_trim_seconds: list[float] | None = None, segment_duration_seconds: list[float | None] | None = None, audio_stream_indices: list[int] | None = None) -> None: |
| extracted_paths: list[str] = [] |
| list_path = os.path.join(work_dir, "audio.txt") |
| try: |
| for segment_no, segment_path in enumerate(segment_paths, start=1): |
| extracted_path = os.path.join(work_dir, f"segment_{segment_no}_audio.mka") |
| trim_seconds = max(0.0, float(segment_trim_seconds[segment_no - 1])) if segment_trim_seconds is not None and segment_no - 1 < len(segment_trim_seconds) else 0.0 |
| duration_seconds = None |
| if segment_duration_seconds is not None and segment_no - 1 < len(segment_duration_seconds) and segment_duration_seconds[segment_no - 1] is not None: |
| duration_seconds = max(0.0, float(segment_duration_seconds[segment_no - 1])) |
| audio_stream_index = max(0, int(audio_stream_indices[segment_no - 1])) if audio_stream_indices is not None and segment_no - 1 < len(audio_stream_indices) else 0 |
| command = [ffmpeg_path, "-y", "-v", "error"] |
| coarse_seek_seconds, fine_seek_seconds = _split_sparse_fine_seek(trim_seconds) |
| if coarse_seek_seconds > 0.0: |
| command += ["-ss", f"{coarse_seek_seconds:.12g}"] |
| command += ["-i", segment_path] |
| if fine_seek_seconds > 0.0: |
| command += ["-ss", f"{fine_seek_seconds:.12g}"] |
| if duration_seconds is not None and duration_seconds > 0.0: |
| command += ["-t", f"{duration_seconds:.12g}"] |
| command += ["-map", f"0:a:{audio_stream_index}?", "-c", "copy", "-avoid_negative_ts", "make_zero", extracted_path] |
| result = subprocess.run(command, capture_output=True, text=True) |
| if result.returncode != 0 or not os.path.isfile(extracted_path): |
| raise gr.Error((result.stderr or result.stdout or f"ffmpeg failed to extract audio from {segment_path}").strip()) |
| extracted_paths.append(extracted_path) |
| _write_concat_list(list_path, extracted_paths) |
| result = subprocess.run([ffmpeg_path, "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", output_path], capture_output=True, text=True) |
| if result.returncode != 0 or not os.path.isfile(output_path): |
| raise gr.Error((result.stderr or result.stdout or "ffmpeg failed to concatenate audio streams").strip()) |
| finally: |
| for extracted_path in extracted_paths: |
| if os.path.isfile(extracted_path): |
| os.remove(extracted_path) |
| if os.path.isfile(list_path): |
| os.remove(list_path) |
|
|
|
|
| def concat_video_segments( |
| ffmpeg_path: str, |
| segment_paths: list[str], |
| output_path: str, |
| video_codec: str, |
| video_container: str, |
| audio_codec_key: str, |
| *, |
| segment_audio_trim_seconds: list[float] | None = None, |
| segment_audio_duration_seconds: list[float | None] | None = None, |
| fps_float: float | None = None, |
| selected_audio_track_no: int | None = None, |
| reserved_metadata_path: str | None = None, |
| source_audio_path: str | None = None, |
| source_audio_start_seconds: float | None = None, |
| source_audio_duration_seconds: float | None = None, |
| source_audio_track_no: int | None = None, |
| ) -> None: |
| normalized_segment_paths: list[str] = [] |
| for path in segment_paths: |
| segment_path = str(Path(str(path).strip()).resolve()) |
| if len(segment_path) == 0 or not os.path.isfile(segment_path): |
| raise gr.Error(f"Output segment is missing and cannot be merged: {path}") |
| normalized_segment_paths.append(segment_path) |
| segment_paths = normalized_segment_paths |
| if len(segment_paths) == 0: |
| raise gr.Error("No output segments available to merge.") |
| if len(segment_paths) == 1: |
| if str(Path(segment_paths[0]).resolve()) != str(Path(output_path).resolve()): |
| try: |
| os.replace(segment_paths[0], output_path) |
| except OSError as exc: |
| raise ContinuationMergeOutputLockedError(output_path) from exc |
| return |
| ffprobe_path = resolve_media_binary("ffprobe") |
| layouts = [_probe_media_stream_layout(ffprobe_path, path) for path in segment_paths] |
| if any(video_count != 1 for video_count, _ in layouts): |
| raise gr.Error("All continuation segments must contain exactly one video stream.") |
| use_source_audio_merge = isinstance(source_audio_path, str) and len(str(source_audio_path).strip()) > 0 |
| if use_source_audio_merge: |
| validate_audio_copy_container(ffprobe_path, str(source_audio_path), video_container, source_audio_track_no) |
| audio_stream_counts = [audio_count for _, audio_count in layouts] |
| has_audio = use_source_audio_merge or any(audio_count > 0 for audio_count in audio_stream_counts) |
| if not use_source_audio_merge and has_audio and any(audio_count <= 0 for audio_count in audio_stream_counts): |
| raise gr.Error("All continuation segments must expose an audio stream.") |
| fps_value = float(fps_float or 0.0) |
| concat_dir = _make_output_temp_dir(output_path, "wangp_process_full_video_concat_") |
| merged_video_path = os.path.join(concat_dir, "merged_video.mkv") |
| temp_output_path = os.path.join(concat_dir, f"{Path(output_path).stem}_merged{Path(output_path).suffix}") |
| try: |
| video_codec_name = _probe_primary_video_codec(ffprobe_path, segment_paths[0]) |
| video_bsf = "h264_mp4toannexb" if video_codec_name == "h264" else "hevc_mp4toannexb" if video_codec_name in ("hevc", "h265") else "" |
| if len(video_bsf) == 0: |
| raise gr.Error(f"Unsupported continuation video codec for no-reencode merge: {video_codec_name}") |
| reconstructed_video_bsf = f"{_build_video_reconstruct_bsf(fps_value)},{video_bsf}" |
| if use_source_audio_merge: |
| command = [ffmpeg_path, "-y", "-v", "error", "-f", "mpegts", "-i", "pipe:0", "-ss", f"{max(0.0, float(source_audio_start_seconds or 0.0)):.12g}", "-t", f"{max(0.0, float(source_audio_duration_seconds or 0.0)):.12g}", "-i", str(source_audio_path)] |
| if reserved_metadata_path and os.path.isfile(reserved_metadata_path): |
| command += ["-f", "ffmetadata", "-i", reserved_metadata_path, "-map_metadata", "2"] |
| else: |
| command += ["-map_metadata", "1"] |
| command += ["-map", "0:v:0"] |
| if source_audio_track_no is None: |
| command += ["-map", "1:a?"] |
| else: |
| command += ["-map", f"1:a:{max(0, int(source_audio_track_no) - 1)}?"] |
| command += ["-c", "copy"] |
| if is_quicktime_output_container(video_container): |
| command += ["-movflags", "+faststart"] |
| command += [temp_output_path] |
| mux_process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=0) |
| try: |
| if mux_process.stdin is None: |
| raise gr.Error("ffmpeg source-audio merge did not expose a writable video pipe.") |
| for segment_path in segment_paths: |
| segment_process = subprocess.Popen([ffmpeg_path, "-v", "error", "-i", segment_path, "-map", "0:v:0", "-c", "copy", "-bsf:v", reconstructed_video_bsf, "-f", "mpegts", "-"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) |
| try: |
| if segment_process.stdout is None: |
| raise gr.Error(f"ffmpeg failed to expose the TS stream for {segment_path}.") |
| shutil.copyfileobj(segment_process.stdout, mux_process.stdin, length=1024 * 1024) |
| except Exception: |
| segment_process.kill() |
| segment_process.wait(timeout=5) |
| raise |
| finally: |
| if segment_process.stdout is not None: |
| segment_process.stdout.close() |
| segment_returncode = segment_process.wait() |
| segment_stderr = segment_process.stderr.read().decode("utf-8", errors="ignore").strip() if segment_process.stderr is not None else "" |
| if segment_returncode != 0: |
| raise gr.Error((segment_stderr or f"ffmpeg failed to stream {segment_path} for concat").strip()) |
| mux_returncode, mux_stderr, _ = finalize_mux_process(mux_process) |
| except Exception: |
| try: |
| if mux_process.stdin is not None and not mux_process.stdin.closed: |
| mux_process.stdin.close() |
| except OSError: |
| pass |
| mux_process.kill() |
| mux_process.wait(timeout=5) |
| raise |
| if mux_returncode != 0 or not os.path.isfile(temp_output_path): |
| raise gr.Error((mux_stderr or "ffmpeg source-audio merge failed").strip()) |
| else: |
| concat_ts_path = os.path.join(concat_dir, "segments.ts") |
| ts_paths: list[str] = [] |
| for index, segment_path in enumerate(segment_paths, start=1): |
| ts_path = os.path.join(concat_dir, f"segment_{index}.ts") |
| result = subprocess.run([ffmpeg_path, "-y", "-v", "error", "-i", segment_path, "-map", "0:v:0", "-c", "copy", "-bsf:v", reconstructed_video_bsf, "-f", "mpegts", ts_path], capture_output=True, text=True) |
| if result.returncode != 0 or not os.path.isfile(ts_path): |
| raise gr.Error((result.stderr or result.stdout or f"ffmpeg failed to prepare {segment_path} for concat").strip()) |
| ts_paths.append(ts_path) |
| with open(concat_ts_path, "wb") as handle: |
| for ts_path in ts_paths: |
| with open(ts_path, "rb") as ts_file: |
| shutil.copyfileobj(ts_file, handle, length=1024 * 1024) |
| result = subprocess.run([ffmpeg_path, "-y", "-v", "error", "-i", concat_ts_path, "-map", "0:v:0", "-c", "copy", merged_video_path], capture_output=True, text=True) |
| if result.returncode != 0 or not os.path.isfile(merged_video_path): |
| raise gr.Error((result.stderr or result.stdout or "ffmpeg failed to concatenate video stream").strip()) |
| command = [ffmpeg_path, "-y", "-v", "error", "-i", merged_video_path] |
| if has_audio: |
| merged_audio_path = os.path.join(concat_dir, "merged_audio.mka") |
| selected_audio_index = max(0, int(selected_audio_track_no or 1) - 1) |
| audio_stream_indices = [0 if audio_count <= 1 else min(audio_count - 1, selected_audio_index) for audio_count in audio_stream_counts] |
| resolved_audio_duration_seconds = [None] * len(segment_paths) |
| if segment_audio_duration_seconds is not None: |
| for segment_index, duration_seconds in enumerate(segment_audio_duration_seconds[:len(segment_paths)]): |
| if duration_seconds is not None: |
| resolved_audio_duration_seconds[segment_index] = max(0.0, float(duration_seconds)) |
| elif fps_value > 0.0: |
| for segment_index, segment_path in enumerate(segment_paths): |
| segment_frame_count, _ = probe_resume_frame_count(ffprobe_path, segment_path, fps_value) |
| if segment_frame_count > 0: |
| resolved_audio_duration_seconds[segment_index] = float(segment_frame_count) / fps_value |
| _concat_audio_segments(ffmpeg_path, segment_paths, merged_audio_path, concat_dir, segment_trim_seconds=segment_audio_trim_seconds, segment_duration_seconds=resolved_audio_duration_seconds, audio_stream_indices=audio_stream_indices) |
| command += ["-i", merged_audio_path] |
| if reserved_metadata_path and os.path.isfile(reserved_metadata_path): |
| command += ["-f", "ffmetadata", "-i", reserved_metadata_path] |
| command += ["-map_metadata", "2" if has_audio else "1"] |
| else: |
| command += ["-map_metadata", "0"] |
| command += ["-map", "0:v:0"] |
| if has_audio: |
| command += ["-map", "1:a:0"] |
| command += ["-c", "copy"] |
| if is_quicktime_output_container(video_container): |
| command += ["-movflags", "+faststart"] |
| command += [temp_output_path] |
| result = subprocess.run(command, capture_output=True, text=True) |
| if result.returncode != 0 or not os.path.isfile(temp_output_path): |
| raise gr.Error((result.stderr or result.stdout or "ffmpeg concat failed").strip()) |
| timeline_gap = _probe_video_frame_gap(ffprobe_path, temp_output_path, fps_value, near_time=_probe_media_duration(ffprobe_path, segment_paths[0])) |
| if timeline_gap is not None: |
| gap_start, gap_end, gap_seconds = timeline_gap |
| raise gr.Error(f"Merged video timeline contains a {gap_seconds:.6f}s gap near {gap_start:.3f}s -> {gap_end:.3f}s.") |
| try: |
| os.replace(temp_output_path, output_path) |
| except OSError as exc: |
| raise ContinuationMergeOutputLockedError(output_path) from exc |
| finally: |
| shutil.rmtree(concat_dir, ignore_errors=True) |
|
|