diff --git a/packages/domain/hms_domain/__init__.py b/packages/domain/hms_domain/__init__.py index 23b9f50..631f444 100644 --- a/packages/domain/hms_domain/__init__.py +++ b/packages/domain/hms_domain/__init__.py @@ -2,6 +2,29 @@ from hms_domain.identity import NodeIdentity, NodeRole from hms_domain.ids import new_node_id, new_uuid, persistent_node_id +from hms_domain.model import ( + DOMAIN_SCHEMA_VERSION, + BlendMode, + ColorControls, + ColorSpace, + Composition, + Crop, + EffectInstance, + EffectScope, + Layer, + LayerType, + LoopMode, + MediaAsset, + OutputSurface, + PresetScene, + Project, + QualityMode, + Source, + SourceType, + Transform2D, + TransitionType, + TransportState, +) __all__ = [ "new_uuid", @@ -9,4 +32,25 @@ __all__ = [ "persistent_node_id", "NodeIdentity", "NodeRole", + "DOMAIN_SCHEMA_VERSION", + "Project", + "Composition", + "Layer", + "LayerType", + "Source", + "SourceType", + "EffectInstance", + "EffectScope", + "BlendMode", + "Transform2D", + "Crop", + "ColorControls", + "ColorSpace", + "LoopMode", + "TransportState", + "QualityMode", + "MediaAsset", + "OutputSurface", + "PresetScene", + "TransitionType", ] diff --git a/packages/domain/hms_domain/model.py b/packages/domain/hms_domain/model.py new file mode 100644 index 0000000..0550de4 --- /dev/null +++ b/packages/domain/hms_domain/model.py @@ -0,0 +1,452 @@ +"""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) diff --git a/tests/unit/test_domain_model.py b/tests/unit/test_domain_model.py new file mode 100644 index 0000000..3a477fa --- /dev/null +++ b/tests/unit/test_domain_model.py @@ -0,0 +1,249 @@ +"""Unit-Tests Domänenmodell (PLAN.md §10.1, §12, §13.2, §18.1).""" + +from __future__ import annotations + +import pytest +from hms_domain import ( + BlendMode, + Composition, + EffectInstance, + Layer, + LayerType, + MediaAsset, + OutputSurface, + PresetScene, + Project, + Source, + SourceType, + TransitionType, +) +from pydantic import ValidationError + + +def _media_source(asset_id: str | None = None) -> Source: + return Source( + plugin_id="hms.source.video", + source_type=SourceType.VIDEO, + asset_id=asset_id or "00000000-0000-0000-0000-000000000001", + ) + + +def _generator_source() -> Source: + return Source( + plugin_id="hms.generator.solid", + source_type=SourceType.GENERATOR, + ) + + +def _media_layer(**overrides) -> Layer: + defaults = dict(name="Video A", layer_type=LayerType.MEDIA, source=_media_source()) + defaults.update(overrides) + return Layer(**defaults) + + +# ---------- Source (§10.1, §12.5) ---------- + + +def test_media_source_requires_asset() -> None: + with pytest.raises(ValidationError, match="asset_id"): + Source(plugin_id="hms.source.video", source_type=SourceType.VIDEO) + + +def test_generator_source_needs_no_asset() -> None: + src = _generator_source() + assert src.asset_id is None + assert src.speed == 1.0 + + +def test_in_point_must_be_before_out_point() -> None: + with pytest.raises(ValidationError, match="in_point"): + Source( + plugin_id="hms.source.video", + source_type=SourceType.VIDEO, + asset_id="00000000-0000-0000-0000-000000000001", + in_point=0.8, + out_point=0.2, + ) + + +def test_speed_bounds_per_plan() -> None: + """§16.6: bis maximal 4x, negative Geschwindigkeit zugelassen.""" + src = _media_source() + assert src.speed == 1.0 + with pytest.raises(ValidationError): + Source( + plugin_id="hms.source.video", + source_type=SourceType.VIDEO, + asset_id="00000000-0000-0000-0000-000000000001", + speed=8.0, + ) + + +# ---------- Layer (§10.1, §4.1, §12.3) ---------- + + +def test_media_layer_defaults() -> None: + layer = _media_layer() + assert layer.opacity == 1.0 + assert layer.blend_mode is BlendMode.NORMAL + assert layer.enabled is True + assert layer.effects == [] + assert layer.transform.scale_x == 1.0 + + +def test_layer_requires_source_for_media_types() -> None: + with pytest.raises(ValidationError, match="Quelle"): + Layer(name="Ohne Quelle", layer_type=LayerType.MEDIA) + + +def test_adjustment_layer_rejects_source() -> None: + with pytest.raises(ValidationError, match="keine eigene Quelle"): + Layer( + name="Adj", + layer_type=LayerType.ADJUSTMENT, + source=_generator_source(), + ) + + +def test_max_two_effect_slots() -> None: + def fx(i: int) -> EffectInstance: + return EffectInstance(plugin_id="com.hms.fx.x", order_index=i) + + with pytest.raises(ValidationError, match="zwei Effekt"): + _media_layer(effects=[fx(0), fx(1), fx(2)]) + + +def test_effect_order_index_unique() -> None: + fx = EffectInstance(plugin_id="com.hms.fx.x", order_index=0) + with pytest.raises(ValidationError, match="eindeutig"): + _media_layer(effects=[fx, fx.model_copy()]) + + +def test_opacity_bounds() -> None: + with pytest.raises(ValidationError): + _media_layer(opacity=1.5) + + +# ---------- Composition (§10.1) ---------- + + +def test_composition_layer_ids_unique() -> None: + base = _media_layer() + duplicate = base.model_copy() + with pytest.raises(ValidationError, match="eindeutig"): + Composition(name="C", layers=[base, duplicate]) + + +def test_composition_max_64_layers() -> None: + layers = [ + _media_layer(name=f"L{i}", source=_media_source()) for i in range(65) + ] + with pytest.raises(ValidationError, match="64"): + Composition(name="C", layers=layers) + + +def test_composition_canvas_bounds() -> None: + comp = Composition(name="HD", width=1920, height=1080, fps=60.0) + assert comp.width == 1920 + uhd = Composition(name="UHD", width=3840, height=2160) # Desktop-Zielprofil (§3.3) + assert uhd.width == 3840 + with pytest.raises(ValidationError): + Composition(name="Zu groß", width=9000) + + +def test_layer_by_id_lookup() -> None: + layer = _media_layer() + comp = Composition(name="C", layers=[layer]) + assert comp.layer_by_id(layer.id) is layer + assert comp.layer_by_id("unbekannt") is None + + +# ---------- MediaAsset (§13.2, §9.1) ---------- + + +def test_media_asset_relative_path_required() -> None: + asset = MediaAsset(rel_path="clips/intro.mp4", container="mp4", video_codec="h264") + assert asset.content_hash is None # optional bis Projektpaket (§13.2) + assert asset.seek_suitability == "unknown" # ungeprüft, nie optimistisch + + +def test_media_asset_rejects_absolute_path() -> None: + with pytest.raises(ValidationError, match="portabel-relativ"): + MediaAsset(rel_path="/absolut/clip.mp4") + + +def test_media_asset_rejects_traversal() -> None: + with pytest.raises(ValidationError, match="portabel-relativ"): + MediaAsset(rel_path="../outside/clip.mp4") + + +def test_project_rejects_duplicate_asset_paths() -> None: + a = MediaAsset(rel_path="clips/a.mp4") + b = MediaAsset(rel_path="clips/a.mp4") + with pytest.raises(ValidationError, match="Pfade müssen eindeutig"): + Project(media_assets=[a, b]) # §13.3: Duplikate erkennen + + +def test_project_rejects_duplicate_asset_ids() -> None: + a = MediaAsset(id="1", rel_path="clips/a.mp4") + b = MediaAsset(id="1", rel_path="clips/b.mp4") + with pytest.raises(ValidationError, match="MediaAsset-IDs"): + Project(media_assets=[a, b]) + + +# ---------- OutputSurface (§10.1, §22.2) ---------- + + +def test_output_surface_defaults_hold_last_frame() -> None: + out = OutputSurface(node_id="00000000-0000-0000-0000-000000000009") + assert out.fallback_policy == "hold_last_frame" # §26.1 + assert out.slice_w == 1.0 and out.slice_h == 1.0 # volle Canvas als Default + + +def test_output_surface_slice_bounds() -> None: + with pytest.raises(ValidationError): + OutputSurface( + node_id="00000000-0000-0000-0000-000000000009", slice_w=1.5 + ) + + +# ---------- PresetScene (§18.1, §18.3) ---------- + + +def test_preset_scene_snapshot_and_transition() -> None: + comp = Composition(name="Show", layers=[_media_layer()]) + scene = PresetScene( + name="Look 1", + composition_snapshot=comp.model_dump(), + transition_type=TransitionType.CROSSFADE, + transition_duration_s=2.0, + ) + assert scene.composition_snapshot["name"] == "Show" + assert scene.transition_type is TransitionType.CROSSFADE + with pytest.raises(ValidationError): + PresetScene(name="X", composition_snapshot={}, transition_duration_s=99.0) + + +# ---------- Project (§10.1, §24.4) ---------- + + +def test_project_schema_version_and_roundtrip() -> None: + asset = MediaAsset(rel_path="clips/intro.mp4") + comp = Composition(name="Main", layers=[_media_layer()]) + project = Project(name="Meine Show", media_assets=[asset], compositions=[comp]) + assert project.schema_version == 1 + data = project.model_dump() + restored = Project.model_validate(data) + assert restored == project # Persistenz-Roundtrip (SQLite speichert JSON, §24.2) + restored_comp = restored.composition_by_id(comp.id) + restored_asset = restored.asset_by_id(asset.id) + assert restored_comp is not None and restored_comp.id == comp.id + assert restored_asset is not None and restored_asset.rel_path == asset.rel_path + + +def test_project_updated_at_changes_on_mutation() -> None: + """Timestamps verwaltet der Control Core explizit, nicht das Modell.""" + project = Project(name="P") + before = project.updated_at + project.name = "Neuer Name" + assert project.updated_at == before