first commit
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user