@@ -15,8 +15,13 @@ class ComposeError(RuntimeError):
1515
1616
1717class Composer :
18- def __init__ (self , config : Config ) -> None :
18+ def __init__ (self , config : Config , ffmpeg_timeout_sec : int | None = None ) -> None :
1919 self .config = config
20+ self .ffmpeg_timeout_sec = (
21+ int (ffmpeg_timeout_sec )
22+ if ffmpeg_timeout_sec is not None
23+ else int (self .config .ffmpeg_timeout_sec )
24+ )
2025
2126 def compose_segments (self , segment_ids : list [str ], * , strict : bool = True ) -> int :
2227 composed = 0
@@ -30,7 +35,9 @@ def compose_segments(self, segment_ids: list[str], *, strict: bool = True) -> in
3035 if vtype == "manim" :
3136 ok = self ._compose_simple (seg_id , self ._manim_path (vmap ), strict = strict )
3237 elif vtype == "vhs" :
33- ok = self ._compose_simple (seg_id , self ._vhs_path (vmap ), strict = strict )
38+ video_path = self ._vhs_path (vmap )
39+ self ._warn_if_stale_vhs (vmap , video_path )
40+ ok = self ._compose_simple (seg_id , video_path , strict = strict )
3441 elif vtype == "mixed" :
3542 sources = [self ._resolve_source (s ) for s in vmap .get ("sources" , [])]
3643 ok = self ._compose_mixed (seg_id , sources )
@@ -223,16 +230,24 @@ def _find_audio(self, seg_id: str) -> Path | None:
223230
224231 def _manim_path (self , vmap : dict [str , Any ]) -> Path :
225232 src = vmap .get ("source" , "" )
226- return self .config .animations_dir / "media" / "videos" / "scenes" / "720p30" / src
233+ if not src :
234+ return self .config .animations_dir / "media" / "videos" / "scenes" / "720p30"
235+
236+ for base in self ._manim_video_dirs ():
237+ candidate = base / src
238+ if candidate .exists ():
239+ return candidate
240+ return self ._manim_video_dirs ()[0 ] / src
227241
228242 def _vhs_path (self , vmap : dict [str , Any ]) -> Path :
229243 src = vmap .get ("source" , "" )
230244 return self .config .terminal_dir / "rendered" / src
231245
232246 def _resolve_source (self , source : str ) -> Path :
233- manim_path = self .config .animations_dir / "media" / "videos" / "scenes" / "720p30" / source
234- if manim_path .exists ():
235- return manim_path
247+ for base in self ._manim_video_dirs ():
248+ manim_path = base / source
249+ if manim_path .exists ():
250+ return manim_path
236251 vhs_path = self .config .terminal_dir / "rendered" / source
237252 if vhs_path .exists ():
238253 return vhs_path
@@ -254,13 +269,69 @@ def _probe_duration(path: Path) -> float | None:
254269 except (ValueError , subprocess .TimeoutExpired , FileNotFoundError ):
255270 return None
256271
257- @staticmethod
258- def _run_ffmpeg (cmd : list [str ]) -> None :
272+ def _run_ffmpeg (self , cmd : list [str ]) -> None :
273+ timeout_sec = max (1 , int (self .ffmpeg_timeout_sec ))
274+ out_path = Path (cmd [- 1 ]) if cmd else None
259275 try :
260- subprocess .run (cmd , check = True , capture_output = True , text = True , timeout = 300 )
276+ subprocess .run (cmd , check = True , capture_output = True , text = True , timeout = timeout_sec )
261277 except FileNotFoundError :
262- print ( " ERROR: ffmpeg not found in PATH" )
278+ raise ComposeError ( " ffmpeg not found in PATH" )
263279 except subprocess .CalledProcessError as exc :
264- print (f" ERROR: ffmpeg failed: { exc .stderr [:300 ]} " )
280+ detail = (exc .stderr or exc .stdout or "" )[:400 ]
281+ raise ComposeError (f"ffmpeg failed: { detail } " )
265282 except subprocess .TimeoutExpired :
266- print (" ERROR: ffmpeg timed out" )
283+ if out_path and out_path .exists () and out_path .stat ().st_size > 0 :
284+ print (
285+ f" WARNING: ffmpeg timed out after { timeout_sec } s, "
286+ f"but output exists at { out_path } ."
287+ )
288+ return
289+ raise ComposeError (f"ffmpeg timed out after { timeout_sec } s" )
290+
291+ def _manim_video_dirs (self ) -> list [Path ]:
292+ root = self .config .animations_dir / "media" / "videos"
293+ quality = str (self .config .manim_quality ).strip ().lower ()
294+ fallback_qualities = [
295+ quality ,
296+ "1080p30" ,
297+ "1080p60" ,
298+ "1440p30" ,
299+ "1440p60" ,
300+ "720p30" ,
301+ "480p15" ,
302+ "2160p60" ,
303+ ]
304+
305+ ordered_qualities : list [str ] = []
306+ for q in fallback_qualities :
307+ if q and q not in ordered_qualities :
308+ ordered_qualities .append (q )
309+
310+ dirs : list [Path ] = []
311+ for q in ordered_qualities :
312+ dirs .append (root / "scenes" / q )
313+ dirs .append (root / q )
314+ return dirs
315+
316+ def _warn_if_stale_vhs (self , vmap : dict [str , Any ], video_path : Path ) -> None :
317+ if not self .config .warn_stale_vhs :
318+ return
319+
320+ tape_name = str (vmap .get ("tape" , "" )).strip ()
321+ if not tape_name :
322+ source_name = str (vmap .get ("source" , "" )).strip ()
323+ if source_name :
324+ tape_name = f"{ Path (source_name ).stem } .tape"
325+ if not tape_name :
326+ return
327+
328+ tape_path = self .config .terminal_dir / tape_name
329+ if not tape_path .exists () or not video_path .exists ():
330+ return
331+
332+ if tape_path .stat ().st_mtime > (video_path .stat ().st_mtime + 1 ):
333+ print (
334+ " WARNING: tape is newer than rendered video "
335+ f"({ tape_path .name } > { video_path .name } ). "
336+ "Run `docgen vhs` before `docgen compose` to avoid stale output."
337+ )
0 commit comments