From 4c6c9b856997221e0416b02c0050df1c123da744 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Wed, 29 Jul 2026 14:55:28 +0200 Subject: [PATCH] Fixing volume propagation settings through events.json, and fixed bug where failure to match the text on the end slide of a segment now issues a warning, and uses the last word + 2 seconds, rather than the full duration of the clip --- gnommo/cli.py | 15 +++++++++++++-- gnommo/models.py | 3 +++ gnommo/parser.py | 8 +++++--- gnommo/renderer.py | 3 ++- gnommo/scaffold.py | 14 +++++++++++--- gnommo/transformer.py | 5 +++++ 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/gnommo/cli.py b/gnommo/cli.py index 2be2e77..a82eac5 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -3241,8 +3241,19 @@ def cmd_trim( end_text = slide_texts.get(end_slide, "") end_ts = _find_slide_end_in_transcript(words, end_text, verbose) if end_text else None if end_ts is None: - end_abs = total_dur - end_note = f"end auto: S{end_slide} not found → to end {total_dur:.2f}s" + # Slide-end text didn't match the transcript (e.g. the + # narrator drifted from the manuscript). Fall back to the + # last spoken word + tail — NOT total_dur, which includes + # trailing silence and leaves the clip playing long. + end_abs = min(words[-1].end + _TRIM_TAIL_OUT, total_dur) + end_note = f"end auto: S{end_slide} not found → last word {words[-1].end:.2f}s +{_TRIM_TAIL_OUT:g}s → {end_abs:.2f}s" + print( + f"\n ⚠️ {seg_id}: could NOT match the end of S{end_slide} in the transcript.\n" + f" The manuscript text for S{end_slide} likely drifted from what was\n" + f" spoken. Falling back to the last spoken word ({words[-1].end:.2f}s)\n" + f" + {_TRIM_TAIL_OUT:g}s instead of the raw file length ({total_dur:.2f}s).\n" + f" Check S{end_slide}'s text or set an explicit `end` for {seg_id}." + ) else: end_abs = min(end_ts + _TRIM_TAIL_OUT, total_dur) end_note = f"end auto: S{end_slide} last word {end_ts:.2f}s +{_TRIM_TAIL_OUT:g}s → {end_abs:.2f}s" diff --git a/gnommo/models.py b/gnommo/models.py index c8d57e0..9fca268 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -434,6 +434,9 @@ class VideoEvent: cutout: "CutoutDefinition" cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display layer: str = "above" # "above" = on top of slides; "below" = behind slides + # Effective audio volume for THIS occurrence: an events.json override if present, + # else the videos.json default. The renderer reads this (not video_source.volume). + volume: float = 1.0 # Chunking v2 seam (see docs/chunking_v2.md): when a clip began before this # chunk's window, the render must seek into it so it resumes mid-clip instead of # restarting at the boundary. None = play from video_source.skip (the v1/default). diff --git a/gnommo/parser.py b/gnommo/parser.py index e195c0b..11ab7b5 100644 --- a/gnommo/parser.py +++ b/gnommo/parser.py @@ -58,13 +58,15 @@ def _resolve_case_insensitive(path: Path) -> Path: # presentation fields materialized onto events.json and resolved per-event # (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not # yet overridable inline; unknown keys are ignored. -_MARKER_OVERRIDE_KEYS = frozenset({"cutout", "layer", "end_on", "take"}) +_MARKER_OVERRIDE_KEYS = frozenset({"cutout", "layer", "end_on", "take", "volume"}) +# Override keys that are numeric (coerced to float). +_MARKER_NUMERIC_KEYS = frozenset({"take", "volume"}) def _coerce_marker_value(key: str, raw: str): - """Coerce an inline override value: `take` → float; everything else → string.""" + """Coerce an inline override value: numeric keys → float; everything else → string.""" v = raw.strip().strip('"').strip("'") - if key == "take": + if key in _MARKER_NUMERIC_KEYS: try: return float(v) except ValueError: diff --git a/gnommo/renderer.py b/gnommo/renderer.py index 844e44b..6c4298b 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -1568,7 +1568,8 @@ def build_filter_complex( delay_ms = int(event.start_time * 1000) label = f"tvaud{i}" - vol = event.video_source.volume + # event.volume = events.json override if set, else the videos.json default. + vol = event.volume vol_filter = f",volume={vol:.2f}" if vol != 1.0 else "" filters.append( f"[{video_idx}:a]atrim=0:{duration:.3f}," diff --git a/gnommo/scaffold.py b/gnommo/scaffold.py index 858772d..30a9f5d 100644 --- a/gnommo/scaffold.py +++ b/gnommo/scaffold.py @@ -30,9 +30,13 @@ from typing import Optional from .models import CAMERA_PRESETS from .transformer import MarkerTiming, resolve_video_presentation -# Per-occurrence presentation fields materialized onto video events (atomic events.json, -# GUI-ready). Round-tripped as overrides so a stored value drives render over the default. +# Per-occurrence presentation fields ALWAYS materialized onto video events (atomic +# events.json, GUI-ready). Round-tripped as overrides so a stored value drives render. _PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take") +# `volume` is materialized SPARSELY — only when actually overridden (inline/GUI/manual), +# so the videos.json value keeps flowing as the default and only a real override pins it. +# Round-tripping still carries it whenever present in the event dict. +_EVENT_OVERRIDE_KEYS = _PRESENTATION_KEYS + ("volume",) EVENTS_FILE = "events.json" SCAFFOLD_FILE = "scaffold.json" @@ -184,6 +188,10 @@ def derive_events( e["end_on"] = pres["end_on"] if pres["take"] is not None: e["take"] = pres["take"] + # volume is sparse: written only when actually overridden, so the + # videos.json default keeps flowing until someone pins it here. + if t.overrides and "volume" in t.overrides: + e["volume"] = pres["volume"] pd = _pause_duration(t.marker_id, videos) if pd: e["pause_duration"] = pd @@ -327,7 +335,7 @@ def events_to_marker_timings(events: list[dict]) -> list[MarkerTiming]: eff = (n + e.get("adjustment", 0.0)) if n is not None else -1.0 # Carry any stored presentation as overrides so render honors the atomic event # (e.g. a GUI edit) over the shorthand/videos.json default. Absent on old events. - overrides = {k: e[k] for k in _PRESENTATION_KEYS if k in e} or None + overrides = {k: e[k] for k in _EVENT_OVERRIDE_KEYS if k in e} or None timings.append( MarkerTiming( marker_id=e["id"], diff --git a/gnommo/transformer.py b/gnommo/transformer.py index 5d855de..6ca0e50 100644 --- a/gnommo/transformer.py +++ b/gnommo/transformer.py @@ -97,6 +97,8 @@ def resolve_video_presentation( pause_narration = overrides.get( "pause_narration", video_source.pause_narration or 0.0 ) + # Volume defaults to the videos.json value; an events.json/inline override wins. + volume = overrides["volume"] if "volume" in overrides else video_source.volume return { "handle": handle, @@ -105,6 +107,7 @@ def resolve_video_presentation( "end_on": end_on, "take": take, "pause_narration": float(pause_narration or 0.0), + "volume": float(volume if volume is not None else 1.0), } @@ -1368,6 +1371,7 @@ def _extract_video_events( end_on = pres["end_on"] take = pres["take"] pause_narration = pres["pause_narration"] + volume = pres["volume"] if end_on == "take" and take is not None: end_time = start_time + take @@ -1440,6 +1444,7 @@ def _extract_video_events( cutout=cutout, cutout_name=cutout_name, layer=layer, + volume=volume, skip_override=skip_override, ) )