"""Domänenmodell (PLAN.md §10.1, §12.3, §13.2). Alle Modelle sind plattformneutral (Pydantic) und werden sowohl für Persistenz als auch API/IPC verwendet (§10). Sie enthalten keinerlei D3D11-/HLSL-/OpenGL-/GLSL-Typen (§12.6). Regeln: - stabile UUIDs für alle Objekte (§3.6) - Parameter werden über stabile Pfade adressiert, nie über Namen (§10.2) - Layer-Reihenfolge = Z-Reihenfolge, eindeutig und lückenlos - zwei Effekt-Slots je Layer im MVP (§4.1) - Blend-Modi V1 fest definiert (§12.4) - Schema-Version für Migrationen (§24.4) """ from __future__ import annotations import uuid from datetime import UTC, datetime from enum import StrEnum from pathlib import PurePosixPath from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator DOMAIN_SCHEMA_VERSION = 1 def _now() -> str: return datetime.now(UTC).isoformat() def _new_uuid() -> str: return str(uuid.uuid4()) class LayerType(StrEnum): """Layer-Typen V1 (§12.3). Media/Generator/Adjustment/Group.""" MEDIA = "media" IMAGE = "image" SOLID = "solid" GENERATOR = "generator" ADJUSTMENT = "adjustment" GROUP = "group" class BlendMode(StrEnum): """Blend-Modi V1 (§12.4) – mit Golden-Image-Tests zu prüfen.""" NORMAL = "normal" ADD = "add" MULTIPLY = "multiply" SCREEN = "screen" LIGHTEN = "lighten" DARKEN = "darken" DIFFERENCE = "difference" OVERLAY = "overlay" ALPHA_PREMULTIPLIED = "alpha_premultiplied" class SourceType(StrEnum): """Quelltypen V1 (§12.3 Media Layer + Generator Layer).""" VIDEO = "video" IMAGE = "image" SOLID = "solid" GENERATOR = "generator" class LoopMode(StrEnum): """Playback-Loop (§12.5). Ping-Pong nur wenn technisch unterstützt.""" ONCE = "once" LOOP = "loop" PING_PONG = "ping_pong" class TransportState(StrEnum): """Transportzustand einer Quelle (§12.5).""" STOPPED = "stopped" PLAYING = "playing" PAUSED = "paused" class ColorSpace(StrEnum): COLOR_SRGB = "sRGB" LINEAR = "linear" class EffectScope(StrEnum): """Instanzierbare Scopes für Filter (§14.8). V1: Layer/Group/Master.""" SOURCE = "source" LAYER = "layer" GROUP = "group" MASTER = "master" OUTPUT = "output" class QualityMode(StrEnum): """Auto Quality oder feste Stufe (§14.8).""" AUTO = "auto" FIXED = "fixed" # ---------- Transformation (2D, §10.1 Layer) ---------- class Transform2D(BaseModel): """Feste 2D-Transformation je Layer (§10.1). position_x/y und anchor in normalisierten Canvas-Koordinaten (-1..1 relativ zur Canvas-Mitte), rotation in Grad, scale um Anchor. Zusätzliche Transformeffekte stehen zusätzlich in der Effektkette (§15.2 Transform2D-Effekt). """ position_x: float = 0.0 position_y: float = 0.0 anchor_x: float = 0.5 anchor_y: float = 0.5 scale_x: float = 1.0 scale_y: float = 1.0 rotation_deg: float = 0.0 # ---------- Crop (§10.1) ---------- class Crop(BaseModel): """Beschnitt je Seite, normalisiert 0..1.""" left: float = Field(default=0.0, ge=0.0, le=1.0) right: float = Field(default=0.0, ge=0.0, le=1.0) top: float = Field(default=0.0, ge=0.0, le=1.0) bottom: float = Field(default=0.0, ge=0.0, le=1.0) # ---------- Farbsteuerung (§10.1 color_controls) ---------- class ColorControls(BaseModel): """Layer-Farbregler (Layer64-Kanäle 37–40, §16.4).""" hue: float = Field(default=0.0, ge=-0.5, le=0.5) saturation: float = Field(default=1.0, ge=0.0, le=2.0) brightness: float = Field(default=1.0, ge=0.0, le=2.0) contrast: float = Field(default=1.0, ge=0.0, le=2.0) # ---------- Quelle (§10.1 Source) ---------- class Source(BaseModel): """Medien-/Generator-Quelle eines Layers (§10.1). - plugin_id/plugin_version: versionierter Plugin-Vertrag (§14) - asset_id: stabile UUID des MediaAsset, nie ein Pfad (§13.2) - in_point/out_point: normalisiert 0..1 (§12.5) - speed: 1.0 = Normalwiedergabe; Reverse nur wenn Medium geeignet (§12.5) """ plugin_id: str plugin_version: str = "1.0.0" source_type: SourceType asset_id: str | None = None parameters: dict[str, float] = Field(default_factory=dict) playback_state: TransportState = TransportState.STOPPED in_point: float = Field(default=0.0, ge=0.0, le=1.0) out_point: float = Field(default=1.0, ge=0.0, le=1.0) loop_mode: LoopMode = LoopMode.LOOP speed: float = Field(default=1.0, ge=-4.0, le=4.0) volume: float = Field(default=1.0, ge=0.0, le=1.0) audio_enabled: bool = True @model_validator(mode="after") def _validate_points(self) -> Source: if self.in_point >= self.out_point: raise ValueError("in_point muss kleiner als out_point sein") if self.source_type in (SourceType.VIDEO, SourceType.IMAGE) and not self.asset_id: raise ValueError("Medien-Quellen benötigen ein asset_id") return self # ---------- Effektinstanz (§10.1 EffectInstance) ---------- class EffectInstance(BaseModel): """Effektinstanz mit unverändertem Effektvertrag (§14.8). - mix 0 bypassed kostengünstig (§15.3) - requested_quality persistiert; resolved_quality ist Laufzeitzustand und wird nicht persistiert - bypass_on_error: Pluginfehler überbrücken, Layer bleibt aktiv (§12.2) """ id: str = Field(default_factory=_new_uuid) plugin_id: str plugin_version: str = "1.0.0" scope: EffectScope = EffectScope.LAYER order_index: int = 0 enabled: bool = True mix: float = Field(default=1.0, ge=0.0, le=1.0) effect_blend_mode: BlendMode = BlendMode.NORMAL parameters: dict[str, float] = Field(default_factory=dict) preset_id: str | None = None quality_mode: QualityMode = QualityMode.AUTO requested_quality: str = "auto" bypass_on_error: bool = True # ---------- Layer (§10.1 Layer) ---------- class Layer(BaseModel): """Layer einer Composition (§10.1). z_index: eindeutige Z-Reihenfolge; 0 = unten (§10.1 „layers[] in eindeutiger Z-Reihenfolge"). 8 gleichzeitig patchbare Layer im MVP (§4.1); maximal 64 werden schema-seitig zugelassen. """ model_config = ConfigDict(validate_assignment=True) id: str = Field(default_factory=_new_uuid) name: str = "Layer" enabled: bool = True layer_type: LayerType source: Source | None = None opacity: float = Field(default=1.0, ge=0.0, le=1.0) blend_mode: BlendMode = BlendMode.NORMAL transform: Transform2D = Field(default_factory=Transform2D) crop: Crop = Field(default_factory=Crop) color_controls: ColorControls = Field(default_factory=ColorControls) effects: list[EffectInstance] = Field(default_factory=list) mask_id: str | None = None target_group_id: str | None = None dmx_patch_id: str | None = None @field_validator("effects") @classmethod def _max_two_effect_slots(cls, v: list[EffectInstance]) -> list[EffectInstance]: if len(v) > 2: raise ValueError("maximal zwei Effekt-Slots je Layer im MVP (§4.1)") order = [e.order_index for e in v] if len(order) != len(set(order)): raise ValueError("order_index muss eindeutig sein (§14.8)") return v @model_validator(mode="after") def _type_source_consistency(self) -> Layer: media_types = ( LayerType.MEDIA, LayerType.IMAGE, LayerType.SOLID, LayerType.GENERATOR, ) if self.layer_type in media_types: if self.source is None: raise ValueError("Layer dieses Typs benötigen eine Quelle") if self.layer_type in (LayerType.ADJUSTMENT, LayerType.GROUP) and self.source is not None: raise ValueError("Adjustment-/Group-Layer besitzen keine eigene Quelle") return self # ---------- Composition (§10.1) ---------- class Composition(BaseModel): """Master-Canvas-Definition (§10.1). - width/height: virtuelle Canvas (Desktop bis 3840×2160, §3.3) - fps: feste Master-Bildrate (§12.2) - layers: eindeutige Z-Reihenfolge, unten = 0 """ model_config = ConfigDict(validate_assignment=True) id: str = Field(default_factory=_new_uuid) name: str = "Composition" width: int = Field(default=1920, ge=16, le=8192) height: int = Field(default=1080, ge=16, le=8192) fps: float = Field(default=60.0, gt=0.0, le=240.0) color_space: ColorSpace = ColorSpace.COLOR_SRGB background_color: str = "#000000" duration: float | None = None layers: list[Layer] = Field(default_factory=list) @field_validator("layers") @classmethod def _unique_z_order(cls, v: list[Layer]) -> list[Layer]: ids = [layer.id for layer in v] if len(ids) != len(set(ids)): raise ValueError("Layer-IDs müssen eindeutig sein") if len(v) > 64: raise ValueError("maximal 64 Layer je Composition") return v def layer_by_id(self, layer_id: str) -> Layer | None: return next((layer for layer in self.layers if layer.id == layer_id), None) def sorted_layers(self) -> list[Layer]: """Render-Reihenfolge: unten zuerst (Z aufsteigend über Listenposition).""" return list(self.layers) # ---------- MediaAsset (§13.2) ---------- class MediaAsset(BaseModel): """Medienasset (§13.2): stabile UUID + relativer portabler Pfad. - rel_path: relativ zum Projekt-/Media-Root, POSIX-Notation (§9.1: keine Laufwerksbuchstaben, keine absoluten Pfade) - content_hash: bei Projektpaketen verpflichtend (§13.2) - seek_suitability: dokumentierte Eignung für Seek/Reverse (§12.5); ungeprüft = unknown, niemals optimistisch """ id: str = Field(default_factory=_new_uuid) rel_path: str file_size_bytes: int = Field(default=0, ge=0) mtime_ns: int = 0 content_hash: str | None = None container: str | None = None video_codec: str | None = None audio_codec: str | None = None width: int | None = None height: int | None = None fps: float | None = None duration_s: float | None = None has_alpha: bool = False audio_streams: int = 0 thumbnail_rel_path: str | None = None proxy_rel_path: str | None = None seek_suitability: str = "unknown" # unknown|fast_seek|slow_seek|unsuitable analysis_state: str = "pending" # pending|running|done|failed @field_validator("rel_path") @classmethod def _portable_relative_path(cls, v: str) -> str: p = PurePosixPath(v) if p.is_absolute() or ".." in p.parts: raise ValueError(f"rel_path muss portabel-relativ sein: {v!r} (§9.1)") if not v: raise ValueError("rel_path darf nicht leer sein") return v # ---------- OutputSurface (§10.1, §22.2) ---------- class OutputSurface(BaseModel): """Physische/Logische Ausgabefläche (§10.1 OutputSurface, §22.2). Das Mapping-Datenmodell ist ab V1 vorhanden (§22.1), auch wenn der vollständige Mapping-Editor später folgt. """ id: str = Field(default_factory=_new_uuid) node_id: str display_id: str = "" enabled: bool = True width: int = Field(default=1920, ge=16, le=8192) height: int = Field(default=1080, ge=16, le=8192) refresh_rate_hz: float = Field(default=60.0, gt=0.0) # Canvas-Ausschnitt (§22.2): Quellrechteck in normalisierten Koordinaten slice_x: float = Field(default=0.0, ge=0.0, le=1.0) slice_y: float = Field(default=0.0, ge=0.0, le=1.0) slice_w: float = Field(default=1.0, ge=0.0, le=1.0) slice_h: float = Field(default=1.0, ge=0.0, le=1.0) destination_x: int = 0 destination_y: int = 0 rotation_deg: float = 0.0 flip_horizontal: bool = False flip_vertical: bool = False test_pattern: int = 0 # 0 = aus; Enum folgt mit Mapping-Phase fallback_policy: str = "hold_last_frame" # §26.1 # ---------- PresetScene (§18.1) ---------- class TransitionType(StrEnum): """Preset-Übergänge V1 (§18.3).""" CUT = "cut" CROSSFADE = "crossfade" DIP_TO_BLACK = "dip_to_black" WIPE_HORIZONTAL = "wipe_horizontal" WIPE_VERTICAL = "wipe_vertical" LUMA_FADE = "luma_fade" PLUGIN = "plugin" class PresetScene(BaseModel): """Szene/Preset: normalisierter Composition-Snapshot (§18.1). Empfehlung §18.1: intern normalisierter Snapshot mit deduplizierten Asset-/Plugin-Referenzen; Übergänge berechnen den Diff zur Laufzeit. """ id: str = Field(default_factory=_new_uuid) name: str composition_snapshot: dict # normalisiertes Composition-Serialisat transition_type: TransitionType = TransitionType.CUT transition_duration_s: float = Field(default=0.0, ge=0.0, le=60.0) preload_hints: list[str] = Field(default_factory=list) # asset_ids # ---------- Project (§10.1) ---------- class Project(BaseModel): """Projekt (§10.1) mit schema_version für Migrationen (§24.4).""" model_config = ConfigDict(validate_assignment=True) schema_version: int = DOMAIN_SCHEMA_VERSION id: str = Field(default_factory=_new_uuid) name: str = "Neues Projekt" created_at: str = Field(default_factory=_now) updated_at: str = Field(default_factory=_now) settings: dict = Field(default_factory=dict) media_assets: list[MediaAsset] = Field(default_factory=list) compositions: list[Composition] = Field(default_factory=list) scenes: list[PresetScene] = Field(default_factory=list) timelines: list[dict] = Field(default_factory=list) # Phase 6+ (§19) outputs: list[OutputSurface] = Field(default_factory=list) control_bindings: list[dict] = Field(default_factory=list) audio_profiles: list[dict] = Field(default_factory=list) automation_policies: list[dict] = Field(default_factory=list) plugin_requirements: list[dict] = Field(default_factory=list) @field_validator("media_assets") @classmethod def _unique_asset_ids(cls, v: list[MediaAsset]) -> list[MediaAsset]: ids = [a.id for a in v] if len(ids) != len(set(ids)): raise ValueError("MediaAsset-IDs müssen eindeutig sein") paths = [a.rel_path for a in v] if len(paths) != len(set(paths)): raise ValueError("MediaAsset-Pfade müssen eindeutig sein (§13.3 Duplikate)") return v def composition_by_id(self, composition_id: str) -> Composition | None: return next((c for c in self.compositions if c.id == composition_id), None) def asset_by_id(self, asset_id: str) -> MediaAsset | None: return next((a for a in self.media_assets if a.id == asset_id), None)