Improve intro logo timing

This commit is contained in:
hbrain 2026-05-28 14:08:45 +00:00
parent 2f829382c4
commit cdb57525ca
3 changed files with 118 additions and 10 deletions

View file

@ -34,6 +34,14 @@ DATA_NAMES = {"data.txt", "info.txt", "title.txt"}
BASE_DIR = Path(__file__).resolve().parent
FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg"
FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe"
INTRO_LOGO_NAME = "intro.png"
DEFAULT_INTRO_LOGO = BASE_DIR / "img" / "moto_travel.png"
INTRO_LOGO_SCALE = 1.35
INTRO_LOGO_EXTRA_HOLD = 6.0
INTRO_LOGO_FADE_OFFSET = 3.0
INTRO_TITLE_FADE_DELAY = 2.0
INTRO_TITLE_FADE_LENGTH = 1.0
INTRO_TITLE_VISIBLE = 4.0
JAMENDO_ENV = Path.home() / ".config" / "movmaker" / "jamendo.env"
LOG_PATH: Path | None = None
@ -164,6 +172,26 @@ def ffprobe_duration(path: Path) -> float:
return 3.0
def ffprobe_resolution(path: Path) -> tuple[int, int] | None:
try:
out = capture([
tool_path(FFPROBE, "ffprobe"), "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "json",
str(path),
])
data = json.loads(out)
for stream in data.get("streams", []):
width = int(stream.get("width") or 0)
height = int(stream.get("height") or 0)
if width > 0 and height > 0:
return width, height
except Exception:
return None
return None
def ffprobe_creation_date(path: Path) -> datetime | None:
try:
out = capture([
@ -630,6 +658,32 @@ def drawtext_escape(text: str) -> str:
)
def render_intro_logo_frame(logo: Path, out: Path, width: int, height: int, alpha: float) -> None:
if width <= 0 or height <= 0:
raise ValueError("intro logo frame must have positive width/height")
alpha = min(1.0, max(0.0, alpha))
scale_expr_w = f"min(iw*{INTRO_LOGO_SCALE}, {width})"
scale_expr_h = f"min(ih*{INTRO_LOGO_SCALE}, {height})"
filters = (
f"[1:v]scale=w='{scale_expr_w}':h='{scale_expr_h}':force_original_aspect_ratio=decrease,"
f"format=rgba,colorchannelmixer=aa={alpha:.3f}[logo];"
f"[0:v][logo]overlay=x=(W-w)/2:y=(H-h)/2:format=auto,format=rgba[vout]"
)
cmd = [
tool_path(FFMPEG, "ffmpeg"), "-y",
"-f", "lavfi", "-i", f"color=c=black:s={width}x{height}",
"-i", str(logo),
"-filter_complex", filters,
"-map", "[vout]",
"-frames:v", "1",
"-pix_fmt", "rgba",
"-f", "image2",
"-update", "1",
str(out),
]
run(cmd)
def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int, preset: str, crf: int) -> None:
vf = (
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
@ -801,12 +855,16 @@ def build_video(
audio_fade: float,
audio_fade_in: float,
video_fade: float,
intro_logo: Path | None,
intro_logo_alpha: float,
width: int,
height: int,
fps: int,
preset: str,
crf: int,
text_dir: Path,
video_intro_duration: float | None = None,
intro_visible_lead: float = 0.0,
) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
@ -849,7 +907,7 @@ def build_video(
current = next_label
total_duration += durations[i] - fade
intro_duration = audio_fade_in if audio_fade_in > 0 else 0.0
intro_duration = max(0.0, video_intro_duration) if video_intro_duration is not None else (audio_fade_in if audio_fade_in > 0 else 0.0)
if intro_duration > 0:
intro_label = "intro_padded"
filters.append(
@ -858,6 +916,7 @@ def build_video(
current = intro_label
total_duration += intro_duration
fade_label = current
if video_fade > 0:
fade_in_start = intro_duration
@ -879,34 +938,34 @@ def build_video(
subtitle_font_size = max(40, height // 18)
main_title = data.title or title_lines[0]
subtitle = " | ".join(x for x in [data.date, data.place] if x)
title_alpha = "if(lt(t\\,5)\\,1\\,if(lt(t\\,6)\\,6-t\\,0))"
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))))"
if subtitle:
title_y = f"(h-text_h)/2-{title_font_size // 2}"
filters.append(
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"alpha='{title_alpha}':enable='between(t,0,6)'[title0]"
f"alpha='{title_alpha}':enable='between(t,0,7)'[title0]"
)
filters.append(
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"alpha='{title_alpha}':enable='between(t,0,6)'[title0b]"
f"alpha='{title_alpha}':enable='between(t,0,7)'[title0b]"
)
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"fontsize={subtitle_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:"
f"alpha='{title_alpha}':enable='between(t,0,6)'[{title_label}]"
f"alpha='{title_alpha}':enable='between(t,0,7)'[{title_label}]"
)
else:
filters.append(
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"alpha='{title_alpha}':enable='between(t,0,6)'[title0]"
f"alpha='{title_alpha}':enable='between(t,0,7)'[title0]"
)
filters.append(
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"alpha='{title_alpha}':enable='between(t,0,6)'[{title_label}]"
f"alpha='{title_alpha}':enable='between(t,0,7)'[{title_label}]"
)
else:
filters.append(f"[{current}]copy[{title_label}]")
@ -1059,6 +1118,8 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--audio-fade", type=float, default=10.0, help="music fade-out duration in seconds")
parser.add_argument("--audio-fade-in", type=float, default=2.0, help="music fade-in duration in seconds")
parser.add_argument("--video-fade", type=float, default=1.0, help="video fade-in/fade-out duration in seconds")
parser.add_argument("--intro-logo", type=Path, default=DEFAULT_INTRO_LOGO, help="PNG logo shown centered over the black intro")
parser.add_argument("--intro-logo-alpha", type=float, default=1.0, help="opacity for the intro logo, from 0 to 1")
parser.add_argument("--resolution", type=parse_resolution, default=(1920, 1080), help="output resolution, e.g. 1920x1080")
parser.add_argument("--fps", type=int, default=30, help="output frames per second")
parser.add_argument("--preset", default="veryfast", help="x264 speed preset, e.g. ultrafast, superfast, veryfast")
@ -1083,12 +1144,13 @@ def main(argv: list[str] | None = None) -> int:
crf = args.crf
if args.preview:
width, height = (1280, 720)
fps = 24
fps = 20
preset = "ultrafast"
crf = 28
media, audio_files, data = scan_input(args.input, args.image_duration)
first_media_date = media_date(media[0].path)
first_media_resolution = ffprobe_resolution(media[0].path)
if not data.date:
data.date = display_date(first_media_date)
if not data.place:
@ -1097,6 +1159,8 @@ def main(argv: list[str] | None = None) -> int:
audio_fade = max(0.0, args.audio_fade)
audio_fade_in = max(0.0, args.audio_fade_in)
video_fade = max(0.0, args.video_fade)
intro_logo_alpha = min(1.0, max(0.0, args.intro_logo_alpha))
intro_logo_path = args.intro_logo if args.intro_logo.exists() else DEFAULT_INTRO_LOGO if DEFAULT_INTRO_LOGO.exists() else None
if not audio_files:
downloaded_audio = download_jamendo_music(args.music_genre, args.input)
@ -1176,7 +1240,27 @@ def main(argv: list[str] | None = None) -> int:
artist, title = audio_metadata(audio)
if artist or title:
console("Audio metadata: " + " - ".join(x for x in [artist, title] if x))
build_video(clips, durations, audio, clip_audio_paths, output, data, first_media_date, captions, clip_stamps, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp)
video_intro_duration = audio_fade_in
intro_visible_lead = 0.0
if intro_logo_path is not None and audio_fade_in > 0:
if first_media_resolution is None:
console("Warning: Could not determine first media resolution; skipping intro logo frame")
else:
intro_width, intro_height = first_media_resolution
intro_image = tmp / "clip_intro_logo.png"
intro_clip = tmp / "clip_intro_logo.mp4"
console("Preparing intro logo frame")
render_intro_logo_frame(intro_logo_path, intro_image, intro_width, intro_height, intro_logo_alpha)
intro_item = MediaItem(path=intro_image, kind="image", duration=audio_fade_in + INTRO_LOGO_EXTRA_HOLD)
normalize_clip(intro_item, intro_clip, width, height, fps, preset, crf)
clips = [intro_clip, *clips]
durations = [intro_item.duration, *durations]
captions = ["", *captions]
clip_audio_paths = [None, *clip_audio_paths]
clip_stamps = ["", *clip_stamps]
intro_visible_lead = min(intro_item.duration, INTRO_LOGO_FADE_OFFSET)
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)
console(f"Done: {output}")
return 0