import os
import json
import subprocess
import uuid
import sys
import argparse
import shutil
import zipfile
from datetime import timedelta
def timestamp_to_srt(seconds):
td = timedelta(seconds=seconds)
total_seconds = int(td.total_seconds())
micros = td.microseconds
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02}:{minutes:02}:{seconds:02},{micros//1000:03}"
def json_to_srt(json_data):
"""
Converts internal JSON subtitle format to SRT.
If 'words' key is present, generates word-level timestamps (Karaoke/Editing style).
Otherwise, uses segment-level timestamps.
"""
srt_content = ""
counter = 1
for block in json_data:
# Check if words detail is available for Word-Level SRT
if isinstance(block, dict) and "words" in block and block["words"]:
for word_obj in block["words"]:
start = word_obj.get('start', 0)
end = word_obj.get('end', 0)
text = word_obj.get('word', "")
srt_content += f"{counter}\n"
srt_content += f"{timestamp_to_srt(start)} --> {timestamp_to_srt(end)}\n"
srt_content += f"{text}\n\n"
counter += 1
else:
# Fallback to segment level
start = 0
end = 0
text = ""
if isinstance(block, dict):
start = block.get('start', 0)
end = block.get('end', 0)
text = block.get('text', "")
elif isinstance(block, (list, tuple)) and len(block) >= 3:
start, end, text = block[0], block[1], block[2]
srt_content += f"{counter}\n"
srt_content += f"{timestamp_to_srt(start)} --> {timestamp_to_srt(end)}\n"
srt_content += f"{text}\n\n"
counter += 1
return srt_content
# ... (rest of the file until export_pack end)
# 8. ZIP IT
zip_filename = f"{export_name}.zip"
zip_path = os.path.join(project_path, zip_filename)
# Create zip from stage_dir (base_name is without extension)
shutil.make_archive(os.path.join(project_path, export_name), 'zip', stage_dir)
print(f"SUCCESS: Export Pack created at {zip_path}")
# Cleanup
try:
shutil.rmtree(stage_dir)
except: pass
return zip_path
def get_video_dims(vid_path):
"""Returns (width, height, duration_frames)"""
try:
cmd_w = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
width = int(subprocess.check_output(cmd_w).decode().strip())
cmd_h = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=height", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
height = int(subprocess.check_output(cmd_h).decode().strip())
cmd_dur = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
dur_sec = float(subprocess.check_output(cmd_dur).decode().strip())
# Assume 30fps for calculation if not probed, but probing is better
cmd_fps = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", vid_path]
fps_str = subprocess.check_output(cmd_fps).decode().strip()
num, den = map(int, fps_str.split('/'))
fps = num / den if den > 0 else 30.0
return width, height, int(dur_sec * fps), fps
except Exception as e:
print(f"Error probing video: {e}")
return 1920, 1080, 300, 30.0
def render_segmented_overlays(ass_path, segments, video_path, output_dir):
"""
Renders segments using a physical transparent PNG canvas to ensure alpha correctness.
"""
width, height, _, fps = get_video_dims(video_path)
ass_path_sanitized = ass_path.replace("\\", "/").replace(":", "\\:")
# Generate Base Canvas (Robust Way)
canvas_png = os.path.join(output_dir, "base_canvas.png")
# FFmpeg create transparent png
subprocess.run([
"ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=black@0.0:s={width}x{height}",
"-frames:v", "1", "-c:v", "png", canvas_png
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
overlay_data = []
print(f"Rendering {len(segments)} subtitle segments (Mode: Canvas Overlay)...")
for i, seg in enumerate(segments):
start = seg.get('start', 0)
end = seg.get('end', 0)
duration = end - start
if duration <= 0: continue
filename = f"caption_{i}.mov"
out_path = os.path.join(output_dir, filename)
# Input is PNG LOOP (Infinite) -> Trim duration -> Apply ASS -> Encode PNG Codec
cmd = [
"ffmpeg", "-y",
"-loop", "1", "-i", canvas_png,
"-vf", f"format=rgba,setpts=PTS+{start}/TB,ass='{ass_path_sanitized}',setpts=PTS-{start}/TB,format=rgba",
"-t", str(duration),
"-c:v", "png",
"-pix_fmt", "rgba",
"-an",
out_path
]
try:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
rel_path = os.path.join("captions", filename).replace("\\", "/")
overlay_data.append({ "path": rel_path, "start": start, "end": end, "index": i })
print(f" [Seg {i}] Rendered {duration:.2f}s")
except subprocess.CalledProcessError as e:
print(f" [Seg {i}] Failed: {e}")
# Cleanup canvas
if os.path.exists(canvas_png): os.remove(canvas_png)
return overlay_data
def create_premiere_xml(project_name, video_path, overlay_segments, duration_frames, width=1080, height=1920, timebase=30, video_file_id=None, audio_file_id=None, scale_value=100.0, face_data=None, source_width=1920, source_height=1080):
"""
Generates a Premiere Pro XML (xmeml version 4) with segmented cuts and overlays.
overlay_segments: List of dicts [{'path', 'start', 'end', 'index'}]
"""
# Generate unique IDs
def get_uid(): return str(uuid.uuid4())[:12]
if not video_file_id: video_file_id = f"file-video-{get_uid()}"
if not audio_file_id: audio_file_id = f"file-audio-{get_uid()}"
sequence_uuid = str(uuid.uuid4())
# helper for file blocks
def get_file_block(fid, fpath, is_audio_only=False):
audio_blk = "" if is_audio_only else ""
width_f = source_width
height_f = source_height
return f"""{os.path.basename(fpath)}{fpath}{timebase}FALSE{duration_frames}{audio_blk}"""
# --- VIDEO TRACK SEGMENTATION ---
cuts = []
frame_face_map = {}
if face_data:
s_factor = scale_value / 100.0
src_cx = source_width / 2.0
src_cy = source_height / 2.0
for entry in face_data:
frame_idx = entry.get('frame')
faces = entry.get('faces', [])
if not faces: continue
best_face = max(faces, key=lambda f: (f[2]-f[0]) * (f[3]-f[1]))
cx = (best_face[0] + best_face[2]) / 2.0
cy = (best_face[1] + best_face[3]) / 2.0
off_x = cx - src_cx
off_y = cy - src_cy
tgt_h = - (off_x * s_factor) / width
tgt_v = - (off_y * s_factor) / height
frame_face_map[frame_idx] = (tgt_h, tgt_v)
fps_float = float(timebase)
if overlay_segments:
current_frame = 0
last_cam_center = (0.0, 0.0)
if 0 in frame_face_map: last_cam_center = frame_face_map[0]
sorted_segs = sorted(overlay_segments, key=lambda x: x['start'])
for seg in sorted_segs:
start_f = int(seg['start'] * fps_float)
end_f = int(seg['end'] * fps_float)
if start_f > current_frame:
cuts.append({
"start": current_frame,
"end": start_f,
"center": last_cam_center
})
# Determine Mode Center (Avoid Middle Split)
candidates_h = []
candidates_v = []
for f_idx in range(start_f, end_f):
if f_idx in frame_face_map:
pos = frame_face_map[f_idx]
candidates_h.append(round(pos[0], 2)) # Round to cluster
candidates_v.append(round(pos[1], 2))
if candidates_h:
import statistics
try:
# MODE: Pick the most frequent position
# Multi-mode handling: min(multimode) ensures consistency
best_h = min(statistics.multimode(candidates_h))
best_v = min(statistics.multimode(candidates_v))
current_cam_center = (best_h, best_v)
except:
current_cam_center = last_cam_center
else:
current_cam_center = last_cam_center
cuts.append({"start": start_f, "end": end_f, "center": current_cam_center})
last_cam_center = current_cam_center
current_frame = end_f
if current_frame < duration_frames:
cuts.append({"start": current_frame, "end": duration_frames, "center": last_cam_center})
else:
cuts.append({"start": 0, "end": duration_frames, "center": (0.0, 0.0)})
video_track_items = ""
for cut in cuts:
seg_start = cut['start']
seg_end = cut['end']
c_h, c_v = cut['center']
if seg_end - seg_start <= 0: continue
seg_id = f"clipitem-video-{get_uid()}"
basic_motion = f"""Basic MotionbasicmotionmotionvideoscaleScale{scale_value}centerCenter{c_h:.5f}{c_v:.5f}centerOffsetAnchor Point-0.5-0.5"""
video_track_items += f"""{os.path.basename(video_path)}{duration_frames}{timebase}FALSE{seg_start}{seg_end}{seg_start}{seg_end}{get_file_block(video_file_id, video_path)}{basic_motion}"""
# --- OVERLAY TRACK SEGMENTATION (Opus Style) ---
track_overlay_block = ""
if overlay_segments and len(overlay_segments) > 0:
overlay_clips = ""
fps_float = float(timebase) # Assuming roughly match
for seg in overlay_segments:
start_f = int(seg['start'] * fps_float)
end_f = int(seg['end'] * fps_float)
clip_dur = end_f - start_f
if clip_dur <= 0: continue
ov_name = seg['path']
ov_fid = f"file-ov-{seg['index']}-{get_uid()}"
ov_cid = f"clip-ov-{seg['index']}-{get_uid()}"
# File block specifically for this WebP
file_blk = f"""{os.path.basename(seg['path'])}{seg['path']}{timebase}FALSE{clip_dur}"""
overlay_clips += f"""{os.path.basename(seg['path'])}{clip_dur}{timebase}FALSE{start_f}{end_f}0{clip_dur}{file_blk}normal"""
track_overlay_block = f""
else:
track_overlay_block = ""
# --- ASSEMBLE ---
timecode_block = f"""{timebase}FALSE00:00:00:000NDF"""
audio_blk = f""""""
return f"""{project_name}_CutRef{duration_frames}{timebase}FALSE{timecode_block}"""
def export_pack(project_path, segment_index, output_format="premiere"):
"""
Generates a ZIP Pack for the segment.
"""
print(f"Starting Export Pack for Project: {os.path.basename(project_path)}, Segment: {segment_index}")
# Paths
proj_name = os.path.basename(project_path)
cut_dir = os.path.join(project_path, "cuts")
# 1. IDENTIFY VIDEO FILE
video_file = None
original_scale_file = None
if os.path.exists(cut_dir):
files = os.listdir(cut_dir)
# Search for {index}_..._original_scale.mp4 or similar
prefix_idx = f"{segment_index:03d}_"
for f in files:
if f.startswith(prefix_idx) and (f.endswith(".mp4") or f.endswith(".mov")):
video_file = os.path.join(cut_dir, f)
break
if not video_file:
print(f"Error: No video file found for segment {segment_index} in {cut_dir}")
return
print(f"Selected Video: {video_file}")
# 2. IDENTIFY SUBTITLE FILES
subs_dir = os.path.join(project_path, "subs_ass")
ass_file = None
if os.path.exists(subs_dir):
sub_files = os.listdir(subs_dir)
prefix_idx = f"{segment_index:03d}_"
# Prioritize Clean Processed > Processed > Any
patterns = [
(lambda f: f.endswith(".ass") and f.startswith(prefix_idx) and "processed" in f and "original" not in f),
(lambda f: f.endswith(".ass") and f.startswith(prefix_idx) and "processed" in f),
(lambda f: f.endswith(".ass") and f.startswith(prefix_idx))
]
for p in patterns:
if ass_file: break
for f in sub_files:
if p(f):
ass_file = os.path.join(subs_dir, f)
break
# JSON in 'subs' usually
subs_json_dir = os.path.join(project_path, "subs")
json_file = None
if os.path.exists(subs_json_dir):
sub_files = os.listdir(subs_json_dir)
prefix_idx = f"{segment_index:03d}_"
# Same pattern priority
json_patterns = [
(lambda f: f.endswith(".json") and f.startswith(prefix_idx) and "processed" in f),
(lambda f: f.endswith(".json") and f.startswith(prefix_idx))
]
for p in json_patterns:
if json_file: break
for f in sub_files:
if p(f):
json_file = os.path.join(subs_json_dir, f)
break
# 2.1 IDENTIFY FACE COORDS
final_dir = os.path.join(project_path, "final")
face_data = None
if os.path.exists(final_dir):
final_files = os.listdir(final_dir)
prefix_idx = f"{segment_index:03d}_"
for f in final_files:
if f.startswith(prefix_idx) and f.endswith("_coords.json"):
try:
with open(os.path.join(final_dir, f), 'r') as fd:
face_data = json.load(fd)
print(f"Found Face Coordinates: {f}")
except Exception as e:
print(f"Face coords load error: {e}")
break
# 3. PREPARE STAGING
export_name = f"export_{proj_name}_seg{segment_index}"
stage_dir = os.path.join(project_path, export_name)
if os.path.exists(stage_dir):
try:
shutil.rmtree(stage_dir)
except Exception:
import random
stage_dir += f"_{random.randint(1000,9999)}"
os.makedirs(stage_dir, exist_ok=True)
# 4. COPY VIDEO
dest_video = os.path.join(stage_dir, "video_cut.mp4")
shutil.copy2(video_file, dest_video)
# 5. RENDER OVERLAYS (SEGMENTED)
overlay_segments = []
if ass_file and json_file:
try:
with open(json_file, 'r', encoding='utf-8') as f:
jdata = json.load(f)
# Extract segment list
jdata_segs = []
if isinstance(jdata, dict) and "segments" in jdata:
jdata_segs = jdata["segments"]
elif isinstance(jdata, list):
jdata_segs = jdata
if jdata_segs:
# Create 'captions' subfolder for organization
captions_dir = os.path.join(stage_dir, "captions")
os.makedirs(captions_dir, exist_ok=True)
# Render into subfolder
overlay_segments = render_segmented_overlays(ass_file, jdata_segs, video_file, captions_dir)
except Exception as e:
print(f"Error preparing overlay segments: {e}")
else:
print("Missing ASS or JSON for subtitles. Skipping overlays.")
# 6. GENERATE SRT (Standard)
dest_srt = os.path.join(stage_dir, f"{proj_name}_Seg{segment_index}.srt")
if json_file:
try:
with open(json_file, 'r', encoding='utf-8') as f:
jdata_srt = json.load(f)
if isinstance(jdata_srt, dict) and "segments" in jdata_srt:
jdata_srt = jdata_srt["segments"]
srt_content = json_to_srt(jdata_srt)
with open(dest_srt, 'w', encoding='utf-8') as f:
f.write(srt_content)
except Exception: pass
# 7. GENERATE XML
width_src, height_src, frames, fps = get_video_dims(dest_video)
# Validation for resolution mismatch (same as before)
if face_data:
max_x = 0
for entry in face_data:
for f in entry.get('faces', []):
if len(f) >= 3 and f[2] > max_x: max_x = f[2]
if max_x > width_src:
print(f"Correction: Detecting 4K source based on face coords ({max_x} > {width_src})")
width_src = 3840
height_src = 2160
print(f"Using Source Resolution: {width_src}x{height_src}")
xml_content = create_premiere_xml(
project_name=f"{proj_name}_Seg{segment_index}",
video_path="video_cut.mp4",
overlay_segments=overlay_segments, # LIST PASSED HERE
duration_frames=frames,
width=1080, # Target Vertical Width
height=1920,
timebase=int(float(fps) + 0.5),
source_width=width_src,
source_height=height_src
)
xml_output = os.path.join(stage_dir, "timeline.xml")
with open(xml_output, "w", encoding="utf-8") as f:
f.write(xml_content)
print("Generated Custom Premiere XML (Opus-Style Segments).")
# 8. ZIP IT
zip_path = f"{stage_dir}.zip"
shutil.make_archive(stage_dir, 'zip', stage_dir)
print(f"SUCCESS: Export Pack created at {zip_path}")
# Cleanup
try:
shutil.rmtree(stage_dir)
except: pass
return zip_path
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--project", required=True)
parser.add_argument("--segment", type=int, required=True)
parser.add_argument("--format", default="premiere")
args = parser.parse_args()
export_pack(args.project, args.segment, args.format)