Adding pexels downloader and fixes

This commit is contained in:
2026-06-07 11:19:19 +02:00
parent 980bb84dac
commit b9b5a8e77d
12 changed files with 957 additions and 146 deletions
+103 -28
View File
@@ -237,8 +237,27 @@ def _resolve_video_path(
source_path = base_dir / video_source.source_file
if project_path:
resolved, _ = resolve_with_cache(source_path, project_path)
return resolved
return source_path
else:
resolved = source_path
if not resolved.exists():
# File not found anywhere — substitute PlaceholderVideo so FFmpeg doesn't crash
placeholder = None
if shared_assets_dir:
p = shared_assets_dir / "PlaceholderVideo.mp4"
if project_path:
p, _ = resolve_with_cache(p, project_path)
if p.exists():
placeholder = p
if placeholder:
import sys
print(
f" Warning: {video_source.source_file} not found — using PlaceholderVideo",
file=sys.stderr,
)
return placeholder
return resolved
def _has_audio_stream(video_path: Path) -> bool:
@@ -362,6 +381,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
f"Background handle '{bg_handle}' not found in shared_assets/videos.json"
)
bg_path = shared_assets_dir / bg_videos[bg_handle]["source_file"]
bg_path, _ = resolve_with_cache(bg_path, plan.project_path)
if not bg_path.exists():
raise RenderError(
f"Background file not found: {bg_path} (from handle '{bg_handle}')"
@@ -404,12 +424,29 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
videos_dir, event.video_source, shared_assets_dir, project_path
)
skip = event.video_source.skip or 0.0
# How long this clip needs to play in the output
clip_duration = event.end_time - event.start_time
if event.video_source.take is not None:
clip_duration = min(clip_duration, event.video_source.take)
# Loop the clip if the file is shorter than the display window.
# Don't loop pause-narration videos — they intentionally play once and stop.
needs_loop = False
if event.video_source.duration is not None and not event.video_source.pause_narration:
remaining = event.video_source.duration - skip
needs_loop = remaining < clip_duration - 0.1 # 0.1 s tolerance
if needs_loop:
cmd.extend(["-stream_loop", "-1"])
if skip > 0:
cmd.extend(["-ss", f"{skip:.3f}"])
cmd.extend(["-analyzeduration", "0", "-probesize", "1000"])
# Use pre-probed duration to tell FFmpeg exactly how much to read,
# preventing scans of ghost audio tracks on empty MP4 audio streams.
if event.video_source.duration is not None:
# Use pre-probed duration (or loop-limited duration) to tell FFmpeg exactly
# how much to read, preventing scans of ghost audio tracks on empty streams.
if needs_loop:
cmd.extend(["-t", f"{clip_duration:.3f}"])
elif event.video_source.duration is not None:
remaining = event.video_source.duration - skip
if remaining > 0:
cmd.extend(["-t", f"{remaining:.3f}"])
@@ -881,31 +918,12 @@ def build_filter_complex(
enable_expr = f"between(t\\,{event.start_time:.3f}\\,{effective_end:.3f})"
filters.append(
f"[{current_label}][{video_label}]overlay="
f"x={cut_x}:y={cut_y}:enable={enable_expr}"
f"x={cut_x}:y={cut_y}:enable={enable_expr}:eof_action=pass"
f"[{next_label}]"
)
current_label = next_label
# Layer 3: Slides (transparent in the talking-head cutout area)
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
# Layer 3: Talking head — above below-videos, but under slides so fullscreen slides cover it
for i, (video_id, video_source, cutout) in enumerate(plan.narration_videos):
input_idx = always_visible_inputs[i]
cut_x, cut_y, cut_width, cut_height = _calculate_cutout_position(
@@ -958,7 +976,64 @@ def build_filter_complex(
)
current_label = next_label
# Layer 5: "above" triggered videos (vft/vf2t/vst) — topmost, covers slides and talking head
# Layer 4: "mid" triggered videos (vfm/vsm) — above talking head, below slides
# Use case: content that should show through a slide's transparent "screen hole"
for i, event in enumerate(plan.video_events):
if event.layer != "mid":
continue
video_idx = video_inputs[i]
cut_x, cut_y, cut_width, cut_height = _calculate_cutout_position(
event.cutout, width, height
)
duration = event.end_time - event.start_time
if event.video_source.take is not None:
duration = min(duration, event.video_source.take)
effective_end = event.start_time + duration
zoom = event.video_source.zoom
zoomed_width = int(cut_width * zoom)
zoomed_height = int(cut_height * zoom)
video_label = f"tvm{i}"
start_pts = event.start_time
filters.append(
f"[{video_idx}:v]format=yuva444p10le,"
f"setpts=PTS-STARTPTS+{start_pts:.3f}/TB,"
f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase,"
f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2,"
f"format=rgba[{video_label}]"
)
next_label = f"tvmbase{i}"
enable_expr = f"between(t\\,{event.start_time:.3f}\\,{effective_end:.3f})"
filters.append(
f"[{current_label}][{video_label}]overlay="
f"x={cut_x}:y={cut_y}:enable={enable_expr}:eof_action=pass"
f"[{next_label}]"
)
current_label = next_label
# Layer 5: Slides — on top of talking head so fullscreen slides cover the narrator
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 6: "above" triggered videos (vft/vf2t/vst) — topmost, covers slides and talking head
# Use case: fullscreen video that intentionally masks the narrator
for i, event in enumerate(plan.video_events):
if event.layer != "above":
@@ -991,7 +1066,7 @@ def build_filter_complex(
enable_expr = f"between(t\\,{event.start_time:.3f}\\,{effective_end:.3f})"
filters.append(
f"[{current_label}][{video_label}]overlay="
f"x={cut_x}:y={cut_y}:enable={enable_expr}:format=auto"
f"x={cut_x}:y={cut_y}:enable={enable_expr}:format=auto:eof_action=pass"
f"[{next_label}]"
)
current_label = next_label