Phase 2: Domänenmodell nach §10.1 (plattformneutral, Pydantic)
- Project/Composition/Layer/Source/EffectInstance/MediaAsset/OutputSurface/ PresetScene mit schema_version 1 (§10, §24.4) - Validierungen: 2 Effekt-Slots (§4.1), eindeutige Layer-IDs/Z-Reihenfolge, max 64 Layer, in<out-Punkte, speed ±4x (§16.6), portable relative Pfade ohne Traversal (§9.1), eindeutige Asset-Pfade (§13.3), Canvas-Bounds bis 8192 (§3.3), Adjustment/Group ohne Quelle (§12.3) - OutputSurface mit Canvas-Slice-Modell (§22.2 Mapping-Datenmodell ab V1) - PresetScene: normalisierter Snapshot + Übergänge V1 (§18.1, §18.3) - keine Backend-Typen im Modell (§12.6) - 24 Unit-Tests; Gesamtsuite 223 gruen, Ruff gruen
This commit is contained in:
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
from hms_domain.identity import NodeIdentity, NodeRole
|
from hms_domain.identity import NodeIdentity, NodeRole
|
||||||
from hms_domain.ids import new_node_id, new_uuid, persistent_node_id
|
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__ = [
|
__all__ = [
|
||||||
"new_uuid",
|
"new_uuid",
|
||||||
@@ -9,4 +32,25 @@ __all__ = [
|
|||||||
"persistent_node_id",
|
"persistent_node_id",
|
||||||
"NodeIdentity",
|
"NodeIdentity",
|
||||||
"NodeRole",
|
"NodeRole",
|
||||||
|
"DOMAIN_SCHEMA_VERSION",
|
||||||
|
"Project",
|
||||||
|
"Composition",
|
||||||
|
"Layer",
|
||||||
|
"LayerType",
|
||||||
|
"Source",
|
||||||
|
"SourceType",
|
||||||
|
"EffectInstance",
|
||||||
|
"EffectScope",
|
||||||
|
"BlendMode",
|
||||||
|
"Transform2D",
|
||||||
|
"Crop",
|
||||||
|
"ColorControls",
|
||||||
|
"ColorSpace",
|
||||||
|
"LoopMode",
|
||||||
|
"TransportState",
|
||||||
|
"QualityMode",
|
||||||
|
"MediaAsset",
|
||||||
|
"OutputSurface",
|
||||||
|
"PresetScene",
|
||||||
|
"TransitionType",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user