From 78e3aef0f2649fe72179c98c6a7e77f0936b44e8 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Thu, 23 Jul 2026 21:56:24 +0200 Subject: [PATCH] Add error handling for ffmpg --- gnommo/cli.py | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/gnommo/cli.py b/gnommo/cli.py index b6dab04..77da238 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -4221,6 +4221,97 @@ def cmd_build( ) +# ── Render logging ──────────────────────────────────────────────────────────── +# A hard crash on Windows/Linux (the OS OOM-killing ffmpeg or python, a native +# segfault) leaves no Python traceback and just prints "Terminated". So we tee the +# whole render to /.log with line-flushing: the log keeps a +# header (platform / ffmpeg / memory / args) and the exact ffmpeg command, so a +# run that dies mid-encode can still be diagnosed from the last lines written. + +_RENDER_LOGFILE = None + + +class _TeeStream: + """Write to the real stream and mirror completed lines into a log file. + + Progress-bar redraws (carriage returns with no newline) are dropped from the + log; only whole lines are kept, so the log stays greppable. Everything is + flushed immediately so a hard kill still leaves the trail on disk. + """ + + def __init__(self, stream, logfile): + self._stream = stream + self._logfile = logfile + self._buf = "" + + def write(self, data): + self._stream.write(data) + self._buf += data + while "\n" in self._buf: + line, self._buf = self._buf.split("\n", 1) + try: + self._logfile.write(line.split("\r")[-1] + "\n") + except Exception: + pass + try: + self._logfile.flush() + except Exception: + pass + + def flush(self): + self._stream.flush() + try: + self._logfile.flush() + except Exception: + pass + + def __getattr__(self, name): + return getattr(self._stream, name) + + +def _render_log(msg: str) -> None: + """Write a line only to the render log (not the terminal).""" + if _RENDER_LOGFILE is not None: + try: + _RENDER_LOGFILE.write(msg + "\n") + _RENDER_LOGFILE.flush() + except Exception: + pass + + +def _write_render_log_header(logfile, project_path, res, slides_arg, force, chunk_slides): + import os + import platform as _platform + + try: + _ff = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True) + ffmpeg_ver = _ff.stdout.splitlines()[0] if _ff.stdout else "unknown" + except Exception: + ffmpeg_ver = "unavailable" + + try: + import psutil # optional dependency + + vm = psutil.virtual_memory() + mem = f"{vm.available / 1e9:.1f} GB free / {vm.total / 1e9:.1f} GB total" + except Exception: + mem = "unknown (install psutil for memory info)" + + logfile.write("=" * 70 + "\n") + logfile.write(f"gnommo render log — {project_path.name}\n") + logfile.write(f"time : {datetime.now().isoformat(timespec='seconds')}\n") + logfile.write(f"platform : {_platform.platform()}\n") + logfile.write(f"python : {sys.version.split()[0]}\n") + logfile.write(f"ffmpeg : {ffmpeg_ver}\n") + logfile.write(f"cpu_count : {os.cpu_count()}\n") + logfile.write(f"memory : {mem}\n") + logfile.write( + f"args : res={res} slides={slides_arg} force={force} chunk_slides={chunk_slides}\n" + ) + logfile.write("=" * 70 + "\n\n") + logfile.flush() + + def cmd_render( project_path: Path, verbose: bool, @@ -4232,6 +4323,72 @@ def cmd_render( _output_path_override: Path = None, plan_only: bool = False, realign: bool = False, +) -> int: + """Render entry point — opens /.log, then runs the render. + + Internal chunk sub-renders (_output_path_override set) and any nested call + while a log is already open reuse the parent log instead of clobbering it. + """ + global _RENDER_LOGFILE + + passthrough = dict( + slides_arg=slides_arg, + res=res, + force=force, + chunk_slides=chunk_slides, + _output_path_override=_output_path_override, + plan_only=plan_only, + realign=realign, + ) + + if _output_path_override is not None or _RENDER_LOGFILE is not None: + return _cmd_render_impl(project_path, verbose, dry_run, **passthrough) + + log_path = project_path / f"{project_path.name}.log" + try: + logfile = open(log_path, "w", encoding="utf-8", buffering=1) + except OSError: + return _cmd_render_impl(project_path, verbose, dry_run, **passthrough) + + _write_render_log_header(logfile, project_path, res, slides_arg, force, chunk_slides) + _orig_out, _orig_err = sys.stdout, sys.stderr + sys.stdout = _TeeStream(_orig_out, logfile) + sys.stderr = _TeeStream(_orig_err, logfile) + _RENDER_LOGFILE = logfile + try: + return _cmd_render_impl(project_path, verbose, dry_run, **passthrough) + except BaseException: + import traceback + + logfile.write("\n=== EXCEPTION / ABORT ===\n") + traceback.print_exc(file=logfile) + logfile.flush() + raise + finally: + sys.stdout = _orig_out + sys.stderr = _orig_err + _RENDER_LOGFILE = None + try: + logfile.write( + f"\n[render log closed {datetime.now().isoformat(timespec='seconds')}]\n" + ) + logfile.close() + except Exception: + pass + print(f" (render log: {log_path})") + + +def _cmd_render_impl( + project_path: Path, + verbose: bool, + dry_run: bool, + slides_arg: str = None, + res: str = "full", + force: bool = False, + chunk_slides: int = 0, + _output_path_override: Path = None, + plan_only: bool = False, + realign: bool = False, ) -> int: """Render final video. @@ -4662,6 +4819,12 @@ def cmd_render( return 0 print("\n[4/4] Rendering...") + # Record the exact ffmpeg command in the log only (not the terminal), so a + # render that gets hard-killed mid-encode can still be reproduced/diagnosed. + try: + _render_log("FFmpeg command:\n" + generate_ffmpeg_command_string(plan, output_path)) + except Exception as _e: + _render_log(f"(could not serialize ffmpeg command: {_e})") render(plan, output_path, verbose=verbose) print(f" Output: {output_path}")