11from __future__ import annotations
22
33import json
4+ import logging
45import shutil
6+ import subprocess
57import tempfile
68import time
79from collections .abc import Callable
8- from dataclasses import dataclass
10+ from dataclasses import dataclass , field
911from pathlib import Path
1012from 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
1434class 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+
50188class 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 ,
0 commit comments