diff --git a/gnommo/cache.py b/gnommo/cache.py index 6f956b6..75ae0a4 100644 --- a/gnommo/cache.py +++ b/gnommo/cache.py @@ -49,6 +49,28 @@ def get_ffmpeg_thread_count() -> int: return max(1, int(cpu_count * cpu_limit)) +def get_render_chunk_size() -> Optional[int]: + """Return slides-per-chunk for auto-chunked rendering, or None if not configured. + + When set, cmd_render splits the filter graph into chunks of this many slides + to avoid OOM from allocating filter buffers for the entire video at once. + + Example ~/.gnommo.conf: + [performance] + render_chunk_slides = 15 + """ + global _perf_config + if _perf_config is None: + get_ffmpeg_thread_count() # populates _perf_config + val = _perf_config.get("render_chunk_slides") + if val is None: + return None + try: + return max(1, int(val)) + except (ValueError, TypeError): + return None + + def load_cache_config() -> Optional[Path]: """Load gnommo.conf and return cache path if configured. diff --git a/gnommo/cli.py b/gnommo/cli.py index 4602d8c..f5e1310 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -133,6 +133,13 @@ Examples: type=str, help="Render only a range of slides (e.g., S1:S10, S5:, S10:S20)", ) + parser.add_argument( + "--chunk-slides", + type=int, + default=0, + dest="chunk_slides", + help="Split render into chunks of N slides each and concatenate (overrides render_chunk_slides in .gnommo.conf)", + ) parser.add_argument( "--res", type=str, @@ -261,6 +268,7 @@ Examples: args.slides, args.res, args.force, + chunk_slides=args.chunk_slides, ) elif action == "transcribe": return cmd_transcribe(project_path, args.verbose, args.res, args.final) @@ -2441,6 +2449,90 @@ def _writeback_video_metadata(plan, project_path, config) -> None: print(f" Updated videos.json: {written}") +def _chunked_render( + project_path: Path, + verbose: bool, + dry_run: bool, + res: str, + force: bool, + chunk_size: int, + slide_ids: list[str], + out_dir: Path, + final_output: Path, +) -> int: + """Render in slide-based chunks then concatenate — avoids filter graph OOM.""" + import math + + # Split slide IDs into groups of chunk_size + groups = [slide_ids[i : i + chunk_size] for i in range(0, len(slide_ids), chunk_size)] + print( + f"\n Auto-chunking: {len(slide_ids)} slides → {len(groups)} chunks of ≤{chunk_size}" + ) + + chunks_dir = out_dir / "chunks" + chunks_dir.mkdir(parents=True, exist_ok=True) + + chunk_paths: list[Path] = [] + for i, group in enumerate(groups): + start = group[0] + end = groups[i + 1][0] if i + 1 < len(groups) else None + slides_arg = f"{start}:{end}" if end else f"{start}:" + chunk_path = chunks_dir / f"chunk_{i+1:03d}_{start}-{end or 'end'}.mp4" + + print(f"\n {'='*56}") + print(f" Chunk {i+1}/{len(groups)}: {slides_arg} → {chunk_path.name}") + print(f" {'='*56}") + + result = cmd_render( + project_path, + verbose, + dry_run, + slides_arg=slides_arg, + res=res, + force=force, + _output_path_override=chunk_path, + ) + if result != 0: + print(f"\n Chunk {i+1} failed — aborting.", file=sys.stderr) + return result + chunk_paths.append(chunk_path) + + if dry_run: + print(f"\n [dry-run] Would concatenate {len(chunk_paths)} chunks → {final_output}") + return 0 + + # Concatenate chunks + print(f"\n Concatenating {len(chunk_paths)} chunks → {final_output.name}...") + concat_list = chunks_dir / "concat.txt" + with open(concat_list, "w") as f: + for p in chunk_paths: + f.write(f"file '{p.resolve()}'\n") + + concat_cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", + "-i", str(concat_list), + "-c", "copy", + str(final_output), + ] + result = subprocess.run(concat_cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f" Concatenation failed:\n{result.stderr}", file=sys.stderr) + return 1 + + # Clean up chunk files + for p in chunk_paths: + p.unlink(missing_ok=True) + concat_list.unlink(missing_ok=True) + try: + chunks_dir.rmdir() + except OSError: + pass + + print(f" Output: {final_output}") + return 0 + + def cmd_render( project_path: Path, verbose: bool, @@ -2448,6 +2540,8 @@ def cmd_render( slides_arg: str = None, res: str = "full", force: bool = False, + chunk_slides: int = 0, + _output_path_override: Path = None, ) -> int: """Render final video.""" from .parser import ( @@ -2702,17 +2796,35 @@ def cmd_render( # Stage 4: Render # Determine output filename and directory - if config.output_video: + if _output_path_override: + output_path = _output_path_override + out_dir = output_path.parent + out_filename = output_path.name + elif config.output_video: out_filename = config.output_video + out_dir = project_path / "out" / res if res != "full" else project_path / "out" + output_path = out_dir / out_filename elif slide_range: start, end = slide_range range_suffix = f"_{start}-{end}" if end else f"_{start}-end" out_filename = f"final{range_suffix}.mp4" + out_dir = project_path / "out" / res if res != "full" else project_path / "out" + output_path = out_dir / out_filename else: out_filename = f"{config.co}.mp4" + out_dir = project_path / "out" / res if res != "full" else project_path / "out" + output_path = out_dir / out_filename + + # Check if chunked rendering is needed (avoids filter graph OOM on long videos) + from .cache import get_render_chunk_size + _chunk_size = chunk_slides or get_render_chunk_size() or 0 + _slide_ids = [e.slide_id for e in plan.slide_events] + if _chunk_size > 0 and not slide_range and len(_slide_ids) > _chunk_size: + return _chunked_render( + project_path, verbose, dry_run, res, force, + _chunk_size, _slide_ids, out_dir, output_path, + ) - out_dir = project_path / "out" / res if res != "full" else project_path / "out" - output_path = out_dir / out_filename plan.output_path = output_path if dry_run: