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,35 @@
|
||||
"""hms_renderer – Render-Worker (PLAN.md §6.1C, §12, §36 Nr. 4–6).
|
||||
|
||||
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. 4–5).
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user