Files
gnommo/gnommo/models.py
T

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,
},
}