Fixing gnommo

This commit is contained in:
2026-03-26 10:46:05 +01:00
parent 0e22fcfbb3
commit 7c75610fce
15 changed files with 2028 additions and 410 deletions
+175 -55
View File
@@ -1,6 +1,7 @@
"""Transform stage: resolve timings and build render plan."""
import re
import string
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@@ -99,6 +100,16 @@ def _normalize_text(text: str) -> str:
return text.strip()
def _normalize_token(word: str) -> str:
"""Normalize a single word token for comparison.
Strips leading/trailing punctuation and lowercases. Interior characters
(e.g. apostrophes in contractions) are preserved so "don't" stays "don't".
Applied to both transcript tokens and phrase words at comparison time.
"""
return word.lower().strip(string.punctuation)
def _is_known_marker(
marker_id: str, slides: dict = None, videos: dict = None, audio: dict = None
) -> bool:
@@ -122,8 +133,9 @@ def _is_known_marker(
if marker_id in slides:
return True
# Video/narration triggers
if marker_id.startswith("video:") or marker_id.startswith("narration:"):
# Video/narration triggers (all supported prefixes)
_VIDEO_PREFIXES = ("video:", "narration:", "vft:", "vfb:", "vst:", "vsb:", "vftp:", "vfbp:", "vstp:", "vsbp:")
if any(marker_id.startswith(p) for p in _VIDEO_PREFIXES):
return True
# Camera presets
@@ -143,20 +155,11 @@ def _strip_unknown_markers(
text: str, slides: dict = None, videos: dict = None, audio: dict = None
) -> str:
"""
Remove unknown markers from text.
Remove all [...] markers from context text — none are pronounced aloud.
Unknown markers aren't pronounced, so they should be stripped
before fuzzy matching. Note: [cite:...] markers are already
stripped at parse time by parse_manuscript().
Note: [cite:...] markers are already stripped at parse time by parse_manuscript().
"""
def replace_marker(match):
marker_id = match.group(1)
if _is_known_marker(marker_id, slides, videos, audio):
return match.group(0) # Keep known markers
return "" # Strip unknown markers
return re.sub(r"\[([A-Za-z0-9_:]+)\]", replace_marker, text)
return re.sub(r"\[([^\]]+)\]", "", text)
def _extract_marker_contexts(
@@ -177,8 +180,9 @@ def _extract_marker_contexts(
videos = videos or {}
audio = audio or {}
# Split by markers, keeping the markers
parts = re.split(r"\[([A-Za-z0-9_:]+)\]", manuscript_text)
# Split by markers, keeping the markers — broad pattern handles any content
# including paths with / and - (e.g. [vfb:pexels/7670835-uhd_3840_2160_30fps])
parts = re.split(r"\[([^\]]+)\]", manuscript_text)
# parts: [text_before, marker1, text_after1, marker2, text_after2, ...]
raw_contexts = []
@@ -189,16 +193,27 @@ def _extract_marker_contexts(
if not _is_known_marker(marker_id, slides, videos, audio):
continue
if i + 1 < len(parts):
following_text = parts[i + 1].strip()
# Clean up: remove newlines, collapse whitespace
following_text = " ".join(following_text.split())
# Strip unknown markers from following text (they're not pronounced)
following_text = _strip_unknown_markers(
following_text, slides, videos, audio
)
following_text = " ".join(following_text.split()) # Clean up extra spaces
raw_contexts.append((marker_id, following_text))
# Collect all following text, looking past unknown markers until the
# next known marker. This handles [S1][segment:1] text... where the
# text lives two parts ahead rather than immediately after S1.
text_pieces = []
j = i + 1
while j < len(parts):
chunk = parts[j].strip()
if chunk:
text_pieces.append(chunk)
j += 1 # advance to the marker after this text chunk
if j >= len(parts):
break
if _is_known_marker(parts[j], slides, videos, audio):
break # stop at the next known marker
j += 1 # skip the unknown marker; its following text is next
following_text = " ".join(text_pieces)
following_text = " ".join(following_text.split()) # collapse whitespace
following_text = _strip_unknown_markers(following_text, slides, videos, audio)
following_text = " ".join(following_text.split())
raw_contexts.append((marker_id, following_text))
# For markers with no following text (consecutive markers), look ahead
# Return (marker_id, following_text, is_borrowed) - is_borrowed=True means text came from look-ahead
@@ -209,13 +224,20 @@ def _extract_marker_contexts(
words = following_text.split()[:10]
contexts.append((marker_id, " ".join(words), False))
else:
# Look ahead for next marker with text
# Look ahead for next marker with text, but never borrow from another
# slide marker — slides must align independently to avoid two consecutive
# slides matching the same transcription position simultaneously.
borrowed = False
for j in range(i + 1, len(raw_contexts)):
if raw_contexts[j][1]:
words = raw_contexts[j][1].split()[:10]
next_marker_id, next_text = raw_contexts[j]
if next_text:
if next_marker_id in (slides or {}):
break # Slide owns this text; give up borrowing
words = next_text.split()[:10]
contexts.append((marker_id, " ".join(words), True)) # Borrowed
borrowed = True
break
else:
if not borrowed:
contexts.append((marker_id, "", False))
return contexts
@@ -250,7 +272,8 @@ def _fuzzy_match_ratio(
return 0.0, 0, 0
transcript_words = [
_normalize_text(transcription[j].word) for j in range(start_idx, transcript_end)
_normalize_token(transcription[j].word)
for j in range(start_idx, transcript_end)
]
# Match phrase words sequentially against transcript window
@@ -261,7 +284,7 @@ def _fuzzy_match_ratio(
last_match_end_offset = 0
for phrase_word in phrase_words[:words_to_check]:
normalized = _normalize_text(phrase_word)
normalized = _normalize_token(phrase_word)
if len(normalized) < 2:
continue # skip very short words (a, I, etc.) - don't count them
words_checked += 1
@@ -303,8 +326,12 @@ def _find_phrase_timestamp(
(-1, -1.0, 0.0, -1) if not found. word_index points to the first
matched word. match_end_idx points past the last matched word.
"""
phrase_normalized = _normalize_text(phrase)
phrase_words = phrase_normalized.split()
# Normalize each word individually — same method as transcript tokens.
# This keeps contractions as single tokens ("haven't" stays "haven't") so
# phrase and transcript word counts stay in sync. Using _normalize_text on
# the whole phrase would expand "haven't" → "have not" (2 words), creating
# a phantom "not" that fails to match the transcript and corrupts the window.
phrase_words = [tok for tok in (_normalize_token(w) for w in phrase.split()) if tok]
if not phrase_words:
return -1, -1.0, 0.0, -1
@@ -504,7 +531,9 @@ def build_render_plan(
cached_files: set[str] = set()
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = []
video_path, is_cached = _resolve_video_path(videos_dir, narration_video, shared_assets_dir, project_path)
video_path, is_cached = _resolve_video_path(
videos_dir, narration_video, shared_assets_dir, project_path
)
if is_cached:
cached_files.add(narration_video_id)
full_duration = get_video_duration(video_path)
@@ -798,40 +827,127 @@ def _extract_video_events(
]
)
# Collect video markers
video_markers: list[tuple[float, str, str]] = [] # (time, video_id, type)
# Mapping from shorthand marker prefix → (implied_cutout_name, implied_layer)
# These are the defaults; videos.json values act as a base but the marker wins.
_SHORTHAND: dict[str, tuple[str, str]] = {
"vft:": ("fullscreen", "above"),
"vfb:": ("fullscreen", "below"),
"vst:": ("square", "above"),
"vsb:": ("square", "below"),
"vftp:": ("fullscreen", "above", "pause_narration"),
"vfbp:": ("fullscreen", "below", "pause_narration"),
"vstp:": ("square", "above", "pause_narration"),
"vsbp:": ("square", "below", "pause_narration"),
}
# Collect video markers: (time, video_id, event_type, cutout_name_override, layer_override)
# event_type is "video" (ends at next slide) or "narration" (runs to end)
video_markers: list[tuple[float, str, str, str | None, str | None]] = []
for timing in marker_timings:
if timing.timestamp < 0:
continue
if timing.marker_id.startswith("video:"):
video_id = timing.marker_id[6:]
if video_id in videos:
video_source = videos[video_id]
if video_source.cutout and video_source.cutout in cutouts:
video_markers.append((timing.timestamp, video_id, "video"))
mid = timing.marker_id
elif timing.marker_id.startswith("narration:"):
video_id = timing.marker_id[10:]
if video_id in videos:
video_source = videos[video_id]
if video_source.cutout and video_source.cutout in cutouts:
video_markers.append((timing.timestamp, video_id, "narration"))
# --- shorthand markers: vft/vfb/vst/vsb ---
shorthand_match = next((p for p in _SHORTHAND if mid.startswith(p)), None)
if shorthand_match:
video_id = mid[len(shorthand_match) :]
if video_id not in videos:
raise ValueError(
f"Marker [{mid}] references unknown video '{video_id}'. "
f"Add it to videos.json or remove the marker."
)
implied_cutout, implied_layer = _SHORTHAND[shorthand_match]
if implied_cutout not in cutouts:
raise ValueError(
f"Marker [{mid}] uses shorthand '{shorthand_match}' which requires "
f"cutout '{implied_cutout}' but it is not defined in project config. "
f"Available cutouts: {list(cutouts.keys())}"
)
video_markers.append(
(timing.timestamp, video_id, "video", implied_cutout, implied_layer)
)
continue
# --- legacy [video:xxx] ---
if mid.startswith("video:"):
video_id = mid[6:]
if video_id not in videos:
raise ValueError(
f"Marker [video:{video_id}] references unknown video '{video_id}'. "
f"Add it to videos.json or remove the marker."
)
video_source = videos[video_id]
if not video_source.cutout:
raise ValueError(
f"Marker [video:{video_id}] — video '{video_id}' has no 'cutout' set in videos.json."
)
if video_source.cutout not in cutouts:
raise ValueError(
f"Marker [video:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
f"Available: {list(cutouts.keys())}"
)
video_markers.append(
(timing.timestamp, video_id, "video", None, None)
)
continue
# --- [narration:xxx] ---
if mid.startswith("narration:"):
video_id = mid[10:]
if video_id not in videos:
raise ValueError(
f"Marker [narration:{video_id}] references unknown video '{video_id}'. "
f"Add it to videos.json or remove the marker."
)
video_source = videos[video_id]
if not video_source.cutout:
raise ValueError(
f"Marker [narration:{video_id}] — video '{video_id}' has no 'cutout' set in videos.json."
)
if video_source.cutout not in cutouts:
raise ValueError(
f"Marker [narration:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
f"Available: {list(cutouts.keys())}"
)
video_markers.append(
(timing.timestamp, video_id, "narration", None, None)
)
events: list[VideoEvent] = []
for start_time, video_id, marker_type in video_markers:
for (
start_time,
video_id,
marker_type,
cutout_override,
layer_override,
) in video_markers:
video_source = videos[video_id]
cutout = cutouts[video_source.cutout]
if marker_type == "video":
# End at next slide
# Resolve cutout: marker override > videos.json cutout
# (validation already ensured cutout exists — this is a safety assertion)
cutout_name = cutout_override or video_source.cutout
cutout = cutouts[cutout_name]
# Resolve layer: marker override > videos.json layer
layer = layer_override if layer_override is not None else video_source.layer
end_on = video_source.end_on
if end_on == "take" and video_source.take is not None:
end_time = start_time + video_source.take
elif end_on == "end":
end_time = total_duration
elif end_on == "next_slide" or (end_on is None and marker_type == "video"):
# End at next slide marker
end_time = total_duration
for slide_time in slide_times:
if slide_time > start_time:
end_time = slide_time
break
else:
# narration: runs to end
# end_on is None and marker_type == "narration": runs to end
end_time = total_duration
# Filter by time range
@@ -846,6 +962,8 @@ def _extract_video_events(
end_time=end_time,
video_source=video_source,
cutout=cutout,
cutout_name=cutout_name,
layer=layer,
)
)
@@ -992,7 +1110,9 @@ def _extract_outro_events(
video_source = videos[video_id]
# Get the video duration
video_path, is_cached = _resolve_video_path(videos_dir, video_source, shared_assets_dir, project_path)
video_path, is_cached = _resolve_video_path(
videos_dir, video_source, shared_assets_dir, project_path
)
if is_cached and cached_files is not None:
cached_files.add(video_id)
if video_path.exists():