Add CI/CD workflow
This commit is contained in:
@@ -59,7 +59,21 @@ class MultiResolutionHLSOutput:
|
||||
ipfs_gateway: str = "https://ipfs.io/ipfs",
|
||||
on_playlist_update: callable = None,
|
||||
audio_source: str = None,
|
||||
resume_from: Optional[Dict] = None,
|
||||
):
|
||||
"""Initialize multi-resolution HLS output.
|
||||
|
||||
Args:
|
||||
output_dir: Directory for HLS output files
|
||||
source_size: (width, height) of source frames
|
||||
fps: Frames per second
|
||||
segment_duration: Duration of each HLS segment in seconds
|
||||
ipfs_gateway: IPFS gateway URL for playlist URLs
|
||||
on_playlist_update: Callback when playlists are updated
|
||||
audio_source: Optional audio file to mux with video
|
||||
resume_from: Optional dict to resume from checkpoint with keys:
|
||||
- segment_cids: Dict of quality -> {seg_num: cid}
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.source_width, self.source_height = source_size
|
||||
@@ -75,6 +89,13 @@ class MultiResolutionHLSOutput:
|
||||
self.qualities: Dict[str, QualityLevel] = {}
|
||||
self._setup_quality_levels()
|
||||
|
||||
# Restore segment CIDs if resuming (don't re-upload existing segments)
|
||||
if resume_from and resume_from.get('segment_cids'):
|
||||
for name, cids in resume_from['segment_cids'].items():
|
||||
if name in self.qualities:
|
||||
self.qualities[name].segment_cids = dict(cids)
|
||||
print(f"[MultiResHLS] Restored {len(cids)} segment CIDs for {name}", file=sys.stderr)
|
||||
|
||||
# IPFS client
|
||||
from ipfs_client import add_file, add_bytes
|
||||
self._ipfs_add_file = add_file
|
||||
|
||||
@@ -128,6 +128,14 @@ class StreamInterpreter:
|
||||
# Signature: on_progress(percent: float, frame_num: int, total_frames: int)
|
||||
self.on_progress: callable = None
|
||||
|
||||
# Callback for checkpoint saves (called at segment boundaries for resumability)
|
||||
# Signature: on_checkpoint(checkpoint: dict)
|
||||
# checkpoint contains: frame_num, t, scans
|
||||
self.on_checkpoint: callable = None
|
||||
|
||||
# Frames per segment for checkpoint timing (default 4 seconds at 30fps = 120 frames)
|
||||
self._frames_per_segment: int = 120
|
||||
|
||||
def _resolve_name(self, name: str) -> Optional[Path]:
|
||||
"""Resolve a friendly name to a file path using the naming service."""
|
||||
try:
|
||||
@@ -989,8 +997,40 @@ class StreamInterpreter:
|
||||
else:
|
||||
scan['state'] = {'acc': new_state}
|
||||
|
||||
def run(self, duration: float = None, output: str = "pipe"):
|
||||
"""Run the streaming pipeline."""
|
||||
def _restore_checkpoint(self, checkpoint: dict):
|
||||
"""Restore scan states from a checkpoint.
|
||||
|
||||
Called when resuming a render from a previous checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint: Dict with 'scans' key containing {scan_name: state_dict}
|
||||
"""
|
||||
scans_state = checkpoint.get('scans', {})
|
||||
for name, state in scans_state.items():
|
||||
if name in self.scans:
|
||||
self.scans[name]['state'] = dict(state)
|
||||
print(f"Restored scan '{name}' state from checkpoint", file=sys.stderr)
|
||||
|
||||
def _get_checkpoint_state(self) -> dict:
|
||||
"""Get current scan states for checkpointing.
|
||||
|
||||
Returns:
|
||||
Dict mapping scan names to their current state dicts
|
||||
"""
|
||||
return {name: dict(scan['state']) for name, scan in self.scans.items()}
|
||||
|
||||
def run(self, duration: float = None, output: str = "pipe", resume_from: dict = None):
|
||||
"""Run the streaming pipeline.
|
||||
|
||||
Args:
|
||||
duration: Duration in seconds (auto-detected from audio if None)
|
||||
output: Output mode ("pipe", "preview", path/hls, path/ipfs-hls, or file path)
|
||||
resume_from: Checkpoint dict to resume from, with keys:
|
||||
- frame_num: Last completed frame
|
||||
- t: Time value for checkpoint frame
|
||||
- scans: Dict of scan states to restore
|
||||
- segment_cids: Dict of quality -> {seg_num: cid} for output resume
|
||||
"""
|
||||
# Import output classes - handle both package and direct execution
|
||||
try:
|
||||
from .output import PipeOutput, DisplayOutput, FileOutput, HLSOutput, IPFSHLSOutput
|
||||
@@ -1010,6 +1050,11 @@ class StreamInterpreter:
|
||||
|
||||
self._init()
|
||||
|
||||
# Restore checkpoint state if resuming
|
||||
if resume_from:
|
||||
self._restore_checkpoint(resume_from)
|
||||
print(f"Resuming from frame {resume_from.get('frame_num', 0)}", file=sys.stderr)
|
||||
|
||||
if not self.frame_pipeline:
|
||||
print("Error: no (frame ...) pipeline defined", file=sys.stderr)
|
||||
return
|
||||
@@ -1061,6 +1106,12 @@ class StreamInterpreter:
|
||||
hls_dir = output[:-9] # Remove /ipfs-hls suffix
|
||||
import os
|
||||
ipfs_gateway = os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs")
|
||||
|
||||
# Build resume state for output if resuming
|
||||
output_resume = None
|
||||
if resume_from and resume_from.get('segment_cids'):
|
||||
output_resume = {'segment_cids': resume_from['segment_cids']}
|
||||
|
||||
# Use multi-resolution output (renders original + 720p + 360p)
|
||||
if MultiResolutionHLSOutput is not None:
|
||||
print(f"[StreamInterpreter] Using multi-resolution HLS output ({w}x{h} + 720p + 360p)", file=sys.stderr)
|
||||
@@ -1071,6 +1122,7 @@ class StreamInterpreter:
|
||||
ipfs_gateway=ipfs_gateway,
|
||||
on_playlist_update=self.on_playlist_update,
|
||||
audio_source=audio,
|
||||
resume_from=output_resume,
|
||||
)
|
||||
# Fallback to GPU single-resolution if multi-res not available
|
||||
elif GPUHLSOutput is not None and check_gpu_encode_available():
|
||||
@@ -1083,6 +1135,16 @@ class StreamInterpreter:
|
||||
else:
|
||||
out = FileOutput(output, size=(w, h), fps=fps, audio_source=audio)
|
||||
|
||||
# Calculate frames per segment based on fps and segment duration (4 seconds default)
|
||||
segment_duration = 4.0
|
||||
self._frames_per_segment = int(fps * segment_duration)
|
||||
|
||||
# Determine start frame (resume from checkpoint + 1, or 0)
|
||||
start_frame = 0
|
||||
if resume_from and resume_from.get('frame_num') is not None:
|
||||
start_frame = resume_from['frame_num'] + 1
|
||||
print(f"Starting from frame {start_frame} (checkpoint was at {resume_from['frame_num']})", file=sys.stderr)
|
||||
|
||||
try:
|
||||
frame_times = []
|
||||
profile_interval = 30 # Profile every N frames
|
||||
@@ -1090,7 +1152,7 @@ class StreamInterpreter:
|
||||
eval_times = []
|
||||
write_times = []
|
||||
|
||||
for frame_num in range(n_frames):
|
||||
for frame_num in range(start_frame, n_frames):
|
||||
if not out.is_open:
|
||||
break
|
||||
|
||||
@@ -1127,6 +1189,19 @@ class StreamInterpreter:
|
||||
frame_elapsed = time.time() - frame_start
|
||||
frame_times.append(frame_elapsed)
|
||||
|
||||
# Checkpoint at segment boundaries (every _frames_per_segment frames)
|
||||
if frame_num > 0 and frame_num % self._frames_per_segment == 0:
|
||||
if self.on_checkpoint:
|
||||
try:
|
||||
checkpoint = {
|
||||
'frame_num': frame_num,
|
||||
't': ctx.t,
|
||||
'scans': self._get_checkpoint_state(),
|
||||
}
|
||||
self.on_checkpoint(checkpoint)
|
||||
except Exception as e:
|
||||
print(f"Warning: checkpoint callback failed: {e}", file=sys.stderr)
|
||||
|
||||
# Progress with timing and profile breakdown
|
||||
if frame_num % profile_interval == 0 and frame_num > 0:
|
||||
pct = 100 * frame_num / n_frames
|
||||
|
||||
Reference in New Issue
Block a user