fix: trim black frames from video start/end and improve concat
Browse files- Add trim_black_frames() utility to detect and remove black intro/outro frames
- Integrate black frame trimming in asset download, AI SDK, and video analysis
- Improve video_renderer vf_filter with consistent properties (setsar, format, crop)
- Use stream copy for concat with +genpts and avoid_negative_ts flags
- Add video_track_timescale for consistent timing across clips
- src/asset_manager/asset_downloader.py +4 -2
- src/google_src/ai_studio_sdk.py +1 -0
- src/utils.py +200 -0
- src/video_renderer.py +32 -12
- video_analysis/app.py +4 -1
src/asset_manager/asset_downloader.py
CHANGED
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
| 9 |
from typing import Dict, List, Optional, Any
|
| 10 |
from urllib.parse import urlparse
|
| 11 |
|
| 12 |
-
from src.utils import logger, is_valid_video, resize_video, remove_black_padding
|
| 13 |
from file_downloader import get_file_downloader
|
| 14 |
from src.config import get_config_value
|
| 15 |
from .video_lib import get_video_lib, VideoLib
|
|
@@ -125,6 +125,7 @@ class AssetDownloader:
|
|
| 125 |
for video in downloaded_videos:
|
| 126 |
local_path = video["local_path"]
|
| 127 |
try:
|
|
|
|
| 128 |
remove_black_padding(local_path, overwrite=True)
|
| 129 |
resize_video(local_path, overwrite=True)
|
| 130 |
videos.append(video)
|
|
@@ -237,7 +238,8 @@ class AssetDownloader:
|
|
| 237 |
|
| 238 |
if not local_path:
|
| 239 |
raise Exception(f"Download returned None for {url}")
|
| 240 |
-
|
|
|
|
| 241 |
if remove_padding:
|
| 242 |
remove_black_padding(str(local_path), overwrite=True)
|
| 243 |
if resize:
|
|
|
|
| 9 |
from typing import Dict, List, Optional, Any
|
| 10 |
from urllib.parse import urlparse
|
| 11 |
|
| 12 |
+
from src.utils import logger, is_valid_video, resize_video, remove_black_padding, trim_black_frames
|
| 13 |
from file_downloader import get_file_downloader
|
| 14 |
from src.config import get_config_value
|
| 15 |
from .video_lib import get_video_lib, VideoLib
|
|
|
|
| 125 |
for video in downloaded_videos:
|
| 126 |
local_path = video["local_path"]
|
| 127 |
try:
|
| 128 |
+
trim_black_frames(local_path, overwrite=True)
|
| 129 |
remove_black_padding(local_path, overwrite=True)
|
| 130 |
resize_video(local_path, overwrite=True)
|
| 131 |
videos.append(video)
|
|
|
|
| 238 |
|
| 239 |
if not local_path:
|
| 240 |
raise Exception(f"Download returned None for {url}")
|
| 241 |
+
|
| 242 |
+
trim_black_frames(str(local_path), overwrite=True)
|
| 243 |
if remove_padding:
|
| 244 |
remove_black_padding(str(local_path), overwrite=True)
|
| 245 |
if resize:
|
src/google_src/ai_studio_sdk.py
CHANGED
|
@@ -78,6 +78,7 @@ def generate_video(prompt: str, output_path: str, image: str = None) -> str | No
|
|
| 78 |
client.files.download(file=generated_video.video)
|
| 79 |
|
| 80 |
generated_video.video.save(output_path)
|
|
|
|
| 81 |
utils.remove_black_padding(output_path, overwrite=True)
|
| 82 |
utils.resize_video(output_path, overwrite=True)
|
| 83 |
print(output_path)
|
|
|
|
| 78 |
client.files.download(file=generated_video.video)
|
| 79 |
|
| 80 |
generated_video.video.save(output_path)
|
| 81 |
+
utils.trim_black_frames(output_path, overwrite=True)
|
| 82 |
utils.remove_black_padding(output_path, overwrite=True)
|
| 83 |
utils.resize_video(output_path, overwrite=True)
|
| 84 |
print(output_path)
|
src/utils.py
CHANGED
|
@@ -841,6 +841,206 @@ def remove_black_padding(input_path: str, overwrite: bool = False, threshold_pct
|
|
| 841 |
|
| 842 |
return tmp_output
|
| 843 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
def ratio_1x1_to9x16(video_path, overwrite=False):
|
| 845 |
"""
|
| 846 |
Convert a 1:1 video to 9:16 by adding blurred padding using FFmpeg.
|
|
|
|
| 841 |
|
| 842 |
return tmp_output
|
| 843 |
|
| 844 |
+
def trim_black_frames(
|
| 845 |
+
input_path: str,
|
| 846 |
+
overwrite: bool = False,
|
| 847 |
+
black_threshold: int = 20,
|
| 848 |
+
min_frames_to_trim: int = 1,
|
| 849 |
+
max_frames_to_trim: int = 30
|
| 850 |
+
) -> str:
|
| 851 |
+
"""
|
| 852 |
+
Detect and remove solid black frames from the start and end of a video.
|
| 853 |
+
|
| 854 |
+
Uses FFmpeg showinfo filter to analyze frame luminance (Y channel mean).
|
| 855 |
+
A frame is considered black if its Y mean is <= black_threshold.
|
| 856 |
+
|
| 857 |
+
Args:
|
| 858 |
+
input_path: Path to the input video
|
| 859 |
+
overwrite: If True, replace the original file
|
| 860 |
+
black_threshold: Maximum Y luminance value to consider a frame as black (0-255)
|
| 861 |
+
Default 20 catches pure black (16) with some tolerance
|
| 862 |
+
min_frames_to_trim: Minimum black frames at start/end to trigger trimming
|
| 863 |
+
max_frames_to_trim: Maximum frames to check at start/end
|
| 864 |
+
|
| 865 |
+
Returns:
|
| 866 |
+
Path to the trimmed video, or original path if no trimming needed
|
| 867 |
+
"""
|
| 868 |
+
if not os.path.exists(input_path):
|
| 869 |
+
raise FileNotFoundError(f"Input video not found: {input_path}")
|
| 870 |
+
|
| 871 |
+
# Get video info
|
| 872 |
+
probe_cmd = [
|
| 873 |
+
"ffprobe", "-v", "error",
|
| 874 |
+
"-select_streams", "v:0",
|
| 875 |
+
"-show_entries", "stream=nb_frames,r_frame_rate,duration",
|
| 876 |
+
"-show_entries", "format=duration",
|
| 877 |
+
"-of", "json", input_path
|
| 878 |
+
]
|
| 879 |
+
probe_result = subprocess.run(probe_cmd, capture_output=True, text=True)
|
| 880 |
+
|
| 881 |
+
if probe_result.returncode != 0:
|
| 882 |
+
logger.warning(f"Failed to probe video: {input_path}")
|
| 883 |
+
return input_path
|
| 884 |
+
|
| 885 |
+
probe_data = json.loads(probe_result.stdout)
|
| 886 |
+
|
| 887 |
+
# Get FPS
|
| 888 |
+
fps_str = probe_data.get("streams", [{}])[0].get("r_frame_rate", "25/1")
|
| 889 |
+
fps_parts = fps_str.split("/")
|
| 890 |
+
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else float(fps_parts[0])
|
| 891 |
+
|
| 892 |
+
# Get total duration
|
| 893 |
+
duration = float(probe_data.get("format", {}).get("duration", 0))
|
| 894 |
+
if duration == 0:
|
| 895 |
+
duration = float(probe_data.get("streams", [{}])[0].get("duration", 0))
|
| 896 |
+
|
| 897 |
+
if duration <= 0:
|
| 898 |
+
logger.warning(f"Could not determine video duration: {input_path}")
|
| 899 |
+
return input_path
|
| 900 |
+
|
| 901 |
+
# Analyze first N frames for black frames at start
|
| 902 |
+
start_black_frames = _count_black_frames_at_position(
|
| 903 |
+
input_path, "start", max_frames_to_trim, black_threshold, fps
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
# Analyze last N frames for black frames at end
|
| 907 |
+
end_black_frames = _count_black_frames_at_position(
|
| 908 |
+
input_path, "end", max_frames_to_trim, black_threshold, fps, duration
|
| 909 |
+
)
|
| 910 |
+
|
| 911 |
+
logger.info(f"🎬 Black frame analysis: start={start_black_frames}, end={end_black_frames}")
|
| 912 |
+
|
| 913 |
+
# Check if trimming is needed
|
| 914 |
+
if start_black_frames < min_frames_to_trim and end_black_frames < min_frames_to_trim:
|
| 915 |
+
logger.info(f"✅ No black frames to trim in: {os.path.basename(input_path)}")
|
| 916 |
+
return input_path
|
| 917 |
+
|
| 918 |
+
# Calculate trim times
|
| 919 |
+
start_trim_time = start_black_frames / fps if start_black_frames >= min_frames_to_trim else 0
|
| 920 |
+
end_trim_time = end_black_frames / fps if end_black_frames >= min_frames_to_trim else 0
|
| 921 |
+
|
| 922 |
+
# New duration after trimming
|
| 923 |
+
new_duration = duration - start_trim_time - end_trim_time
|
| 924 |
+
|
| 925 |
+
if new_duration <= 0.1:
|
| 926 |
+
logger.warning(f"⚠️ Trimming would remove entire video, skipping: {input_path}")
|
| 927 |
+
return input_path
|
| 928 |
+
|
| 929 |
+
logger.info(
|
| 930 |
+
f"✂️ Trimming black frames: {os.path.basename(input_path)} "
|
| 931 |
+
f"(start: {start_trim_time:.3f}s, end: {end_trim_time:.3f}s)"
|
| 932 |
+
)
|
| 933 |
+
|
| 934 |
+
# Generate output path
|
| 935 |
+
temp_output = os.path.join("/tmp", f"{uuid.uuid4().hex}_trimmed.mp4")
|
| 936 |
+
|
| 937 |
+
# Build FFmpeg command
|
| 938 |
+
cmd = [
|
| 939 |
+
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
| 940 |
+
"-ss", str(start_trim_time),
|
| 941 |
+
"-i", input_path,
|
| 942 |
+
"-t", str(new_duration),
|
| 943 |
+
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
| 944 |
+
"-pix_fmt", "yuv420p",
|
| 945 |
+
"-c:a", "copy",
|
| 946 |
+
temp_output
|
| 947 |
+
]
|
| 948 |
+
|
| 949 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 950 |
+
|
| 951 |
+
if result.returncode != 0:
|
| 952 |
+
logger.error(f"FFmpeg trim failed: {result.stderr}")
|
| 953 |
+
return input_path
|
| 954 |
+
|
| 955 |
+
logger.info(f"✅ Trimmed video saved: {temp_output}")
|
| 956 |
+
|
| 957 |
+
# Handle overwrite
|
| 958 |
+
if overwrite:
|
| 959 |
+
shutil.move(temp_output, input_path)
|
| 960 |
+
return input_path
|
| 961 |
+
|
| 962 |
+
return temp_output
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
def _count_black_frames_at_position(
|
| 966 |
+
video_path: str,
|
| 967 |
+
position: str, # "start" or "end"
|
| 968 |
+
max_frames: int,
|
| 969 |
+
black_threshold: int,
|
| 970 |
+
fps: float,
|
| 971 |
+
duration: float = 0
|
| 972 |
+
) -> int:
|
| 973 |
+
"""
|
| 974 |
+
Count consecutive black frames at the start or end of a video.
|
| 975 |
+
|
| 976 |
+
Args:
|
| 977 |
+
video_path: Path to video file
|
| 978 |
+
position: "start" or "end"
|
| 979 |
+
max_frames: Maximum frames to analyze
|
| 980 |
+
black_threshold: Y luminance threshold for black detection
|
| 981 |
+
fps: Video frame rate
|
| 982 |
+
duration: Video duration (required for "end" position)
|
| 983 |
+
|
| 984 |
+
Returns:
|
| 985 |
+
Number of consecutive black frames at the specified position
|
| 986 |
+
"""
|
| 987 |
+
# For start: analyze first max_frames frames
|
| 988 |
+
# For end: seek to near end and analyze last max_frames frames
|
| 989 |
+
if position == "end" and duration > 0:
|
| 990 |
+
seek_time = max(0, duration - (max_frames / fps) - 0.5)
|
| 991 |
+
ss_arg = ["-ss", str(seek_time)]
|
| 992 |
+
else:
|
| 993 |
+
ss_arg = []
|
| 994 |
+
|
| 995 |
+
# Use showinfo filter to get frame luminance
|
| 996 |
+
cmd = [
|
| 997 |
+
"ffmpeg", "-hide_banner",
|
| 998 |
+
*ss_arg,
|
| 999 |
+
"-i", video_path,
|
| 1000 |
+
"-vf", f"select='lte(n,{max_frames})',showinfo",
|
| 1001 |
+
"-frames:v", str(max_frames + 5),
|
| 1002 |
+
"-f", "null", "-"
|
| 1003 |
+
]
|
| 1004 |
+
|
| 1005 |
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
| 1006 |
+
|
| 1007 |
+
if result.returncode != 0:
|
| 1008 |
+
return 0
|
| 1009 |
+
|
| 1010 |
+
# Parse showinfo output for mean values
|
| 1011 |
+
# Format: mean:[Y U V] where Y is luminance
|
| 1012 |
+
# A pure black frame has Y=16 in YUV (limited range)
|
| 1013 |
+
frame_means = []
|
| 1014 |
+
for line in result.stderr.split('\n'):
|
| 1015 |
+
match = re.search(r'mean:\[(\d+)\s+\d+\s+\d+\]', line)
|
| 1016 |
+
if match:
|
| 1017 |
+
y_mean = int(match.group(1))
|
| 1018 |
+
frame_means.append(y_mean)
|
| 1019 |
+
|
| 1020 |
+
if not frame_means:
|
| 1021 |
+
return 0
|
| 1022 |
+
|
| 1023 |
+
# Count consecutive black frames
|
| 1024 |
+
if position == "start":
|
| 1025 |
+
# Count from beginning
|
| 1026 |
+
black_count = 0
|
| 1027 |
+
for y_mean in frame_means:
|
| 1028 |
+
if y_mean <= black_threshold:
|
| 1029 |
+
black_count += 1
|
| 1030 |
+
else:
|
| 1031 |
+
break
|
| 1032 |
+
return black_count
|
| 1033 |
+
else:
|
| 1034 |
+
# Count from end (reverse)
|
| 1035 |
+
black_count = 0
|
| 1036 |
+
for y_mean in reversed(frame_means):
|
| 1037 |
+
if y_mean <= black_threshold:
|
| 1038 |
+
black_count += 1
|
| 1039 |
+
else:
|
| 1040 |
+
break
|
| 1041 |
+
return black_count
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
def ratio_1x1_to9x16(video_path, overwrite=False):
|
| 1045 |
"""
|
| 1046 |
Convert a 1:1 video to 9:16 by adding blurred padding using FFmpeg.
|
src/video_renderer.py
CHANGED
|
@@ -1162,33 +1162,43 @@ class VideoRenderer:
|
|
| 1162 |
temp_clip_path = os.path.abspath(str(self.temp_dir / f"clip_{video_idx+1:03d}.mp4"))
|
| 1163 |
|
| 1164 |
# Determine filter
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
if loop_short_videos and video_duration_src < 4:
|
|
|
|
| 1166 |
vf_filter = (
|
| 1167 |
"[0:v]split=2[a][b];[b]reverse[br];[a][br]concat=n=2:v=1:a=0[loop1];"
|
| 1168 |
"[loop1]split=2[c][d];[d]reverse[dr];[c][dr]concat=n=2:v=1:a=0[looped];"
|
| 1169 |
-
"[looped]
|
| 1170 |
-
"scale=1080:1920:force_original_aspect_ratio=
|
| 1171 |
-
"
|
| 1172 |
)
|
| 1173 |
use_filter_complex = True
|
|
|
|
|
|
|
|
|
|
| 1174 |
elif video_duration_src < target_duration:
|
| 1175 |
loop_count = int(target_duration / video_duration_src) + 1
|
| 1176 |
-
vf_filter = f"loop={loop_count}:size=999:start=0,scale=1080:1920:force_original_aspect_ratio=
|
| 1177 |
use_filter_complex = False
|
|
|
|
| 1178 |
else:
|
| 1179 |
-
vf_filter = "scale=1080:1920:force_original_aspect_ratio=
|
| 1180 |
use_filter_complex = False
|
| 1181 |
-
|
| 1182 |
-
trim_duration = min(target_duration, 3.0 if loop_short_videos and video_duration_src < 4 else video_duration_src)
|
| 1183 |
|
| 1184 |
if use_filter_complex:
|
| 1185 |
cmd = ["ffmpeg", "-y", "-i", video_path, "-filter_complex", vf_filter,
|
| 1186 |
"-map", "[out]", "-t", str(trim_duration), "-c:v", "libx264",
|
| 1187 |
-
"-preset", "ultrafast", "-r", "25", "-pix_fmt", "yuv420p",
|
|
|
|
| 1188 |
else:
|
| 1189 |
cmd = ["ffmpeg", "-y", "-i", video_path, "-t", str(trim_duration),
|
| 1190 |
"-vf", vf_filter, "-c:v", "libx264", "-preset", "ultrafast",
|
| 1191 |
-
"-r", "25", "-pix_fmt", "yuv420p",
|
|
|
|
| 1192 |
|
| 1193 |
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
| 1194 |
|
|
@@ -1226,9 +1236,18 @@ class VideoRenderer:
|
|
| 1226 |
|
| 1227 |
output_path = os.path.abspath(str(self.temp_dir / f"merged_{uuid.uuid4().hex[:8]}.mp4"))
|
| 1228 |
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1232 |
|
| 1233 |
logger.info(f"🎬 Merging {len(temp_clips)} clips...")
|
| 1234 |
result = subprocess.run(concat_cmd, capture_output=True, text=True, timeout=120)
|
|
@@ -1241,6 +1260,7 @@ class VideoRenderer:
|
|
| 1241 |
return output_path
|
| 1242 |
|
| 1243 |
finally:
|
|
|
|
| 1244 |
for clip_path in temp_clips:
|
| 1245 |
try:
|
| 1246 |
if os.path.exists(clip_path):
|
|
|
|
| 1162 |
temp_clip_path = os.path.abspath(str(self.temp_dir / f"clip_{video_idx+1:03d}.mp4"))
|
| 1163 |
|
| 1164 |
# Determine filter
|
| 1165 |
+
# IMPORTANT: All clips must have identical properties to avoid black frames during concat
|
| 1166 |
+
# - setsar=1:1 ensures consistent sample aspect ratio
|
| 1167 |
+
# - format=yuv420p ensures consistent pixel format
|
| 1168 |
+
# - fps=25 ensures consistent frame rate
|
| 1169 |
if loop_short_videos and video_duration_src < 4:
|
| 1170 |
+
# Ping-pong loop (Forward-Reverse-Forward-Reverse) -> 4x duration
|
| 1171 |
vf_filter = (
|
| 1172 |
"[0:v]split=2[a][b];[b]reverse[br];[a][br]concat=n=2:v=1:a=0[loop1];"
|
| 1173 |
"[loop1]split=2[c][d];[d]reverse[dr];[c][dr]concat=n=2:v=1:a=0[looped];"
|
| 1174 |
+
"[looped]setpts=PTS-STARTPTS,"
|
| 1175 |
+
"scale=1080:1920:force_original_aspect_ratio=increase,"
|
| 1176 |
+
"crop=1080:1920,setsar=1:1,format=yuv420p[out]"
|
| 1177 |
)
|
| 1178 |
use_filter_complex = True
|
| 1179 |
+
# Allow utilizing the full 4x duration if needed
|
| 1180 |
+
max_possible = video_duration_src * 4
|
| 1181 |
+
trim_duration = min(target_duration, max_possible)
|
| 1182 |
elif video_duration_src < target_duration:
|
| 1183 |
loop_count = int(target_duration / video_duration_src) + 1
|
| 1184 |
+
vf_filter = f"loop={loop_count}:size=999:start=0,setpts=PTS-STARTPTS,scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1:1,format=yuv420p"
|
| 1185 |
use_filter_complex = False
|
| 1186 |
+
trim_duration = target_duration
|
| 1187 |
else:
|
| 1188 |
+
vf_filter = "setpts=PTS-STARTPTS,scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1:1,format=yuv420p"
|
| 1189 |
use_filter_complex = False
|
| 1190 |
+
trim_duration = min(target_duration, video_duration_src)
|
|
|
|
| 1191 |
|
| 1192 |
if use_filter_complex:
|
| 1193 |
cmd = ["ffmpeg", "-y", "-i", video_path, "-filter_complex", vf_filter,
|
| 1194 |
"-map", "[out]", "-t", str(trim_duration), "-c:v", "libx264",
|
| 1195 |
+
"-preset", "ultrafast", "-r", "25", "-pix_fmt", "yuv420p",
|
| 1196 |
+
"-video_track_timescale", "12800", "-an", temp_clip_path]
|
| 1197 |
else:
|
| 1198 |
cmd = ["ffmpeg", "-y", "-i", video_path, "-t", str(trim_duration),
|
| 1199 |
"-vf", vf_filter, "-c:v", "libx264", "-preset", "ultrafast",
|
| 1200 |
+
"-r", "25", "-pix_fmt", "yuv420p",
|
| 1201 |
+
"-video_track_timescale", "12800", "-an", temp_clip_path]
|
| 1202 |
|
| 1203 |
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
| 1204 |
|
|
|
|
| 1236 |
|
| 1237 |
output_path = os.path.abspath(str(self.temp_dir / f"merged_{uuid.uuid4().hex[:8]}.mp4"))
|
| 1238 |
|
| 1239 |
+
# Use stream copy since all clips are already encoded with identical properties
|
| 1240 |
+
# This avoids re-encoding artifacts and timing issues that cause black frames
|
| 1241 |
+
concat_cmd = [
|
| 1242 |
+
"ffmpeg", "-y",
|
| 1243 |
+
"-fflags", "+genpts", # Generate fresh PTS for clean concatenation
|
| 1244 |
+
"-f", "concat", "-safe", "0", "-i", concat_file_path,
|
| 1245 |
+
"-c", "copy", # Stream copy - no re-encoding
|
| 1246 |
+
"-avoid_negative_ts", "make_zero", # Fix timestamp issues at clip boundaries
|
| 1247 |
+
"-t", str(music_duration),
|
| 1248 |
+
"-an",
|
| 1249 |
+
output_path
|
| 1250 |
+
]
|
| 1251 |
|
| 1252 |
logger.info(f"🎬 Merging {len(temp_clips)} clips...")
|
| 1253 |
result = subprocess.run(concat_cmd, capture_output=True, text=True, timeout=120)
|
|
|
|
| 1260 |
return output_path
|
| 1261 |
|
| 1262 |
finally:
|
| 1263 |
+
# Clean up temp clips
|
| 1264 |
for clip_path in temp_clips:
|
| 1265 |
try:
|
| 1266 |
if os.path.exists(clip_path):
|
video_analysis/app.py
CHANGED
|
@@ -204,6 +204,7 @@ def sync_videos_from_drive_recursive(service, downloader, folder_id: str, downlo
|
|
| 204 |
|
| 205 |
# STRICT ENFORCEMENT: Convert to 9:16 immediately (Sequential)
|
| 206 |
try:
|
|
|
|
| 207 |
resize_video(str(dest_path), overwrite=True)
|
| 208 |
except Exception as e:
|
| 209 |
print(f"⚠️ Conversion failed for {file_name}: {e}")
|
|
@@ -275,6 +276,7 @@ def sync_videos_from_local_recursive(download_path: Path, source_folder: Path):
|
|
| 275 |
|
| 276 |
# STRICT ENFORCEMENT: Convert to 9:16 immediately
|
| 277 |
try:
|
|
|
|
| 278 |
resize_video(str(dest_path), overwrite=True)
|
| 279 |
except Exception as e:
|
| 280 |
print(f"⚠️ Conversion failed for {file_name}: {e}")
|
|
@@ -406,7 +408,7 @@ def get_uploaded_videos(sheet_id: str = None) -> set:
|
|
| 406 |
print(f"Error getting uploaded videos: {e}")
|
| 407 |
return set()
|
| 408 |
|
| 409 |
-
from src.utils import resize_video
|
| 410 |
from src.google_src.drive_utils import search_file_by_name, get_drive_service, extract_drive_file_id, upload_file_to_drive, update_file_content
|
| 411 |
|
| 412 |
def upload_to_video_library(video_path: str, sheet_id: str = None, upload_folder_id: str = None) -> dict:
|
|
@@ -419,6 +421,7 @@ def upload_to_video_library(video_path: str, sheet_id: str = None, upload_folder
|
|
| 419 |
# 0. Ensure Local is 9:16 (Strict Enforcement)
|
| 420 |
try:
|
| 421 |
# This overwrites the local file with 9:16 version if needed
|
|
|
|
| 422 |
video_path = resize_video(video_path, overwrite=True)
|
| 423 |
except Exception as e:
|
| 424 |
return {"success": False, "uploaded": False, "message": f"Conversion failed: {str(e)}"}
|
|
|
|
| 204 |
|
| 205 |
# STRICT ENFORCEMENT: Convert to 9:16 immediately (Sequential)
|
| 206 |
try:
|
| 207 |
+
trim_black_frames(str(dest_path), overwrite=True)
|
| 208 |
resize_video(str(dest_path), overwrite=True)
|
| 209 |
except Exception as e:
|
| 210 |
print(f"⚠️ Conversion failed for {file_name}: {e}")
|
|
|
|
| 276 |
|
| 277 |
# STRICT ENFORCEMENT: Convert to 9:16 immediately
|
| 278 |
try:
|
| 279 |
+
trim_black_frames(str(dest_path), overwrite=True)
|
| 280 |
resize_video(str(dest_path), overwrite=True)
|
| 281 |
except Exception as e:
|
| 282 |
print(f"⚠️ Conversion failed for {file_name}: {e}")
|
|
|
|
| 408 |
print(f"Error getting uploaded videos: {e}")
|
| 409 |
return set()
|
| 410 |
|
| 411 |
+
from src.utils import resize_video, trim_black_frames
|
| 412 |
from src.google_src.drive_utils import search_file_by_name, get_drive_service, extract_drive_file_id, upload_file_to_drive, update_file_content
|
| 413 |
|
| 414 |
def upload_to_video_library(video_path: str, sheet_id: str = None, upload_folder_id: str = None) -> dict:
|
|
|
|
| 421 |
# 0. Ensure Local is 9:16 (Strict Enforcement)
|
| 422 |
try:
|
| 423 |
# This overwrites the local file with 9:16 version if needed
|
| 424 |
+
trim_black_frames(video_path, overwrite=True)
|
| 425 |
video_path = resize_video(video_path, overwrite=True)
|
| 426 |
except Exception as e:
|
| 427 |
return {"success": False, "uploaded": False, "message": f"Conversion failed: {str(e)}"}
|