Files
gart/code/fcpxml/templates.py
T

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