601 lines
23 KiB
Python
601 lines
23 KiB
Python
"""Data models for GnommoEditor pipeline."""
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional, Union
|
|
|
|
|
|
@dataclass
|
|
class CutoutDefinition:
|
|
"""Definition of a named zone for placing video content.
|
|
|
|
All positioning values support both pixels (int) and percentages (str like "50%").
|
|
Percentage values are stored as floats (0.0-1.0) with pixel value set to -1.
|
|
|
|
Videos placed in cutouts are cropped to fit the cutout dimensions.
|
|
"""
|
|
|
|
x: int # in pixels, or -1 for percentage-based
|
|
y: int # in pixels, or -1 for percentage-based
|
|
height: int # in pixels, or -1 for percentage-based
|
|
width: int = (
|
|
-1
|
|
) # in pixels, or -1 for percentage-based (defaults to height for square)
|
|
x_percent: float = 0.0 # percentage (0.0-1.0) if x is -1
|
|
y_percent: float = 0.0 # percentage (0.0-1.0) if y is -1
|
|
height_percent: float = 0.0 # percentage (0.0-1.0) if height is -1
|
|
width_percent: float = 0.0 # percentage (0.0-1.0) if width is -1
|
|
|
|
# New center-based model (opt-in via cx/cy in project.json). When cx_percent is
|
|
# set: cx/cy are the CENTER as fractions of frame width/height; width_percent and
|
|
# height_percent are fractions of min(W,H) (so equal values → a true square on any
|
|
# aspect ratio); margin_percent is a per-side inset, also a fraction of min(W,H).
|
|
cx_percent: Optional[float] = None
|
|
cy_percent: Optional[float] = None
|
|
margin_percent: float = 0.0
|
|
size_relative_to_min: bool = False
|
|
|
|
|
|
# Backwards compatibility alias
|
|
TalkingHeadConfig = CutoutDefinition
|
|
|
|
|
|
@dataclass
|
|
class ProjectConfig:
|
|
"""Global project configuration from project.json."""
|
|
|
|
resolution: tuple[int, int]
|
|
fps: int
|
|
default_slide_type: str
|
|
cutouts: dict[str, CutoutDefinition] = field(
|
|
default_factory=dict
|
|
) # Named zones for video placement
|
|
default_filters: dict[str, list[dict]] = field(
|
|
default_factory=dict
|
|
) # Named filter presets that can be referenced in videos.json
|
|
background: str = "" # Background image or video path (in shared_assets/)
|
|
background_video: str = "" # Deprecated: use background instead
|
|
slides_path: str = "slides.json" # path to slides.json relative to project
|
|
videos_path: str = "videos.json" # path to videos.json relative to project
|
|
audio_path: str = "audio.json" # path to audio.json relative to project
|
|
transcript_path: Optional[str] = None # path to transcript.json relative to project (always saved locally)
|
|
audio_source: Optional[str] = None # defaults to talking head
|
|
main_video: Optional[
|
|
Union[str, list]
|
|
] = None # ID(s) of main video(s) - array for multi-segment narration
|
|
gnommo_scratch: Optional[
|
|
str
|
|
] = None # directory for intermediate files (e.g., external SSD)
|
|
process_cache: Optional[
|
|
str
|
|
] = None # external directory for processed/combined outputs (saves laptop disk space)
|
|
default_begin: float = 0.0 # Trim this many seconds from the start of each segment (if no explicit begin/skip)
|
|
default_end_trim: float = 0.0 # Trim this many seconds from the end of each segment (if no explicit end/take)
|
|
# Outro sequence - plays after narration ends (not marker-triggered)
|
|
outro: list[str] = field(
|
|
default_factory=list
|
|
) # List of video IDs to play in sequence after narration
|
|
# YouTube description fields
|
|
description: str = "" # Video description text for YouTube
|
|
footer: str = "" # Footer text (social links, subscribe CTA, etc.)
|
|
output_video: str = (
|
|
"" # Output filename (e.g. "DISC_INT3.mp4"); placed in out/ or out/<res>/
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class SlideDefinition:
|
|
"""Definition of a single slide from slides.json."""
|
|
|
|
image: str
|
|
type: str # "fullscreen" | "square"
|
|
|
|
|
|
@dataclass
|
|
class ChromaKeyConfig:
|
|
"""Configuration for chroma key (green screen) filter."""
|
|
|
|
color: tuple[int, int, int] = (0, 255, 0) # RGB color to key out
|
|
similarity: float = (
|
|
0.4 # Color similarity threshold (0.0-1.0), higher = more aggressive
|
|
)
|
|
blend: float = 0.08 # Edge blend/feathering (0.0-1.0), lower = tighter edges
|
|
spill: float = 0.1 # Spill suppression amount (0.0-1.0)
|
|
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5), removes green fringe
|
|
# Color protection - restore opacity for colors that shouldn't be keyed
|
|
protect_color: tuple[int, int, int] = None # RGB color to protect from keying
|
|
protect_tolerance: float = (
|
|
0.15 # How much variation from protect_color to allow (0-1)
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class GnommoKeyConfig:
|
|
"""Configuration for gnommokey filter - Keylight-style color-difference keyer.
|
|
|
|
Uses YCbCr color-difference keying (like Keylight/Ultimatte) instead of
|
|
simple Euclidean distance. This handles lighting variation much better
|
|
than basic chromakey.
|
|
"""
|
|
|
|
# Screen color (the green/blue screen color to key out)
|
|
screen_color: tuple[int, int, int] = (0, 177, 64) # RGB of the screen
|
|
|
|
# Key extraction strength (default 100, higher = more aggressive)
|
|
# Values 80-150 are typical. Maps to Keylight's Screen Gain.
|
|
screen_gain: float = 100.0
|
|
|
|
# Balance between chrominance and luminance in key calculation (0-100)
|
|
# 0 = pure color-difference, 100 = luminance weighted
|
|
# Maps to Keylight's Screen Balance.
|
|
screen_balance: float = 50.0
|
|
|
|
# Alpha/matte adjustments
|
|
clip_black: float = 0.0 # Crush blacks (0-100). Higher = more transparent areas
|
|
clip_white: float = 100.0 # Crush whites (0-100). Lower = more opaque areas
|
|
|
|
# Despill: color to shift green spill toward (RGB)
|
|
# Typical values: skin tone [217, 200, 180] or neutral [200, 200, 200]
|
|
despill_bias: tuple[int, int, int] = None
|
|
|
|
# How aggressively to apply despill (0-1)
|
|
despill_strength: float = 0.5
|
|
|
|
# Interior green-limiter (0.0-2.0, 0 = off). Suppresses green cast/spill
|
|
# across the whole frame even where green is NOT the dominant channel — the
|
|
# case the bias/edge despill misses (e.g. green bounce on skin/a bald head).
|
|
# Caps green at a reference through the other two channels: max(r,b) [0.0] ->
|
|
# average [1.0] -> min(r,b) [2.0]. 0.5-0.7 for light cast; >1.0 for heavy
|
|
# close-up spill (2.0 = green can never exceed the smallest channel).
|
|
spill_suppress: float = 0.0
|
|
|
|
# Protect saturated yellows/warm fabrics from spill_suppress (0.0-1.0, 0 = off).
|
|
# spill_suppress caps green everywhere, which turns legit yellow (high r+g,
|
|
# low b) into orange. Blue is the tell: skin keeps some blue, yellow fabric
|
|
# reflects almost none. This gates the green-limiter down where
|
|
# min(r,g)-b is high (yellow) while leaving skin/scalp spill fully suppressed.
|
|
# 1.0 = full protection for strong yellows. Only matters when spill_suppress>0.
|
|
yellow_protect: float = 0.0
|
|
|
|
# Alpha bias: influences edge treatment (RGB)
|
|
# Can help with edge color contamination
|
|
alpha_bias: tuple[int, int, int] = None
|
|
|
|
# Luminance protection: pixels with luma above this stay fully opaque (0-255, -1 = off)
|
|
# Use ~220 to protect white objects (headphones, teeth) from being partially keyed.
|
|
protect_luma: int = -1
|
|
|
|
# Shadow boost: extra key strength for dark pixels (0.0-5.0, 0 = off)
|
|
# Ramps up key signal proportionally to how dark a pixel is, helping key dark greens
|
|
# without affecting bright foreground areas. Values 1.0-2.0 are typical.
|
|
shadow_boost: float = 0.0
|
|
|
|
# Edge refinement
|
|
edge_erode: int = 0 # Pixels to erode from alpha edge (0-5)
|
|
edge_soften: float = 0.0 # Blur the alpha edge (0-5 pixels)
|
|
|
|
|
|
@dataclass
|
|
class ColorGradeConfig:
|
|
"""Configuration for color grading filter.
|
|
|
|
Applies color balance, contrast curves, and saturation adjustments
|
|
while preserving the alpha channel.
|
|
"""
|
|
|
|
# Color balance (range: -1.0 to 1.0, 0 = no change)
|
|
# Midtones
|
|
rm: float = 0.0 # Red midtones adjustment
|
|
gm: float = 0.0 # Green midtones adjustment
|
|
bm: float = 0.0 # Blue midtones adjustment
|
|
# Highlights
|
|
rh: float = 0.0 # Red highlights adjustment
|
|
gh: float = 0.0 # Green highlights adjustment
|
|
bh: float = 0.0 # Blue highlights adjustment
|
|
# Shadows
|
|
rs: float = 0.0 # Red shadows adjustment
|
|
gs: float = 0.0 # Green shadows adjustment
|
|
bs: float = 0.0 # Blue shadows adjustment
|
|
|
|
# Curves preset (none, lighter, darker, increase_contrast, medium_contrast, etc.)
|
|
curves_preset: str = "none"
|
|
|
|
# EQ adjustments
|
|
contrast: float = 1.0 # Contrast multiplier (0.0-2.0, 1.0 = no change)
|
|
brightness: float = 0.0 # Brightness adjustment (-1.0 to 1.0, 0 = no change)
|
|
saturation: float = 1.0 # Saturation multiplier (0.0-3.0, 1.0 = no change)
|
|
|
|
# Auto-levels: a fixed histogram stretch (crush blacks, lift whites) for
|
|
# punch, like Photoshop auto-levels. 0 = off, 1 = strong. Fixed (not
|
|
# per-frame adaptive) so it can't flicker; it remaps a constant [lo,hi]
|
|
# window to full range, so the keyed-out background never skews it.
|
|
auto_levels: float = 0.0
|
|
|
|
# Yellow tint: hue-selective nudge of ONLY the yellow range (leaves reds/skin
|
|
# alone), to counter the orange shift auto-levels/saturation gives a yellow
|
|
# costume. <0 pulls yellows back toward green/pure yellow (preserve), >0
|
|
# pushes them warmer/orange. Range roughly -1.0..1.0. 0 = off.
|
|
yellow_tint: float = 0.0
|
|
|
|
# Custom curves for lift/gamma/gain control
|
|
# Format: "0/0 0.5/0.56 1/1" means (input/output) control points
|
|
curves_r: str = "" # Red channel curve
|
|
curves_g: str = "" # Green channel curve
|
|
curves_b: str = "" # Blue channel curve
|
|
curves_master: str = "" # Master (luminance) curve
|
|
|
|
|
|
@dataclass
|
|
class EQBand:
|
|
"""A single parametric EQ band."""
|
|
|
|
freq: float # Center frequency in Hz
|
|
gain: float # Gain in dB (negative = cut, positive = boost)
|
|
q: float = 1.0 # Q factor (bandwidth), higher = narrower
|
|
type: str = "peak" # "peak", "lowshelf", or "highshelf"
|
|
|
|
|
|
@dataclass
|
|
class AudioNormalizeConfig:
|
|
"""Configuration for audio normalization filter.
|
|
|
|
Applies noise reduction, compression, and loudness normalization
|
|
to improve audio quality and consistency.
|
|
"""
|
|
enabled: bool = True # Master switch to enable/disable all audio processing
|
|
# Parametric EQ bands (applied before other processing)
|
|
eq_bands: list[EQBand] = field(default_factory=list)
|
|
|
|
# High-pass filter (remove room rumble)
|
|
highpass: float = (
|
|
0.0 # High-pass frequency in Hz (0 = disabled, try 80-120 for voice)
|
|
)
|
|
|
|
# Low-pass filter (remove harsh highs)
|
|
lowpass: float = (
|
|
0.0 # Low-pass frequency in Hz (0 = disabled, try 12000-16000 if needed)
|
|
)
|
|
|
|
# Room resonance EQ cut (reduce muddy room buildup)
|
|
room_eq: bool = False # Enable room resonance cut
|
|
room_eq_freq: float = 300.0 # Center frequency for room cut (Hz, typically 200-400)
|
|
room_eq_gain: float = -4.0 # Gain in dB (negative = cut)
|
|
room_eq_width: float = 1.5 # Q/bandwidth (higher = narrower cut)
|
|
|
|
# Noise gate (reduce reverb tails during pauses)
|
|
gate: bool = False # Enable noise gate
|
|
gate_threshold: float = -35.0 # Threshold in dB (signal below this gets attenuated)
|
|
gate_range: float = -20.0 # Attenuation amount in dB when gate is closed
|
|
gate_attack: float = 10.0 # Attack time in ms
|
|
gate_release: float = 150.0 # Release time in ms
|
|
|
|
# Neural de-reverb (arnndn filter - very effective but needs model file)
|
|
dereverb_model: str = "" # Path to RNNoise model file (empty = disabled)
|
|
dereverb_mix: float = (
|
|
0.8 # Mix ratio 0.0-1.0 (1.0 = full effect, 0.8 = preserve some natural room)
|
|
)
|
|
|
|
# Noise reduction (afftdn filter)
|
|
denoise: bool = True # Enable noise reduction
|
|
noise_floor: float = (
|
|
-25.0
|
|
) # Noise floor in dB (default -25, lower = more aggressive)
|
|
|
|
# Compression (acompressor filter)
|
|
compress: bool = True # Enable dynamic range compression
|
|
threshold: float = -20.0 # Compression threshold in dB
|
|
ratio: float = 4.0 # Compression ratio (4:1 default)
|
|
attack: float = 5.0 # Attack time in ms
|
|
release: float = 50.0 # Release time in ms
|
|
makeup: float = 2.0 # Makeup gain in dB
|
|
|
|
# Loudness normalization (loudnorm filter - EBU R128)
|
|
normalize: bool = True # Enable loudness normalization
|
|
target_lufs: float = (
|
|
-16.0
|
|
) # Target integrated loudness (YouTube recommends -14 to -16)
|
|
target_lra: float = 11.0 # Target loudness range
|
|
target_tp: float = -1.5 # Target true peak in dB
|
|
|
|
|
|
@dataclass
|
|
class FilterConfig:
|
|
"""Base configuration for a preprocessing filter."""
|
|
|
|
type: str
|
|
# Type-specific config stored in subclasses or as dict
|
|
|
|
|
|
@dataclass
|
|
class Attribution:
|
|
"""Attribution information for stock footage (e.g., Pexels)."""
|
|
|
|
source: str # Source platform (e.g., "pexels", "pixabay", "unsplash")
|
|
creator: str # Creator/photographer name
|
|
url: Optional[str] = None # URL to the original content
|
|
|
|
|
|
@dataclass
|
|
class VideoSource:
|
|
"""Video source definition from videos.json."""
|
|
|
|
source_file: str # Source video filename (relative to videos.json location or shared_assets/)
|
|
filter: list[dict] = field(default_factory=list) # List of filter config dicts
|
|
output_file: Optional[
|
|
str
|
|
] = None # Path to preprocessed output (relative to videos.json)
|
|
take: Optional[
|
|
float
|
|
] = None # Max duration to play (seconds). Default: until next slide or end of clip
|
|
skip: float = 0.0 # Skip this many seconds at start of video (seek point)
|
|
zoom: float = (
|
|
1.0 # Scale factor for video (1.0 = fit to cutout height, >1 = enlarge)
|
|
)
|
|
cutout: Optional[
|
|
str
|
|
] = None # Name of cutout to place video in (from project.json cutouts)
|
|
always_visible: bool = False # If True, video is always shown (like talking head)
|
|
is_shared: bool = False # If True, source_file is relative to shared_assets/
|
|
pause_narration: float = (
|
|
0.0 # Seconds to pause narration during this video (0 = no pause)
|
|
)
|
|
attribution: Optional[Attribution] = None # Attribution for stock footage
|
|
use_audio_channels: str = (
|
|
"both" # Audio channel selection: "both", "left", or "right"
|
|
)
|
|
defer_loudnorm: bool = (
|
|
False # If True, skip loudnorm during preprocessing (apply after concatenation)
|
|
)
|
|
volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce)
|
|
layer: str = "above" # "above" = on top of slides; "mid" = above narrator/below slides; "below" = behind narrator
|
|
duration: Optional[
|
|
float
|
|
] = None # Pre-probed file duration in seconds (set by import)
|
|
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
|
|
end_on: Optional[
|
|
str
|
|
] = None # When video event ends: "end" (play once to natural length) | "loop" (loop to render end)
|
|
# | "next_slide" | "next_video" | "take" (None = marker-type default: next_slide for videos)
|
|
|
|
|
|
@dataclass
|
|
class VideoMetadata:
|
|
"""
|
|
Metadata for a video source, typically from a .json file.
|
|
|
|
This allows defining preprocessing steps separately from videos.json,
|
|
enabling per-video preprocessing configuration.
|
|
"""
|
|
|
|
source_file: str # Original source video file
|
|
preprocess: list[dict] = field(default_factory=list) # Preprocessing filters
|
|
output: Optional[
|
|
dict
|
|
] = None # Output config {"file": "...", "colorspace": "...", "alpha": "..."}
|
|
|
|
|
|
@dataclass
|
|
class SlideEvent:
|
|
"""A resolved slide event with timing information."""
|
|
|
|
slide_id: str
|
|
start_time: float
|
|
end_time: float
|
|
slide_def: SlideDefinition
|
|
|
|
|
|
@dataclass
|
|
class AudioDefinition:
|
|
"""Definition of an audio clip from audio.json."""
|
|
|
|
file: str # Audio filename (relative to audio.json location, or to shared_assets/media/audio/ if is_shared)
|
|
volume: float = 1.0 # Volume multiplier (0.0-1.0)
|
|
loop: bool = False # If True, loop for entire duration from trigger point
|
|
overlap: Optional[float] = None # Crossfade overlap in seconds when looping
|
|
ignore_pauses: bool = (
|
|
False # If True, audio continues playing during narration pauses
|
|
)
|
|
duration: Optional[float] = None # Pre-probed duration in seconds (set by import)
|
|
is_shared: bool = False # If True, file is relative to shared_assets/media/audio/
|
|
|
|
|
|
@dataclass
|
|
class Citation:
|
|
"""A citation extracted from manuscript.txt [cite:...] markers."""
|
|
|
|
reference: str # The literal reference text after cite:
|
|
marker_id: str # The full marker (e.g., "cite:Smith et al...")
|
|
timestamp: float = -1.0 # Aligned timestamp (-1 if not aligned)
|
|
context: str = "" # Text following the citation for alignment
|
|
|
|
|
|
@dataclass
|
|
class AudioEvent:
|
|
"""A resolved audio event with timing information."""
|
|
|
|
audio_id: str
|
|
start_time: float # When to start playing (marker time - offset)
|
|
audio_def: AudioDefinition
|
|
|
|
|
|
@dataclass
|
|
class VideoEvent:
|
|
"""A resolved video event with timing information."""
|
|
|
|
video_id: str
|
|
start_time: float
|
|
end_time: float
|
|
video_source: "VideoSource"
|
|
cutout: "CutoutDefinition"
|
|
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
|
|
layer: str = "above" # "above" = on top of slides; "below" = behind slides
|
|
|
|
|
|
@dataclass
|
|
class CameraState:
|
|
"""State of the virtual camera at a point in time.
|
|
|
|
The camera transforms the entire composed scene (background, slides, cutouts).
|
|
This ensures all elements stay spatially synchronized when zooming/tilting.
|
|
"""
|
|
|
|
zoom: float = 1.0 # 1.0 = 100%, 1.25 = 125%, etc.
|
|
rotation: float = 0.0 # degrees, positive = clockwise
|
|
pan_x: float = 0.0 # -1.0 to 1.0, percentage of frame width
|
|
pan_y: float = 0.0 # -1.0 to 1.0, percentage of frame height
|
|
focal_x: float = 0.5 # 0.0 to 1.0, zoom focal point X (0.5 = center)
|
|
focal_y: float = 0.5 # 0.0 to 1.0, zoom focal point Y (0.5 = center)
|
|
|
|
def __post_init__(self):
|
|
# Clamp values to reasonable ranges
|
|
self.zoom = max(0.5, min(3.0, self.zoom))
|
|
self.rotation = max(-45.0, min(45.0, self.rotation))
|
|
self.pan_x = max(-1.0, min(1.0, self.pan_x))
|
|
self.pan_y = max(-1.0, min(1.0, self.pan_y))
|
|
self.focal_x = max(0.0, min(1.0, self.focal_x))
|
|
self.focal_y = max(0.0, min(1.0, self.focal_y))
|
|
|
|
def is_default(self) -> bool:
|
|
"""Check if this is the default camera state (no transform)."""
|
|
return (
|
|
self.zoom == 1.0
|
|
and self.rotation == 0.0
|
|
and self.pan_x == 0.0
|
|
and self.pan_y == 0.0
|
|
and self.focal_x == 0.5
|
|
and self.focal_y == 0.5
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class CameraEvent:
|
|
"""A camera state change at a specific time.
|
|
|
|
Camera events can be instant (duration=0) or animated (duration>0).
|
|
When animated, the camera smoothly transitions from its current state
|
|
to the target state over the specified duration using the easing function.
|
|
"""
|
|
|
|
time: float # timestamp in seconds
|
|
target_state: CameraState
|
|
duration: float = 0.2 # transition duration (0 = instant snap)
|
|
easing: str = "ease-out" # linear, ease-in, ease-out, ease-in-out
|
|
|
|
|
|
# Camera effect presets - map marker names to camera states
|
|
# Effect strengths are intentionally subtle for professional look
|
|
CAMERA_PRESETS: dict[str, CameraState] = {
|
|
# Zoom levels (halved for subtlety)
|
|
"Zoom0": CameraState(zoom=1.0),
|
|
"Zoom1": CameraState(zoom=1.05),
|
|
"Zoom2": CameraState(zoom=1.125),
|
|
"Zoom3": CameraState(zoom=1.25),
|
|
# Tilt/rotation (halved)
|
|
"TiltLeft": CameraState(rotation=-7.5),
|
|
"TiltRight": CameraState(rotation=7.5),
|
|
"NoTilt": CameraState(), # Full reset to default state
|
|
# Pan (halved)
|
|
"PanLeft": CameraState(pan_x=-0.1),
|
|
"PanRight": CameraState(pan_x=0.1),
|
|
"PanUp": CameraState(pan_y=-0.075),
|
|
"PanDown": CameraState(pan_y=0.075),
|
|
"PanCenter": CameraState(pan_x=0.0, pan_y=0.0),
|
|
# Reset all
|
|
"Reset": CameraState(),
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class NarrationPause:
|
|
"""A pause in the narration timeline for an interstitial video."""
|
|
|
|
output_time: float # When the pause starts in the OUTPUT timeline
|
|
narration_time: float # Where we are in the NARRATION source when pause starts
|
|
duration: float # How long the pause lasts
|
|
video_id: str # The video that plays during the pause
|
|
|
|
|
|
@dataclass
|
|
class OutroEvent:
|
|
"""A video that plays as part of the outro sequence (after narration ends)."""
|
|
|
|
video_id: str
|
|
start_time: float # When this outro video starts (in output timeline)
|
|
end_time: float # When this outro video ends
|
|
video_source: "VideoSource"
|
|
cutout: Optional["CutoutDefinition"] = None # None = fullscreen
|
|
|
|
|
|
@dataclass
|
|
class RenderPlan:
|
|
"""Complete plan for rendering the final video."""
|
|
|
|
project_path: Path
|
|
config: ProjectConfig
|
|
slide_events: list[SlideEvent]
|
|
total_duration: float
|
|
slides: dict[str, SlideDefinition]
|
|
videos: dict[str, VideoSource] = field(default_factory=dict)
|
|
video_events: list[VideoEvent] = field(
|
|
default_factory=list
|
|
) # Triggered video overlays
|
|
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = field(
|
|
default_factory=list
|
|
) # (video_id, source, cutout)
|
|
slides_dir: Path = None # directory containing slide images
|
|
videos_dir: Path = None # directory containing videos.json and video files
|
|
audio_events: list[AudioEvent] = field(default_factory=list)
|
|
audio: dict[str, AudioDefinition] = field(default_factory=dict)
|
|
audio_dir: Path = None # directory containing audio.json and audio files
|
|
camera_events: list[CameraEvent] = field(
|
|
default_factory=list
|
|
) # Virtual camera keyframes
|
|
# Partial rendering support
|
|
time_offset: float = (
|
|
0.0 # Offset subtracted from all timestamps (for partial render)
|
|
)
|
|
initial_camera_state: "CameraState" = (
|
|
None # Camera state at render start (for partial render)
|
|
)
|
|
input_seek_time: float = 0.0 # Seek position for input videos (for partial render)
|
|
# Shared assets support
|
|
shared_assets_dir: Path = None # Directory containing shared assets (pexels, etc.)
|
|
# Narration pause support
|
|
narration_pauses: list[NarrationPause] = field(
|
|
default_factory=list
|
|
) # Gaps in narration for interstitial videos
|
|
# Render-time narration concat: ordered segments (skip/take + offset) to
|
|
# concatenate directly at render time. Typed loosely (list of
|
|
# narration.NarrationSegment) to avoid a circular import between models and
|
|
# narration.
|
|
narration_segments: list = field(default_factory=list)
|
|
# Outro sequence (plays after narration ends)
|
|
outro_events: list["OutroEvent"] = field(
|
|
default_factory=list
|
|
) # Videos that play after narration ends
|
|
narration_end_time: float = 0.0 # When narration ends (before outro starts)
|
|
# GnommoCache support
|
|
cached_files: set = field(
|
|
default_factory=set
|
|
) # Video IDs loaded from external cache (show 📁 indicator)
|
|
output_path: Optional[
|
|
Path
|
|
] = None # Final output file path (set after plan is built)
|
|
|
|
|
|
# Slide layout configurations (hardcoded for POC)
|
|
SLIDE_LAYOUTS = {
|
|
"fullscreen": {
|
|
"x": 0,
|
|
"y": 0,
|
|
"width": 1920,
|
|
"height": 1080,
|
|
},
|
|
"square": {
|
|
"x": 560, # centered horizontally: (1920 - 800) / 2
|
|
"y": 140, # positioned in upper area
|
|
"width": 800,
|
|
"height": 800,
|
|
},
|
|
}
|