Fixing gnommo
This commit is contained in:
+175
-55
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user