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:
HMS MediaEngine Agent
2026-09-11 01:15:11 +02:00
parent e4e76a7ede
commit 402ff7268c
3 changed files with 745 additions and 0 deletions
+44
View File
@@ -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",
]
+452
View File
@@ -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 3740, §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)