"""
TSX Component Builder - Generates Remotion React components from templates.
Uses Jinja2 templates to generate type-safe TSX components.
"""
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel
from ..themes.youtube_themes import YOUTUBE_THEMES
from ..tokens.motion import MOTION_TOKENS
from ..tokens.spacing import SPACING_TOKENS
from ..tokens.typography import TYPOGRAPHY_TOKENS
class ComponentBuilder:
"""Builds TSX components from templates and configurations."""
def __init__(self):
"""Initialize the component builder with Jinja2 environment."""
# Get template directory - now in components folder
self.components_dir = Path(__file__).parent.parent / "components"
# Also keep old template directory for backwards compatibility
self.old_template_dir = Path(__file__).parent / "templates"
# Create Jinja2 environment with multiple search paths
search_paths = [str(self.components_dir)]
if self.old_template_dir.exists():
search_paths.append(str(self.old_template_dir))
self.env = Environment(
loader=FileSystemLoader(search_paths),
trim_blocks=True,
lstrip_blocks=True,
autoescape=False, # Don't escape TSX code
# Use [[ ]] for variables and [% %] for statements to avoid JSX {} conflicts
variable_start_string="[[",
variable_end_string="]]",
block_start_string="[%",
block_end_string="%]",
)
# Add custom filters
self.env.filters["to_camel_case"] = self._to_camel_case
self.env.filters["to_pascal_case"] = self._to_pascal_case
# Template categories for organized template discovery
self.template_categories = [
"charts",
"overlays",
"layouts",
"code",
"animations",
"content",
"frames",
"transitions",
"text_animations",
]
def _to_camel_case(self, snake_str: str) -> str:
"""Convert snake_case to camelCase."""
components = snake_str.split("_")
return components[0] + "".join(x.title() for x in components[1:])
def _to_pascal_case(self, snake_str: str) -> str:
"""Convert snake_case to PascalCase."""
return "".join(x.title() for x in snake_str.split("_"))
def _find_template(self, component_name: str) -> str:
"""
Find template file in component folders or old template structure.
Args:
component_name: Name of the component (e.g., "LowerThird")
Returns:
Template path relative to search paths
"""
# New structure: components/[category]/[Component]/template.tsx.j2
for category in self.template_categories:
component_template = self.components_dir / category / component_name / "template.tsx.j2"
if component_template.exists():
return f"{category}/{component_name}/template.tsx.j2"
# Old structure: templates/[category]/[Component].tsx.j2 (backwards compatibility)
template_filename = f"{component_name}.tsx.j2"
# Try old root directory
if self.old_template_dir.exists():
root_template = self.old_template_dir / template_filename
if root_template.exists():
return template_filename
# Try old category subdirectories
for category in self.template_categories:
category_template = self.old_template_dir / category / template_filename
if category_template.exists():
return f"{category}/{template_filename}"
# If not found, raise error
raise ValueError(f"Template {template_filename} not found in any category")
def build_component(
self, component_name: str, config: dict[str, Any], theme_name: str = "tech"
) -> str:
"""
Build a TSX component from configuration.
Args:
component_name: Name of the component (e.g., "LowerThird")
config: Component configuration dictionary
theme_name: Theme to apply
Returns:
Generated TSX component code as string
"""
# Get theme (now returns a Pydantic Theme model)
theme = YOUTUBE_THEMES.get(theme_name, YOUTUBE_THEMES["tech"])
assert theme is not None, f"Theme '{theme_name}' not found"
# Find and get template
try:
template_path = self._find_template(component_name)
template = self.env.get_template(template_path)
except Exception as e:
raise ValueError(f"Template not found for {component_name}: {e}") from e
# Get font sizes for the theme's resolution
resolution = theme.typography.default_resolution
font_sizes = getattr(TYPOGRAPHY_TOKENS.font_sizes, resolution)
# Build typography context with font_sizes for template compatibility
# This maintains backwards compatibility with existing templates that expect typography.font_sizes
typography_context = theme.typography.model_dump()
typography_context["font_sizes"] = TYPOGRAPHY_TOKENS.font_sizes.model_dump(by_alias=True)
typography_context["font_weights"] = TYPOGRAPHY_TOKENS.font_weights.model_dump()
typography_context["line_heights"] = TYPOGRAPHY_TOKENS.line_heights.model_dump()
typography_context["letter_spacing"] = TYPOGRAPHY_TOKENS.letter_spacing.model_dump()
# Create Pydantic model for combined motion context
# Combines full motion tokens with theme-specific defaults
class CombinedMotionContext(BaseModel):
"""Combined motion tokens and theme defaults for template rendering."""
# Full motion tokens
duration: Any # DurationTokens
easing: Any # EasingTokens
spring_configs: Any # SpringConfigs
enter: Any # EnterExitTokens
exit: Any # EnterExitTokens
tempo: Any # TempoTokens
platform_timing: Any # PlatformTiming
# Theme-specific defaults
default_spring: Any
default_easing: Any
default_duration: Any
class Config:
arbitrary_types_allowed = True
motion_context = CombinedMotionContext(
duration=MOTION_TOKENS.duration,
easing=MOTION_TOKENS.easing,
spring_configs=MOTION_TOKENS.spring_configs,
enter=MOTION_TOKENS.enter,
exit=MOTION_TOKENS.exit,
tempo=MOTION_TOKENS.tempo,
platform_timing=MOTION_TOKENS.platform_timing,
default_spring=theme.motion.default_spring,
default_easing=theme.motion.default_easing,
default_duration=theme.motion.default_duration,
)
# Render template - convert Pydantic models to dicts for Jinja2 template compatibility
tsx_code = template.render(
config=config,
theme=theme.model_dump(),
colors=theme.colors.model_dump(),
typography=typography_context,
motion=motion_context, # Now includes full motion tokens + theme defaults
spacing=SPACING_TOKENS.model_dump(by_alias=True),
font_sizes=font_sizes.model_dump(by_alias=True),
# Also include global token singletons for convenience
TYPOGRAPHY_TOKENS=TYPOGRAPHY_TOKENS,
SPACING_TOKENS=SPACING_TOKENS,
MOTION_TOKENS=MOTION_TOKENS,
)
return tsx_code
def build_lower_third(
self,
name: str,
title: str | None = None,
variant: str = "glass",
position: str = "bottom_left",
theme_name: str = "tech",
) -> str:
"""
Build a LowerThird component.
Args:
name: Main name/text
title: Optional subtitle
variant: Style variant (minimal, standard, glass, bold, animated)
position: Screen position
theme_name: Theme to apply
Returns:
TSX component code
"""
config = {"name": name, "title": title, "variant": variant, "position": position}
return self.build_component("LowerThird", config, theme_name)
def build_title_scene(
self,
text: str,
subtitle: str | None = None,
variant: str = "bold",
animation: str = "fade_zoom",
theme_name: str = "tech",
) -> str:
"""
Build a TitleScene component.
Args:
text: Main title text
subtitle: Optional subtitle
variant: Style variant (minimal, standard, bold, kinetic)
animation: Animation style
theme_name: Theme to apply
Returns:
TSX component code
"""
config = {"text": text, "subtitle": subtitle, "variant": variant, "animation": animation}
return self.build_component("TitleScene", config, theme_name)
def get_theme_styles(self, theme_name: str) -> str:
"""
Generate CSS-in-JS styles for a theme.
Args:
theme_name: Name of the theme
Returns:
JavaScript object with theme styles
"""
theme = YOUTUBE_THEMES.get(theme_name, YOUTUBE_THEMES["tech"])
assert theme is not None, f"Theme '{theme_name}' not found"
colors = theme.colors
styles = {
"primary": colors.primary[0] if isinstance(colors.primary, list) else colors.primary,
"accent": colors.accent[0] if isinstance(colors.accent, list) else colors.accent,
"gradient": colors.gradient,
"background": colors.background.model_dump(),
"text": colors.text.model_dump(),
}
return f"export const themeStyles = {styles};"