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:
+13
-2
@@ -3241,8 +3241,19 @@ def cmd_trim(
|
|||||||
end_text = slide_texts.get(end_slide, "")
|
end_text = slide_texts.get(end_slide, "")
|
||||||
end_ts = _find_slide_end_in_transcript(words, end_text, verbose) if end_text else None
|
end_ts = _find_slide_end_in_transcript(words, end_text, verbose) if end_text else None
|
||||||
if end_ts is None:
|
if end_ts is None:
|
||||||
end_abs = total_dur
|
# Slide-end text didn't match the transcript (e.g. the
|
||||||
end_note = f"end auto: S{end_slide} not found → to end {total_dur:.2f}s"
|
# 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:
|
else:
|
||||||
end_abs = min(end_ts + _TRIM_TAIL_OUT, total_dur)
|
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"
|
end_note = f"end auto: S{end_slide} last word {end_ts:.2f}s +{_TRIM_TAIL_OUT:g}s → {end_abs:.2f}s"
|
||||||
|
|||||||
@@ -434,6 +434,9 @@ class VideoEvent:
|
|||||||
cutout: "CutoutDefinition"
|
cutout: "CutoutDefinition"
|
||||||
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
|
cutout_name: str = "" # resolved cutout name (e.g. "fullscreen"), for display
|
||||||
layer: str = "above" # "above" = on top of slides; "below" = behind slides
|
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
|
# 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
|
# 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).
|
# restarting at the boundary. None = play from video_source.skip (the v1/default).
|
||||||
|
|||||||
+5
-3
@@ -58,13 +58,15 @@ def _resolve_case_insensitive(path: Path) -> Path:
|
|||||||
# presentation fields materialized onto events.json and resolved per-event
|
# presentation fields materialized onto events.json and resolved per-event
|
||||||
# (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not
|
# (transformer.resolve_video_presentation). Global params (skip/zoom/volume/…) are not
|
||||||
# yet overridable inline; unknown keys are ignored.
|
# 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):
|
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("'")
|
v = raw.strip().strip('"').strip("'")
|
||||||
if key == "take":
|
if key in _MARKER_NUMERIC_KEYS:
|
||||||
try:
|
try:
|
||||||
return float(v)
|
return float(v)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
+2
-1
@@ -1568,7 +1568,8 @@ def build_filter_complex(
|
|||||||
delay_ms = int(event.start_time * 1000)
|
delay_ms = int(event.start_time * 1000)
|
||||||
label = f"tvaud{i}"
|
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 ""
|
vol_filter = f",volume={vol:.2f}" if vol != 1.0 else ""
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{video_idx}:a]atrim=0:{duration:.3f},"
|
f"[{video_idx}:a]atrim=0:{duration:.3f},"
|
||||||
|
|||||||
+11
-3
@@ -30,9 +30,13 @@ from typing import Optional
|
|||||||
from .models import CAMERA_PRESETS
|
from .models import CAMERA_PRESETS
|
||||||
from .transformer import MarkerTiming, resolve_video_presentation
|
from .transformer import MarkerTiming, resolve_video_presentation
|
||||||
|
|
||||||
# Per-occurrence presentation fields materialized onto video events (atomic events.json,
|
# Per-occurrence presentation fields ALWAYS materialized onto video events (atomic
|
||||||
# GUI-ready). Round-tripped as overrides so a stored value drives render over the default.
|
# events.json, GUI-ready). Round-tripped as overrides so a stored value drives render.
|
||||||
_PRESENTATION_KEYS = ("cutout", "layer", "end_on", "take")
|
_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"
|
EVENTS_FILE = "events.json"
|
||||||
SCAFFOLD_FILE = "scaffold.json"
|
SCAFFOLD_FILE = "scaffold.json"
|
||||||
@@ -184,6 +188,10 @@ def derive_events(
|
|||||||
e["end_on"] = pres["end_on"]
|
e["end_on"] = pres["end_on"]
|
||||||
if pres["take"] is not None:
|
if pres["take"] is not None:
|
||||||
e["take"] = pres["take"]
|
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)
|
pd = _pause_duration(t.marker_id, videos)
|
||||||
if pd:
|
if pd:
|
||||||
e["pause_duration"] = 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
|
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
|
# 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.
|
# (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(
|
timings.append(
|
||||||
MarkerTiming(
|
MarkerTiming(
|
||||||
marker_id=e["id"],
|
marker_id=e["id"],
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ def resolve_video_presentation(
|
|||||||
pause_narration = overrides.get(
|
pause_narration = overrides.get(
|
||||||
"pause_narration", video_source.pause_narration or 0.0
|
"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 {
|
return {
|
||||||
"handle": handle,
|
"handle": handle,
|
||||||
@@ -105,6 +107,7 @@ def resolve_video_presentation(
|
|||||||
"end_on": end_on,
|
"end_on": end_on,
|
||||||
"take": take,
|
"take": take,
|
||||||
"pause_narration": float(pause_narration or 0.0),
|
"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"]
|
end_on = pres["end_on"]
|
||||||
take = pres["take"]
|
take = pres["take"]
|
||||||
pause_narration = pres["pause_narration"]
|
pause_narration = pres["pause_narration"]
|
||||||
|
volume = pres["volume"]
|
||||||
|
|
||||||
if end_on == "take" and take is not None:
|
if end_on == "take" and take is not None:
|
||||||
end_time = start_time + take
|
end_time = start_time + take
|
||||||
@@ -1440,6 +1444,7 @@ def _extract_video_events(
|
|||||||
cutout=cutout,
|
cutout=cutout,
|
||||||
cutout_name=cutout_name,
|
cutout_name=cutout_name,
|
||||||
layer=layer,
|
layer=layer,
|
||||||
|
volume=volume,
|
||||||
skip_override=skip_override,
|
skip_override=skip_override,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user