AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""hms_domain – plattformneutrale Domänenobjekte (PLAN.md §10)."""
|
||||
|
||||
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",
|
||||
"new_node_id",
|
||||
"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",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Node-Identität und Rollen (PLAN.md §6.3, §10.1, §3.6).
|
||||
|
||||
Der Control Core lädt beim Start:
|
||||
- persistente node_id aus userdata/identity/node_id (IP-/hostname-unabhängig)
|
||||
- konfigurierbare Rollen (RENDER_NODE, COORDINATOR, CONTROL_DESK)
|
||||
- Anzeigename (editierbar, nicht identitätsstiftend)
|
||||
|
||||
Rollen sind Pflicht für Discovery (TXT 'roles') und Cluster-Protokoll.
|
||||
Es gibt in V1 keine automatische Leader-Wahl (§6.3): der Betreiber legt die
|
||||
Coordinator-Rolle fest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from hms_domain.ids import new_node_id, persistent_node_id
|
||||
|
||||
|
||||
class NodeRole(StrEnum):
|
||||
"""Rollen gemäß §6.3. Keine Auto-Leader-Wahl: Rollen sind Konfiguration."""
|
||||
|
||||
RENDER_NODE = "RENDER_NODE"
|
||||
COORDINATOR = "COORDINATOR"
|
||||
CONTROL_DESK = "CONTROL_DESK"
|
||||
|
||||
|
||||
_VALID_COMBOS = {
|
||||
# reine Render-Nodes: decodieren und rendern lokal (§6.3)
|
||||
frozenset({NodeRole.RENDER_NODE}),
|
||||
# Coordinator kann auf einem Render-Node mitlaufen (§6.3)
|
||||
frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR}),
|
||||
# reiner Coordinator / Control-PC ohne Renderer (HEADLESS_CONTROL, §6.3)
|
||||
frozenset({NodeRole.COORDINATOR}),
|
||||
frozenset({NodeRole.CONTROL_DESK}),
|
||||
frozenset({NodeRole.CONTROL_DESK, NodeRole.COORDINATOR}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NodeIdentity:
|
||||
"""Stabiles Identitätsbündel eines Servers (§3.6, §10.1).
|
||||
|
||||
- node_id: persistent, UUID, unabhängig von IP/Hostname
|
||||
- display_name: editierbar; Umbenennung zerstört keine Automationen,
|
||||
weil Parameterpfade UUID-basiert sind (§10.2)
|
||||
- roles: konfigurierte Rollen; RENDER_NODE impliziert lokalen Renderer
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
display_name: str
|
||||
roles: frozenset[NodeRole]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.node_id:
|
||||
raise ValueError("node_id darf nicht leer sein")
|
||||
if not self.roles:
|
||||
raise ValueError("mindestens eine Rolle erforderlich (§6.3)")
|
||||
if frozenset(self.roles) not in _VALID_COMBOS:
|
||||
allowed = ", ".join(
|
||||
sorted("|".join(sorted(r.value for r in c)) for c in _VALID_COMBOS)
|
||||
)
|
||||
raise ValueError(
|
||||
f"ungültige Rollenkombination {sorted(r.value for r in self.roles)}; "
|
||||
f"erlaubt: {allowed}"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_coordinator(self) -> bool:
|
||||
return NodeRole.COORDINATOR in self.roles
|
||||
|
||||
@property
|
||||
def renders_locally(self) -> bool:
|
||||
return NodeRole.RENDER_NODE in self.roles
|
||||
|
||||
@classmethod
|
||||
def load_or_create(
|
||||
cls,
|
||||
identity_dir: Path,
|
||||
display_name: str,
|
||||
roles: frozenset[NodeRole],
|
||||
) -> NodeIdentity:
|
||||
"""Lädt die persistente node_id oder erzeugt sie genau einmal.
|
||||
|
||||
IP-Wechsel ändern die node_id nicht; Doppelvergabe wird über die
|
||||
Datei (O_EXCL) verhindert (§6.3, hms_domain.ids).
|
||||
"""
|
||||
identity_file = identity_dir / "node_id"
|
||||
node_id = persistent_node_id(identity_file)
|
||||
return cls(node_id=node_id, display_name=display_name, roles=roles)
|
||||
|
||||
@classmethod
|
||||
def ephemeral(cls, display_name: str, roles: frozenset[NodeRole]) -> NodeIdentity:
|
||||
"""Nur für Tests: Identität ohne Persistenz (kein Showbetrieb)."""
|
||||
return cls(node_id=new_node_id(), display_name=display_name, roles=roles)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Stabile IDs (PLAN.md §3.6, §10.1).
|
||||
|
||||
- UUIDs für alle Show-Objekte.
|
||||
- Persistente node_id: einmal erzeugt, dauerhaft gespeichert; unabhängig
|
||||
von IP-Adresse und Hostname (§6.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
"""Stabile UUID für Show-Objekte (Layer, Effekte, Outputs, ...)."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _machine_independent_seed() -> bytes:
|
||||
"""Einstreu ohne IP/Hostname: OS-Urandom hat Priorität (§6.3)."""
|
||||
return os.urandom(16)
|
||||
|
||||
|
||||
def new_node_id() -> str:
|
||||
"""Erzeugt eine neue, netzwerkunabhängige node_id (UUIDv4)."""
|
||||
return str(uuid.UUID(bytes=_machine_independent_seed(), version=4))
|
||||
|
||||
|
||||
def persistent_node_id(identity_file: Path) -> str:
|
||||
"""Lädt die node_id aus identity_file oder erzeugt sie genau einmal.
|
||||
|
||||
IP-Wechsel ändern die node_id nicht; doppelte Vergabe über die Datei
|
||||
wird durch exklusives Erzeugen (O_EXCL) verhindert.
|
||||
"""
|
||||
identity_file = Path(identity_file)
|
||||
if identity_file.exists():
|
||||
existing = identity_file.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
uuid.UUID(existing) # Validierung: muss UUID sein
|
||||
return existing
|
||||
identity_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
candidate = new_node_id()
|
||||
try:
|
||||
fd = os.open(identity_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(candidate)
|
||||
return candidate
|
||||
except FileExistsError:
|
||||
existing = identity_file.read_text(encoding="utf-8").strip()
|
||||
if not existing:
|
||||
raise
|
||||
return existing
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user