jebin2 commited on
Commit
6eec0dc
·
1 Parent(s): b2426a5

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 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]trim=0:3,setpts=PTS-STARTPTS,"
1170
- "scale=1080:1920:force_original_aspect_ratio=decrease,"
1171
- "pad=1080:1920:(ow-iw)/2:(oh-ih)/2[out]"
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=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2"
1177
  use_filter_complex = False
 
1178
  else:
1179
- vf_filter = "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2"
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", "-an", temp_clip_path]
 
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", "-an", temp_clip_path]
 
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
- concat_cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file_path,
1230
- "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-pix_fmt", "yuv420p",
1231
- "-r", "25", "-t", str(music_duration), "-an", output_path]
 
 
 
 
 
 
 
 
 
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)}"}