You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

225 lines
7.2 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import subprocess
import json
import os
import tempfile
import shutil
from video_funcs import get_video_info, get_target_bitrate, get_target_resolution, get_fps
TEMP_DIR = ".temp"
CONCATED_DIR = "concated"
# --- helpers --------------------------------------------------------------- #
def get_signature(fp: str) -> tuple:
"""
A signature is everything that has to match for a bit-perfect concat:
video: codec, width, height, fps (as a float), pix_fmt, color_range
audio: codec, sample_rate, channels, channel_layout
"""
def ffprobe_json(fp: str) -> dict:
"""Return the full ffprobe-JSON for a media file."""
cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", "-show_format", fp
]
return json.loads(subprocess.check_output(cmd, text=True))
info = ffprobe_json(fp)
v_stream = next(s for s in info["streams"] if s["codec_type"] == "video")
a_stream = next((s for s in info["streams"] if s["codec_type"] == "audio"), None)
def fps(stream):
fr = stream.get("r_frame_rate", "0/0")
num, den = map(int, fr.split("/"))
return round(num / den, 3) if den else 0.0
sig = (
v_stream["codec_name"],
int(v_stream["width"]), int(v_stream["height"]),
fps(v_stream),
v_stream.get("pix_fmt"),
v_stream.get("color_range"),
a_stream["codec_name"] if a_stream else None,
int(a_stream["sample_rate"]) if a_stream else None,
a_stream.get("channels") if a_stream else None,
a_stream.get("channel_layout") if a_stream else None,
)
return sig
def all_signatures_equal(videos):
ref = get_signature(videos[0]["filepath"])
return all(get_signature(v["filepath"]) == ref for v in videos[1:])
# --- concat functions --------------------------------------------------------------- #
def concat_copy(videos, out_path):
"""Lossless concat with the *concat demuxer* (-c copy)."""
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
for v in videos:
f.write(f"file '{os.path.abspath(v['filepath']).replace('\'', '\\\'')}'\n")
list_file = f.name
cmd = [
"ffmpeg", "-y",
"-f", "concat", "-safe", "0",
"-i", list_file,
"-c", "copy",
out_path,
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
os.unlink(list_file)
# Look for specific error patterns in FFmpeg's stderr
ffmpeg_errors = [
"corrupt input packet",
"Invalid OBU",
"Failed to parse temporal unit",
"Packet corrupt",
"partial file",
"Non-monotonic DTS"
]
if result.returncode != 0 or any(err in result.stderr for err in ffmpeg_errors):
print("❌ FFmpeg concat failed or produced corrupted output.")
print("FFmpeg stderr:")
print(result.stderr)
# Remove broken file if it exists
if os.path.exists(out_path):
os.remove(out_path)
print(f"🗑️ Removed corrupt output: {out_path}")
return False
print("✅ FFmpeg concat completed successfully.")
return True
def concatenate_videos(videos_list, reencode_concate = False):
"""
Concatenate pre-grouped videos, then re-encode them using AV1 (NVENC)
while forcing a unified resolution and frame rate on each input
before final concatenation in one ffmpeg command.
"""
if len(videos_list) <= 1:
return False
copy_concat = copy_concatenate_videos(videos_list)
if copy_concat:
return copy_concat
if not reencode_concate:
return False
print("Falling back to re-encoding due to concat failure.")
return encode_concatenate_videos(videos_list)
def copy_concatenate_videos(videos_list):
from concat_helper import all_signatures_equal, concat_copy
if not (len(videos_list) > 1 and all_signatures_equal(videos_list)):
print("Streams are not compatible for lossless concat.")
return False
print("All streams are compatible attempting lossless concat …")
main_video = videos_list[0]
video_path = main_video["filepath"]
output_path = os.path.join(TEMP_DIR, os.path.basename(video_path))
os.makedirs(CONCATED_DIR, exist_ok=True)
success = concat_copy(videos_list, output_path)
if not success:
return False
# Remove originals
for v in videos_list:
os.remove(v["filepath"])
# move temp to concated folder
os.rename(output_path, os.path.join(CONCATED_DIR, os.path.basename(video_path)))
return main_video
def encode_concatenate_videos(videos_list):
"""Encode and concatenate videos without ffmpeg spam in console."""
main_video = videos_list[0]
video_path = main_video["filepath"]
os.makedirs(TEMP_DIR, exist_ok=True)
os.makedirs(CONCATED_DIR, exist_ok=True)
temp_path = os.path.join(TEMP_DIR, os.path.basename(video_path))
output_path = os.path.join(CONCATED_DIR, os.path.basename(video_path))
video_info = get_video_info(videos_list[0]['filepath'])
current_bitrate = int(video_info.get('bitrate') or 0)
target_width, target_height = get_target_resolution(videos_list)
target_bitrate_kbps = get_target_bitrate(target_width, target_height)
if current_bitrate > 0:
target_bitrate_kbps = min(target_bitrate_kbps, current_bitrate)
max_bitrate_kbps = min(int(1.5 * target_bitrate_kbps), current_bitrate)
else:
max_bitrate_kbps = int(1.5 * target_bitrate_kbps)
fps_float = get_fps(video_path) or video_info.get('fps') or 30.0
if fps_float <= 0:
fps_float = 30.0
keyframe_interval = int(fps_float)
print(f"Concatenating {len(videos_list)} videos into {temp_path}")
print(f" Mode Resolution: {target_width}x{target_height}")
print(f" Target Bitrate: {target_bitrate_kbps}k (max ~{max_bitrate_kbps}k)")
print(f" Keyframe Interval: {keyframe_interval}")
cmd = ["ffmpeg", "-y"]
for v in videos_list:
cmd.extend(["-i", v["filepath"]])
filter_statements = []
concat_streams = []
n = len(videos_list)
unified_fps = 30
for i in range(n):
filter_statements.append(
f"[{i}:v]fps={unified_fps},scale={target_width}:{target_height}[v{i}]"
)
concat_streams.append(f"[v{i}][{i}:a]")
concat_line = "".join(concat_streams) + f"concat=n={n}:v=1:a=1[outv][outa]"
filter_statements.append(concat_line)
filter_complex = ";".join(filter_statements)
cmd.extend([
"-filter_complex", filter_complex,
"-map", "[outv]",
"-map", "[outa]",
"-c:v", "av1_nvenc",
"-b:v", f"{target_bitrate_kbps}k",
"-maxrate", f"{max_bitrate_kbps}k",
"-bufsize", f"{max_bitrate_kbps}k",
"-preset", "p5",
"-g", str(keyframe_interval),
"-c:a", "aac",
"-b:a", "192k",
temp_path
])
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
return False
for video in videos_list:
os.remove(video["filepath"])
shutil.move(temp_path, output_path)
return main_video