import os import sys import json import uuid import subprocess from datetime import datetime from collections import defaultdict # --- CONFIGURATION --- CROSSFADE_SEC = 1.5 FADE_IN_SEC = 1.5 FADE_OUT_SEC = 1.5 MIN_CLIP_DURATION = 5.0 # Skip clips under 5 seconds TITLE_START_SEC = 5.0 TITLE_HOLD_SEC = 5.0 TITLE_FADE_SEC = 1.5 def get_video_info(file_path): """Extracts duration, resolution, frame rate, stream metadata, and creation date using ffprobe.""" cmd = [ "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", file_path ] try: result = subprocess.run(cmd, capture_output=True, text=True, check=True) data = json.loads(result.stdout) duration = float(data['format']['duration']) creation_str = data['format'].get('tags', {}).get('creation_time') if creation_str: dt = datetime.fromisoformat(creation_str.replace('Z', '+00:00')) else: dt = datetime.fromtimestamp(os.path.getmtime(file_path)) streams = data.get('streams', []) v_stream = next((s for s in streams if s.get('codec_type') == 'video'), {}) a_stream = next((s for s in streams if s.get('codec_type') == 'audio'), {}) width = int(v_stream.get('width', 1920)) height = int(v_stream.get('height', 1080)) fps_str = v_stream.get('r_frame_rate', '30/1') num, den = map(int, fps_str.split('/')) fps_val = num / den if den != 0 else 30.0 return { "duration": duration, "dt": dt, "width": width, "height": height, "fps_val": fps_val, "vcodec": v_stream.get('codec_name', 'h264'), "acodec": a_stream.get('codec_name', 'aac'), "video_stream_index": v_stream.get('index', 0), "audio_stream_index": a_stream.get('index', 1) } except Exception as e: print(f"Error reading {file_path}: {e}") return None def normalize_fps(fps_val): """Normalizes Variable Frame Rates (VFR) to standard project rates.""" if fps_val > 45.0: return {"num": 60, "den": 1, "val": 60.0} elif fps_val > 27.0: return {"num": 30, "den": 1, "val": 30.0} elif fps_val > 22.0: return {"num": 24, "den": 1, "val": 24.0} else: return {"num": int(round(fps_val)), "den": 1, "val": float(round(fps_val))} def generate_title_svg(output_path, event_name, date_str): """Generates an SVG lower-third title card using hex escapes for clean copying.""" svg_content = f"""\x3csvg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"\x3e \x3cstyle\x3e .title {{ font-family: 'Arial', sans-serif; font-size: 52px; fill: white; font-weight: bold; text-anchor: middle; }} .subtitle {{ font-family: 'Arial', sans-serif; font-size: 34px; fill: #E0E0E0; text-anchor: middle; }} \x3c/style\x3e \x3cfilter id="shadow"\x3e \x3cfeDropShadow dx="2" dy="2" stdDeviation="3" flood-color="black" flood-opacity="0.8"/\x3e \x3c/filter\x3e \x3cg filter="url(#shadow)"\x3e \x3ctext x="960" y="920" class="title"\x3e{event_name}\x3c/text\x3e \x3ctext x="960" y="970" class="subtitle"\x3e{date_str}\x3c/text\x3e \x3c/g\x3e \x3c/svg\x3e""" with open(output_path, "w", encoding="utf-8") as f: f.write(svg_content) def make_keyframe(val): """Generates OpenShot's standard keyframe curve structure with required handle_type.""" return { "Points": [ { "co": {"X": 1.0, "Y": float(val)}, "interpolation": 0, "handle_type": 0, "handle_left": {"X": 0.5, "Y": 0.5}, "handle_right": {"X": 0.5, "Y": 0.5} } ] } def make_keyframe_points(points_sec, fps): """Converts (seconds, alpha) tuples into valid OpenShot JSON keyframes.""" pts = [] for t, alpha in points_sec: frame = max(1, int(round(t * fps)) + 1) pts.append({ "co": {"X": float(frame), "Y": float(alpha)}, "interpolation": 0, "handle_type": 0, "handle_left": {"X": 0.5, "Y": 0.5}, "handle_right": {"X": 0.5, "Y": 0.5} }) return {"Points": pts} def create_openshot_clip(file_id, file_reader, position, start, end, layer=1, title=""): """Creates a fully compliant OpenShot clip object matching native 3.x schema.""" clip_duration = float(end - start) return { "id": str(uuid.uuid4()), "file_id": file_id, "title": title, "position": position, "start": start, "end": end, "duration": clip_duration, "layer": layer, "reader": file_reader, # --- Hierarchy & Orientation Attributes --- "parentObjectId": "", "reader_orientation_mode": "reader", # --- Mandatory Keyframe Property Dictionaries --- "location_x": make_keyframe(0.0), "location_y": make_keyframe(0.0), "scale_x": make_keyframe(1.0), "scale_y": make_keyframe(1.0), "scale": 1, # Best Fit "rotation": make_keyframe(0.0), "shear_x": make_keyframe(0.0), "shear_y": make_keyframe(0.0), "origin_x": make_keyframe(0.5), "origin_y": make_keyframe(0.5), "corner_radius": make_keyframe(0.0), "margin": make_keyframe(0.0), "alpha": make_keyframe(1.0), "volume": make_keyframe(1.0), "time": make_keyframe(1.0), # --- Perspective Keyframes --- "perspective_c1_x": make_keyframe(-0.5), "perspective_c1_y": make_keyframe(-0.5), "perspective_c2_x": make_keyframe(0.5), "perspective_c2_y": make_keyframe(-0.5), "perspective_c3_x": make_keyframe(-0.5), "perspective_c3_y": make_keyframe(0.5), "perspective_c4_x": make_keyframe(0.5), "perspective_c4_y": make_keyframe(0.5), # --- Audio, Channel & Display Defaults --- "channel_filter": make_keyframe(-1.0), "channel_mapping": make_keyframe(-1.0), "has_audio": make_keyframe(-1.0), # -1.0 default sentinel for enabled stream "has_video": make_keyframe(-1.0), # -1.0 default sentinel for enabled stream "waveform": False, "waveform_mode": 1, "wave_color": { "red": make_keyframe(0.0), "green": make_keyframe(123.0), "blue": make_keyframe(255.0), "alpha": make_keyframe(255.0) }, "anchor": 0, "gravity": 4, # Center alignment "composite": 0, "mixing": 0, "display": 0 } def create_osp_project(date_str, event_name, video_clips, output_dir): """Assembles OpenShot project JSON with standard schema and normalized FPS.""" os.makedirs(output_dir, exist_ok=True) max_raw_fps = max(c["fps_val"] for c in video_clips) fps_info = normalize_fps(max_raw_fps) master_fps = fps_info["val"] max_width = max(c["width"] for c in video_clips) max_height = max(c["height"] for c in video_clips) # Generate Title SVG title_svg_path = os.path.abspath(os.path.join(output_dir, f"title_{date_str}.svg")) formatted_date = datetime.strptime(date_str, "%Y-%m-%d").strftime("%B %d, %Y") generate_title_svg(title_svg_path, event_name, formatted_date) osp_files = [] osp_clips = [] # 1. Title File & Reader Setup title_file_id = str(uuid.uuid4()) title_dur = TITLE_FADE_SEC + TITLE_HOLD_SEC + TITLE_FADE_SEC title_video_length = int(round(title_dur * master_fps)) title_reader = { "id": title_file_id, "path": title_svg_path, "type": "QtImageReader", "duration": title_dur, "video_length": title_video_length, "width": 1920, "height": 1080, "has_audio": False, "has_video": True, "fps": {"num": fps_info["num"], "den": fps_info["den"]} } osp_files.append({ "id": title_file_id, "path": title_svg_path, "media_type": "image", "duration": title_dur, "video_length": title_video_length, "width": 1920, "height": 1080, "fps": {"num": fps_info["num"], "den": fps_info["den"]}, "has_audio": False, "has_video": True }) # Title Clip Creation title_clip = create_openshot_clip( file_id=title_file_id, file_reader=title_reader, position=TITLE_START_SEC, start=0.0, end=title_dur, layer=4, title=f"title_{date_str}.svg" ) title_clip["alpha"] = make_keyframe_points([ (0.0, 0.0), (TITLE_FADE_SEC, 1.0), (TITLE_FADE_SEC + TITLE_HOLD_SEC, 1.0), (title_dur, 0.0) ], master_fps) osp_clips.append(title_clip) # 2. Video Files & Clips Creation current_pos = 0.0 num_clips = len(video_clips) for idx, c in enumerate(video_clips): file_id = str(uuid.uuid4()) dur = c["duration"] file_path = os.path.abspath(c["path"]) video_length = int(round(dur * master_fps)) file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 # Reader Object attached to the Clip video_reader = { "id": file_id, "path": file_path, "type": "FFmpegReader", "duration": dur, "video_length": video_length, "width": c["width"], "height": c["height"], "video_stream_index": c.get("video_stream_index", 0), "audio_stream_index": c.get("audio_stream_index", 1), "vcodec": c.get("vcodec", "h264"), "acodec": c.get("acodec", "aac"), "fps": {"num": fps_info["num"], "den": fps_info["den"]}, "sample_rate": 44100, "channels": 2, "channel_layout": 3, "has_audio": True, "has_video": True, "pixel_format": 12, "pixel_ratio": {"num": 1, "den": 1}, "display_ratio": {"num": 16, "den": 9} } # Flattened top-level File Object in osp_files osp_files.append({ "id": file_id, "path": file_path, "media_type": "video", "duration": dur, "file_size": file_size, "width": c["width"], "height": c["height"], "video_length": video_length, "video_stream_index": c.get("video_stream_index", 0), "audio_stream_index": c.get("audio_stream_index", 1), "vcodec": c.get("vcodec", "h264"), "acodec": c.get("acodec", "aac"), "fps": {"num": fps_info["num"], "den": fps_info["den"]}, "sample_rate": 44100, "channels": 2, "channel_layout": 3, "has_audio": True, "has_video": True, "pixel_format": 12, "pixel_ratio": {"num": 1, "den": 1}, "display_ratio": {"num": 16, "den": 9} }) layer = 2 if (idx % 2 == 0) else 3 alpha_pts = [] if idx == 0: alpha_pts.extend([(0.0, 0.0), (FADE_IN_SEC, 1.0)]) else: alpha_pts.extend([(0.0, 0.0), (CROSSFADE_SEC, 1.0)]) if idx == num_clips - 1: alpha_pts.extend([(dur - FADE_OUT_SEC, 1.0), (dur, 0.0)]) else: alpha_pts.extend([(dur - CROSSFADE_SEC, 1.0), (dur, 0.0)]) video_clip = create_openshot_clip( file_id=file_id, file_reader=video_reader, position=current_pos, start=0.0, end=dur, layer=layer, title=os.path.basename(file_path) ) video_clip["alpha"] = make_keyframe_points(alpha_pts, master_fps) osp_clips.append(video_clip) current_pos += (dur - CROSSFADE_SEC) # Validated OpenShot Project Schema osp_data = { "width": max_width, "height": max_height, "fps": {"num": fps_info["num"], "den": fps_info["den"]}, "display_ratio": {"num": 16, "den": 9}, "pixel_ratio": {"num": 1, "den": 1}, "sample_rate": 44100, "channels": 2, "channel_layout": 3, "settings": {}, "profile": "Custom", "files": osp_files, "clips": osp_clips, "effects": [], "markers": [], "layers": [ {"id": "L1", "layer": 1, "label": "Track 1", "number": 1}, {"id": "L2", "layer": 2, "label": "Track 2", "number": 2}, {"id": "L3", "layer": 3, "label": "Track 3", "number": 3}, {"id": "L4", "layer": 4, "label": "Track 4", "number": 4}, {"id": "L5", "layer": 5, "label": "Track 5", "number": 5} ], "history": {"undo": [], "redo": []}, "scale": 1.0, "tick_method": "FRAME", "version": { "openshot-qt": "3.1.1", "libopenshot": "0.3.2" } } osp_filename = os.path.join(output_dir, f"{date_str}_{event_name.replace(' ', '_')}.osp") with open(osp_filename, "w", encoding="utf-8") as f: json.dump(osp_data, f, indent=2) print(f"Project created: {osp_filename} ({max_width}x{max_height} @ {fps_info['num']}/{fps_info['den']} fps)") def process_folder(input_folder, event_name="Home Video"): valid_exts = ('.mp4', '.mov', '.m4v', '.mkv', '.avi') videos_by_day = defaultdict(list) for fname in sorted(os.listdir(input_folder)): if fname.lower().endswith(valid_exts): fpath = os.path.join(input_folder, fname) info = get_video_info(fpath) if not info: continue if info["duration"] < MIN_CLIP_DURATION: print(f"Skipping short clip (< {MIN_CLIP_DURATION}s): {fname}") continue info["path"] = fpath day_key = info["dt"].strftime("%Y-%m-%d") videos_by_day[day_key].append(info) for day_key, clips in videos_by_day.items(): if clips: print(f"\nProcessing {len(clips)} clip(s) for date: {day_key}") create_osp_project(day_key, event_name, clips, output_dir=input_folder) if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python build_openshot_project.py [Folder Path] [Event Title]") sys.exit(1) folder_path = sys.argv[1] title = sys.argv[2] if len(sys.argv) > 2 else "Family Memories" process_folder(folder_path, title)