Reuse intro logo as outro and refine title fade

This commit is contained in:
hbrain 2026-05-28 14:48:10 +00:00
parent 61fbf6382e
commit 2ac00e0e67
2 changed files with 34 additions and 11 deletions

View file

@ -256,9 +256,9 @@ Adjust these constants and rerun `movmaker.py` to change the intro behavior with
- if no audio file exists in the input folder, searches Jamendo using `--music-genre` and downloads one random Creative Commons track that allows non-commercial reuse and derivative works; default query is `cinematic punk rock` - if no audio file exists in the input folder, searches Jamendo using `--music-genre` and downloads one random Creative Commons track that allows non-commercial reuse and derivative works; default query is `cinematic punk rock`
- reads the Jamendo client ID from `JAMENDO_CLIENT_ID` or `~/.config/movmaker/jamendo.env` - reads the Jamendo client ID from `JAMENDO_CLIENT_ID` or `~/.config/movmaker/jamendo.env`
- fades music in over `DEFAULT_AUDIO_FADE_IN` seconds (2s) and out over `DEFAULT_AUDIO_FADE_OUT` seconds (10s) by default - fades music in over `DEFAULT_AUDIO_FADE_IN` seconds (2s) and out over `DEFAULT_AUDIO_FADE_OUT` seconds (10s) by default
- builds a black intro still (matching the first media items resolution) when `--intro-logo` is provided, scales the logo to `INTRO_LOGO_TARGET_HEIGHT_RATIO` of the frame height (75% by default), shows it at `INTRO_LOGO_DEFAULT_ALPHA` opacity (1.0 = fully opaque) from frame 0, and keeps it on screen for `audio_fade_in + INTRO_LOGO_EXTRA_HOLD` seconds (with defaults this is `DEFAULT_AUDIO_FADE_IN + INTRO_LOGO_EXTRA_HOLD`, i.e. 8s) before allowing the first crossfade - builds a black intro still (matching the first media items resolution) when `--intro-logo` is provided, scales the logo to `INTRO_LOGO_TARGET_HEIGHT_RATIO` of the frame height (75% by default), shows it at `INTRO_LOGO_DEFAULT_ALPHA` opacity (1.0 = fully opaque) from frame 0, and keeps it on screen for `audio_fade_in + INTRO_LOGO_EXTRA_HOLD` seconds (with defaults this is `DEFAULT_AUDIO_FADE_IN + INTRO_LOGO_EXTRA_HOLD`, i.e. 8s) before allowing the first crossfade; the same clip is appended at the end so the last real scene crossfades back into the logo-on-black frame
- opening title text fades in after `INTRO_TITLE_FADE_DELAY` seconds (2s), stays visible for `INTRO_TITLE_VISIBLE` seconds (4s), then fades out over `INTRO_TITLE_FADE_LENGTH` seconds (1s) - opening title text fades in after `INTRO_TITLE_FADE_DELAY` seconds (2s), stays visible for `INTRO_TITLE_VISIBLE` seconds (4s), then fades out over `INTRO_TITLE_FADE_LENGTH` seconds (1s)
- fades the first real clip in and the final clip out with the `--video-fade` duration (default `DEFAULT_VIDEO_FADE`, currently 1s); the black intro/logo pad respects this fade-in only when `INTRO_VIDEO_FADE_ENABLED` is `True` - fades the first real clip in with the `--video-fade` duration (default `DEFAULT_VIDEO_FADE`, currently 1s) while leaving the logo intro instant-on when `INTRO_VIDEO_FADE_ENABLED` is `False`; when a logo intro is present, the reusable outro clip removes the need for a final global fade-out, so the crossfade into the outro handles the closing visuals while audio still follows `DEFAULT_AUDIO_FADE_OUT`
- displays optional per-file captions at bottom center with slightly off-white text and a soft shadow; use `|` to split caption lines - displays optional per-file captions at bottom center with slightly off-white text and a soft shadow; use `|` to split caption lines
- adds persistent bottom-left title/date/place and bottom-right `Bubulescu.Org` stamps - adds persistent bottom-left title/date/place and bottom-right `Bubulescu.Org` stamps
- preserves special characters in rendered overlays and metadata - preserves special characters in rendered overlays and metadata

View file

@ -36,7 +36,7 @@ FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg"
FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe" FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe"
INTRO_LOGO_NAME = "intro.png" INTRO_LOGO_NAME = "intro.png"
DEFAULT_INTRO_LOGO = BASE_DIR / "img" / "moto_travel.png" DEFAULT_INTRO_LOGO = BASE_DIR / "img" / "moto_travel.png"
INTRO_LOGO_TARGET_HEIGHT_RATIO = 0.75 INTRO_LOGO_TARGET_HEIGHT_RATIO = 0.90
INTRO_LOGO_EXTRA_HOLD = 6.0 INTRO_LOGO_EXTRA_HOLD = 6.0
INTRO_LOGO_FADE_OFFSET = 3.0 INTRO_LOGO_FADE_OFFSET = 3.0
INTRO_LOGO_DEFAULT_ALPHA = 1.0 INTRO_LOGO_DEFAULT_ALPHA = 1.0
@ -883,6 +883,7 @@ def build_video(
text_dir: Path, text_dir: Path,
video_intro_duration: float | None = None, video_intro_duration: float | None = None,
intro_visible_lead: float = 0.0, intro_visible_lead: float = 0.0,
skip_video_fade_out: bool = False,
) -> None: ) -> None:
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
@ -937,7 +938,7 @@ def build_video(
fade_label = current fade_label = current
apply_fade_in = video_fade > 0 and INTRO_VIDEO_FADE_ENABLED apply_fade_in = video_fade > 0 and INTRO_VIDEO_FADE_ENABLED
apply_fade_out = video_fade > 0 apply_fade_out = video_fade > 0 and not skip_video_fade_out
if apply_fade_in or apply_fade_out: if apply_fade_in or apply_fade_out:
fade_in_start = intro_duration fade_in_start = intro_duration
fade_out_start = max(0.0, total_duration - video_fade) fade_out_start = max(0.0, total_duration - video_fade)
@ -961,34 +962,44 @@ def build_video(
subtitle_font_size = max(40, height // 18) subtitle_font_size = max(40, height // 18)
main_title = data.title or title_lines[0] main_title = data.title or title_lines[0]
subtitle = " | ".join(x for x in [data.date, data.place] if x) subtitle = " | ".join(x for x in [data.date, data.place] if x)
title_alpha = "if(lt(t\\,2)\\,0\\,if(lt(t\\,3)\\,(t-2)/1\\,if(lt(t\\,7)\\,1\\,if(lt(t\\,8)\\,8-t\\,0))))" fade_in_start = INTRO_TITLE_FADE_DELAY
fade_in_end = fade_in_start + INTRO_TITLE_FADE_LENGTH
visible_end = fade_in_end + INTRO_TITLE_VISIBLE
fade_out_end = visible_end + INTRO_TITLE_FADE_LENGTH
title_alpha = (
f"if(lt(t\\,{fade_in_start:.3f})\\,0\\,"
f"if(lt(t\\,{fade_in_end:.3f})\\,(t-{fade_in_start:.3f})/{INTRO_TITLE_FADE_LENGTH:.3f}\\,"
f"if(lt(t\\,{visible_end:.3f})\\,1\\,"
f"if(lt(t\\,{fade_out_end:.3f})\\,({fade_out_end:.3f}-t)/{INTRO_TITLE_FADE_LENGTH:.3f}\\,0))))"
)
title_enable = f"enable='between(t,{fade_in_start:.3f},{fade_out_end:.3f})'"
if subtitle: if subtitle:
title_y = f"(h-text_h)/2-{title_font_size // 2}" title_y = f"(h-text_h)/2-{title_font_size // 2}"
filters.append( filters.append(
f"[{current}]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2:y={title_y}:" f"[{current}]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2:y={title_y}:"
f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:"
f"alpha='{title_alpha}':enable='between(t,0,7)'[title0]" f"alpha='{title_alpha}':{title_enable}[title0]"
) )
filters.append( filters.append(
f"[title0]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2+1:y={title_y}:" f"[title0]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2+1:y={title_y}:"
f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:"
f"alpha='{title_alpha}':enable='between(t,0,7)'[title0b]" f"alpha='{title_alpha}':{title_enable}[title0b]"
) )
filters.append( filters.append(
f"[title0b]drawtext=fontfile='{title_font}':{textfile_arg(subtitle)}:x=(w-text_w)/2:y=(h-text_h)/2+{subtitle_font_size}:" f"[title0b]drawtext=fontfile='{title_font}':{textfile_arg(subtitle)}:x=(w-text_w)/2:y=(h-text_h)/2+{subtitle_font_size}:"
f"fontsize={subtitle_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:" f"fontsize={subtitle_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:"
f"alpha='{title_alpha}':enable='between(t,0,7)'[{title_label}]" f"alpha='{title_alpha}':{title_enable}[{title_label}]"
) )
else: else:
filters.append( filters.append(
f"[{current}]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2:y=(h-text_h)/2:" f"[{current}]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2:y=(h-text_h)/2:"
f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:"
f"alpha='{title_alpha}':enable='between(t,0,7)'[title0]" f"alpha='{title_alpha}':{title_enable}[title0]"
) )
filters.append( filters.append(
f"[title0]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2+1:y=(h-text_h)/2:" f"[title0]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2+1:y=(h-text_h)/2:"
f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:"
f"alpha='{title_alpha}':enable='between(t,0,7)'[{title_label}]" f"alpha='{title_alpha}':{title_enable}[{title_label}]"
) )
else: else:
filters.append(f"[{current}]copy[{title_label}]") filters.append(f"[{current}]copy[{title_label}]")
@ -1265,6 +1276,8 @@ def main(argv: list[str] | None = None) -> int:
console("Audio metadata: " + " - ".join(x for x in [artist, title] if x)) console("Audio metadata: " + " - ".join(x for x in [artist, title] if x))
video_intro_duration = audio_fade_in video_intro_duration = audio_fade_in
intro_visible_lead = 0.0 intro_visible_lead = 0.0
intro_clip_path: Path | None = None
intro_clip_duration = 0.0
if intro_logo_path is not None and audio_fade_in > 0: if intro_logo_path is not None and audio_fade_in > 0:
if first_media_resolution is None: if first_media_resolution is None:
console("Warning: Could not determine first media resolution; skipping intro logo frame") console("Warning: Could not determine first media resolution; skipping intro logo frame")
@ -1278,12 +1291,22 @@ def main(argv: list[str] | None = None) -> int:
normalize_clip(intro_item, intro_clip, width, height, fps, preset, crf) normalize_clip(intro_item, intro_clip, width, height, fps, preset, crf)
clips = [intro_clip, *clips] clips = [intro_clip, *clips]
durations = [intro_item.duration, *durations] durations = [intro_item.duration, *durations]
intro_clip_path = intro_clip
intro_clip_duration = intro_item.duration
captions = ["", *captions] captions = ["", *captions]
clip_audio_paths = [None, *clip_audio_paths] clip_audio_paths = [None, *clip_audio_paths]
clip_stamps = ["", *clip_stamps] clip_stamps = ["", *clip_stamps]
intro_visible_lead = min(intro_item.duration, INTRO_LOGO_FADE_OFFSET) intro_visible_lead = min(intro_item.duration, INTRO_LOGO_FADE_OFFSET)
video_intro_duration = 0.0 video_intro_duration = 0.0
build_video(clips, durations, audio, clip_audio_paths, output, data, first_media_date, captions, clip_stamps, fade, audio_fade, audio_fade_in, video_fade, args.intro_logo, intro_logo_alpha, width, height, fps, preset, crf, tmp, video_intro_duration, intro_visible_lead=intro_visible_lead) outro_added = False
if intro_clip_path is not None:
clips.append(intro_clip_path)
durations.append(intro_clip_duration)
captions.append("")
clip_audio_paths.append(None)
clip_stamps.append("")
outro_added = True
build_video(clips, durations, audio, clip_audio_paths, output, data, first_media_date, captions, clip_stamps, fade, audio_fade, audio_fade_in, video_fade, args.intro_logo, intro_logo_alpha, width, height, fps, preset, crf, tmp, video_intro_duration, intro_visible_lead=intro_visible_lead, skip_video_fade_out=outro_added)
console(f"Done: {output}") console(f"Done: {output}")
return 0 return 0