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:
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -32,6 +32,8 @@ packages = [
|
|||||||
"packages/plugin_sdk/hms_plugin_sdk",
|
"packages/plugin_sdk/hms_plugin_sdk",
|
||||||
"packages/persistence/hms_persistence",
|
"packages/persistence/hms_persistence",
|
||||||
"packages/cluster/hms_cluster",
|
"packages/cluster/hms_cluster",
|
||||||
|
"packages/media/hms_media",
|
||||||
|
"packages/content_sync/hms_content_sync",
|
||||||
"apps/renderer/hms_renderer",
|
"apps/renderer/hms_renderer",
|
||||||
"apps/control_server/hms_control_server",
|
"apps/control_server/hms_control_server",
|
||||||
"apps/launcher/hms_launcher",
|
"apps/launcher/hms_launcher",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ _PACKAGE_DIRS = [
|
|||||||
"packages/plugin_sdk",
|
"packages/plugin_sdk",
|
||||||
"packages/persistence",
|
"packages/persistence",
|
||||||
"packages/cluster",
|
"packages/cluster",
|
||||||
|
"packages/media",
|
||||||
|
"packages/content_sync",
|
||||||
"apps/renderer",
|
"apps/renderer",
|
||||||
"apps/control_server",
|
"apps/control_server",
|
||||||
"apps/launcher",
|
"apps/launcher",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Unit-Tests Content-Manifest (PLAN.md §6.4, §10.1, §29.1).
|
||||||
|
|
||||||
|
Manifest-Chunks/-Hashes, Diff für Sync, Verifikation gegen Fälschung
|
||||||
|
und Rundreise – Grundlage für Preflight und resumierbare Übertragung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from hms_content_sync import CHUNK_SIZE, ContentManifest, hash_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def content_tree(tmp_path: Path) -> Path:
|
||||||
|
root = tmp_path / "content"
|
||||||
|
(root / "clips").mkdir(parents=True)
|
||||||
|
(root / "clips" / "intro.mp4").write_bytes(b"A" * 1000)
|
||||||
|
(root / "clips" / "loop.mp4").write_bytes(b"B" * 500)
|
||||||
|
(root / "presets").mkdir()
|
||||||
|
(root / "presets" / "look.json").write_text("{}", encoding="utf-8")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_file_chunks_match_content(tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "f.bin"
|
||||||
|
f.write_bytes(b"x" * (CHUNK_SIZE + 100)) # > 1 Chunk
|
||||||
|
file_hash, chunks = hash_file(f)
|
||||||
|
assert len(chunks) == 2 # voller + Rest-Chunk
|
||||||
|
assert len(file_hash) == 64 # SHA-256 Hex
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_file_empty_file_yields_empty_chunk(tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "empty.bin"
|
||||||
|
f.write_bytes(b"")
|
||||||
|
file_hash, chunks = hash_file(f)
|
||||||
|
assert len(chunks) == 1
|
||||||
|
assert file_hash # auch leere Dateien haben einen definierten Hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_build_covers_all_files(content_tree: Path) -> None:
|
||||||
|
manifest = ContentManifest.build(content_tree, revision=7)
|
||||||
|
assert len(manifest) == 3
|
||||||
|
assert manifest.revision == 7
|
||||||
|
assert "clips/intro.mp4" in manifest.entries
|
||||||
|
assert "presets/look.json" in manifest.entries
|
||||||
|
entry = manifest.entries["clips/intro.mp4"]
|
||||||
|
assert entry.size_bytes == 1000
|
||||||
|
assert len(entry.sha256) == 64
|
||||||
|
assert entry.kind == "media" # Default-Art
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_kind_classification(content_tree: Path) -> None:
|
||||||
|
manifest = ContentManifest.build(
|
||||||
|
content_tree,
|
||||||
|
kind_of=lambda rel: "plugin" if rel.endswith(".json") else "media",
|
||||||
|
)
|
||||||
|
assert manifest.entries["presets/look.json"].kind == "plugin"
|
||||||
|
assert manifest.entries["clips/intro.mp4"].kind == "media"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_roundtrip_preserves_entries(content_tree: Path) -> None:
|
||||||
|
manifest = ContentManifest.build(content_tree, revision=3)
|
||||||
|
restored = ContentManifest.from_dict(json.loads(json.dumps(manifest.to_dict())))
|
||||||
|
assert restored.revision == 3
|
||||||
|
assert set(restored.entries) == set(manifest.entries)
|
||||||
|
orig = manifest.entries["clips/intro.mp4"]
|
||||||
|
copy = restored.entries["clips/intro.mp4"]
|
||||||
|
assert copy.sha256 == orig.sha256
|
||||||
|
assert copy.chunk_hashes == orig.chunk_hashes
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_is_deterministic_across_builds(content_tree: Path) -> None:
|
||||||
|
"""Gleicher Inhalt → gleiche Einträge (nur manifest_id ist neu)."""
|
||||||
|
a = ContentManifest.build(content_tree)
|
||||||
|
b = ContentManifest.build(content_tree)
|
||||||
|
assert a.to_dict()["entries"] == b.to_dict()["entries"]
|
||||||
|
assert a.manifest_id != b.manifest_id # Instanz-ID ist zeitlich unique
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_detects_tampered_file(content_tree: Path) -> None:
|
||||||
|
manifest = ContentManifest.build(content_tree)
|
||||||
|
assert manifest.verify_file(content_tree, "clips/intro.mp4")
|
||||||
|
(content_tree / "clips" / "intro.mp4").write_bytes(b"TAMPERED" * 50)
|
||||||
|
assert not manifest.verify_file(content_tree, "clips/intro.mp4") # §6.4 Hashprüfung
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_detects_missing_and_unknown(content_tree: Path) -> None:
|
||||||
|
manifest = ContentManifest.build(content_tree)
|
||||||
|
(content_tree / "clips" / "loop.mp4").unlink()
|
||||||
|
assert not manifest.verify_file(content_tree, "clips/loop.mp4")
|
||||||
|
assert not manifest.verify_file(content_tree, "gibts/nicht.mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_drives_sync_plan(content_tree: Path) -> None:
|
||||||
|
"""Diff liefert exakt die Übertragungsliste (§6.4: added/removed/changed)."""
|
||||||
|
old = ContentManifest.build(content_tree, revision=1)
|
||||||
|
# Ändern + Hinzufügen + Entfernen
|
||||||
|
(content_tree / "clips" / "intro.mp4").write_bytes(b"GEAENDERT")
|
||||||
|
(content_tree / "clips" / "neu.mp4").write_bytes(b"N")
|
||||||
|
(content_tree / "clips" / "loop.mp4").unlink()
|
||||||
|
new = ContentManifest.build(content_tree, revision=2)
|
||||||
|
|
||||||
|
diff = old.diff(new)
|
||||||
|
assert diff.added == frozenset({"clips/neu.mp4"})
|
||||||
|
assert diff.removed == frozenset({"clips/loop.mp4"})
|
||||||
|
assert diff.changed == frozenset({"clips/intro.mp4"})
|
||||||
|
assert not diff.empty
|
||||||
|
|
||||||
|
# identische Manifeste: leerer Diff
|
||||||
|
same = old.diff(ContentManifest.from_dict(old.to_dict()))
|
||||||
|
assert same.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_rejects_missing_root(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
ContentManifest.build(tmp_path / "gibts-nicht")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Unit-Tests Medienbibliothek (PLAN.md §13.2, §13.3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from hms_media import ImportStatus, MediaLibrary
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def media_root(tmp_path: Path) -> Path:
|
||||||
|
root = tmp_path / "media"
|
||||||
|
(root / "clips").mkdir(parents=True)
|
||||||
|
(root / "clips" / "intro.mp4").write_bytes(b"video-bytes-1" * 20)
|
||||||
|
(root / "clips" / "loop.mp4").write_bytes(b"video-bytes-2" * 20)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_creates_asset_with_hash(media_root: Path) -> None:
|
||||||
|
lib = MediaLibrary(media_root)
|
||||||
|
outcome = lib.import_file(media_root / "clips" / "intro.mp4")
|
||||||
|
assert outcome.status is ImportStatus.IMPORTED
|
||||||
|
assert outcome.asset is not None
|
||||||
|
assert outcome.asset.content_hash # §13.2: Hash vorhanden
|
||||||
|
assert outcome.asset.rel_path == "clips/intro.mp4"
|
||||||
|
assert len(lib) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_content_detected(media_root: Path) -> None:
|
||||||
|
"""Gleicher Inhalt unter anderem Namen = Duplikat (§13.3)."""
|
||||||
|
lib = MediaLibrary(media_root)
|
||||||
|
first = lib.import_file(media_root / "clips" / "intro.mp4")
|
||||||
|
copy = media_root / "clips" / "kopie.mp4"
|
||||||
|
copy.write_bytes((media_root / "clips" / "intro.mp4").read_bytes())
|
||||||
|
outcome = lib.import_file(copy)
|
||||||
|
assert outcome.status is ImportStatus.DUPLICATE
|
||||||
|
assert outcome.duplicate_of == first.asset.id # verweist auf das Original
|
||||||
|
assert len(lib) == 1 # kein zweiter Eintrag
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_missing_file_reports_cleanly(media_root: Path) -> None:
|
||||||
|
lib = MediaLibrary(media_root)
|
||||||
|
outcome = lib.import_file(media_root / "clips" / "fehlt.mp4")
|
||||||
|
assert outcome.status is ImportStatus.MISSING_FILE
|
||||||
|
assert outcome.asset is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bank_slots_are_explicit_metadata(media_root: Path) -> None:
|
||||||
|
"""Bank/Index als Show-Metadaten, unabhängig vom Dateinamen (§13.2)."""
|
||||||
|
lib = MediaLibrary(media_root)
|
||||||
|
asset = lib.import_file(media_root / "clips" / "intro.mp4").asset
|
||||||
|
lib.register_bank_slot(asset.id, bank=1, index=5)
|
||||||
|
found = lib.asset_at(bank=1, index=5)
|
||||||
|
assert found is not None and found.id == asset.id
|
||||||
|
assert lib.asset_at(bank=1, index=6) is None
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
lib.register_bank_slot("unbekannt", 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_detection_and_relink(media_root: Path) -> None:
|
||||||
|
"""Fehlende Dateien werden markiert; Relink bindet neu (§13.3)."""
|
||||||
|
lib = MediaLibrary(media_root)
|
||||||
|
asset = lib.import_file(media_root / "clips" / "loop.mp4").asset
|
||||||
|
(media_root / "clips" / "loop.mp4").unlink() # Datei verschwindet
|
||||||
|
assert lib.mark_missing() == [asset.id]
|
||||||
|
assert asset.id in lib.missing_asset_ids
|
||||||
|
|
||||||
|
# Relink auf eine neue Datei mit gleichem Namen
|
||||||
|
(media_root / "clips" / "loop.mp4").write_bytes(b"neuer-inhalt")
|
||||||
|
updated = lib.relink(asset.id, media_root / "clips" / "loop.mp4")
|
||||||
|
assert updated.content_hash != asset.content_hash # neuer Inhalt → neuer Hash
|
||||||
|
assert asset.id not in lib.missing_asset_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_relink_rejects_missing_target(media_root: Path) -> None:
|
||||||
|
lib = MediaLibrary(media_root)
|
||||||
|
asset = lib.import_file(media_root / "clips" / "loop.mp4").asset
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
lib.relink(asset.id, media_root / "nirgendwo.mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_all_and_get(media_root: Path) -> None:
|
||||||
|
lib = MediaLibrary(media_root)
|
||||||
|
a = lib.import_file(media_root / "clips" / "intro.mp4").asset
|
||||||
|
b = lib.import_file(media_root / "clips" / "loop.mp4").asset
|
||||||
|
assert {x.id for x in lib.all()} == {a.id, b.id}
|
||||||
|
assert lib.get(a.id).rel_path == "clips/intro.mp4"
|
||||||
|
assert lib.get("gibts-nicht") is None
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Unit-Tests Playback-Transport (PLAN.md §12.2, §12.5, §16.6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from hms_domain import LoopMode, TransportState
|
||||||
|
from hms_media import PlaybackController, PlaybackEventKind, PreloadSlot
|
||||||
|
|
||||||
|
|
||||||
|
def _controller(**overrides) -> PlaybackController:
|
||||||
|
defaults = dict(
|
||||||
|
source_id="src-1",
|
||||||
|
in_point=0.0,
|
||||||
|
out_point=1.0,
|
||||||
|
loop_mode=LoopMode.LOOP,
|
||||||
|
speed=1.0,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return PlaybackController(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Konstruktion und Invarianten ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_invalid_in_out_points() -> None:
|
||||||
|
with pytest.raises(ValueError, match="in_point"):
|
||||||
|
_controller(in_point=0.9, out_point=0.1)
|
||||||
|
with pytest.raises(ValueError, match="in_point"):
|
||||||
|
_controller(in_point=0.5, out_point=0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state_stopped_at_in_point() -> None:
|
||||||
|
c = _controller(in_point=0.2)
|
||||||
|
assert c.state is TransportState.STOPPED
|
||||||
|
assert c.position == pytest.approx(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Transportbefehle (§12.5) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_pause_resume_cycle() -> None:
|
||||||
|
c = _controller()
|
||||||
|
c.play()
|
||||||
|
c.advance(0.1)
|
||||||
|
pos_after_play = c.position
|
||||||
|
c.pause()
|
||||||
|
assert c.state is TransportState.PAUSED
|
||||||
|
events = c.advance(1.0) # pausiert: keine Bewegung
|
||||||
|
assert events == []
|
||||||
|
assert c.position == pytest.approx(pos_after_play)
|
||||||
|
c.play()
|
||||||
|
c.advance(0.1)
|
||||||
|
assert c.position > pos_after_play
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_resets_position_and_direction() -> None:
|
||||||
|
c = _controller(in_point=0.1)
|
||||||
|
c.play()
|
||||||
|
c.advance(0.3)
|
||||||
|
c.stop()
|
||||||
|
assert c.state is TransportState.STOPPED
|
||||||
|
assert c.position == pytest.approx(0.1)
|
||||||
|
assert c.direction == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrigger_restarts_from_in_point() -> None:
|
||||||
|
c = _controller()
|
||||||
|
c.play()
|
||||||
|
c.advance(0.4)
|
||||||
|
c.retrigger() # §16.5: flankenbasierter Neustart
|
||||||
|
assert c.position == pytest.approx(0.0)
|
||||||
|
assert c.state is TransportState.PLAYING
|
||||||
|
|
||||||
|
|
||||||
|
def test_seek_bounds_enforced() -> None:
|
||||||
|
c = _controller(in_point=0.2, out_point=0.8)
|
||||||
|
c.seek(0.5)
|
||||||
|
assert c.position == pytest.approx(0.5)
|
||||||
|
with pytest.raises(ValueError, match="In/Out"):
|
||||||
|
c.seek(0.9)
|
||||||
|
with pytest.raises(ValueError, match="In/Out"):
|
||||||
|
c.seek(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_speed_zero_rejected_pause_is_the_way() -> None:
|
||||||
|
c = _controller()
|
||||||
|
with pytest.raises(ValueError, match="speed 0"):
|
||||||
|
c.set_speed(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_speed_plays_backward() -> None:
|
||||||
|
"""§16.6: negative Geschwindigkeit zugelassen; Rückwärts am In-Point
|
||||||
|
wrappt im Loop-Modus korrekt ans Ende des Fensters (§12.5)."""
|
||||||
|
c = _controller()
|
||||||
|
c.play()
|
||||||
|
c.set_speed(-1.0)
|
||||||
|
events = c.advance(0.05)
|
||||||
|
assert c.position == pytest.approx(0.95) # Wrap nach hinten: out - 0.05
|
||||||
|
assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP
|
||||||
|
# danach läuft es weiter rückwärts Richtung In-Point
|
||||||
|
c.advance(0.05)
|
||||||
|
assert c.position == pytest.approx(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Loop-Verhalten ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_loop_wraps_with_event() -> None:
|
||||||
|
c = _controller(out_point=1.0)
|
||||||
|
c.play()
|
||||||
|
events = c.advance(1.25) # über das Ende hinaus
|
||||||
|
assert c.position == pytest.approx(0.25) # Wrap: Überschuss am Anfang
|
||||||
|
assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP
|
||||||
|
assert c.state is TransportState.PLAYING # Loop läuft weiter
|
||||||
|
|
||||||
|
|
||||||
|
def test_once_stops_at_out_point_with_end_event() -> None:
|
||||||
|
c = _controller(loop_mode=LoopMode.ONCE)
|
||||||
|
c.play()
|
||||||
|
events = c.advance(0.5)
|
||||||
|
assert events == [] # noch nicht am Ende
|
||||||
|
events = c.advance(0.6) # über das Ende hinaus
|
||||||
|
assert c.state is TransportState.STOPPED # §12.5: Ende-Ereignis
|
||||||
|
assert c.position == pytest.approx(1.0)
|
||||||
|
assert events and events[0].kind is PlaybackEventKind.END_OF_MEDIA
|
||||||
|
# weiteres Advancement bleibt gestoppt
|
||||||
|
assert c.advance(0.1) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ping_pong_reverses_direction() -> None:
|
||||||
|
c = _controller(loop_mode=LoopMode.PING_PONG)
|
||||||
|
c.play()
|
||||||
|
events = c.advance(1.2) # Ende erreicht → Richtungsumkehr
|
||||||
|
assert c.direction == -1
|
||||||
|
assert c.position == pytest.approx(0.8) # 0.2 zurückgeprallt
|
||||||
|
assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE
|
||||||
|
events = c.advance(1.6) # zurück zum Anfang → wieder vorwärts
|
||||||
|
assert c.direction == 1
|
||||||
|
assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE
|
||||||
|
assert c.state is TransportState.PLAYING # Ping-Pong läuft endlos
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_out_window_respected() -> None:
|
||||||
|
"""Loop beachtet In/Out-Fenster, nicht nur 0..1 (§12.5)."""
|
||||||
|
c = _controller(in_point=0.2, out_point=0.8)
|
||||||
|
c.play()
|
||||||
|
c.advance(0.7) # 0.2 + 0.7 = 0.9 > out 0.8
|
||||||
|
assert c.position == pytest.approx(0.3) # Wrap innerhalb des Fensters
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_rejects_negative_dt() -> None:
|
||||||
|
c = _controller()
|
||||||
|
c.play()
|
||||||
|
with pytest.raises(ValueError, match="negativ"):
|
||||||
|
c.advance(-0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_carry_source_and_monotonic_time() -> None:
|
||||||
|
c = _controller()
|
||||||
|
c.play()
|
||||||
|
events = c.advance(1.5, now_ns=123456789)
|
||||||
|
assert events[0].source_id == "src-1"
|
||||||
|
assert events[0].monotonic_ns == 123456789
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- PreloadSlot: atomarer Clipwechsel (§12.2, §16.5) ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_preload_commit_atomic_flow() -> None:
|
||||||
|
slot = PreloadSlot()
|
||||||
|
assert slot.commit() is None # nichts geladen → kein Wechsel
|
||||||
|
slot.preload("asset-a")
|
||||||
|
assert slot.pending == "asset-a"
|
||||||
|
assert slot.commit() is None # noch nicht bereit → alter Clip bleibt
|
||||||
|
slot.mark_ready("asset-a")
|
||||||
|
assert slot.commit() == "asset-a" # atomar umgeschaltet
|
||||||
|
assert slot.pending is None
|
||||||
|
assert slot.commit() is None # Slot ist wieder leer
|
||||||
|
|
||||||
|
|
||||||
|
def test_preload_cancel_discards() -> None:
|
||||||
|
slot = PreloadSlot()
|
||||||
|
slot.preload("asset-a")
|
||||||
|
slot.mark_ready("asset-a")
|
||||||
|
slot.cancel()
|
||||||
|
assert slot.pending is None
|
||||||
|
assert slot.commit() is None # abgebrochen wird nie committed
|
||||||
|
|
||||||
|
|
||||||
|
def test_preload_rejects_foreign_ready() -> None:
|
||||||
|
slot = PreloadSlot()
|
||||||
|
slot.preload("asset-a")
|
||||||
|
with pytest.raises(ValueError, match="anderes Asset"):
|
||||||
|
slot.mark_ready("asset-b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_preload_new_preload_resets_ready() -> None:
|
||||||
|
slot = PreloadSlot()
|
||||||
|
slot.preload("asset-a")
|
||||||
|
slot.mark_ready("asset-a")
|
||||||
|
slot.preload("asset-b") # umgeladen: a wird verworfen
|
||||||
|
assert slot.ready is False
|
||||||
|
assert slot.commit() is None
|
||||||
Reference in New Issue
Block a user