Skip to content

Commit e83e6ed

Browse files
author
SentienceDEV
committed
Phase 4: ffmpeg generate video clips;
1 parent 1a26e19 commit e83e6ed

File tree

2 files changed

+298
-1
lines changed

2 files changed

+298
-1
lines changed

sentience/failure_artifacts.py

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,34 @@
11
from __future__ import annotations
22

33
import json
4+
import logging
45
import shutil
6+
import subprocess
57
import tempfile
68
import time
79
from collections.abc import Callable
8-
from dataclasses import dataclass
10+
from dataclasses import dataclass, field
911
from pathlib import Path
1012
from typing import Any, Literal
1113

14+
logger = logging.getLogger(__name__)
15+
16+
17+
@dataclass
18+
class ClipOptions:
19+
"""Options for generating video clips from frames."""
20+
21+
mode: Literal["off", "auto", "on"] = "auto"
22+
"""Clip generation mode:
23+
- "off": Never generate clips
24+
- "auto": Generate only if ffmpeg is available on PATH
25+
- "on": Always attempt to generate (will warn if ffmpeg missing)
26+
"""
27+
fps: int = 8
28+
"""Frames per second for the generated video."""
29+
seconds: float | None = None
30+
"""Duration of clip in seconds. If None, uses buffer_seconds."""
31+
1232

1333
@dataclass
1434
class FailureArtifactsOptions:
@@ -19,6 +39,7 @@ class FailureArtifactsOptions:
1939
output_dir: str = ".sentience/artifacts"
2040
on_before_persist: Callable[[RedactionContext], RedactionResult] | None = None
2141
redact_snapshot_values: bool = True
42+
clip: ClipOptions = field(default_factory=ClipOptions)
2243

2344

2445
@dataclass
@@ -47,6 +68,123 @@ class _FrameRecord:
4768
path: Path
4869

4970

71+
def _is_ffmpeg_available() -> bool:
72+
"""Check if ffmpeg is available on the system PATH."""
73+
try:
74+
result = subprocess.run(
75+
["ffmpeg", "-version"],
76+
capture_output=True,
77+
timeout=5,
78+
)
79+
return result.returncode == 0
80+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
81+
return False
82+
83+
84+
def _generate_clip_from_frames(
85+
frames_dir: Path,
86+
output_path: Path,
87+
fps: int = 8,
88+
frame_pattern: str = "frame_*.png",
89+
) -> bool:
90+
"""
91+
Generate an MP4 video clip from a directory of frames using ffmpeg.
92+
93+
Args:
94+
frames_dir: Directory containing frame images
95+
output_path: Output path for the MP4 file
96+
fps: Frames per second for the output video
97+
frame_pattern: Glob pattern to match frame files
98+
99+
Returns:
100+
True if clip was generated successfully, False otherwise
101+
"""
102+
# Find all frames and sort by timestamp (extracted from filename)
103+
frame_files = sorted(frames_dir.glob(frame_pattern))
104+
if not frame_files:
105+
# Try jpeg pattern as well
106+
frame_files = sorted(frames_dir.glob("frame_*.jpeg"))
107+
if not frame_files:
108+
frame_files = sorted(frames_dir.glob("frame_*.jpg"))
109+
if not frame_files:
110+
logger.warning("No frame files found for clip generation")
111+
return False
112+
113+
# Create a temporary file list for ffmpeg concat demuxer
114+
# This approach handles arbitrary frame filenames and timing
115+
list_file = frames_dir / "frames_list.txt"
116+
try:
117+
# Calculate frame duration based on FPS
118+
frame_duration = 1.0 / fps
119+
120+
with open(list_file, "w") as f:
121+
for frame_path in frame_files:
122+
# ffmpeg concat format: file 'path' + duration
123+
f.write(f"file '{frame_path.name}'\n")
124+
f.write(f"duration {frame_duration}\n")
125+
# Add last frame again (ffmpeg concat quirk)
126+
if frame_files:
127+
f.write(f"file '{frame_files[-1].name}'\n")
128+
129+
# Run ffmpeg to generate the clip
130+
# -y: overwrite output file
131+
# -f concat: use concat demuxer
132+
# -safe 0: allow unsafe file paths
133+
# -i: input file list
134+
# -vsync vfr: variable frame rate
135+
# -pix_fmt yuv420p: compatibility with most players
136+
# -c:v libx264: H.264 codec
137+
# -crf 23: quality (lower = better, 23 is default)
138+
cmd = [
139+
"ffmpeg",
140+
"-y",
141+
"-f",
142+
"concat",
143+
"-safe",
144+
"0",
145+
"-i",
146+
str(list_file),
147+
"-vsync",
148+
"vfr",
149+
"-pix_fmt",
150+
"yuv420p",
151+
"-c:v",
152+
"libx264",
153+
"-crf",
154+
"23",
155+
str(output_path),
156+
]
157+
158+
result = subprocess.run(
159+
cmd,
160+
capture_output=True,
161+
timeout=60, # 1 minute timeout
162+
cwd=str(frames_dir), # Run from frames dir for relative paths
163+
)
164+
165+
if result.returncode != 0:
166+
logger.warning(
167+
f"ffmpeg failed with return code {result.returncode}: "
168+
f"{result.stderr.decode('utf-8', errors='replace')[:500]}"
169+
)
170+
return False
171+
172+
return output_path.exists()
173+
174+
except subprocess.TimeoutExpired:
175+
logger.warning("ffmpeg timed out during clip generation")
176+
return False
177+
except Exception as e:
178+
logger.warning(f"Error generating clip: {e}")
179+
return False
180+
finally:
181+
# Clean up the list file
182+
try:
183+
list_file.unlink(missing_ok=True)
184+
except Exception:
185+
pass
186+
187+
50188
class FailureArtifactBuffer:
51189
"""
52190
Ring buffer of screenshots with minimal persistence on failure.
@@ -215,6 +353,42 @@ def persist(
215353
if diagnostics_payload is not None:
216354
self._write_json_atomic(run_dir / "diagnostics.json", diagnostics_payload)
217355

356+
# Generate video clip from frames (optional, requires ffmpeg)
357+
clip_generated = False
358+
clip_path: Path | None = None
359+
clip_options = self.options.clip
360+
361+
if not drop_frames and len(frame_paths) > 0 and clip_options.mode != "off":
362+
should_generate = False
363+
364+
if clip_options.mode == "auto":
365+
# Only generate if ffmpeg is available
366+
should_generate = _is_ffmpeg_available()
367+
if not should_generate:
368+
logger.debug("ffmpeg not available, skipping clip generation (mode=auto)")
369+
elif clip_options.mode == "on":
370+
# Always attempt to generate
371+
should_generate = True
372+
if not _is_ffmpeg_available():
373+
logger.warning(
374+
"ffmpeg not found on PATH but clip.mode='on'. "
375+
"Install ffmpeg to generate video clips."
376+
)
377+
should_generate = False
378+
379+
if should_generate:
380+
clip_path = run_dir / "failure.mp4"
381+
clip_generated = _generate_clip_from_frames(
382+
frames_dir=frames_out,
383+
output_path=clip_path,
384+
fps=clip_options.fps,
385+
)
386+
if clip_generated:
387+
logger.info(f"Generated failure clip: {clip_path}")
388+
else:
389+
logger.warning("Failed to generate video clip")
390+
clip_path = None
391+
218392
manifest = {
219393
"run_id": self.run_id,
220394
"created_at_ms": ts,
@@ -227,6 +401,8 @@ def persist(
227401
),
228402
"snapshot": "snapshot.json" if snapshot_payload is not None else None,
229403
"diagnostics": "diagnostics.json" if diagnostics_payload is not None else None,
404+
"clip": "failure.mp4" if clip_generated else None,
405+
"clip_fps": clip_options.fps if clip_generated else None,
230406
"metadata": metadata or {},
231407
"frames_redacted": not drop_frames and self.options.on_before_persist is not None,
232408
"frames_dropped": drop_frames,

tests/unit/test_failure_artifacts.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
22

33
import json
4+
from unittest.mock import patch
45

56
from sentience.failure_artifacts import (
7+
ClipOptions,
68
FailureArtifactBuffer,
79
FailureArtifactsOptions,
810
RedactionContext,
911
RedactionResult,
12+
_is_ffmpeg_available,
1013
)
1114

1215

@@ -90,3 +93,121 @@ def redactor(ctx: RedactionContext) -> RedactionResult:
9093
manifest = json.loads((run_dir / "manifest.json").read_text())
9194
assert manifest["frame_count"] == 0
9295
assert manifest["frames_dropped"] is True
96+
97+
98+
# -------------------- Phase 4: Clip generation tests --------------------
99+
100+
101+
def test_clip_mode_off_skips_generation(tmp_path) -> None:
102+
"""When clip.mode='off', no clip generation is attempted."""
103+
opts = FailureArtifactsOptions(
104+
output_dir=str(tmp_path),
105+
clip=ClipOptions(mode="off"),
106+
)
107+
buf = FailureArtifactBuffer(run_id="run-clip-off", options=opts)
108+
buf.add_frame(b"frame")
109+
110+
run_dir = buf.persist(reason="fail", status="failure")
111+
assert run_dir is not None
112+
manifest = json.loads((run_dir / "manifest.json").read_text())
113+
assert manifest["clip"] is None
114+
assert manifest["clip_fps"] is None
115+
116+
117+
def test_clip_mode_auto_skips_when_ffmpeg_missing(tmp_path) -> None:
118+
"""When clip.mode='auto' and ffmpeg is not available, skip silently."""
119+
with patch("sentience.failure_artifacts._is_ffmpeg_available", return_value=False):
120+
opts = FailureArtifactsOptions(
121+
output_dir=str(tmp_path),
122+
clip=ClipOptions(mode="auto", fps=10),
123+
)
124+
buf = FailureArtifactBuffer(run_id="run-clip-auto", options=opts)
125+
buf.add_frame(b"frame")
126+
127+
run_dir = buf.persist(reason="fail", status="failure")
128+
assert run_dir is not None
129+
manifest = json.loads((run_dir / "manifest.json").read_text())
130+
assert manifest["clip"] is None
131+
assert manifest["clip_fps"] is None
132+
133+
134+
def test_clip_mode_on_warns_when_ffmpeg_missing(tmp_path) -> None:
135+
"""When clip.mode='on' and ffmpeg is not available, log warning but don't fail."""
136+
with patch("sentience.failure_artifacts._is_ffmpeg_available", return_value=False):
137+
opts = FailureArtifactsOptions(
138+
output_dir=str(tmp_path),
139+
clip=ClipOptions(mode="on"),
140+
)
141+
buf = FailureArtifactBuffer(run_id="run-clip-on-missing", options=opts)
142+
buf.add_frame(b"frame")
143+
144+
run_dir = buf.persist(reason="fail", status="failure")
145+
assert run_dir is not None
146+
manifest = json.loads((run_dir / "manifest.json").read_text())
147+
# Should not have clip since ffmpeg is not available
148+
assert manifest["clip"] is None
149+
150+
151+
def test_clip_generation_with_mock_ffmpeg(tmp_path) -> None:
152+
"""Test clip generation logic with mocked ffmpeg subprocess."""
153+
with patch("sentience.failure_artifacts._is_ffmpeg_available", return_value=True):
154+
with patch("sentience.failure_artifacts._generate_clip_from_frames") as mock_gen:
155+
mock_gen.return_value = True # Simulate successful clip generation
156+
157+
opts = FailureArtifactsOptions(
158+
output_dir=str(tmp_path),
159+
clip=ClipOptions(mode="on", fps=12),
160+
)
161+
buf = FailureArtifactBuffer(run_id="run-clip-mock", options=opts)
162+
buf.add_frame(b"frame1")
163+
buf.add_frame(b"frame2")
164+
165+
run_dir = buf.persist(reason="fail", status="failure")
166+
assert run_dir is not None
167+
168+
# Verify _generate_clip_from_frames was called with correct args
169+
assert mock_gen.called
170+
call_args = mock_gen.call_args
171+
assert call_args.kwargs["fps"] == 12
172+
173+
manifest = json.loads((run_dir / "manifest.json").read_text())
174+
assert manifest["clip"] == "failure.mp4"
175+
assert manifest["clip_fps"] == 12
176+
177+
178+
def test_clip_not_generated_when_frames_dropped(tmp_path) -> None:
179+
"""Clip should not be generated when frames are dropped by redaction."""
180+
with patch("sentience.failure_artifacts._is_ffmpeg_available", return_value=True):
181+
with patch("sentience.failure_artifacts._generate_clip_from_frames") as mock_gen:
182+
opts = FailureArtifactsOptions(
183+
output_dir=str(tmp_path),
184+
clip=ClipOptions(mode="on"),
185+
on_before_persist=lambda ctx: RedactionResult(drop_frames=True),
186+
)
187+
buf = FailureArtifactBuffer(run_id="run-clip-dropped", options=opts)
188+
buf.add_frame(b"frame")
189+
190+
run_dir = buf.persist(reason="fail", status="failure")
191+
assert run_dir is not None
192+
193+
# Should not call clip generation when frames are dropped
194+
assert not mock_gen.called
195+
manifest = json.loads((run_dir / "manifest.json").read_text())
196+
assert manifest["clip"] is None
197+
assert manifest["frames_dropped"] is True
198+
199+
200+
def test_is_ffmpeg_available_with_missing_binary() -> None:
201+
"""Test _is_ffmpeg_available returns False when ffmpeg is not found."""
202+
with patch("sentience.failure_artifacts.subprocess.run") as mock_run:
203+
mock_run.side_effect = FileNotFoundError("ffmpeg not found")
204+
assert _is_ffmpeg_available() is False
205+
206+
207+
def test_is_ffmpeg_available_with_timeout() -> None:
208+
"""Test _is_ffmpeg_available returns False on timeout."""
209+
import subprocess
210+
211+
with patch("sentience.failure_artifacts.subprocess.run") as mock_run:
212+
mock_run.side_effect = subprocess.TimeoutExpired(cmd="ffmpeg", timeout=5)
213+
assert _is_ffmpeg_available() is False

0 commit comments

Comments
 (0)