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

This commit is contained in:
2026-07-29 14:55:28 +02:00
parent c72c118d76
commit 4c6c9b8569
6 changed files with 39 additions and 9 deletions
+13 -2
View File
@@ -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"
+3
View File
@@ -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).
+5 -3
View File
@@ -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:
+2 -1
View File
@@ -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},"
+11 -3
View File
@@ -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"],
+5
View File
@@ -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,
)
)