|
|
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,
|
|
|
]
|
|
|
|
|
|
print("Running FFmpeg concat...")
|
|
|
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
|
|
|
|
|
|
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:
|
|
|
print("Falling back to re-encoding due to concat failure.")
|
|
|
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):
|
|
|
"""Your existing function to encode and concatenate videos."""
|
|
|
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)
|
|
|
|
|
|
# Clamp target bitrate to not exceed source
|
|
|
if current_bitrate > 0:
|
|
|
target_bitrate_kbps = min(target_bitrate_kbps, current_bitrate)
|
|
|
|
|
|
# Max bitrate shouldn't exceed source either
|
|
|
if current_bitrate > 0:
|
|
|
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"] # Overwrite output if exists
|
|
|
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]")
|
|
|
|
|
|
# Example final: [v0][0:a][v1][1:a]concat=n=2:v=1:a=1[outv][outa]
|
|
|
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)
|
|
|
except:
|
|
|
return False
|
|
|
|
|
|
for video in videos_list:
|
|
|
os.remove(video["filepath"])
|
|
|
|
|
|
shutil.move(temp_path, output_path)
|
|
|
|
|
|
return main_video |