388 lines
14 KiB
Python
Executable File
388 lines
14 KiB
Python
Executable File
"""
|
|
Template System — Pre-built timeline structures for common editing patterns.
|
|
|
|
Templates define slot-based layouts that can be filled with clips to generate
|
|
complete FCPXML timelines. Each template has named slots (video, audio, title,
|
|
gap) with duration constraints and lane assignments.
|
|
"""
|
|
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from .models import TimeValue
|
|
from .writer import (
|
|
_create_asset_element,
|
|
_sanitize_xml_value,
|
|
write_fcpxml,
|
|
)
|
|
|
|
# ============================================================================
|
|
# DATA CLASSES
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class TemplateSlot:
|
|
"""A slot in a template that can be filled with a clip.
|
|
|
|
Attributes:
|
|
name: Unique slot name (e.g. "intro", "main", "music_bed").
|
|
slot_type: Type of content: "video", "audio", "title", or "gap".
|
|
min_duration: Minimum duration in seconds (0 = no minimum).
|
|
max_duration: Maximum duration in seconds (0 = no maximum).
|
|
default_duration: Default duration if no clip duration specified.
|
|
lane: Lane assignment (0 = primary spine, positive = above, negative = below).
|
|
role: Audio/video role (e.g. "music", "dialogue", "titles").
|
|
required: Whether this slot must be filled.
|
|
"""
|
|
name: str
|
|
slot_type: str = "video"
|
|
min_duration: float = 0.0
|
|
max_duration: float = 0.0
|
|
default_duration: float = 5.0
|
|
lane: int = 0
|
|
role: str = ""
|
|
required: bool = True
|
|
|
|
|
|
@dataclass
|
|
class Template:
|
|
"""A timeline template with named slots.
|
|
|
|
Attributes:
|
|
name: Template identifier (e.g. "intro_outro").
|
|
description: Human-readable description.
|
|
slots: Ordered list of template slots.
|
|
"""
|
|
name: str
|
|
description: str
|
|
slots: List[TemplateSlot] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class ClipSpec:
|
|
"""Specification for filling a template slot.
|
|
|
|
Provide either asset_id (for existing assets) or src (for new media).
|
|
|
|
Attributes:
|
|
asset_id: Reference to an existing asset in the FCPXML.
|
|
src: File path for new media.
|
|
name: Clip display name.
|
|
duration: Override duration in seconds (uses slot default if not set).
|
|
"""
|
|
asset_id: Optional[str] = None
|
|
src: Optional[str] = None
|
|
name: str = "Untitled"
|
|
duration: Optional[float] = None
|
|
|
|
|
|
# ============================================================================
|
|
# BUILTIN TEMPLATES
|
|
# ============================================================================
|
|
|
|
BUILTIN_TEMPLATES: Dict[str, Template] = {
|
|
"intro_outro": Template(
|
|
name="intro_outro",
|
|
description=(
|
|
"Title card + main content + end card with optional music bed. "
|
|
"Classic YouTube/corporate structure."
|
|
),
|
|
slots=[
|
|
TemplateSlot(
|
|
name="intro_card", slot_type="video",
|
|
default_duration=5.0, max_duration=15.0,
|
|
),
|
|
TemplateSlot(
|
|
name="main_content", slot_type="video",
|
|
default_duration=60.0, min_duration=5.0,
|
|
),
|
|
TemplateSlot(
|
|
name="end_card", slot_type="video",
|
|
default_duration=5.0, max_duration=15.0,
|
|
),
|
|
TemplateSlot(
|
|
name="music_bed", slot_type="audio",
|
|
default_duration=0.0, lane=-1, role="music",
|
|
required=False,
|
|
),
|
|
],
|
|
),
|
|
"lower_thirds": Template(
|
|
name="lower_thirds",
|
|
description=(
|
|
"Main content with title overlay positions at lane +1. "
|
|
"Useful for interview graphics, name supers."
|
|
),
|
|
slots=[
|
|
TemplateSlot(
|
|
name="main_content", slot_type="video",
|
|
default_duration=60.0, min_duration=5.0,
|
|
),
|
|
TemplateSlot(
|
|
name="lower_third_1", slot_type="title",
|
|
default_duration=4.0, max_duration=10.0,
|
|
lane=1, role="titles", required=False,
|
|
),
|
|
TemplateSlot(
|
|
name="lower_third_2", slot_type="title",
|
|
default_duration=4.0, max_duration=10.0,
|
|
lane=1, role="titles", required=False,
|
|
),
|
|
TemplateSlot(
|
|
name="lower_third_3", slot_type="title",
|
|
default_duration=4.0, max_duration=10.0,
|
|
lane=1, role="titles", required=False,
|
|
),
|
|
],
|
|
),
|
|
"music_video": Template(
|
|
name="music_video",
|
|
description=(
|
|
"A/B roll structure with music bed. "
|
|
"Alternating performance and cutaway shots over a music track."
|
|
),
|
|
slots=[
|
|
TemplateSlot(
|
|
name="a_roll_1", slot_type="video",
|
|
default_duration=8.0,
|
|
),
|
|
TemplateSlot(
|
|
name="b_roll_1", slot_type="video",
|
|
default_duration=4.0,
|
|
),
|
|
TemplateSlot(
|
|
name="a_roll_2", slot_type="video",
|
|
default_duration=8.0,
|
|
),
|
|
TemplateSlot(
|
|
name="b_roll_2", slot_type="video",
|
|
default_duration=4.0,
|
|
),
|
|
TemplateSlot(
|
|
name="a_roll_3", slot_type="video",
|
|
default_duration=8.0,
|
|
),
|
|
TemplateSlot(
|
|
name="b_roll_3", slot_type="video",
|
|
default_duration=4.0,
|
|
),
|
|
TemplateSlot(
|
|
name="music_bed", slot_type="audio",
|
|
default_duration=0.0, lane=-1, role="music",
|
|
required=False,
|
|
),
|
|
],
|
|
),
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# PUBLIC API
|
|
# ============================================================================
|
|
|
|
def list_templates() -> List[Dict[str, Any]]:
|
|
"""Return all available templates with their slot definitions.
|
|
|
|
Returns:
|
|
List of dicts with template name, description, and slot details.
|
|
"""
|
|
result = []
|
|
for name, tmpl in BUILTIN_TEMPLATES.items():
|
|
result.append({
|
|
'name': tmpl.name,
|
|
'description': tmpl.description,
|
|
'slots': [
|
|
{
|
|
'name': s.name,
|
|
'slot_type': s.slot_type,
|
|
'default_duration': s.default_duration,
|
|
'min_duration': s.min_duration,
|
|
'max_duration': s.max_duration,
|
|
'lane': s.lane,
|
|
'role': s.role,
|
|
'required': s.required,
|
|
}
|
|
for s in tmpl.slots
|
|
],
|
|
})
|
|
return result
|
|
|
|
|
|
def apply_template(
|
|
template_name: str,
|
|
clips_map: Dict[str, ClipSpec],
|
|
output_path: str,
|
|
fps: float = 24.0,
|
|
) -> str:
|
|
"""Fill a template with clips and write the resulting FCPXML.
|
|
|
|
Args:
|
|
template_name: Name of a builtin template (e.g. "intro_outro").
|
|
clips_map: Dict mapping slot names to ClipSpec objects.
|
|
output_path: Where to write the generated FCPXML.
|
|
fps: Frame rate for the output timeline.
|
|
|
|
Returns:
|
|
The output file path.
|
|
|
|
Raises:
|
|
ValueError: If template not found or required slots are missing.
|
|
"""
|
|
template = BUILTIN_TEMPLATES.get(template_name)
|
|
if template is None:
|
|
available = ', '.join(BUILTIN_TEMPLATES.keys())
|
|
raise ValueError(
|
|
f"Template '{template_name}' not found. Available: {available}"
|
|
)
|
|
|
|
# Validate required slots
|
|
for slot in template.slots:
|
|
if slot.required and slot.name not in clips_map:
|
|
raise ValueError(
|
|
f"Required slot '{slot.name}' not filled in template '{template_name}'."
|
|
)
|
|
|
|
# Build FCPXML
|
|
fps_int = int(fps)
|
|
root = ET.Element('fcpxml', version='1.13')
|
|
resources = ET.SubElement(root, 'resources')
|
|
|
|
# Format resource
|
|
format_id = 'r1'
|
|
ET.SubElement(resources, 'format',
|
|
id=format_id,
|
|
name=f"FFVideoFormat1080p{fps_int}",
|
|
frameDuration=f"1/{fps_int}s",
|
|
width="1920", height="1080")
|
|
|
|
# Create assets for each filled slot
|
|
asset_map: Dict[str, str] = {} # slot_name → asset_id
|
|
asset_counter = 2
|
|
for slot_name, spec in clips_map.items():
|
|
if spec.asset_id:
|
|
asset_map[slot_name] = spec.asset_id
|
|
elif spec.src:
|
|
aid = f"r{asset_counter}"
|
|
asset_counter += 1
|
|
slot = next((s for s in template.slots if s.name == slot_name), None)
|
|
dur = spec.duration or (slot.default_duration if slot else 5.0)
|
|
dur_tv = TimeValue.from_seconds(dur, fps)
|
|
_create_asset_element(
|
|
resources, aid, spec.name, spec.src,
|
|
duration=dur_tv.to_fcpxml(),
|
|
has_video="1" if (not slot or slot.slot_type != "audio") else "0",
|
|
has_audio="1",
|
|
)
|
|
asset_map[slot_name] = aid
|
|
|
|
# Library/event/project structure
|
|
library = ET.SubElement(root, 'library',
|
|
location="file:///Users/editor/Movies/Template.fcpbundle/")
|
|
event = ET.SubElement(library, 'event',
|
|
name=template_name, uid=str(uuid.uuid4()).upper())
|
|
project_elem = ET.SubElement(event, 'project',
|
|
name=template.name,
|
|
uid=str(uuid.uuid4()).upper(),
|
|
modDate=datetime.now().strftime("%Y-%m-%d %H:%M:%S -0500"))
|
|
|
|
# Calculate total spine duration (only lane-0 slots)
|
|
total_duration = TimeValue.zero()
|
|
for slot in template.slots:
|
|
if slot.lane != 0:
|
|
continue
|
|
spec = clips_map.get(slot.name)
|
|
if spec:
|
|
dur = spec.duration or slot.default_duration
|
|
else:
|
|
dur = slot.default_duration
|
|
total_duration = total_duration + TimeValue.from_seconds(dur, fps)
|
|
|
|
sequence = ET.SubElement(project_elem, 'sequence',
|
|
format=format_id,
|
|
duration=total_duration.to_fcpxml(),
|
|
tcStart="0s", tcFormat="NDF",
|
|
audioLayout="stereo", audioRate="48k")
|
|
spine = ET.SubElement(sequence, 'spine')
|
|
|
|
# Fill spine slots (lane 0) and track connected slots
|
|
current_offset = TimeValue.zero()
|
|
spine_clips: Dict[str, ET.Element] = {} # slot_name → spine clip element
|
|
|
|
for slot in template.slots:
|
|
if slot.lane != 0:
|
|
continue
|
|
|
|
spec = clips_map.get(slot.name)
|
|
dur = (spec.duration if spec and spec.duration else slot.default_duration)
|
|
dur_tv = TimeValue.from_seconds(dur, fps)
|
|
aid = asset_map.get(slot.name, format_id)
|
|
|
|
if spec:
|
|
clip_elem = ET.SubElement(spine, 'asset-clip',
|
|
ref=aid,
|
|
offset=current_offset.to_fcpxml(),
|
|
name=_sanitize_xml_value(spec.name, 512),
|
|
start="0s",
|
|
duration=dur_tv.to_fcpxml(),
|
|
format=format_id,
|
|
tcFormat="NDF")
|
|
else:
|
|
# Gap placeholder for unfilled optional slot
|
|
clip_elem = ET.SubElement(spine, 'gap',
|
|
name=slot.name,
|
|
offset=current_offset.to_fcpxml(),
|
|
duration=dur_tv.to_fcpxml())
|
|
|
|
spine_clips[slot.name] = clip_elem
|
|
current_offset = current_offset + dur_tv
|
|
|
|
# Fill connected slots (lane != 0) — attach to first spine clip
|
|
first_spine_clip = None
|
|
for s in template.slots:
|
|
if s.lane == 0 and s.name in spine_clips:
|
|
first_spine_clip = spine_clips[s.name]
|
|
break
|
|
|
|
for slot in template.slots:
|
|
if slot.lane == 0:
|
|
continue
|
|
spec = clips_map.get(slot.name)
|
|
if not spec:
|
|
continue
|
|
|
|
dur = spec.duration or slot.default_duration
|
|
dur_tv = TimeValue.from_seconds(dur, fps)
|
|
aid = asset_map.get(slot.name, format_id)
|
|
|
|
parent = first_spine_clip if first_spine_clip is not None else spine
|
|
|
|
if slot.slot_type == "audio":
|
|
# Audio: spans total if duration is 0
|
|
if slot.default_duration == 0.0 and spec.duration is None:
|
|
dur_tv = total_duration
|
|
connected = ET.SubElement(parent, 'asset-clip',
|
|
ref=aid,
|
|
lane=str(slot.lane),
|
|
offset="0s",
|
|
name=_sanitize_xml_value(spec.name, 512),
|
|
start="0s",
|
|
duration=dur_tv.to_fcpxml(),
|
|
audioRole=slot.role or "music")
|
|
else:
|
|
# Title or video overlay
|
|
connected = ET.SubElement(parent, 'asset-clip',
|
|
ref=aid,
|
|
lane=str(slot.lane),
|
|
offset="0s",
|
|
name=_sanitize_xml_value(spec.name, 512),
|
|
start="0s",
|
|
duration=dur_tv.to_fcpxml())
|
|
if slot.role:
|
|
connected.set('videoRole', slot.role)
|
|
|
|
write_fcpxml(root, output_path)
|
|
return output_path
|