Adding pexels downloader and fixes
This commit is contained in:
+103
-28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user