import json import os import subprocess import re # --- CONFIGURATION --- VIDS_DIR = "./vids" # Subfolder containing video and .llc files XFADE_DUR = 1.5 # Duration of crossfades between segments/videos (seconds) FADE_IN_OUT_DUR = 1.5 # Duration of global Fade In (start) and Fade Out (end) OUTPUT_FILE = "final_edited_video.mp4" # Video Encoder Options: "h264_nvenc" (NVIDIA GPU), "h264_qsv" (Intel), "h264_amf" (AMD), "libx264" (CPU) ENCODER = "hevc_nvenc" # Explicit input pairs (Targeting ./vids) INPUT_FILES = [ ( os.path.join(VIDS_DIR, "PXL_20260201_163136402.TS.mp4"), os.path.join(VIDS_DIR, "PXL_20260201_163136402.TS-proj.llc") ), ( os.path.join(VIDS_DIR, "PXL_20260201_163651197.TS.mp4"), os.path.join(VIDS_DIR, "PXL_20260201_163651197.TS-proj.llc") ), ( os.path.join(VIDS_DIR, "PXL_20260201_163930934.TS.mp4"), os.path.join(VIDS_DIR, "PXL_20260201_163930934.TS-proj.llc") ), ] def get_video_duration(video_path): """Uses ffprobe to retrieve the exact duration of an input video file.""" cmd = [ "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", video_path ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) data = json.loads(result.stdout) return float(data["format"]["duration"]) def parse_llc_content(raw_text): """Converts a JavaScript object literal into valid JSON syntax.""" # 1. Convert single-quoted strings to double-quoted strings cleaned = re.sub(r"'([^'\\]*(?:\\.[^'\\]*)*)'", r'"\1"', raw_text) # 2. Enclose unquoted keys in double quotes (e.g., version: -> "version":) cleaned = re.sub(r'([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', cleaned) # 3. Remove trailing commas before closing braces or brackets cleaned = re.sub(r',\s*([}\]])', r'\1', cleaned) return json.loads(cleaned) def get_kept_segments(llc_path, total_duration): """ Parses LosslessCut .llc project files (supports strict JSON and JS object literals). Assumes cutSegments represent discarded regions (cutouts to remove). Returns a list of kept tuples: [(kept_start, kept_end), ...] """ if not os.path.exists(llc_path): print(f"Warning: {llc_path} not found. Keeping full video.") return [(0.0, total_duration)] with open(llc_path, "r", encoding="utf-8") as f: content = f.read().strip() if not content: print(f"Warning: {llc_path} is empty. Keeping full video.") return [(0.0, total_duration)] try: data = parse_llc_content(content) except Exception as e: print(f"⚠️ Could not parse '{llc_path}': {e}. Keeping full video.") return [(0.0, total_duration)] cut_segments = data.get("cutSegments", []) if isinstance(data, dict) else [] if not cut_segments: return [(0.0, total_duration)] # Sort discarded cutouts by start time cut_segments = sorted(cut_segments, key=lambda x: float(x["start"])) kept = [] current_time = 0.0 for seg in cut_segments: cut_start = float(seg["start"]) cut_end = float(seg["end"]) # If there is video content before the cut, keep it if cut_start > current_time: kept.append((current_time, cut_start)) current_time = max(current_time, cut_end) # Keep remaining video content after the final cut if current_time < total_duration: kept.append((current_time, total_duration)) return kept def run_pipeline(): cmd = ["ffmpeg", "-y"] # Verify input files exist for video_path, llc_path in INPUT_FILES: if not os.path.exists(video_path): print(f"Error: Could not find video file at '{video_path}'") return cmd.extend(["-i", video_path]) # Extract and collect kept segments across all videos all_segments = [] for file_idx, (video_path, llc_path) in enumerate(INPUT_FILES): duration = get_video_duration(video_path) kept = get_kept_segments(llc_path, duration) for start, end in kept: seg_dur = end - start if seg_dur > XFADE_DUR: # Segment must be longer than xfade duration all_segments.append({ "file_idx": file_idx, "start": start, "end": end, "duration": seg_dur }) if not all_segments: print("Error: No valid kept segments found.") return print(f"Extracted {len(all_segments)} kept segment(s) from input files in '{VIDS_DIR}'.") filter_chains = [] # 1. Create trim filters for each kept segment for i, seg in enumerate(all_segments): f_idx = seg["file_idx"] s, e = seg["start"], seg["end"] filter_chains.append(f"[{f_idx}:v]trim=start={s:.3f}:end={e:.3f},setpts=PTS-STARTPTS[v{i}];") filter_chains.append(f"[{f_idx}:a]atrim=start={s:.3f}:end={e:.3f},asetpts=PTS-STARTPTS[a{i}];") # 2. Chain crossfades sequentially across all segments current_v = "v0" current_a = "a0" cumulative_dur = all_segments[0]["duration"] for i in range(1, len(all_segments)): next_v, next_a = f"v{i}", f"a{i}" offset = cumulative_dur - XFADE_DUR out_v, out_a = f"vc{i}", f"ac{i}" filter_chains.append( f"[{current_v}][{next_v}]xfade=transition=fade:duration={XFADE_DUR}:offset={offset:.3f}[{out_v}];" ) filter_chains.append( f"[{current_a}][{next_a}]acrossfade=d={XFADE_DUR}[{out_a}];" ) current_v, current_a = out_v, out_a cumulative_dur += all_segments[i]["duration"] - XFADE_DUR # 3. Apply global Fade In at start and Fade Out at end final_v, final_a = "outv", "outa" fade_out_start = max(0.0, cumulative_dur - FADE_IN_OUT_DUR) filter_chains.append( f"[{current_v}]fade=t=in:st=0:d={FADE_IN_OUT_DUR},fade=t=out:st={fade_out_start:.3f}:d={FADE_IN_OUT_DUR}[{final_v}];" ) filter_chains.append( f"[{current_a}]afade=t=in:st=0:d={FADE_IN_OUT_DUR},afade=t=out:st={fade_out_start:.3f}:d={FADE_IN_OUT_DUR}[{final_a}]" ) # 4. Execute FFmpeg Command filtergraph = " ".join(filter_chains) cmd.extend([ "-filter_complex", filtergraph, "-map", f"[{final_v}]", "-map", f"[{final_a}]", # --- Video Settings --- "-c:v", ENCODER, "-tag:v", "hvc1", # Ensures standard MP4 HEVC metadata playback "-b:v", "20M", # Targets Pixel 7a ~20 Mbps bitrate "-maxrate", "25M", "-bufsize", "30M", "-pix_fmt", "yuv420p", "-color_primaries", "bt709", # Maintains Pixel 7a BT.709 color accuracy "-color_trc", "bt709", "-colorspace", "bt709", "-preset", "p4" if "nvenc" in ENCODER else "fast", # --- Audio Settings --- "-c:a", "aac", "-ar", "48000", # Matches 48 kHz source audio sampling "-b:a", "192k", # Matches 192 kbps audio bitrate OUTPUT_FILE ]) print(f"Estimated final output duration: {cumulative_dur:.2f} seconds.") print("Running FFmpeg pipeline...") subprocess.run(cmd, check=True) print(f"Done! Saved output to: {OUTPUT_FILE}") if __name__ == "__main__": run_pipeline()