Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf40a19b4e | |||
| 5d7c77db91 |
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
./gnommo.sh -p video1 all --force --prod
|
||||||
|
./gnommo.sh -p video2 all --force --prod
|
||||||
|
./gnommo.sh -p video3 all --force --prod
|
||||||
|
#./gnommo.sh -p video4 all --force
|
||||||
|
#./gnommo.sh -p video5 all --force
|
||||||
|
#./gnommo.sh -p video6 all --force
|
||||||
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
"footer": "Subscribe for more tutorials!\nTwitter: @example",
|
"footer": "Subscribe for more tutorials!\nTwitter: @example",
|
||||||
"resolution": [1920, 1080],
|
"resolution": [1920, 1080],
|
||||||
"fps": 30,
|
"fps": 30,
|
||||||
"gnommo_scratch": null,
|
|
||||||
"defaultSlideType": "fullscreen",
|
"defaultSlideType": "fullscreen",
|
||||||
"keynote_file": "media/example.key",
|
"keynote_file": "media/example.key",
|
||||||
"transcript": "media/videos/talking_head.transcript.json",
|
"transcript": "media/videos/talking_head.transcript.json",
|
||||||
|
|||||||
+58
-2
@@ -2394,6 +2394,57 @@ def _parse_slide_range(slides_arg: str) -> tuple[str, Optional[str]]:
|
|||||||
return start_slide, end_slide
|
return start_slide, end_slide
|
||||||
|
|
||||||
|
|
||||||
|
def _project_markers_to_videos(
|
||||||
|
markers: list[str], videos_json_path: Path, config
|
||||||
|
) -> None:
|
||||||
|
"""ETL: project shorthand marker semantics into videos.json.
|
||||||
|
|
||||||
|
Scans the manuscript marker list for shorthand prefixes (vft:, vfb:, vst:,
|
||||||
|
vsb:, vf2t:, vf2b: and their pause variants) and writes the implied cutout
|
||||||
|
and layer values directly into videos.json. This runs before parse_videos
|
||||||
|
so the render pass reads already-projected data and needs no shorthand logic.
|
||||||
|
|
||||||
|
The manuscript is the authoritative source: the LAST shorthand reference to
|
||||||
|
a given video_id wins, matching what a human editor would expect when they
|
||||||
|
change a marker near the end of the script.
|
||||||
|
"""
|
||||||
|
if not videos_json_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
from .transformer import _SHORTHAND_PREFIXES # (cutout, layer) lookup table
|
||||||
|
|
||||||
|
# Build projection: video_id → {cutout, layer}
|
||||||
|
projection: dict[str, dict] = {}
|
||||||
|
for marker in markers:
|
||||||
|
for prefix, implied in _SHORTHAND_PREFIXES.items():
|
||||||
|
if marker.startswith(prefix):
|
||||||
|
video_id = marker[len(prefix):]
|
||||||
|
cutout, layer = implied[0], implied[1]
|
||||||
|
projection[video_id] = {"cutout": cutout, "layer": layer}
|
||||||
|
break
|
||||||
|
|
||||||
|
if not projection:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(videos_json_path, "r", encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
for video_id, fields in projection.items():
|
||||||
|
if video_id not in raw:
|
||||||
|
continue
|
||||||
|
for field, value in fields.items():
|
||||||
|
if raw[video_id].get(field) != value:
|
||||||
|
raw[video_id][field] = value
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
with open(videos_json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(raw, f, indent=2, ensure_ascii=False)
|
||||||
|
updated = [vid for vid in projection if vid in raw]
|
||||||
|
print(f" Projected marker semantics → videos.json: {', '.join(updated)}")
|
||||||
|
|
||||||
|
|
||||||
def _writeback_video_metadata(plan, project_path, config) -> None:
|
def _writeback_video_metadata(plan, project_path, config) -> None:
|
||||||
"""Write back cutout/layer derived from shorthand markers to videos.json.
|
"""Write back cutout/layer derived from shorthand markers to videos.json.
|
||||||
|
|
||||||
@@ -2586,6 +2637,12 @@ def cmd_render(
|
|||||||
save_citations(citations, citations_path)
|
save_citations(citations, citations_path)
|
||||||
config = parse_project_config(project_path)
|
config = parse_project_config(project_path)
|
||||||
|
|
||||||
|
# ETL: project shorthand marker semantics (cutout/layer) into videos.json
|
||||||
|
# before parse_videos reads it, so the render pass is purely data-driven.
|
||||||
|
_project_markers_to_videos(
|
||||||
|
markers, project_path / config.videos_path, config
|
||||||
|
)
|
||||||
|
|
||||||
# Override resolution for preview modes
|
# Override resolution for preview modes
|
||||||
if res != "full":
|
if res != "full":
|
||||||
cfg = RES_CONFIGS[res]
|
cfg = RES_CONFIGS[res]
|
||||||
@@ -2732,8 +2789,7 @@ def cmd_render(
|
|||||||
if plan.time_offset > 0:
|
if plan.time_offset > 0:
|
||||||
print(f" Time offset: {plan.time_offset:.1f}s (partial render)")
|
print(f" Time offset: {plan.time_offset:.1f}s (partial render)")
|
||||||
|
|
||||||
# Persist shorthand-derived cutout/layer back to videos.json (idempotent)
|
|
||||||
_writeback_video_metadata(plan, project_path, config)
|
|
||||||
|
|
||||||
# Print detailed render plan with alignment info
|
# Print detailed render plan with alignment info
|
||||||
_print_render_plan_details(plan, marker_timings, slides)
|
_print_render_plan_details(plan, marker_timings, slides)
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ def parse_project_config(project_path: Path) -> ProjectConfig:
|
|||||||
audio_path=data.get("audio", "audio.json"),
|
audio_path=data.get("audio", "audio.json"),
|
||||||
audio_source=data.get("audio_source"),
|
audio_source=data.get("audio_source"),
|
||||||
main_video=data.get("main_video"),
|
main_video=data.get("main_video"),
|
||||||
gnommo_scratch=data.get("gnommo_scratch"),
|
|
||||||
process_cache=data.get("process_cache"),
|
process_cache=data.get("process_cache"),
|
||||||
default_begin=float(data.get("default_begin", 0.0)),
|
default_begin=float(data.get("default_begin", 0.0)),
|
||||||
default_end_trim=float(data.get("default_end_trim", 0.0)),
|
default_end_trim=float(data.get("default_end_trim", 0.0)),
|
||||||
|
|||||||
+36
-55
@@ -814,10 +814,10 @@ def build_filter_complex(
|
|||||||
|
|
||||||
Layer structure (bottom to top):
|
Layer structure (bottom to top):
|
||||||
- Layer 1: Background (solid color, image, or video)
|
- Layer 1: Background (solid color, image, or video)
|
||||||
- Layer 2: "below" triggered videos (vfb/vsb) — behind talking head
|
- Layer 2: "below" triggered videos (vfb/vf2b/vsb) — behind slides, use with slide on top to mask
|
||||||
- Layer 3: Always visible videos (like talking head) in cutouts
|
- Layer 3: Slides (transparent in talking-head cutout area)
|
||||||
- Layer 4: Slides (with time-based enable)
|
- Layer 4: Always visible videos (talking head) — above slides, visible through cutout
|
||||||
- Layer 5: "above" triggered videos (vft/vst) — in front of slides
|
- Layer 5: "above" triggered videos (vft/vf2t/vst) — topmost, covers everything including talking head
|
||||||
- Layer 6: Camera transform
|
- Layer 6: Camera transform
|
||||||
- Layer 7: Outro videos (fullscreen, after narration ends)
|
- Layer 7: Outro videos (fullscreen, after narration ends)
|
||||||
- Audio: Main audio mixed with triggered sound effects and outro audio
|
- Audio: Main audio mixed with triggered sound effects and outro audio
|
||||||
@@ -846,8 +846,7 @@ def build_filter_complex(
|
|||||||
|
|
||||||
current_label = "bg"
|
current_label = "bg"
|
||||||
|
|
||||||
# Add "below" triggered video overlays (vfb/vsb) BEFORE the talking head
|
# Layer 2: "below" triggered video overlays (vfb/vsb) — behind slides and talking head
|
||||||
# so they sit behind it in the composite stack.
|
|
||||||
for i, event in enumerate(plan.video_events):
|
for i, event in enumerate(plan.video_events):
|
||||||
if event.layer != "below":
|
if event.layer != "below":
|
||||||
continue
|
continue
|
||||||
@@ -884,23 +883,37 @@ def build_filter_complex(
|
|||||||
)
|
)
|
||||||
current_label = next_label
|
current_label = next_label
|
||||||
|
|
||||||
# Overlay always_visible videos (like talking head)
|
# Layer 3: Slides (transparent in the talking-head cutout area)
|
||||||
# If there are narration pauses, we need to segment the video
|
for i, event in enumerate(plan.slide_events):
|
||||||
|
slide_idx = slide_inputs[event.slide_id]
|
||||||
|
|
||||||
|
slide_label = f"s{i}"
|
||||||
|
filters.append(
|
||||||
|
f"[{slide_idx}:v]scale={width}:{height}:"
|
||||||
|
f"force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=0x00000000[{slide_label}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
next_label = f"sbase{i}"
|
||||||
|
enable_expr = f"between(t\\,{event.start_time:.3f}\\,{event.end_time:.3f})"
|
||||||
|
filters.append(
|
||||||
|
f"[{current_label}][{slide_label}]overlay="
|
||||||
|
f"x=0:y=0:enable={enable_expr}"
|
||||||
|
f"[{next_label}]"
|
||||||
|
)
|
||||||
|
current_label = next_label
|
||||||
|
|
||||||
|
# Layer 4: Always-visible videos (talking head) — above slides, visible through cutout
|
||||||
for i, (video_id, video_source, cutout) in enumerate(plan.narration_videos):
|
for i, (video_id, video_source, cutout) in enumerate(plan.narration_videos):
|
||||||
input_idx = always_visible_inputs[i]
|
input_idx = always_visible_inputs[i]
|
||||||
cut_x, cut_y, cut_width, cut_height = _calculate_cutout_position(
|
cut_x, cut_y, cut_width, cut_height = _calculate_cutout_position(
|
||||||
cutout, width, height
|
cutout, width, height
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply zoom factor to cutout dimensions
|
|
||||||
zoom = video_source.zoom
|
zoom = video_source.zoom
|
||||||
zoomed_width = int(cut_width * zoom)
|
zoomed_width = int(cut_width * zoom)
|
||||||
zoomed_height = int(cut_height * zoom)
|
zoomed_height = int(cut_height * zoom)
|
||||||
|
|
||||||
if not plan.narration_pauses:
|
if not plan.narration_pauses:
|
||||||
# Simple case: no pauses, continuous overlay
|
|
||||||
# fps+setpts normalise the source to a constant frame rate and reset
|
|
||||||
# the timeline to 0 so the video stays locked to the audio track.
|
|
||||||
video_label = f"av{i}"
|
video_label = f"av{i}"
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{input_idx}:v]fps={plan.config.fps},setpts=PTS-STARTPTS,"
|
f"[{input_idx}:v]fps={plan.config.fps},setpts=PTS-STARTPTS,"
|
||||||
@@ -916,18 +929,12 @@ def build_filter_complex(
|
|||||||
)
|
)
|
||||||
current_label = next_label
|
current_label = next_label
|
||||||
else:
|
else:
|
||||||
# Complex case: narration pauses - segment the video
|
|
||||||
# Each segment is trimmed from source and positioned in output timeline
|
|
||||||
segments = _build_narration_segments(
|
segments = _build_narration_segments(
|
||||||
plan.narration_pauses, plan.total_duration
|
plan.narration_pauses, plan.total_duration
|
||||||
)
|
)
|
||||||
|
|
||||||
for seg_idx, (src_start, src_end, out_start, out_end) in enumerate(
|
for seg_idx, (src_start, src_end, out_start, out_end) in enumerate(segments):
|
||||||
segments
|
|
||||||
):
|
|
||||||
seg_label = f"av{i}_seg{seg_idx}"
|
seg_label = f"av{i}_seg{seg_idx}"
|
||||||
# Trim to source range, then shift PTS to output position
|
|
||||||
# setpts=PTS-STARTPTS puts segment at 0, then +offset/TB shifts to output time
|
|
||||||
pts_offset = out_start
|
pts_offset = out_start
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{input_idx}:v]trim={src_start:.3f}:{src_end:.3f},"
|
f"[{input_idx}:v]trim={src_start:.3f}:{src_end:.3f},"
|
||||||
@@ -938,7 +945,6 @@ def build_filter_complex(
|
|||||||
f"format=rgba[{seg_label}]"
|
f"format=rgba[{seg_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Overlay with enable for this segment's output time range
|
|
||||||
next_label = f"avbase{i}_seg{seg_idx}"
|
next_label = f"avbase{i}_seg{seg_idx}"
|
||||||
enable_expr = f"between(t\\,{out_start:.3f}\\,{out_end:.3f})"
|
enable_expr = f"between(t\\,{out_start:.3f}\\,{out_end:.3f})"
|
||||||
filters.append(
|
filters.append(
|
||||||
@@ -947,29 +953,8 @@ def build_filter_complex(
|
|||||||
)
|
)
|
||||||
current_label = next_label
|
current_label = next_label
|
||||||
|
|
||||||
# Add slide overlays with time-based enable
|
# Layer 5: "above" triggered videos (vft/vf2t/vst) — topmost, covers slides and talking head
|
||||||
for i, event in enumerate(plan.slide_events):
|
# Use case: fullscreen video that intentionally masks the narrator
|
||||||
slide_idx = slide_inputs[event.slide_id]
|
|
||||||
|
|
||||||
# Scale slide to full frame size (transparent areas show through)
|
|
||||||
slide_label = f"s{i}"
|
|
||||||
filters.append(
|
|
||||||
f"[{slide_idx}:v]scale={width}:{height}:"
|
|
||||||
f"force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=0x00000000[{slide_label}]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Overlay at 0,0 (full frame) with time-based enable
|
|
||||||
next_label = f"sbase{i}"
|
|
||||||
enable_expr = f"between(t\\,{event.start_time:.3f}\\,{event.end_time:.3f})"
|
|
||||||
filters.append(
|
|
||||||
f"[{current_label}][{slide_label}]overlay="
|
|
||||||
f"x=0:y=0:enable={enable_expr}"
|
|
||||||
f"[{next_label}]"
|
|
||||||
)
|
|
||||||
|
|
||||||
current_label = next_label
|
|
||||||
|
|
||||||
# Add "above-slides" triggered video overlays (vft/vst or layer="above")
|
|
||||||
for i, event in enumerate(plan.video_events):
|
for i, event in enumerate(plan.video_events):
|
||||||
if event.layer != "above":
|
if event.layer != "above":
|
||||||
continue
|
continue
|
||||||
@@ -978,22 +963,15 @@ def build_filter_complex(
|
|||||||
event.cutout, width, height
|
event.cutout, width, height
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate effective end time (respecting 'take' parameter)
|
|
||||||
duration = event.end_time - event.start_time
|
duration = event.end_time - event.start_time
|
||||||
if event.video_source.take is not None:
|
if event.video_source.take is not None:
|
||||||
duration = min(duration, event.video_source.take)
|
duration = min(duration, event.video_source.take)
|
||||||
effective_end = event.start_time + duration
|
effective_end = event.start_time + duration
|
||||||
|
|
||||||
# Apply zoom factor to cutout dimensions
|
|
||||||
zoom = event.video_source.zoom
|
zoom = event.video_source.zoom
|
||||||
zoomed_width = int(cut_width * zoom)
|
zoomed_width = int(cut_width * zoom)
|
||||||
zoomed_height = int(cut_height * zoom)
|
zoomed_height = int(cut_height * zoom)
|
||||||
|
|
||||||
# Scale to cover the zoomed area (like CSS object-fit: cover)
|
|
||||||
# Then crop to cutout dimensions (centered)
|
|
||||||
# Use setpts to sync video start with overlay enable time
|
|
||||||
# IMPORTANT: convert to rgba FIRST (before scale/crop) so the alpha channel
|
|
||||||
# is preserved throughout. scale in yuva444p10le can silently strip alpha.
|
|
||||||
video_label = f"tv{i}"
|
video_label = f"tv{i}"
|
||||||
start_pts = event.start_time
|
start_pts = event.start_time
|
||||||
filters.append(
|
filters.append(
|
||||||
@@ -1004,8 +982,6 @@ def build_filter_complex(
|
|||||||
f"[{video_label}]"
|
f"[{video_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Overlay with time-based enable; format=auto lets FFmpeg pick the right
|
|
||||||
# compositing format so the RGBA alpha channel is respected.
|
|
||||||
next_label = f"tvbase{i}"
|
next_label = f"tvbase{i}"
|
||||||
enable_expr = f"between(t\\,{event.start_time:.3f}\\,{effective_end:.3f})"
|
enable_expr = f"between(t\\,{event.start_time:.3f}\\,{effective_end:.3f})"
|
||||||
filters.append(
|
filters.append(
|
||||||
@@ -1013,7 +989,6 @@ def build_filter_complex(
|
|||||||
f"x={cut_x}:y={cut_y}:enable={enable_expr}:format=auto"
|
f"x={cut_x}:y={cut_y}:enable={enable_expr}:format=auto"
|
||||||
f"[{next_label}]"
|
f"[{next_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
current_label = next_label
|
current_label = next_label
|
||||||
|
|
||||||
# Scene composition complete - now apply camera transform
|
# Scene composition complete - now apply camera transform
|
||||||
@@ -1279,10 +1254,13 @@ 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
|
||||||
|
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},"
|
||||||
f"asetpts=PTS-STARTPTS,"
|
f"asetpts=PTS-STARTPTS,"
|
||||||
f"adelay={delay_ms}|{delay_ms}[{label}]"
|
f"adelay={delay_ms}|{delay_ms}"
|
||||||
|
f"{vol_filter}[{label}]"
|
||||||
)
|
)
|
||||||
audio_labels_to_mix.append(f"[{label}]")
|
audio_labels_to_mix.append(f"[{label}]")
|
||||||
|
|
||||||
@@ -1298,10 +1276,13 @@ def build_filter_complex(
|
|||||||
delay_ms = int(event.start_time * 1000)
|
delay_ms = int(event.start_time * 1000)
|
||||||
label = f"outroaud{i}"
|
label = f"outroaud{i}"
|
||||||
|
|
||||||
|
vol = event.video_source.volume
|
||||||
|
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},"
|
||||||
f"asetpts=PTS-STARTPTS,"
|
f"asetpts=PTS-STARTPTS,"
|
||||||
f"adelay={delay_ms}|{delay_ms}[{label}]"
|
f"adelay={delay_ms}|{delay_ms}"
|
||||||
|
f"{vol_filter}[{label}]"
|
||||||
)
|
)
|
||||||
audio_labels_to_mix.append(f"[{label}]")
|
audio_labels_to_mix.append(f"[{label}]")
|
||||||
|
|
||||||
|
|||||||
+46
-61
@@ -28,6 +28,26 @@ from .transcriber import TranscribedWord
|
|||||||
# Audio trigger offset: play sound this many seconds before the marker
|
# Audio trigger offset: play sound this many seconds before the marker
|
||||||
AUDIO_OFFSET_SECONDS = 1.0
|
AUDIO_OFFSET_SECONDS = 1.0
|
||||||
|
|
||||||
|
# Shorthand marker prefix → (cutout_name, layer).
|
||||||
|
# These are the ETL source-of-truth: when a manuscript contains [vft:X],
|
||||||
|
# that projects cutout="fullscreen" and layer="above" into videos.json for X.
|
||||||
|
# The pause-variant entries (vftp: etc.) carry a third element "pause_narration"
|
||||||
|
# which is a per-event property, not stored in videos.json.
|
||||||
|
_SHORTHAND_PREFIXES: dict[str, tuple] = {
|
||||||
|
"vft:": ("fullscreen", "above"),
|
||||||
|
"vfb:": ("fullscreen", "below"),
|
||||||
|
"vf2t:": ("fullscreen2", "above"),
|
||||||
|
"vf2b:": ("fullscreen2", "below"),
|
||||||
|
"vst:": ("square", "above"),
|
||||||
|
"vsb:": ("square", "below"),
|
||||||
|
"vftp:": ("fullscreen", "above"),
|
||||||
|
"vfbp:": ("fullscreen", "below"),
|
||||||
|
"vf2tp:": ("fullscreen2", "above"),
|
||||||
|
"vf2bp:": ("fullscreen2", "below"),
|
||||||
|
"vstp:": ("square", "above"),
|
||||||
|
"vsbp:": ("square", "below"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MarkerTiming:
|
class MarkerTiming:
|
||||||
@@ -961,26 +981,14 @@ def _extract_video_events(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mapping from shorthand marker prefix → (implied_cutout_name, implied_layer)
|
# Pause-variant prefixes — the only thing the render pass still needs from
|
||||||
# These are the defaults; videos.json values act as a base but the marker wins.
|
# shorthand markers at event-build time (pause_narration is per-event, not stored in videos.json).
|
||||||
_SHORTHAND: dict[str, tuple[str, str]] = {
|
_PAUSE_PREFIXES = {"vftp:", "vfbp:", "vf2tp:", "vf2bp:", "vstp:", "vsbp:"}
|
||||||
"vft:": ("fullscreen", "above"),
|
|
||||||
"vfb:": ("fullscreen", "below"),
|
|
||||||
"vf2t:": ("fullscreen2", "above"),
|
|
||||||
"vf2b:": ("fullscreen2", "below"),
|
|
||||||
"vst:": ("square", "above"),
|
|
||||||
"vsb:": ("square", "below"),
|
|
||||||
"vftp:": ("fullscreen", "above", "pause_narration"),
|
|
||||||
"vfbp:": ("fullscreen", "below", "pause_narration"),
|
|
||||||
"vf2tp:": ("fullscreen2", "above", "pause_narration"),
|
|
||||||
"vf2bp:": ("fullscreen2", "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)
|
# Collect video markers: (time, video_id, event_type, pause_narration)
|
||||||
# event_type is "video" (ends at next slide) or "narration" (runs to end)
|
# video_markers: (timestamp, video_id, marker_type, pause_narration)
|
||||||
video_markers: list[tuple[float, str, str, str | None, str | None]] = []
|
# cutout and layer are read from videos.json (projected there by _project_markers_to_videos)
|
||||||
|
video_markers: list[tuple[float, str, str, bool]] = []
|
||||||
|
|
||||||
for timing in marker_timings:
|
for timing in marker_timings:
|
||||||
if timing.timestamp < 0:
|
if timing.timestamp < 0:
|
||||||
@@ -988,8 +996,8 @@ def _extract_video_events(
|
|||||||
|
|
||||||
mid = timing.marker_id
|
mid = timing.marker_id
|
||||||
|
|
||||||
# --- shorthand markers: vft/vfb/vst/vsb ---
|
# --- shorthand markers (vft:/vfb:/vst:/vsb: and pause variants) ---
|
||||||
shorthand_match = next((p for p in _SHORTHAND if mid.startswith(p)), None)
|
shorthand_match = next((p for p in _SHORTHAND_PREFIXES if mid.startswith(p)), None)
|
||||||
if shorthand_match:
|
if shorthand_match:
|
||||||
video_id = mid[len(shorthand_match):]
|
video_id = mid[len(shorthand_match):]
|
||||||
if video_id not in videos:
|
if video_id not in videos:
|
||||||
@@ -998,16 +1006,16 @@ def _extract_video_events(
|
|||||||
f"Add it to videos.json or remove the marker."
|
f"Add it to videos.json or remove the marker."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
implied_cutout, implied_layer = _SHORTHAND[shorthand_match]
|
# Validate that videos.json has the correct cutout (written by ETL)
|
||||||
if implied_cutout not in cutouts:
|
video_source = videos[video_id]
|
||||||
|
if not video_source.cutout or video_source.cutout not in cutouts:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"[{mid}] requires cutout '{implied_cutout}' which is not defined in project config — skipped. "
|
f"[{mid}] video '{video_id}' has no valid cutout in videos.json — "
|
||||||
f"Available cutouts: {list(cutouts.keys())}"
|
f"run render once to project values, or set cutout manually."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
video_markers.append(
|
pause_narration = shorthand_match in _PAUSE_PREFIXES
|
||||||
(timing.timestamp, video_id, "video", implied_cutout, implied_layer)
|
video_markers.append((timing.timestamp, video_id, "video", pause_narration))
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- legacy [video:xxx] ---
|
# --- legacy [video:xxx] ---
|
||||||
@@ -1016,22 +1024,15 @@ def _extract_video_events(
|
|||||||
if video_id not in videos:
|
if video_id not in videos:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"[video:{video_id}] references unknown video '{video_id}' — skipped."
|
f"[video:{video_id}] references unknown video '{video_id}' — skipped."
|
||||||
f"Add it to videos.json or remove the marker."
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
video_source = videos[video_id]
|
video_source = videos[video_id]
|
||||||
if not video_source.cutout:
|
if not video_source.cutout or video_source.cutout not in cutouts:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"[video:{video_id}] has no 'cutout' set in videos.json — skipped."
|
f"[video:{video_id}] has no valid cutout in videos.json — skipped."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if video_source.cutout not in cutouts:
|
video_markers.append((timing.timestamp, video_id, "video", False))
|
||||||
warnings.append(
|
|
||||||
f"[video:{video_id}] cutout '{video_source.cutout}' is not defined in project config — skipped. "
|
|
||||||
f"Available: {list(cutouts.keys())}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
video_markers.append((timing.timestamp, video_id, "video", None, None))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- [narration:xxx] ---
|
# --- [narration:xxx] ---
|
||||||
@@ -1040,40 +1041,24 @@ def _extract_video_events(
|
|||||||
if video_id not in videos:
|
if video_id not in videos:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"[narration:{video_id}] references unknown video '{video_id}' — skipped."
|
f"[narration:{video_id}] references unknown video '{video_id}' — skipped."
|
||||||
f"Add it to videos.json or remove the marker."
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
video_source = videos[video_id]
|
video_source = videos[video_id]
|
||||||
if not video_source.cutout:
|
if not video_source.cutout or video_source.cutout not in cutouts:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"[narration:{video_id}] has no 'cutout' set in videos.json — skipped."
|
f"[narration:{video_id}] has no valid cutout in videos.json — skipped."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if video_source.cutout not in cutouts:
|
video_markers.append((timing.timestamp, video_id, "narration", False))
|
||||||
warnings.append(
|
|
||||||
f"[narration:{video_id}] cutout '{video_source.cutout}' is not defined in project config — skipped. "
|
|
||||||
f"Available: {list(cutouts.keys())}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
video_markers.append((timing.timestamp, video_id, "narration", None, None))
|
|
||||||
|
|
||||||
events: list[VideoEvent] = []
|
events: list[VideoEvent] = []
|
||||||
for (
|
for (start_time, video_id, marker_type, pause_narration) in video_markers:
|
||||||
start_time,
|
|
||||||
video_id,
|
|
||||||
marker_type,
|
|
||||||
cutout_override,
|
|
||||||
layer_override,
|
|
||||||
) in video_markers:
|
|
||||||
video_source = videos[video_id]
|
video_source = videos[video_id]
|
||||||
|
|
||||||
# Resolve cutout: marker override > videos.json cutout
|
# Read cutout and layer directly from videos.json (projected by ETL)
|
||||||
# (validation already ensured cutout exists — this is a safety assertion)
|
cutout_name = video_source.cutout
|
||||||
cutout_name = cutout_override or video_source.cutout
|
|
||||||
cutout = cutouts[cutout_name]
|
cutout = cutouts[cutout_name]
|
||||||
|
layer = video_source.layer
|
||||||
# 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
|
end_on = video_source.end_on
|
||||||
if end_on == "take" and video_source.take is not None:
|
if end_on == "take" and video_source.take is not None:
|
||||||
|
|||||||
Reference in New Issue
Block a user