Files
home_video/build_project.py
T
2026-10-03 14:54:07 -04:00

422 lines
14 KiB
Python

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)