Files
hms-mediaengine/packages/domain/hms_domain/model.py
T
HMS MediaEngine Agent 402ff7268c 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
2026-09-11 01:15:11 +02:00

453 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)