Fixing a few things
This commit is contained in:
@@ -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 `<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+37
-5
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user