Fixing a few things

This commit is contained in:
2026-07-24 13:17:35 +02:00
parent c3e892cf89
commit 5a439380ae
7 changed files with 162 additions and 6 deletions
+47
View File
@@ -243,6 +243,53 @@ gnommo -p myproject render --res low # Fast preview at 490x270
gnommo -p myproject render --res tiny # Ultrafast preview at 320x180 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 `<project>/<project>.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\<you>\.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 ## Shortcut: All Stages
+43
View File
@@ -4451,6 +4451,30 @@ def _write_render_log_header(logfile, project_path, res, slides_arg, force, chun
logfile.flush() 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( def cmd_render(
project_path: Path, project_path: Path,
verbose: bool, verbose: bool,
@@ -4559,6 +4583,25 @@ def _cmd_render_impl(
from .renderer import render, generate_ffmpeg_command_string from .renderer import render, generate_ffmpeg_command_string
from .preprocessor import RES_CONFIGS, ensure_downscaled_files_exist 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 # Parse slide range if provided
_verb = "Building scaffold" if plan_only else "Rendering" _verb = "Building scaffold" if plan_only else "Rendering"
slide_range = None slide_range = None
+5
View File
@@ -149,11 +149,16 @@ def cmd_handoff(
# ── Upload ───────────────────────────────────────────────────────────────── # ── Upload ─────────────────────────────────────────────────────────────────
# gnommoweb: POST /api/projects/:id/handoff — uploads to MinIO and bumps the # gnommoweb: POST /api/projects/:id/handoff — uploads to MinIO and bumps the
# project's video_version so it appears on the review page. # project's video_version so it appears on the review page.
extra_data = {}
course = project.get("course")
if course:
extra_data["course"] = course
try: try:
with open(video_path, "rb") as vf: with open(video_path, "rb") as vf:
r = requests.post( r = requests.post(
f"{api_url}/api/projects/{project_id}/handoff", f"{api_url}/api/projects/{project_id}/handoff",
files={"video": (video_path.name, vf, _mime_type(video_path))}, files={"video": (video_path.name, vf, _mime_type(video_path))},
data=extra_data or None,
headers={"Authorization": f"Bearer {api_key}"}, headers={"Authorization": f"Bearer {api_key}"},
timeout=None, timeout=None,
) )
+2 -1
View File
@@ -345,7 +345,8 @@ class VideoSource:
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import) has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
end_on: Optional[ end_on: Optional[
str 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 @dataclass
+2
View File
@@ -199,6 +199,7 @@ def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> d
"project_id": project["id"], "project_id": project["id"],
"name": project["name"], "name": project["name"],
"description": project.get("description"), "description": project.get("description"),
"course": project.get("course"),
"coursecode": project.get("coursecode"), "coursecode": project.get("coursecode"),
"script_content": script_content, "script_content": script_content,
"resolution": project.get("resolution"), "resolution": project.get("resolution"),
@@ -228,6 +229,7 @@ def _build_short_payload(project: dict, project_path: Path, verbose: bool) -> di
"project_id": project["id"], "project_id": project["id"],
"name": project["name"], "name": project["name"],
"description": project.get("description"), "description": project.get("description"),
"course": project.get("course"),
"parent_project": project["parent_project"], "parent_project": project["parent_project"],
"hook": project.get("hook"), "hook": project.get("hook"),
"script_content": script_content, "script_content": script_content,
+37 -5
View File
@@ -44,15 +44,47 @@ _EXACT_THRESHOLD = 0.6
# ── marker classification ───────────────────────────────────────────────────── # ── 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: def marker_type(marker_id: str, slides: dict, videos: dict, audio: dict) -> str:
"""Classify a marker id as slide / video / audio / camera / other.""" """Classify a marker id as slide / video / audio / camera / other.
if slides and marker_id in slides:
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" return "slide"
if videos and marker_id in videos: if marker_id.startswith(_VIDEO_MARKER_PREFIXES) or _ci_contains(videos, marker_id):
return "video" return "video"
if audio and marker_id in audio: if marker_id.startswith("audio:") or _ci_contains(audio, marker_id):
return "audio" 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 "camera"
return "other" return "other"
+26
View File
@@ -1222,6 +1222,10 @@ def _extract_video_events(
continue continue
video_markers.append((timing.timestamp, video_id, "narration", False)) 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] = [] events: list[VideoEvent] = []
for start_time, video_id, marker_type, pause_narration in video_markers: for start_time, video_id, marker_type, pause_narration in video_markers:
video_source = videos[video_id] video_source = videos[video_id]
@@ -1235,7 +1239,29 @@ def _extract_video_events(
if end_on == "take" and video_source.take is not None: if end_on == "take" and video_source.take is not None:
end_time = start_time + video_source.take end_time = start_time + video_source.take
elif end_on == "end": 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 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"): 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 at next slide marker ("slide" is a recognised alias for "next_slide")
end_time = total_duration end_time = total_duration