From 5a439380aea5546ac7adfe53cf42ef92e308a6e7 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Fri, 24 Jul 2026 13:17:35 +0200 Subject: [PATCH] Fixing a few things --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++ gnommo/cli.py | 43 +++++++++++++++++++++++++++++++++++++++ gnommo/handoff.py | 5 +++++ gnommo/models.py | 3 ++- gnommo/push.py | 2 ++ gnommo/scaffold.py | 42 +++++++++++++++++++++++++++++++++----- gnommo/transformer.py | 26 ++++++++++++++++++++++++ 7 files changed, 162 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 19a4c44..9ec0888 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,53 @@ gnommo -p myproject render --res low # Fast preview at 490x270 gnommo -p myproject render --res tiny # Ultrafast preview at 320x180 ``` +A partial `--slides S1:S10` render writes a range-suffixed file (e.g. +`PHIL_COSM_102_S1_S10.mp4`) so sections don't overwrite each other or the full render. + +**Render log:** every render writes `/.log` (e.g. `video2.log`) with +the platform/ffmpeg/memory header, the exact ffmpeg command, and a `[mem …]` memory +sample every 3s. If a render crashes, check the tail of this log first. + +--- + +### Render rig configuration (memory / performance) + +The compositing ffmpeg graph holds a lot at once (RGBA layer buffers, many inputs, the +final mux), so peak RAM is high. Two knobs keep it bounded — both matter on a render rig. + +**1. FFmpeg thread cap — `~/.gnommo.conf`** (on the render machine) + +Fewer filter threads = far less peak memory (each parallel `format=rgba`/swscaler stage +holds its own full-frame buffers). The render honours `[performance] cpu_limit`, a +fraction of logical CPUs. **Unset defaults to 1 thread (safest).** On a memory-tight box +keep it low: + +```ini +[performance] +cpu_limit = 0.25 +``` + +> Note: the render uses `-filter_complex`, capped by `-filter_complex_threads` (not +> `-filter_threads`, which only applies to simple `-vf` graphs). This is why an +> uncapped render graph could OOM even when the preprocessor was fine. + +**2. WSL2 memory/swap — `C:\Users\\.wslconfig`** (Windows host, for an Ubuntu-on-WSL rig) + +A WSL2 VM only gets a *slice* of host RAM (default ~50%, or 8 GB on older builds). If the +render exceeds that slice, **Windows OOM-kills the whole VM** — it surfaces as +`Wsl/Service/E_UNEXPECTED` / "Catastrophic failure", not a normal out-of-memory error, and +the Windows host still shows plenty of RAM free. Raise the cap and give it swap headroom: + +```ini +[wsl2] +memory=24GB # give the VM more of the host RAM +swap=16GB # headroom so it pages instead of dying catastrophically +processors=8 +``` + +Then, from PowerShell: `wsl --shutdown`, and restart the session. Confirm the VM's cap in +the render log header — its `memory: … total` is the VM slice, not the host RAM. + --- ## Shortcut: All Stages diff --git a/gnommo/cli.py b/gnommo/cli.py index d4aaa58..8cd075f 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -4451,6 +4451,30 @@ def _write_render_log_header(logfile, project_path, res, slides_arg, force, chun logfile.flush() +# Minimum ffmpeg the render is validated against. Older builds (e.g. Ubuntu 24.04's +# 6.1.1) mis-mix the paused-narration audio — the talking head goes quiet before +# interstitial videos and recovers after — silently producing a wrong file. +_MIN_FFMPEG = (7, 0, 2) + + +def _ffmpeg_version_at_least(minimum: tuple) -> tuple: + """Return (ok, version_str). ok is False only when a *parseable* version is + below `minimum`. Unparseable output (git/nightly builds) or a missing ffmpeg + is allowed here (ok=True) — those fail later with their own clearer error.""" + import re + + try: + out = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True) + first = out.stdout.splitlines()[0] if out.stdout else "" + except Exception: + return True, "unavailable" + m = re.search(r"version\s+n?(\d+)\.(\d+)(?:\.(\d+))?", first) + if not m: + return True, (first.replace("ffmpeg version", "").strip()[:40] or "unknown") + ver = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)) + return ver >= minimum, ".".join(str(x) for x in ver) + + def cmd_render( project_path: Path, verbose: bool, @@ -4559,6 +4583,25 @@ def _cmd_render_impl( from .renderer import render, generate_ffmpeg_command_string from .preprocessor import RES_CONFIGS, ensure_downscaled_files_exist + # ffmpeg version guard — only for an actual encode (not dry-run/build, and only + # once at the top level, not per chunk sub-render). Older builds silently + # mis-mix the paused-narration audio, so refuse rather than produce a bad file. + if not dry_run and not plan_only and _output_path_override is None: + _ok, _ver = _ffmpeg_version_at_least(_MIN_FFMPEG) + if not _ok: + _min = ".".join(str(x) for x in _MIN_FFMPEG) + print( + f"Error: ffmpeg {_ver} is too old — render requires >= {_min}.", + file=sys.stderr, + ) + print( + " Older builds mis-mix paused-narration audio (talking head goes " + "quiet before interstitials). Install a static build:", + file=sys.stderr, + ) + print(" https://johnvansickle.com/ffmpeg/", file=sys.stderr) + return 1 + # Parse slide range if provided _verb = "Building scaffold" if plan_only else "Rendering" slide_range = None diff --git a/gnommo/handoff.py b/gnommo/handoff.py index 3b72bf8..3be95d2 100644 --- a/gnommo/handoff.py +++ b/gnommo/handoff.py @@ -149,11 +149,16 @@ def cmd_handoff( # ── Upload ───────────────────────────────────────────────────────────────── # gnommoweb: POST /api/projects/:id/handoff — uploads to MinIO and bumps the # project's video_version so it appears on the review page. + extra_data = {} + course = project.get("course") + if course: + extra_data["course"] = course try: with open(video_path, "rb") as vf: r = requests.post( f"{api_url}/api/projects/{project_id}/handoff", files={"video": (video_path.name, vf, _mime_type(video_path))}, + data=extra_data or None, headers={"Authorization": f"Bearer {api_key}"}, timeout=None, ) diff --git a/gnommo/models.py b/gnommo/models.py index 3b8b783..31f4967 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -345,7 +345,8 @@ class VideoSource: has_audio: Optional[bool] = None # Pre-detected audio presence (set by import) end_on: Optional[ str - ] = None # When video event ends: "next_slide" | "end" | "take" (None = marker-type default) + ] = None # When video event ends: "end" (play once to natural length) | "loop" (loop to render end) + # | "next_slide" | "next_video" | "take" (None = marker-type default: next_slide for videos) @dataclass diff --git a/gnommo/push.py b/gnommo/push.py index 78109bb..8bf2f82 100644 --- a/gnommo/push.py +++ b/gnommo/push.py @@ -199,6 +199,7 @@ def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> d "project_id": project["id"], "name": project["name"], "description": project.get("description"), + "course": project.get("course"), "coursecode": project.get("coursecode"), "script_content": script_content, "resolution": project.get("resolution"), @@ -228,6 +229,7 @@ def _build_short_payload(project: dict, project_path: Path, verbose: bool) -> di "project_id": project["id"], "name": project["name"], "description": project.get("description"), + "course": project.get("course"), "parent_project": project["parent_project"], "hook": project.get("hook"), "script_content": script_content, diff --git a/gnommo/scaffold.py b/gnommo/scaffold.py index a12662b..50cecdf 100644 --- a/gnommo/scaffold.py +++ b/gnommo/scaffold.py @@ -44,15 +44,47 @@ _EXACT_THRESHOLD = 0.6 # ── marker classification ───────────────────────────────────────────────────── +# Shorthand prefixes that denote a video/narration trigger (mirrors transformer). +_VIDEO_MARKER_PREFIXES = ( + "video:", "narration:", + "vft:", "vfb:", "vfm:", "vf2t:", "vf2b:", "vf2m:", + "vst:", "vsb:", "vsm:", + "vftp:", "vfbp:", "vfmp:", "vf2tp:", "vf2bp:", "vf2mp:", + "vstp:", "vsbp:", "vsmp:", +) + + +def _ci_contains(d: dict, key: str) -> bool: + """Case-insensitive membership: the render lowercases video/audio handles + (e.g. the marker vst:UnconstrainedLight resolves to videos.json's + unconstrainedlight), so classification must match case-insensitively too.""" + if not d: + return False + if key in d: + return True + lk = key.lower() + return any(k.lower() == lk for k in d) + + def marker_type(marker_id: str, slides: dict, videos: dict, audio: dict) -> str: - """Classify a marker id as slide / video / audio / camera / other.""" - if slides and marker_id in slides: + """Classify a marker id as slide / video / audio / camera / other. + + Prefix-aware and case-insensitive. Video markers carry a shorthand prefix + (vst:, vfb:, video:, …) and their handle is stored lowercased in videos.json, + so a marker like `vst:UnconstrainedLight` is a video even though videos.json + only has `unconstrainedlight`. + """ + if _ci_contains(slides, marker_id): return "slide" - if videos and marker_id in videos: + if marker_id.startswith(_VIDEO_MARKER_PREFIXES) or _ci_contains(videos, marker_id): return "video" - if audio and marker_id in audio: + if marker_id.startswith("audio:") or _ci_contains(audio, marker_id): return "audio" - if marker_id in CAMERA_PRESETS: + if marker_id.startswith("A") and len(marker_id) > 1: + aid = marker_id[1:] + if aid.isdigit() or _ci_contains(audio, aid): + return "audio" + if _ci_contains(CAMERA_PRESETS, marker_id): return "camera" return "other" diff --git a/gnommo/transformer.py b/gnommo/transformer.py index d108a83..4001772 100644 --- a/gnommo/transformer.py +++ b/gnommo/transformer.py @@ -1222,6 +1222,10 @@ def _extract_video_events( continue video_markers.append((timing.timestamp, video_id, "narration", False)) + # Sorted start times of all video markers — used by end_on="next_video" to cap + # a clip when the next video begins, so videos never overlap. + video_start_times = sorted(t for t, _, _, _ in video_markers) + events: list[VideoEvent] = [] for start_time, video_id, marker_type, pause_narration in video_markers: video_source = videos[video_id] @@ -1235,7 +1239,29 @@ def _extract_video_events( if end_on == "take" and video_source.take is not None: end_time = start_time + video_source.take elif end_on == "end": + # Play the clip once through its natural length, then stop — no looping. + # Natural length = explicit take, else the file's own duration past skip. + if video_source.take is not None: + natural = video_source.take + elif video_source.duration is not None: + natural = max(0.0, video_source.duration - (video_source.skip or 0.0)) + else: + natural = None # unknown length — fall back to running to render end + end_time = (start_time + natural) if natural is not None else total_duration + elif end_on == "loop": + # Loop the clip to fill the rest of the render. end_time = total_duration + elif end_on in ("next_video", "video"): + # End when the next video (any) starts, so clips never overlap. Lets a + # video span multiple slides yet still yield to the following video. + end_time = total_duration + for vt in video_start_times: + if vt > start_time: + end_time = vt + break + # A pause-narration video must stay for at least the pause it holds. + if video_source.pause_narration: + end_time = max(end_time, start_time + video_source.pause_narration) elif end_on in ("next_slide", "slide") or (end_on is None and marker_type == "video"): # End at next slide marker ("slide" is a recognised alias for "next_slide") end_time = total_duration