Phase 2: Medien-Engine – Transport, Preload, Library, Content-Manifest

- PlaybackController (§12.5): Play/Pause/Stop/Retrigger, Loop/Once/
  Ping-Pong mit In/Out-Fenstern, variable Geschwindigkeit ±4x (§16.6),
  Ende-Ereignis genau einmal je Pass, monotone Ereignis-Zeitbasis (§12.2)
- PreloadSlot (§12.2): atomarer Clipwechsel preload->ready->commit;
  nicht bereites Medium wechselt nie (Policy beim Aufrufer)
- MediaLibrary (§13.2/§13.3): Import mit SHA-256-Duplikaterkennung,
  Bank-Slots als explizite Show-Metadaten, fehlende Dateien markiert,
  Relink; Import laeuft im Control-Core-Worker, nie im Renderthread
- ContentManifest (§6.4/§10.1): SHA-256 je Datei + resumierbare
  Chunk-Hashes, Verifikation gegen Veraenderung, Diff fuer Sync-Plan,
  deterministische Serialisierung mit Revision
- 35 neue Unit-Tests; Gesamtsuite 258 gruen, Ruff gruen
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 01:25:23 +02:00
parent 402ff7268c
commit d24fe3a963
10 changed files with 1028 additions and 0 deletions
@@ -0,0 +1,17 @@
"""hms_content_sync Content-Manifest und Sync-Basis (§6.4, §10.1)."""
from hms_content_sync.manifest import (
CHUNK_SIZE,
ContentManifest,
ManifestDiff,
ManifestEntry,
hash_file,
)
__all__ = [
"CHUNK_SIZE",
"ContentManifest",
"ManifestDiff",
"ManifestEntry",
"hash_file",
]
@@ -0,0 +1,209 @@
"""ContentManifest (PLAN.md §10.1, §6.4).
Content Sync (§6.4): SHA-256-Manifest, resumierbare Chunks, Hashprüfung,
Staging Ziel sind byte-identische freigegebene Inhalte auf allen Nodes.
Manifest-Einträge decken Medien, Plugins und Projektdateien ab; die Art
wird je Eintrag vermerkt, damit Preflight Plugin-API- und
Backend-Anforderungen prüfen kann (§10.1).
"""
from __future__ import annotations
import hashlib
import os
import uuid
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
CHUNK_SIZE = 4 * 1024 * 1024 # 4 MiB Standard-Chunk für Resume (§6.4)
KIND_MEDIA = "media"
KIND_PLUGIN = "plugin"
KIND_PROJECT = "project"
def hash_file(path: Path | str, chunk_size: int = CHUNK_SIZE) -> tuple[str, list[str]]:
"""SHA-256 über die ganze Datei + je Chunk (resumierbar, §6.4).
Rückgabe: (file_hash_hex, [chunk_hash_hex, ...] in Dateireihenfolge).
"""
if chunk_size <= 0:
raise ValueError("chunk_size muss positiv sein")
file_hash = hashlib.sha256()
chunk_hashes: list[str] = []
with open(path, "rb") as fh:
while True:
data = fh.read(chunk_size)
if not data:
break
chunk_hashes.append(hashlib.sha256(data).hexdigest())
file_hash.update(data)
if not chunk_hashes: # leere Datei: ein leerer Chunk-Hash
chunk_hashes.append(hashlib.sha256(b"").hexdigest())
return file_hash.hexdigest(), chunk_hashes
@dataclass(frozen=True)
class ManifestEntry:
"""Eine Datei im Manifest (§10.1): Pfad, Größe, Hash, Art."""
rel_path: str # portabel, POSIX-relativ
kind: str # media | plugin | project
size_bytes: int
sha256: str
chunk_hashes: tuple[str, ...] = ()
def to_dict(self) -> dict:
return {
"rel_path": self.rel_path,
"kind": self.kind,
"size_bytes": self.size_bytes,
"sha256": self.sha256,
"chunk_hashes": list(self.chunk_hashes),
}
@classmethod
def from_dict(cls, data: dict) -> ManifestEntry:
return cls(
rel_path=data["rel_path"],
kind=data["kind"],
size_bytes=int(data["size_bytes"]),
sha256=data["sha256"],
chunk_hashes=tuple(data.get("chunk_hashes", ())),
)
@dataclass(frozen=True)
class ManifestDiff:
"""Unterschied zweier Manifeste (Basis für Sync-Transfer, §6.4)."""
added: frozenset[str] = field(default_factory=frozenset)
removed: frozenset[str] = field(default_factory=frozenset)
changed: frozenset[str] = field(default_factory=frozenset)
@property
def empty(self) -> bool:
return not (self.added or self.removed or self.changed)
class ContentManifest:
"""Versioniertes Inhalts-Manifest (§10.1 ContentManifest).
- manifest_id: Instanz-ID (UUID)
- revision: monotone Manifest-Revision (neu bei jeder Änderung)
- entries: rel_path → ManifestEntry
Der Manifest-Inhalt selbst ist deterministisch (sortierte Einträge);
die manifest_id identifiziert den Ausgabezeitpunkt.
"""
def __init__(
self,
manifest_id: str | None = None,
revision: int = 0,
entries: dict[str, ManifestEntry] | None = None,
) -> None:
self.manifest_id = manifest_id or str(uuid.uuid4())
self.revision = revision
self._entries: dict[str, ManifestEntry] = dict(entries or {})
@property
def entries(self) -> dict[str, ManifestEntry]:
return dict(self._entries)
# ---------- Aufbau ----------
@classmethod
def build(
cls,
root: Path | str,
kind_of=None,
chunk_size: int = CHUNK_SIZE,
revision: int = 0,
) -> ContentManifest:
"""Baut das Manifest aus einem Verzeichnis (§6.4 SHA-256).
kind_of: callable(rel_path) → "media"|"plugin"|"project";
Default: alles KIND_MEDIA.
"""
root_path = Path(root)
if not root_path.is_dir():
raise FileNotFoundError(f"Manifest-Root fehlt: {root_path}")
entries: dict[str, ManifestEntry] = {}
for dirpath, _dirnames, filenames in os.walk(root_path):
for name in sorted(filenames):
abs_path = Path(dirpath) / name
if not abs_path.is_file():
continue
rel = abs_path.relative_to(root_path).as_posix()
if ".." in PurePosixPath(rel).parts:
continue # defensive: nie Traversal ins Manifest
file_hash, chunks = hash_file(abs_path, chunk_size=chunk_size)
kind = kind_of(rel) if kind_of else KIND_MEDIA
entries[rel] = ManifestEntry(
rel_path=rel,
kind=kind,
size_bytes=abs_path.stat().st_size,
sha256=file_hash,
chunk_hashes=tuple(chunks),
)
return cls(revision=revision, entries=entries)
# ---------- Verifikation (§6.4 Hashprüfung) ----------
def verify_file(self, root: Path | str, rel_path: str) -> bool:
"""Prüft Größe + Hash einer Datei gegen den Manifest-Eintrag."""
entry = self._entries.get(rel_path)
if entry is None:
return False
abs_path = Path(root) / rel_path
if not abs_path.is_file():
return False
stat = abs_path.stat()
if stat.st_size != entry.size_bytes:
return False
file_hash, _chunks = hash_file(abs_path)
return file_hash == entry.sha256
# ---------- Diff (§6.4 Sync-Basis) ----------
def diff(self, other: ContentManifest) -> ManifestDiff:
"""Was muss von self nach other übertragen/gelöscht werden?"""
mine = set(self._entries)
theirs = set(other._entries)
added = frozenset(theirs - mine)
removed = frozenset(mine - theirs)
changed = frozenset(
rel
for rel in mine & theirs
if self._entries[rel].sha256 != other._entries[rel].sha256
)
return ManifestDiff(added=added, removed=removed, changed=changed)
# ---------- Serialisierung ----------
def to_dict(self) -> dict:
return {
"manifest_id": self.manifest_id,
"revision": self.revision,
"entries": {
rel: entry.to_dict()
for rel, entry in sorted(self._entries.items())
},
}
@classmethod
def from_dict(cls, data: dict) -> ContentManifest:
entries = {
rel: ManifestEntry.from_dict(entry)
for rel, entry in data.get("entries", {}).items()
}
return cls(
manifest_id=data.get("manifest_id"),
revision=int(data.get("revision", 0)),
entries=entries,
)
def __len__(self) -> int:
return len(self._entries)
+19
View File
@@ -0,0 +1,19 @@
"""hms_media Playback-Transport und Medienbibliothek (PLAN.md §12.5, §13)."""
from hms_media.library import ImportOutcome, ImportStatus, MediaLibrary
from hms_media.transport import (
PlaybackController,
PlaybackEvent,
PlaybackEventKind,
PreloadSlot,
)
__all__ = [
"PlaybackController",
"PlaybackEvent",
"PlaybackEventKind",
"PreloadSlot",
"MediaLibrary",
"ImportOutcome",
"ImportStatus",
]
+172
View File
@@ -0,0 +1,172 @@
"""Medienbibliothek: Index, Import, Duplikate, Banken (§13.2, §13.3).
Import-Regeln (§13.3):
- Import blockiert niemals den Renderthread: der Control Core ruft diese
Methoden aus einem Worker-Thread/Executor auf (asyncio.to_thread).
- Duplikate werden über den Inhalts-Hash erkannt.
- Medienbank und Clipnummer sind explizite Show-Metadaten, nicht vom
Dateinamen abhängig (§13.2).
- Fehlende Dateien werden markiert und können neu verknüpft werden.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path, PurePosixPath
from hms_domain.model import MediaAsset
class ImportStatus(StrEnum):
IMPORTED = "imported"
DUPLICATE = "duplicate"
MISSING_FILE = "missing_file"
@dataclass(frozen=True)
class ImportOutcome:
"""Ergebnis eines Imports (§13.3)."""
status: ImportStatus
asset: MediaAsset | None
duplicate_of: str | None = None # asset_id des existierenden Duplikats
class MediaLibrary:
"""Medienindex über portablem Media-Root (§13.2).
- Assets werden über stabile UUIDs adressiert, nie über Pfade.
- Duplikaterkennung über SHA-256 (Content-Hash).
- Bank-Registry: (bank, index) → asset_id als Show-Metadaten.
"""
def __init__(self, media_root: Path) -> None:
self._media_root = Path(media_root)
self._assets: dict[str, MediaAsset] = {}
self._by_hash: dict[str, str] = {} # content_hash → asset_id
self._banks: dict[tuple[int, int], str] = {} # (bank, index) → asset_id
self._missing: set[str] = set()
@property
def media_root(self) -> Path:
return self._media_root
# ---------- Import (§13.3) ----------
def import_file(self, abs_path: Path | str) -> ImportOutcome:
"""Importiert eine Datei; Duplikate werden erkannt (§13.3).
Läuft im Control Core in einem Worker-Thread; blockiert den
Renderthread nie (dieser Prozess hat keinen Zugriff auf die Library).
"""
path = Path(abs_path)
if not path.is_file():
return ImportOutcome(status=ImportStatus.MISSING_FILE, asset=None)
from hms_content_sync.manifest import hash_file
file_hash, _chunks = hash_file(path)
existing_id = self._by_hash.get(file_hash)
if existing_id is not None:
return ImportOutcome(
status=ImportStatus.DUPLICATE,
asset=self._assets[existing_id],
duplicate_of=existing_id,
)
rel = self._relative_to_media_root(path)
stat = path.stat()
asset = MediaAsset(
rel_path=rel,
file_size_bytes=stat.st_size,
mtime_ns=stat.st_mtime_ns,
content_hash=file_hash,
)
self._assets[asset.id] = asset
self._by_hash[file_hash] = asset.id
self._missing.discard(asset.id)
return ImportOutcome(status=ImportStatus.IMPORTED, asset=asset)
# ---------- Bank-Registry (§13.2 Show-Metadaten) ----------
def register_bank_slot(self, asset_id: str, bank: int, index: int) -> None:
"""Clipnummer/Bank als explizite Metadaten setzen (§13.2).
Nicht Dateinamen-basiert: Umbenennen der Datei ändert nichts.
"""
if asset_id not in self._assets:
raise KeyError(f"unbekanntes Asset {asset_id}")
if bank < 0 or index < 0:
raise ValueError("bank und index müssen >= 0 sein")
self._banks[(bank, index)] = asset_id
def asset_at(self, bank: int, index: int) -> MediaAsset | None:
asset_id = self._banks.get((bank, index))
return self._assets.get(asset_id) if asset_id else None
# ---------- Fehlende Dateien / Relink (§13.3) ----------
def mark_missing(self) -> list[str]:
"""Prüft Existenz aller Dateien; liefert neu fehlende asset_ids."""
newly_missing: list[str] = []
for asset in self._assets.values():
exists = (self._media_root / asset.rel_path).is_file()
if not exists and asset.id not in self._missing:
self._missing.add(asset.id)
newly_missing.append(asset.id)
elif exists:
self._missing.discard(asset.id)
return newly_missing
@property
def missing_asset_ids(self) -> frozenset[str]:
return frozenset(self._missing)
def relink(self, asset_id: str, new_abs_path: Path | str) -> MediaAsset:
"""Verknüpft ein Asset mit einer neuen Datei (§13.3).
Hash wird neu bestimmt; ändert sich der Inhalt, wird der Hash
aktualisiert (Pfad-Referenz bleibt dieselbe Asset-ID).
"""
asset = self._assets.get(asset_id)
if asset is None:
raise KeyError(f"unbekanntes Asset {asset_id}")
path = Path(new_abs_path)
if not path.is_file():
raise FileNotFoundError(str(path))
from hms_content_sync.manifest import hash_file
file_hash, _chunks = hash_file(path)
rel = self._relative_to_media_root(path)
stat = path.stat()
updated = asset.model_copy(
update={
"rel_path": rel,
"file_size_bytes": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
"content_hash": file_hash,
}
)
self._assets[asset_id] = updated
self._by_hash[file_hash] = asset_id
self._missing.discard(asset_id)
return updated
# ---------- Abfragen ----------
def get(self, asset_id: str) -> MediaAsset | None:
return self._assets.get(asset_id)
def all(self) -> list[MediaAsset]:
return list(self._assets.values())
def __len__(self) -> int:
return len(self._assets)
# ---------- Interna ----------
def _relative_to_media_root(self, path: Path) -> str:
rel = path.resolve().relative_to(self._media_root.resolve())
posix = PurePosixPath(*rel.parts).as_posix()
return posix
+196
View File
@@ -0,0 +1,196 @@
"""Playback-Transportmodell und atomarer Clipwechsel (§12.2, §12.5).
Reine Steuerungslogik ohne Pixelverarbeitung (§33): Positions-, Loop- und
Trigger-Berechnung sind plattformneutral und werden vom Renderer pro Frame
ausgeführt. Ping-Pong ist modellseitig verfügbar ob ein Medium es
performant kann, entscheidet die Codec-Eignung (MediaAsset.seek_suitability,
§12.5); ungeeignete Medien werden sichtbar gemeldet, nicht still verlangsamt.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from enum import StrEnum
from hms_domain.model import LoopMode, TransportState
class PlaybackEventKind(StrEnum):
"""Ereignisse je Advancement-Schritt (§12.5 Ende-Ereignis)."""
END_OF_MEDIA = "end_of_media"
LOOP_WRAP = "loop_wrap"
DIRECTION_CHANGE = "direction_change"
@dataclass(frozen=True)
class PlaybackEvent:
"""Ein Wiedergabe-Ereignis mit Position und monotone Zeit."""
kind: PlaybackEventKind
source_id: str
position: float
monotonic_ns: int
class PlaybackController:
"""Transport-Zustandsmaschine für genau eine Quelle (§12.5).
- play/pause/stop/retrigger
- Position normalisiert 0..1, In-/Out-Punkte aus Source (§10.1)
- Loop, Once, Ping-Pong
- variable Geschwindigkeit; negativ = rückwärts
- advance(dt) liefert Ereignisse; Medienende genau einmal je Pass
Kein interner Timer: der Renderer ruft advance(dt) pro Frame mit der
gemeinsamen monotonen Zeitbasis (§12.2) auf.
"""
def __init__(
self,
source_id: str,
in_point: float = 0.0,
out_point: float = 1.0,
loop_mode: LoopMode = LoopMode.LOOP,
speed: float = 1.0,
) -> None:
if not 0.0 <= in_point < out_point <= 1.0:
raise ValueError("in_point muss kleiner als out_point im Bereich 0..1 sein")
self.source_id = source_id
self.in_point = in_point
self.out_point = out_point
self.loop_mode = loop_mode
self.state = TransportState.STOPPED
self.position = in_point
self.direction = 1 # +1 vorwärts, -1 rückwärts (Ping-Pong)
self.speed = speed
# ---------- Transportbefehle (§12.5) ----------
def play(self) -> None:
self.state = TransportState.PLAYING
def pause(self) -> None:
if self.state is TransportState.PLAYING:
self.state = TransportState.PAUSED
def stop(self) -> None:
"""Stop: Transport angehalten, Position zurück auf In-Point."""
self.state = TransportState.STOPPED
self.position = self.in_point
self.direction = 1
def retrigger(self) -> None:
"""Neustart ab In-Point (flankenbasiert getriggert, §16.5)."""
self.position = self.in_point
self.direction = 1
self.state = TransportState.PLAYING
def seek(self, position: float) -> None:
if not self.in_point <= position <= self.out_point:
raise ValueError(f"seek außerhalb In/Out: {position}")
self.position = position
def set_speed(self, speed: float) -> None:
if speed == 0.0:
raise ValueError("speed 0 unzulässig; pause() verwenden")
self.speed = speed
# ---------- Frame-Advancement ----------
def advance(self, dt_s: float, now_ns: int | None = None) -> list[PlaybackEvent]:
"""Advancement um dt Sekunden; liefert Ereignisse dieses Schritts.
Wird nur im PLAYING-Zustand wirksam. Grenzüberschreitungen werden
gemäß Loop-Modus behandelt; die Position bleibt immer innerhalb
[in_point, out_point].
"""
if dt_s < 0.0:
raise ValueError("dt_s darf nicht negativ sein")
if self.state is not TransportState.PLAYING or dt_s == 0.0:
return []
now = now_ns if now_ns is not None else time.monotonic_ns()
events: list[PlaybackEvent] = []
span = self.out_point - self.in_point
new_pos = self.position + self.direction * self.speed * dt_s
if self.loop_mode is LoopMode.LOOP:
if new_pos > self.out_point:
new_pos = self.in_point + (new_pos - self.out_point) % span
events.append(self._event(PlaybackEventKind.LOOP_WRAP, new_pos, now))
elif new_pos < self.in_point:
overshoot = (self.in_point - new_pos) % span
new_pos = self.out_point - overshoot if overshoot else self.in_point
events.append(self._event(PlaybackEventKind.LOOP_WRAP, new_pos, now))
elif self.loop_mode is LoopMode.ONCE:
if new_pos >= self.out_point or new_pos <= self.in_point:
new_pos = self.out_point if self.direction > 0 else self.in_point
self.state = TransportState.STOPPED
events.append(self._event(PlaybackEventKind.END_OF_MEDIA, new_pos, now))
else: # PING_PONG
if self.direction > 0 and new_pos >= self.out_point:
new_pos = max(self.out_point - (new_pos - self.out_point), self.in_point)
self.direction = -1
events.append(self._event(PlaybackEventKind.DIRECTION_CHANGE, new_pos, now))
elif self.direction < 0 and new_pos <= self.in_point:
new_pos = min(self.in_point + (self.in_point - new_pos), self.out_point)
self.direction = 1
events.append(self._event(PlaybackEventKind.DIRECTION_CHANGE, new_pos, now))
self.position = new_pos
return events
def _event(
self, kind: PlaybackEventKind, position: float, now_ns: int
) -> PlaybackEvent:
return PlaybackEvent(
kind=kind,
source_id=self.source_id,
position=position,
monotonic_ns=now_ns,
)
class PreloadSlot:
"""Vorgepufferte, atomar umschaltbare Clipauswahl (§12.2, §16.5).
Ablauf: preload() → Renderer bereitet Decoder vor → mark_ready() →
commit() wechselt an der Framegrenze atomar um. Ist beim Commit nicht
ready, bleibt das alte Bild aktiv (Policy des Aufrufers: halten/warten/
Fallback, §18.3) niemals ein halb geladenes Medium.
"""
def __init__(self) -> None:
self._pending: str | None = None # asset_id
self._ready: bool = False
@property
def pending(self) -> str | None:
return self._pending
@property
def ready(self) -> bool:
return self._ready
def preload(self, asset_id: str) -> None:
self._pending = asset_id
self._ready = False
def mark_ready(self, asset_id: str) -> None:
if asset_id != self._pending:
raise ValueError("mark_ready für anderes Asset als pending")
self._ready = True
def commit(self) -> str | None:
"""Atomarer Wechsel: gibt das Asset zurück oder None (nicht bereit)."""
if self._pending is not None and self._ready:
asset_id = self._pending
self._pending = None
self._ready = False
return asset_id
return None
def cancel(self) -> None:
self._pending = None
self._ready = False