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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user