add new video edit scripts for combine video and spliting video
This commit is contained in:
1 parent
ee93c06f7d
commit
856ab439f7
10 files changed
+199
-32
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# package/video_editing/__init__.py
|
||||
|
||||
# Expose components from individual modules using relative imports
|
||||
from .combine_videos import merge_videos
|
||||
from .split_video_by_timestamps import split_video
|
||||
from .build_shorts import fit_to_9_16_letterbox
|
||||
|
||||
# Explicitly define what is exposed when importing the subpackage
|
||||
__all__ = [
|
||||
"merge_videos",
|
||||
"split_video",
|
||||
"fit_to_9_16_letterbox"
|
||||
]
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import json
|
||||
import package.utils as utils
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from moviepy.video.VideoClip import TextClip
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
def get_video_info(input_path: Path) -> tuple:
|
||||
"""Uses ffprobe to instantly read input video dimensions and frame rate."""
|
||||
cmd = f"ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate -of json {input_path}"
|
||||
# Run command and capture output (assumes linux.run_command prints or you use subprocess)
|
||||
import subprocess
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
#linux.run_command(cmd)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
stream = data['streams'][0]
|
||||
w = int(stream['width'])
|
||||
h = int(stream['height'])
|
||||
# Convert fractional FPS string (e.g. "60/1" or "30000/1001") to float
|
||||
fps_parts = stream['r_frame_rate'].split('/')
|
||||
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) > 1 else float(fps_parts[0])
|
||||
return w, h, fps
|
||||
except Exception:
|
||||
return 1920, 1080, 60.0 # Safe defaults if probe fails
|
||||
|
||||
def fit_to_9_16_letterbox(input_path: str, top_text: str = "TOP TEXT", bottom_text: str = "BOTTOM TEXT", use_blur: bool = True, force: bool = False):
|
||||
threads = "8"
|
||||
input_file = Path(input_path)
|
||||
output_suffix = "gaussian_9_16" if use_blur else "black_9_16"
|
||||
output_path = input_file.parent / f"{input_file.stem}_{output_suffix}{input_file.suffix}"
|
||||
|
||||
if os.path.exists(output_path):
|
||||
if not force:
|
||||
print(f"❌ Error: Short video already exists: {output_path}")
|
||||
return
|
||||
else:
|
||||
os.remove(output_path)
|
||||
|
||||
# 1. Probe input metadata instantly
|
||||
orig_w, orig_h, fps = get_video_info(input_file)
|
||||
canvas_w = 1080
|
||||
canvas_h = 1920
|
||||
|
||||
print("✍️ Generating text overlay graphics via MoviePy...")
|
||||
# Render static images for text instead of running a video context
|
||||
title_clip = TextClip(
|
||||
text=top_text, font_size=55, color="white", font="DejaVuSans-Bold",
|
||||
text_align="center", size=(canvas_w - 100, 300), method="caption"
|
||||
)
|
||||
bottom_clip = TextClip(
|
||||
text=bottom_text, font_size=55, color="white", font="DejaVuSans-Bold",
|
||||
text_align="center", size=(canvas_w - 100, 300), method="caption"
|
||||
)
|
||||
|
||||
# Save text layers to temporary PNGs
|
||||
top_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
||||
bottom_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
||||
title_clip.save_frame(top_png)
|
||||
bottom_clip.save_frame(bottom_png)
|
||||
|
||||
title_clip.close()
|
||||
bottom_clip.close()
|
||||
|
||||
print("🎬 Dispatching compilation workload to FFmpeg filtergraph...")
|
||||
|
||||
# 2. Build the complex FFmpeg filtergraph
|
||||
# [0:v] is the raw input video stream
|
||||
filter_complex = []
|
||||
|
||||
if use_blur:
|
||||
# Scale height to 1920, crop center 1080x1920, apply fast boxblur (power of 3 approximates Gaussian)
|
||||
filter_complex.append(
|
||||
f"[0:v]scale=-1:{canvas_h},crop={canvas_w}:{canvas_h}:(iw-{canvas_w})/2:0,boxblur=luma_radius=35:luma_power=3[bg];"
|
||||
)
|
||||
else:
|
||||
# Generate a pure black background canvas matching video frame specs
|
||||
filter_complex.append(
|
||||
f"color=c=black:s={canvas_w}x{canvas_h}:r={fps}[bg];"
|
||||
)
|
||||
|
||||
# Scale the foreground video to a clean 1080 width, keeping aspect ratio
|
||||
filter_complex.append(
|
||||
f"[0:v]scale={canvas_w}:-1[fg];"
|
||||
)
|
||||
|
||||
# Layer composition chain:
|
||||
# Overlay 1: Put scaled foreground onto background (centered vertically)
|
||||
filter_complex.append(
|
||||
f"[bg][fg]overlay=0:(H-h)/2[tmp1];"
|
||||
)
|
||||
# Overlay 2: Drop top text asset onto position Y=180
|
||||
filter_complex.append(
|
||||
f"[tmp1][1:v]overlay=(W-w)/2:180[tmp2];"
|
||||
)
|
||||
# Overlay 3: Drop bottom text asset onto position Y=1430
|
||||
filter_complex.append(
|
||||
f"[tmp2][2:v]overlay=(W-w)/2:1430[finalv]"
|
||||
)
|
||||
|
||||
filter_graph = "".join(filter_complex)
|
||||
|
||||
# 3. Execute the native assembly command
|
||||
# -map_chapters -1 -sn: Strips unnecessary metadata chunks instantly
|
||||
# -c:a copy: Safely pulls original digital audio directly without decompression cycles
|
||||
# -threads 0: Forces FFmpeg to auto-consume all available processing cores
|
||||
ffmpeg_cmd = (
|
||||
f'ffmpeg -y -v error -i "{input_file}" -i "{top_png}" -i "{bottom_png}" '
|
||||
f'-filter_complex "{filter_graph}" '
|
||||
f'-map "[finalv]" -map 0:a? -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p '
|
||||
f'-c:a copy -map_chapters -1 -sn -threads {threads} "{output_path}"'
|
||||
)
|
||||
|
||||
try:
|
||||
utils.run_command(ffmpeg_cmd)
|
||||
print(f"🎉 High-speed processing complete! Video saved to: {output_path}")
|
||||
finally:
|
||||
# Clean up temporary PNG picture files safely
|
||||
for path in (top_png, bottom_png):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
creator_name = "greenskiesbluegrass"
|
||||
top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp"
|
||||
bottom_txt = f"Clipped By: {creator_name}."
|
||||
fit_to_9_16_letterbox("download/clips/AbnegateAgitatedGrassPJSalt/AbnegateAgitatedGrassPJSalt.mp4", top_txt, bottom_txt, True, True)
|
||||
|
||||
end_time = time.perf_counter()
|
||||
execution_time = end_time - start_time
|
||||
|
||||
print(f"The function took {execution_time:.6f} seconds to complete.")
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Combine multiple video files into one file using FFmpeg.
|
||||
|
||||
Example:
|
||||
python helper_files/combine_videos.py output.mp4 video1.mp4 video2.mp4 video3.mp4
|
||||
python helper_files/combine_videos.py --output merged.mp4 --inputs video1.mp4 video2.mp4
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_concat_list(input_files, list_path):
|
||||
with open(list_path, "w", encoding="utf-8") as f:
|
||||
for path in input_files:
|
||||
f.write(f"file '{os.path.abspath(path)}'\n")
|
||||
|
||||
|
||||
def run_ffmpeg(cmd):
|
||||
print("Running:", " ".join(cmd))
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(result.stdout)
|
||||
print(result.stderr)
|
||||
raise RuntimeError(f"FFmpeg failed with exit code {result.returncode}")
|
||||
print("✅ Finished successfully.")
|
||||
|
||||
|
||||
def concat_videos_copy(input_files, output_path):
|
||||
list_path = Path(output_path).with_suffix(".concat.txt")
|
||||
build_concat_list(input_files, list_path)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", str(list_path),
|
||||
"-c", "copy",
|
||||
str(output_path),
|
||||
]
|
||||
run_ffmpeg(cmd)
|
||||
list_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def concat_videos_reencode(input_files, output_path):
|
||||
inputs = []
|
||||
filter_parts = []
|
||||
audio_parts = []
|
||||
|
||||
for i, video_path in enumerate(input_files):
|
||||
inputs.extend(["-i", str(video_path)])
|
||||
# Use semicolons to separate different input streams in the filter graph
|
||||
filter_parts.append(f"[{i}:v]scale=trunc(iw/2)*2:trunc(ih/2)*2,setpts=PTS-STARTPTS[v{i}]")
|
||||
audio_parts.append(f"[{i}:a]aresample=async=1[a{i}]")
|
||||
|
||||
# Correctly separate individual stream filters with semicolons
|
||||
filter_complex = f"{';'.join(filter_parts)};{';'.join(audio_parts)};"
|
||||
filter_complex += "".join(f"[v{i}][a{i}]" for i in range(len(input_files)))
|
||||
filter_complex += f"concat=n={len(input_files)}:v=1:a=1[outv][outa]"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
*inputs,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[outv]",
|
||||
"-map", "[outa]",
|
||||
"-c:v", "libx264",
|
||||
"-crf", "18", # High quality video
|
||||
"-preset", "slow", # High efficiency
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k", # High quality audio
|
||||
"-movflags", "+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
run_ffmpeg(cmd)
|
||||
|
||||
|
||||
def merge_videos(input_files, output_path):
|
||||
input_files = [str(Path(p).expanduser().resolve()) for p in input_files]
|
||||
output_path = str(Path(output_path).expanduser().resolve())
|
||||
|
||||
if len(input_files) < 2:
|
||||
raise ValueError("You need at least 2 video files to merge.")
|
||||
|
||||
for path in input_files:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
output_dir = Path(output_path).parent
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
concat_videos_copy(input_files, output_path)
|
||||
except Exception as exc:
|
||||
print(f"Fast copy method failed: {exc}")
|
||||
print("Retrying with re-encode fallback...")
|
||||
concat_videos_reencode(input_files, output_path)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Join two or more video files together with FFmpeg.")
|
||||
parser.add_argument("output_file", help="Path to the merged output video")
|
||||
parser.add_argument("input_files", nargs="+", help="Two or more videos to concatenate")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
merge_videos(args.input_files, args.output_file)
|
||||
print(f"Merged video saved to: {args.output_file}")
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
import subprocess
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
|
||||
def split_video(input_file: str, output_dir: str, timestamps: List[Tuple[float, float]], frame_perfect: bool = False) -> None:
|
||||
"""
|
||||
Splits a video file into multiple segments based on specified time ranges.
|
||||
|
||||
Args:
|
||||
input_file (str): The path to the video file to be split.
|
||||
output_dir (str): The directory where the split segments will be saved.
|
||||
timestamps (List[Tuple[float, float]]): A list of tuples representing (start_time, end_time).
|
||||
frame_perfect (bool): If True, skips fast-copy and forces high-quality re-encoding
|
||||
to ensure cuts happen exactly on the requested frame.
|
||||
"""
|
||||
if not os.path.exists(input_file):
|
||||
print(f"Error: Input file not found at {input_file}")
|
||||
return
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
print(f"Starting video split process for {input_file}...")
|
||||
|
||||
for i, (start, end) in enumerate(timestamps):
|
||||
segment_name = f"segment_{i+1:02d}_{start:.2f}s_to_{end:.2f}s.mp4"
|
||||
output_file = os.path.join(output_dir, segment_name)
|
||||
|
||||
print(f"\n-> Processing segment {i+1}/{len(timestamps)}: {segment_name}")
|
||||
|
||||
# Method 1: Fast Copy (Lossless, no re-encoding)
|
||||
# Moving -ss BEFORE -i fixes the frozen/corrupted initial frames.
|
||||
fast_command = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start),
|
||||
"-i", input_file,
|
||||
"-to", str(end),
|
||||
"-c", "copy",
|
||||
output_file
|
||||
]
|
||||
|
||||
# Method 2: High-Quality Re-encode (Frame-perfect fallback)
|
||||
# Used if fast copy fails or if frame-accuracy is explicitly requested.
|
||||
reencode_command = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start),
|
||||
"-i", input_file,
|
||||
"-to", str(end),
|
||||
"-c:v", "libx264",
|
||||
"-crf", "18", # High visual quality
|
||||
"-preset", "slow", # Better compression efficiency
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k", # High-fidelity audio
|
||||
output_file
|
||||
]
|
||||
|
||||
try:
|
||||
if frame_perfect:
|
||||
print("Running in frame-perfect mode (re-encoding)...")
|
||||
subprocess.run(reencode_command, check=True, capture_output=True, text=True)
|
||||
else:
|
||||
try:
|
||||
print("Attempting lightning-fast stream copy...")
|
||||
subprocess.run(fast_command, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError:
|
||||
print("⚠️ Fast copy failed. Falling back to high-quality re-encode...")
|
||||
subprocess.run(reencode_command, check=True, capture_output=True, text=True)
|
||||
|
||||
print(f"Successfully created segment: {output_file}")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ Error splitting video segment {i+1}:")
|
||||
print(f"Command failed with return code {e.returncode}")
|
||||
print(f"STDERR: {e.stderr}")
|
||||
except FileNotFoundError:
|
||||
print("-" * 50)
|
||||
print("CRITICAL ERROR: FFmpeg executable not found.")
|
||||
print("Please ensure 'ffmpeg' is installed and accessible in your system's PATH.")
|
||||
print("-" * 50)
|
||||
return
|
||||
|
||||
def main():
|
||||
"""
|
||||
Example usage:
|
||||
Assumes 'input.mp4' exists and a 'split_videos' directory is ready.
|
||||
"""
|
||||
# --- CONFIGURATION ---
|
||||
INPUT_VIDEO_PATH = "path/to/your/input_video.mp4" # <<< CHANGE THIS
|
||||
OUTPUT_SEGMENTS_DIR = "output_splits" # <<< CHANGE THIS
|
||||
|
||||
# Define time segments: [(start_time, end_time), ...]
|
||||
# Times should be floats representing seconds.
|
||||
# Example 1: 0s to 10s, then 10s to 20s
|
||||
TIMESTAMPS: List[Tuple[float, float]] = [
|
||||
(0.0, 10.0),
|
||||
(15.0, 25.0),
|
||||
(30.0, 35.0)
|
||||
]
|
||||
# ---------------------
|
||||
|
||||
if not os.path.exists(INPUT_VIDEO_PATH):
|
||||
print("!!! IMPORTANT !!!")
|
||||
print(f"Please update the 'INPUT_VIDEO_PATH' variable in the main() function with the correct path to your video.")
|
||||
print("!!! Script execution aborted. !!!")
|
||||
return
|
||||
|
||||
split_video(
|
||||
input_file=INPUT_VIDEO_PATH,
|
||||
output_dir=OUTPUT_SEGMENTS_DIR,
|
||||
timestamps=TIMESTAMPS
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user