Fix lazy audio path resolution for GPU streaming
Some checks are pending
GPU Worker CI/CD / test (push) Waiting to run
GPU Worker CI/CD / deploy (push) Blocked by required conditions

Audio playback path was being resolved during parsing when database
may not be ready, causing fallback to non-existent path. Now resolves
lazily when stream starts, matching how audio analyzer works.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-04 11:32:04 +00:00
parent ef3638d3cf
commit ed617fcdd6
9 changed files with 159 additions and 57 deletions

View File

@@ -235,6 +235,7 @@ class GPUHLSOutput:
self.ipfs_gateway = ipfs_gateway.rstrip("/")
self._on_playlist_update = on_playlist_update
self._is_open = True
self.audio_source = audio_source
# GPU encoder
self._gpu_encoder = GPUEncoder(size[0], size[1], fps, crf)
@@ -266,14 +267,29 @@ class GPUHLSOutput:
print(f"[GPUHLSOutput] Initialized {size[0]}x{size[1]} @ {fps}fps, GPU encoding", file=sys.stderr)
def _setup_muxer(self):
"""Setup ffmpeg for muxing H.264 to MPEG-TS segments."""
"""Setup ffmpeg for muxing H.264 to MPEG-TS segments with optional audio."""
self.local_playlist_path = self.output_dir / "stream.m3u8"
cmd = [
"ffmpeg", "-y",
"-f", "h264", # Input is raw H.264
"-i", "-",
"-c:v", "copy", # Just copy, no re-encoding
]
# Add audio input if provided
if self.audio_source:
cmd.extend(["-i", str(self.audio_source)])
cmd.extend(["-map", "0:v", "-map", "1:a"])
cmd.extend([
"-c:v", "copy", # Just copy video, no re-encoding
])
# Add audio codec if we have audio
if self.audio_source:
cmd.extend(["-c:a", "aac", "-b:a", "128k", "-shortest"])
cmd.extend([
"-f", "hls",
"-hls_time", str(self.segment_duration),
"-hls_list_size", "0",
@@ -281,12 +297,14 @@ class GPUHLSOutput:
"-hls_segment_type", "mpegts",
"-hls_segment_filename", str(self.output_dir / "segment_%05d.ts"),
str(self.local_playlist_path),
]
])
print(f"[GPUHLSOutput] FFmpeg cmd: {' '.join(cmd)}", file=sys.stderr)
self._muxer = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stderr=subprocess.PIPE, # Capture stderr for debugging
)
def write(self, frame: Union[np.ndarray, 'cp.ndarray'], t: float = 0):

View File

@@ -11,6 +11,8 @@ Supports:
import numpy as np
import subprocess
import threading
import queue
from abc import ABC, abstractmethod
from typing import Tuple, Optional, List, Union
from pathlib import Path
@@ -665,12 +667,18 @@ class IPFSHLSOutput(Output):
self.segment_cids: dict = {} # segment_number -> cid
self._last_segment_checked = -1
self._playlist_cid: Optional[str] = None
self._upload_lock = threading.Lock()
# Import IPFS client
from ipfs_client import add_file, add_bytes
self._ipfs_add_file = add_file
self._ipfs_add_bytes = add_bytes
# Background upload thread for async IPFS uploads
self._upload_queue = queue.Queue()
self._upload_thread = threading.Thread(target=self._upload_worker, daemon=True)
self._upload_thread.start()
# Local HLS paths
self.local_playlist_path = self.output_dir / "stream.m3u8"
@@ -727,9 +735,38 @@ class IPFSHLSOutput(Output):
stderr=None,
)
def _upload_new_segments(self):
"""Check for new segments and upload them to IPFS."""
def _upload_worker(self):
"""Background worker thread for async IPFS uploads."""
import sys
while True:
try:
item = self._upload_queue.get(timeout=1.0)
if item is None: # Shutdown signal
break
seg_path, seg_num = item
self._do_upload(seg_path, seg_num)
except queue.Empty:
continue
except Exception as e:
print(f"Upload worker error: {e}", file=sys.stderr)
def _do_upload(self, seg_path: Path, seg_num: int):
"""Actually perform the upload (runs in background thread)."""
import sys
try:
cid = self._ipfs_add_file(seg_path, pin=True)
if cid:
with self._upload_lock:
self.segment_cids[seg_num] = cid
print(f"IPFS: segment_{seg_num:05d}.ts -> {cid}", file=sys.stderr)
self._update_ipfs_playlist()
except Exception as e:
print(f"Failed to upload segment {seg_num}: {e}", file=sys.stderr)
def _upload_new_segments(self):
"""Check for new segments and queue them for async IPFS upload."""
import sys
import time
# Find all segments
segments = sorted(self.output_dir.glob("segment_*.ts"))
@@ -739,53 +776,48 @@ class IPFSHLSOutput(Output):
seg_name = seg_path.stem # segment_00000
seg_num = int(seg_name.split("_")[1])
# Skip if already uploaded
if seg_num in self.segment_cids:
continue
# Skip if already uploaded or queued
with self._upload_lock:
if seg_num in self.segment_cids:
continue
# Skip if segment is still being written (check if file size is stable)
# Skip if segment is still being written (quick non-blocking check)
try:
size1 = seg_path.stat().st_size
if size1 == 0:
continue # Empty file, still being created
import time
time.sleep(0.1)
time.sleep(0.01) # Very short check
size2 = seg_path.stat().st_size
if size1 != size2:
continue # File still being written
except FileNotFoundError:
continue
# Upload to IPFS
cid = self._ipfs_add_file(seg_path, pin=True)
if cid:
self.segment_cids[seg_num] = cid
print(f"IPFS: segment_{seg_num:05d}.ts -> {cid}", file=sys.stderr)
# Update playlist after each segment upload
self._update_ipfs_playlist()
# Queue for async upload (non-blocking!)
self._upload_queue.put((seg_path, seg_num))
def _update_ipfs_playlist(self):
"""Generate and upload IPFS-aware m3u8 playlist."""
if not self.segment_cids:
return
import sys
# Build m3u8 content with IPFS URLs
lines = [
"#EXTM3U",
"#EXT-X-VERSION:3",
f"#EXT-X-TARGETDURATION:{int(self.segment_duration) + 1}",
"#EXT-X-MEDIA-SEQUENCE:0",
]
with self._upload_lock:
if not self.segment_cids:
return
# Add segments in order
for seg_num in sorted(self.segment_cids.keys()):
cid = self.segment_cids[seg_num]
lines.append(f"#EXTINF:{self.segment_duration:.3f},")
lines.append(f"{self.ipfs_gateway}/{cid}")
# Build m3u8 content with IPFS URLs
lines = [
"#EXTM3U",
"#EXT-X-VERSION:3",
f"#EXT-X-TARGETDURATION:{int(self.segment_duration) + 1}",
"#EXT-X-MEDIA-SEQUENCE:0",
]
# Add segments in order
for seg_num in sorted(self.segment_cids.keys()):
cid = self.segment_cids[seg_num]
lines.append(f"#EXTINF:{self.segment_duration:.3f},")
lines.append(f"{self.ipfs_gateway}/{cid}")
playlist_content = "\n".join(lines) + "\n"
@@ -842,9 +874,13 @@ class IPFSHLSOutput(Output):
self._process.wait()
self._is_open = False
# Upload any remaining segments
# Queue any remaining segments
self._upload_new_segments()
# Wait for pending uploads to complete
self._upload_queue.put(None) # Signal shutdown
self._upload_thread.join(timeout=30)
# Generate final playlist with #EXT-X-ENDLIST
if self.segment_cids:
lines = [

View File

@@ -928,7 +928,18 @@ class StreamInterpreter:
ctx = Context(fps=fps)
# Output (with optional audio sync)
# Resolve audio path lazily here if it wasn't resolved during parsing
audio = self.audio_playback
if audio and not Path(audio).exists():
# Try to resolve as friendly name (may have failed during parsing)
audio_name = Path(audio).name # Get just the name part
resolved = self._resolve_name(audio_name)
if resolved and resolved.exists():
audio = str(resolved)
print(f"Lazy resolved audio: {audio}", file=sys.stderr)
else:
print(f"WARNING: Audio file not found: {audio}", file=sys.stderr)
audio = None
if output == "pipe":
out = PipeOutput(size=(w, h), fps=fps, audio_source=audio)
elif output == "preview":