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:
HMS MediaEngine Agent
2026-09-11 23:44:06 +02:00
parent 696e8eb1b3
commit 362e089be0
338 changed files with 24 additions and 387 deletions
View File
@@ -0,0 +1,35 @@
"""hms_renderer Render-Worker (PLAN.md §6.1C, §12, §36 Nr. 46).
Python orchestriert native GStreamer-Komponenten; keine Pixelverarbeitung
in Python (§2.1, §33). Der RemoteStateMirror hält den über IPC übermittelten
Showzustand (§6.2, §6.4); die RenderEngine verbindet Mirror, Playback und
Preload zu Frame-Snapshots (§11.4)."""
from hms_renderer.engine import (
FrameSnapshot,
RenderEngine,
RenderTelemetry,
SourceHandle,
)
from hms_renderer.pipelines import (
D3D11Pipeline,
DevGLPipeline,
build_compositor_pipeline,
build_single_video_pipeline,
gst_available,
)
from hms_renderer.state_mirror import RemoteStateMirror, apply_envelope
__all__ = [
"D3D11Pipeline",
"DevGLPipeline",
"build_single_video_pipeline",
"build_compositor_pipeline",
"gst_available",
"RemoteStateMirror",
"apply_envelope",
"RenderEngine",
"SourceHandle",
"FrameSnapshot",
"RenderTelemetry",
]
@@ -0,0 +1,52 @@
"""Renderer-CLI (Phase-0-Spike).
Aufruf:
python -m hms_renderer --pipeline d3d11 --video-a A --video-b B
Ohne GStreamer-Installation: kontrollierter Abbruch mit Exit-Code 2 und
klarer Meldung niemals Erfolgssimulation (§1.1 Nr. 5, §33).
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from hms_renderer.pipelines import build_compositor_pipeline, build_single_video_pipeline
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="hms-renderer")
parser.add_argument("--pipeline", choices=["d3d11", "devgl"], default="d3d11")
parser.add_argument("--video-a", required=True)
parser.add_argument("--video-b", default=None, help="zweite Quelle für Compositing")
parser.add_argument("--dry-run", action="store_true", help="nur Pipeline-String ausgeben")
args = parser.parse_args(argv)
use_d3d11 = args.pipeline == "d3d11"
if args.video_b:
pipeline = build_compositor_pipeline(args.video_a, args.video_b, d3d11=use_d3d11)
else:
pipeline = build_single_video_pipeline(args.video_a, d3d11=use_d3d11)
if args.dry_run:
print(pipeline)
return 0
gst_launch = shutil.which("gst-launch-1.0")
if gst_launch is None:
print(
"ERROR: gst-launch-1.0 nicht gefunden. GStreamer 1.28.6 muss gebündelt "
"oder installiert sein (build/windows/GSTREAMER.md).",
file=sys.stderr,
)
return 2
result = subprocess.run([gst_launch, "-v", pipeline], check=False)
return result.returncode
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,268 @@
"""Renderer-Engine: verbindet StateMirror, Playback und Preload (§12, §6.4).
Der Rendergraph läuft in nativem GStreamer/Rust (ADR-0004); diese Engine
orchestriert:
- liest Showparameter aus dem RemoteStateMirror (§6.4)
- berechnet Playback-Positionen je Quelle (§12.5)
- verwaltet Preload-Slots für atomaren Clipwechsel (§12.2)
- bildet pro Frame einen unveränderlichen Parameter-Snapshot (§11.4)
- erzeugt Rendertelemetrie (§28.2)
Keine Pixelverarbeitung in Python (§33); die GPU-Pipeline wird über
Pipeline-Definitionen an GStreamer übergeben.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from hms_domain.model import TransportState
from hms_media import PlaybackController, PreloadSlot
from hms_renderer.state_mirror import RemoteStateMirror
@dataclass(frozen=True)
class FrameSnapshot:
"""Unveränderlicher Parameter-Snapshot für genau einen Frame (§11.4).
Der Rendergraph (Rust/GStreamer) erhält genau dieses Objekt pro Frame;
halbe Zustände sind ausgeschlossen.
"""
frame_index: int
monotonic_ns: int
state_revision: int
parameters: dict[str, float]
source_positions: dict[str, float] # source_id → normalisierte Position
source_states: dict[str, str] # source_id → TransportState
active_asset_ids: dict[str, str | None] # layer_key → asset_id (nach Commit)
@dataclass
class RenderTelemetry:
"""Rendertelemetrie je Frame (§28.2, §25.4)."""
frame_index: int = 0
fps: float = 0.0
frame_time_ms: float = 0.0
dropped_frames: int = 0
active_layers: int = 0
preload_pending: int = 0
source_events: int = 0
class SourceHandle:
"""Verwaltet eine Medienquelle im Renderer (§12.5, §12.2).
Verbindet PlaybackController mit Parameterpfaden und einem PreloadSlot:
- Transport-Befehle aus dem StateMirror steuern den Controller
- Der PreloadSlot wechselt Clips atomar bei Commit
"""
def __init__(
self,
source_id: str,
parameter_prefix: str,
fps: float = 60.0,
) -> None:
self.source_id = source_id
self.parameter_prefix = parameter_prefix
self.controller = PlaybackController(source_id=source_id)
self.preload = PreloadSlot()
self._active_asset_id: str | None = None
self._fps = fps
self._last_events: list = []
@property
def active_asset_id(self) -> str | None:
return self._active_asset_id
@property
def position(self) -> float:
return self.controller.position
@property
def state(self) -> TransportState:
return self.controller.state
def sync_from_mirror(self, mirror: RemoteStateMirror) -> list:
"""Liest Transport-Befehle und Parameter aus dem StateMirror.
Rückgabe: PlaybackEvents dieses Sync-Schritts (§12.5).
"""
events: list = []
# Transport-State aus dem Mirror lesen (§11.2)
state_value = mirror.get_value(f"{self.parameter_prefix}/source/state")
if state_value is not None:
state_map = {
0: TransportState.STOPPED,
1: TransportState.PLAYING,
2: TransportState.PAUSED,
}
target_state = state_map.get(int(state_value))
if target_state is not None:
is_playing = target_state is TransportState.PLAYING
is_paused = target_state is TransportState.PAUSED
is_stopped = target_state is TransportState.STOPPED
if is_playing and self.controller.state is not TransportState.PLAYING:
self.controller.play()
elif is_paused and self.controller.state is TransportState.PLAYING:
self.controller.pause()
elif is_stopped and self.controller.state is not TransportState.STOPPED:
self.controller.stop()
# Playback-Parameter aus dem Mirror (§10.2)
speed = mirror.get_value(f"{self.parameter_prefix}/source/speed")
if speed is not None and speed != 0:
self.controller.speed = max(-4.0, min(4.0, speed))
in_point = mirror.get_value(f"{self.parameter_prefix}/source/in_point")
if in_point is not None:
self.controller.in_point = max(0.0, min(0.99, in_point))
out_point = mirror.get_value(f"{self.parameter_prefix}/source/out_point")
if out_point is not None:
self.controller.out_point = max(self.controller.in_point + 0.01, min(1.0, out_point))
# Retrigger: Flankenwert im Mirror (1.0 = triggern, danach zurück auf 0)
retrigger = mirror.get_value(f"{self.parameter_prefix}/source/retrigger")
if retrigger is not None and retrigger > 0.5:
self.controller.retrigger()
# Clip-Auswahl über PreloadSlot (§12.2, §16.5 Load/Commit)
pending_asset = mirror.get_value(f"{self.parameter_prefix}/source/asset_id_pending")
if pending_asset is not None and pending_asset > 0:
# Asset-ID ist als Hash/Integer im Mirror; real: UUID-String aus Registry
# Hier: Asset-Wechsel nur über Commit (PreloadSlot)
self.preload.preload(str(int(pending_asset)))
commit = mirror.get_value(f"{self.parameter_prefix}/source/commit")
if commit is not None and commit > 0.5:
self.preload.mark_ready(str(int(mirror.get_value(
f"{self.parameter_prefix}/source/asset_id_pending", 0
))))
committed = self.preload.commit()
if committed:
self._active_asset_id = committed # atomar gewechselt (§12.2)
self._last_events = events
return events
def advance(self, dt_s: float, now_ns: int) -> list:
"""Advancement des PlaybackControllers; liefert Events."""
return self.controller.advance(dt_s, now_ns)
class RenderEngine:
"""Zentrale Renderer-Engine: orchestriert alle Quellen pro Frame.
Ablauf pro Frame (§12.2: feste Master-Bildrate):
1. sync_from_mirror: Transport-Parameter aus dem StateMirror lesen
2. advance: Playback-Positionen um dt weiterschieben
3. snapshot: unveränderlichen Frame-Snapshot bilden
4. telemetry: Frame-Statistiken aktualisieren
"""
def __init__(self, mirror: RemoteStateMirror, fps: float = 60.0) -> None:
self._mirror = mirror
self._fps = fps
self._frame_duration_s = 1.0 / fps
self._sources: dict[str, SourceHandle] = {}
self._frame_index = 0
self._last_frame_ns = 0
self.telemetry = RenderTelemetry()
@property
def mirror(self) -> RemoteStateMirror:
return self._mirror
@property
def fps(self) -> float:
return self._fps
@property
def frame_index(self) -> int:
return self._frame_index
def add_source(self, source_id: str, parameter_prefix: str) -> SourceHandle:
"""Registriert eine Medienquelle im Renderer."""
handle = SourceHandle(source_id, parameter_prefix, self._fps)
self._sources[source_id] = handle
return handle
def remove_source(self, source_id: str) -> None:
self._sources.pop(source_id, None)
def get_source(self, source_id: str) -> SourceHandle | None:
return self._sources.get(source_id)
def sources(self) -> list[SourceHandle]:
return list(self._sources.values())
def tick(self, now_ns: int | None = None) -> FrameSnapshot:
"""Ein Frame: Sync → Advance → Snapshot.
Wird vom Renderer-Loop mit fester Master-Bildrate aufgerufen (§12.2).
"""
now = now_ns if now_ns is not None else time.monotonic_ns()
if self._last_frame_ns == 0:
self._last_frame_ns = now
dt_s = (now - self._last_frame_ns) / 1e9
self._last_frame_ns = now
# 1. Sync: Parameter aus dem Mirror lesen
all_events: list = []
for handle in self._sources.values():
handle.sync_from_mirror(self._mirror)
# 2. Advance: Playback weiterschieben
for handle in self._sources.values():
events = handle.advance(dt_s, now)
all_events.extend(events)
# 3. Snapshot: unveränderlicher Frame-Zustand (§11.4)
self._frame_index += 1
parameters = self._mirror.all_values()
source_positions: dict[str, float] = {}
source_states: dict[str, str] = {}
active_assets: dict[str, str | None] = {}
for source_id, handle in self._sources.items():
source_positions[source_id] = handle.position
source_states[source_id] = handle.state.value
active_assets[f"{handle.parameter_prefix}/source"] = handle.active_asset_id
snapshot = FrameSnapshot(
frame_index=self._frame_index,
monotonic_ns=now,
state_revision=self._mirror.revision,
parameters=parameters,
source_positions=source_positions,
source_states=source_states,
active_asset_ids=active_assets,
)
# 4. Telemetry (§28.2)
frame_time_ms = dt_s * 1000.0
active_count = sum(
1 for h in self._sources.values()
if h.state is TransportState.PLAYING
)
preload_count = sum(
1 for h in self._sources.values()
if h.preload.pending is not None
)
self.telemetry = RenderTelemetry(
frame_index=self._frame_index,
fps=1000.0 / max(frame_time_ms, 0.01),
frame_time_ms=frame_time_ms,
dropped_frames=0, # zählt der native Rendergraph
active_layers=active_count,
preload_pending=preload_count,
source_events=len(all_events),
)
return snapshot
@@ -0,0 +1,79 @@
"""Renderer-Pipeline-Definitionen (PLAN.md §12, §13, §36 Nr. 45).
Windows-Primärpfad (§12.6): d3d11h264dec → D3D11Memory → d3d11compositor
→ d3d11videosink. Kein CPU-Rundweg (Decoder → RAM → Upload verboten).
DevGL-Pfad: Nur für Entwicklung/CI-Umgebungen ohne D3D11; ausdrücklich
gekennzeichnet, kein stiller Ersatz im Normalbetrieb (§5.2, §33).
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass
@dataclass(frozen=True)
class D3D11Pipeline:
"""Primärpipeline Windows: durchgängig D3D11Memory."""
video_a: str
video_b: str
width: int = 1920
height: int = 1080
fullscreen: bool = True
def launch_string(self) -> str:
sink = "d3d11videosink fullscreen=true" if self.fullscreen else "d3d11videosink"
# Zwei Quellen → Compositor → Ausgabe (§36 Nr. 5: zwei Videos GPU-mischen)
return (
f"d3d11compositor name=mix sink_0::xpos=0 sink_0::ypos=0 "
f"sink_0::width={self.width // 2} sink_0::height={self.height} "
f"sink_1::xpos={self.width // 2} sink_1::ypos=0 "
f"sink_1::width={self.width // 2} sink_1::height={self.height} ! "
f"d3d11convert ! video/x-raw(memory:D3D11Memory),format=RGBA,"
f"width={self.width},height={self.height} ! {sink} "
f"uridecodebin uri=file:///{self.video_a} ! queue ! "
f"d3d11convert ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! "
f"d3d11convert ! mix."
)
@dataclass(frozen=True)
class DevGLPipeline:
"""Entwicklungspfad ohne D3D11 (explizit gekennzeichnet, kein Normalpfad).
Nur für CI/Dev ohne Windows-GPU; die Backend-Schnittstelle bleibt
identisch (§12.6)."""
video_a: str
video_b: str
width: int = 960
height: int = 540
def launch_string(self) -> str:
return (
f"glvideomixer name=mix ! glimagesink "
f"uridecodebin uri=file:///{self.video_a} ! queue ! glupload ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! glupload ! mix."
)
def build_single_video_pipeline(video: str, d3d11: bool = True) -> str:
"""Minimale Einzelquellen-Pipeline (§36 Nr. 4: Testvideo → Vollbild)."""
if d3d11:
return f"uridecodebin uri=file:///{video} ! d3d11convert ! d3d11videosink fullscreen=true"
return f"uridecodebin uri=file:///{video} ! glupload ! glimagesink"
def build_compositor_pipeline(video_a: str, video_b: str, d3d11: bool = True) -> str:
"""Zwei-Quellen-Compositing (§36 Nr. 5: GPU-Mischen ohne CPU-Readback)."""
if d3d11:
return D3D11Pipeline(video_a=video_a, video_b=video_b).launch_string()
return DevGLPipeline(video_a=video_a, video_b=video_b).launch_string()
def gst_available() -> bool:
"""True, wenn ein GStreamer-CLI auf dem System liegt (Diagnose, kein Fake)."""
return shutil.which("gst-launch-1.0") is not None
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""HMS MediaEngine Echter GStreamer-Renderer.
Läuft mit System-Python (hat GStreamer-Bindings) und spielt VIDEOS WIRKLICH ab.
Kommuniziert über IPC mit dem Control Core.
Dieser Prozess:
1. Empfängt Befehle über IPC (MessagePack über TCP)
2. Startet/stoppt/steuert GStreamer-Pipelines
3. Mixed Videos mit videomixer/compositor
4. Sendet Frame-Snapshots zurück
5. Erzeugt einen MJPEG-Preview-Stream für den Browser
"""
import json
import os
import signal
import socket
import struct
import sys
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
# GStreamer muss vor allem anderen importiert werden
import gi
gi.require_version("Gst", "1.0")
gi.require_version("GstVideo", "1.0")
from gi.repository import Gst, GstVideo
Gst.init(None)
class MJPEGPreview:
"""HTTP-Server, der einen MJPEG-Stream aus GStreamer empfängt und an Browser sendet."""
def __init__(self, port=8090):
self.port = port
self.running = False
self.current_jpeg = b""
self.clients = []
self.server = None
self.pipeline = None
def start_pipeline(self, width=640, height=360):
"""Erzeugt eine GStreamer-Pipeline, die Frames als JPEG encodiert."""
pipeline_str = (
f"appsrc name=preview_src is-live=true format=time "
f"caps=video/x-raw,format=RGB,width={width},height={height},framerate=15/1 ! "
f"videoconvert ! jpegenc quality=80 ! "
f"multifilesink location=/dev/null"
)
# Einfacher: appsink der JPEG-Frames liefert
self.pipeline = Gst.parse_launch(
f"videotestsrc is-live=true pattern=2 ! "
f"video/x-raw,width={width},height={height},framerate=15/1 ! "
f"videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true"
)
sink = self.pipeline.get_by_name("preview_sink")
sink.connect("new-sample", self._on_new_sample)
self.pipeline.set_state(Gst.State.PLAYING)
def _on_new_sample(self, sink):
"""Empfängt JPEG-Frames vom appsink."""
sample = sink.emit("pull-sample")
if sample:
buf = sample.get_buffer()
self.current_jpeg = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
def start_server(self):
"""Startet den HTTP-Server für den MJPEG-Stream."""
handler = self._make_handler()
self.server = HTTPServer(("0.0.0.0", self.port), handler)
self.running = True
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
thread.start()
print(f"Preview-Stream: http://0.0.0.0:{self.port}/stream.mjpg")
def _make_handler(self):
preview = self
class StreamHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/stream.mjpg":
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
while preview.running:
if preview.current_jpeg:
self.wfile.write(b"--frame\r\n")
self.wfile.write(b"Content-Type: image/jpeg\r\n")
self.wfile.write(f"Content-Length: {len(preview.current_jpeg)}\r\n\r\n")
self.wfile.write(preview.current_jpeg)
self.wfile.write(b"\r\n")
time.sleep(1.0 / 15) # 15 fps
elif self.path == "/":n self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body style='margin:0;background:#000'>"
b"<img src='/stream.mjpg' style='width:100vw;height:100vh;object-fit:contain' /></body></html>"
)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress log spam
return StreamHandler
def stop(self):
self.running = False
if self.server:
self.server.shutdown()
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
class MediaPipeline:
"""Eine echte GStreamer-Pipeline für eine Videoquelle."""
def __init__(self, video_path: str, x=0, y=0, width=640, height=360, zorder=0):
self.video_path = video_path
self.pipeline = None
self.x = x
self.y = y
self.width = width
self.height = height
self.zorder = zorder
self.opacity = 1.0
self.playing = False
def build(self, output_port=5000):
"""Baut eine echte Pipeline: decodebin -> scale -> position -> UDP."""
uri = f"file://{os.path.abspath(self.video_path)}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width={self.width},height={self.height} ! "
f"videoflip method=none ! "
f"rtpjpegenc ! "
f"udpsink host=127.0.0.1 port={output_port} sync=true"
)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Pipeline-Fehler: {e}")
return False
def build_preview(self, jpeg_sink_name="preview"):
"""Baut eine Pipeline für den Browser-Preview (JPEG-Appsink)."""
uri = f"file://{os.path.abspath(self.video_path)}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width=320,height=180 ! "
f"jpegenc ! appsink name={jpeg_sink_name} emit-signals=true"
)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Preview-Pipeline-Fehler: {e}")
return False
def play(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PLAYING)
self.playing = True
def pause(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PAUSED)
self.playing = False
def stop(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
self.playing = False
def set_opacity(self, value):
"""Setzt Opacity über volume-Element oder Alpha-Channel."""
self.opacity = max(0.0, min(1.0, value))
def get_position(self):
"""Aktuelle Position in Nanosekunden."""
if self.pipeline:
ok, pos = self.pipeline.query_position(Gst.Format.TIME)
if ok:
return pos
return 0
def get_duration(self):
"""Dauer in Nanosekunden."""
if self.pipeline:
ok, dur = self.pipeline.query_duration(Gst.Format.TIME)
if ok:
return dur
return 0
class CompositorPipeline:
"""Mischt mehrere Videoquellen zu einem Composite."""
def __init__(self, width=1280, height=720):
self.width = width
self.height = height
self.sources = {} # name -> sub-pipeline description
self.pipeline = None
def build(self, video_paths: list[str]):
"""Baut eine videomixer-Pipeline mit mehreren Quellen."""
if not video_paths:
return False
# Für eine Quelle: direkt abspielen
if len(video_paths) == 1:
uri = f"file://{os.path.abspath(video_paths[0])}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! "
f"ximagesink sync=false" # Für Container ohne Display: fakesink
)
else:
# Mehrere Quellen mit videomixer
parts = []
for i, path in enumerate(video_paths):
uri = f"file://{os.path.abspath(path)}"
x = (i % 2) * (self.width // 2)
y = (i // 2) * (self.height // 2)
parts.append(
f"uridecodebin uri={uri} ! videoconvert ! videoscale ! "
f"video/x-raw,width={self.width // 2},height={self.height // 2} ! "
f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y} "
f"sink_{i}::zorder={i}"
)
parts.append(
f"mix. ! videoconvert ! ximagesink sync=false"
)
pipeline_str = " ".join(parts)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Compositor-Fehler: {e}")
# Fallback: Einfache Test-Quelle
self.pipeline = Gst.parse_launch(
"videotestsrc is-live=true pattern=1 ! "
f"video/x-raw,width={self.width},height={self.height} ! "
"ximagesink sync=false"
)
return self.pipeline is not None
def build_with_preview(self, video_paths: list[str]):
"""Baut Pipeline mit JPEG-Appsink für Browser-Preview."""
if not video_paths:
pipeline_str = (
"videotestsrc is-live=true pattern=1 ! "
f"video/x-raw,width={self.width},height={self.height} ! "
f"videoscale ! video/x-raw,width=640,height=360 ! "
"videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true"
)
elif len(video_paths) == 1:
uri = f"file://{os.path.abspath(video_paths[0])}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width=640,height=360 ! "
f"jpegenc ! appsink name=preview_sink emit-signals=true"
)
else:
parts = []
for i, path in enumerate(video_paths):
uri = f"file://{os.path.abspath(path)}"
x = (i % 2) * 320
y = (i // 2) * 180
parts.append(
f"uridecodebin uri={uri} ! videoconvert ! videoscale ! "
f"video/x-raw,width=320,height=180 ! "
f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y}"
)
parts.append(
f"mix. ! videoconvert ! videoscale ! "
f"video/x-raw,width=640,height=360 ! "
f"jpegenc ! appsink name=preview_sink emit-signals=true"
)
pipeline_str = " ".join(parts)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Preview-Compositor-Fehler: {e}")
return False
def play(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PLAYING)
def stop(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
class Renderer:
"""Haupt-Renderer: verwaltet Compositor-Pipeline und Preview-Server."""
def __init__(self, preview_port=8090):
self.preview_port = preview_port
self.compositor = None
self.preview = MJPEGPreview(preview_port)
self.video_paths = []
self.running = False
def start(self, video_paths=None):
"""Startet den Renderer mit den gegebenen Videos."""
self.video_paths = video_paths or []
# Compositor-Pipeline mit Preview bauen
self.compositor = CompositorPipeline(width=1280, height=720)
if self.compositor.build_with_preview(self.video_paths):
# Preview-Sink verbinden
if self.compositor.pipeline:
sink = self.compositor.pipeline.get_by_name("preview_sink")
if sink:
sink.connect("new-sample", self._on_preview_frame)
# Pipeline starten
self.compositor.play()
# HTTP-Server für MJPEG starten
self.preview.start_server()
self.running = True
print(f"Renderer gestartet: {len(self.video_paths)} Video(s)")
print(f"Preview: http://0.0.0.0:{self.preview_port}/")
return True
return False
def _on_preview_frame(self, sink):
"""Empfängt JPEG-Frames für den Browser-Preview."""
sample = sink.emit("pull-sample")
if sample:
buf = sample.get_buffer()
self.preview.current_jpeg = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
def stop(self):
if self.compositor:
self.compositor.stop()
self.preview.stop()
self.running = False
def add_video(self, path):
"""Fügt ein Video hinzu (neu starten mit erweitertem Set)."""
self.video_paths.append(path)
self.stop()
self.start(self.video_paths)
def status(self):
return {
"running": self.running,
"videos": self.video_paths,
"preview_port": self.preview_port,
}
def main():
"""Haupteinstieg: Startet den Renderer mit Testvideo."""
# Testvideo erstellen wenn keins existiert
test_video = "/tmp/test_video.mp4"
if not os.path.exists(test_video):
print("Erstelle Testvideo...")
gen = Gst.parse_launch(
"videotestsrc pattern=ball num-buffers=300 ! "
"video/x-raw,width=640,height=480,framerate=30/1 ! "
"x264enc tune=zerolatency ! mp4mux ! "
f"filesink location={test_video}"
)
gen.set_state(Gst.State.PLAYING)
bus = gen.get_bus()
bus.timed_pop_filtered(Gst.CLOCK_TIME_NONE, Gst.MessageType.EOS)
gen.set_state(Gst.State.NULL)
print(f"Testvideo erstellt: {test_video}")
renderer = Renderer(preview_port=8090)
renderer.start([test_video])
# Hauptschleife
print("\nRenderer läuft.")
print(f" Preview im Browser: http://localhost:8090/")
print(f" Videos: {renderer.video_paths}")
print("\nDrücke Ctrl+C zum Beenden.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
renderer.stop()
print("Renderer beendet.")
if __name__ == "__main__":
main()
@@ -0,0 +1,96 @@
"""Remote-State-Mirror auf der Renderer-Seite (PLAN.md §6.2, §6.4).
Der Renderer hält den zuletzt übermittelten Showzustand als lokales
Spiegelbild:
- nach Verbindung: vollständiger Snapshot (§6.2 Pflicht)
- danach inkrementelle Deltas mit monotoner Revision
- Deltas werden erst nach erfolgtem Snapshot akzeptiert (§6.2:
„Deltas erst nach erfolgreichem Re-Sync")
- veraltete/doppelte Deltas sind idempotente No-Ops (§6.5)
Der Mirror ist reine Zustandslogik ohne Pixelbezug (§33); der Rendergraph
liest Werte über get_value() je Frame.
"""
from __future__ import annotations
class RemoteStateMirror:
"""Spiegel des autoritativen Showzustands im Renderer."""
def __init__(self) -> None:
self._revision = 0
self._values: dict[str, float] = {}
self._has_snapshot = False
@property
def revision(self) -> int:
return self._revision
@property
def has_snapshot(self) -> bool:
"""True, nachdem ein vollständiger Snapshot empfangen wurde."""
return self._has_snapshot
def get_value(self, path: str, default: float | None = None) -> float | None:
"""Wirksamer Wert für einen Parameterpfad (§10.2)."""
return self._values.get(path, default)
def all_values(self) -> dict[str, float]:
return dict(self._values)
# ---------- Snapshot / Delta (§6.2, §6.4) ----------
def apply_snapshot(self, payload: dict) -> None:
"""Übernimmt einen vollständigen Snapshot (nach Verbindung/Reconnect)."""
values = payload.get("values", {})
if not isinstance(values, dict):
raise ValueError("Snapshot ohne Werte-Objekt")
self._revision = int(payload["state_revision"])
self._values = {str(k): float(v) for k, v in values.items()}
self._has_snapshot = True
def apply_delta(self, payload: dict) -> bool:
"""Wendet ein Delta an.
Rückgabe:
- True: Delta angewendet ODER als veraltetes Duplikat ignoriert
- False: Re-Sync nötig (noch kein Snapshot empfangen, §6.2)
Gelöschte Pfade sind als None kodiert (StateDelta-Vertrag).
"""
if not self._has_snapshot:
return False # §6.2: Deltas erst nach Snapshot
delta_revision = int(payload["state_revision"])
if delta_revision <= self._revision:
return True # idempotent: Duplikat/veraltet, kein Handlungsbedarf
changes = payload.get("changes", {})
if not isinstance(changes, dict):
raise ValueError("Delta ohne Changes-Objekt")
for path, value in changes.items():
if value is None:
self._values.pop(str(path), None) # gelöscht
else:
self._values[str(path)] = float(value)
self._revision = delta_revision
return True
def apply_envelope(mirror: RemoteStateMirror, envelope) -> bool:
"""Verarbeitet ein IPC-Envelope in den Mirror.
- SNAPSHOT: vollständige Übernahme
- EVENT mit changes: Delta-Anwendung
- alles andere (Heartbeat, Ack, …): keine Zustandswirkung
Rückgabe: True, wenn der Zustand dadurch (re-)synchronisiert wurde;
False, wenn ein Re-Sync (neuer Snapshot) angefordert werden muss.
"""
from hms_protocol import MessageType
if envelope.type is MessageType.SNAPSHOT:
mirror.apply_snapshot(envelope.payload)
return True
if envelope.type is MessageType.EVENT and "changes" in envelope.payload:
return mirror.apply_delta(envelope.payload)
return True # keine Zustandsnachricht: nichts zu tun