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

@ -219,6 +219,21 @@ Fast preview render:
python3 movmaker.py input --preview python3 movmaker.py input --preview
``` ```
## Configuration knobs
The most common visual timing/styling tweaks are exposed as constants near the top of `movmaker.py`:
| Constant | Purpose | Default |
| --- | --- | --- |
| `INTRO_LOGO_SCALE` | Multiplier applied before padding; controls how big the intro logo appears on the black frame. | `1.35` |
| `INTRO_LOGO_EXTRA_HOLD` | Extra seconds the synthesized intro still remains before the first crossfade. | `6.0` |
| `INTRO_LOGO_FADE_OFFSET` | How long the logo must stay fully visible (seconds) before the first xfade can begin. | `3.0` |
| `INTRO_TITLE_FADE_DELAY` | Seconds after start before the main title begins fading in. | `2.0` |
| `INTRO_TITLE_FADE_LENGTH` | Seconds the fade-in/out lasts. | `1.0` |
| `INTRO_TITLE_VISIBLE` | Seconds the title stays fully visible between fade in/out. | `4.0` |
Adjust these constants and rerun `movmaker.py` to change the intro behavior without touching the rest of the pipeline.
## Current behavior ## Current behavior
- scans one flat input directory - scans one flat input directory

View file

@ -34,6 +34,14 @@ DATA_NAMES = {"data.txt", "info.txt", "title.txt"}
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg" FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg"
FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe" 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" JAMENDO_ENV = Path.home() / ".config" / "movmaker" / "jamendo.env"
LOG_PATH: Path | None = None LOG_PATH: Path | None = None
@ -164,6 +172,26 @@ def ffprobe_duration(path: Path) -> float:
return 3.0 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: def ffprobe_creation_date(path: Path) -> datetime | None:
try: try:
out = capture([ 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: def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int, preset: str, crf: int) -> None:
vf = ( vf = (
f"scale={width}:{height}:force_original_aspect_ratio=decrease," f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
@ -801,12 +855,16 @@ def build_video(
audio_fade: float, audio_fade: float,
audio_fade_in: float, audio_fade_in: float,
video_fade: float, video_fade: float,
intro_logo: Path | None,
intro_logo_alpha: float,
width: int, width: int,
height: int, height: int,
fps: int, fps: int,
preset: str, preset: str,
crf: int, crf: int,
text_dir: Path, text_dir: Path,
video_intro_duration: float | None = None,
intro_visible_lead: float = 0.0,
) -> None: ) -> None:
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
@ -849,7 +907,7 @@ def build_video(
current = next_label current = next_label
total_duration += durations[i] - fade 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: if intro_duration > 0:
intro_label = "intro_padded" intro_label = "intro_padded"
filters.append( filters.append(
@ -858,6 +916,7 @@ def build_video(
current = intro_label current = intro_label
total_duration += intro_duration total_duration += intro_duration
fade_label = current fade_label = current
if video_fade > 0: if video_fade > 0:
fade_in_start = intro_duration fade_in_start = intro_duration
@ -879,34 +938,34 @@ 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\\,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: 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,6)'[title0]" f"alpha='{title_alpha}':enable='between(t,0,7)'[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,6)'[title0b]" f"alpha='{title_alpha}':enable='between(t,0,7)'[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,6)'[{title_label}]" f"alpha='{title_alpha}':enable='between(t,0,7)'[{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,6)'[title0]" f"alpha='{title_alpha}':enable='between(t,0,7)'[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,6)'[{title_label}]" f"alpha='{title_alpha}':enable='between(t,0,7)'[{title_label}]"
) )
else: else:
filters.append(f"[{current}]copy[{title_label}]") 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", 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("--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("--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("--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("--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") 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 crf = args.crf
if args.preview: if args.preview:
width, height = (1280, 720) width, height = (1280, 720)
fps = 24 fps = 20
preset = "ultrafast" preset = "ultrafast"
crf = 28 crf = 28
media, audio_files, data = scan_input(args.input, args.image_duration) media, audio_files, data = scan_input(args.input, args.image_duration)
first_media_date = media_date(media[0].path) first_media_date = media_date(media[0].path)
first_media_resolution = ffprobe_resolution(media[0].path)
if not data.date: if not data.date:
data.date = display_date(first_media_date) data.date = display_date(first_media_date)
if not data.place: 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 = max(0.0, args.audio_fade)
audio_fade_in = max(0.0, args.audio_fade_in) audio_fade_in = max(0.0, args.audio_fade_in)
video_fade = max(0.0, args.video_fade) 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: if not audio_files:
downloaded_audio = download_jamendo_music(args.music_genre, args.input) 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) artist, title = audio_metadata(audio)
if artist or title: if artist or title:
console("Audio metadata: " + " - ".join(x for x in [artist, title] if x)) 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}") console(f"Done: {output}")
return 0 return 0

View file

@ -22,6 +22,7 @@ from shlex import quote
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
MOVMAKER = ROOT / "movmaker.py" MOVMAKER = ROOT / "movmaker.py"
INTRO_LOGO = ROOT / "img" / "moto_travel.png"
PUSH_SENDER = ROOT / "send_push.js" PUSH_SENDER = ROOT / "send_push.js"
PUSH_CONFIG = Path.home() / ".config" / "mvlog" / "push.json" PUSH_CONFIG = Path.home() / ".config" / "mvlog" / "push.json"
STATE_NAME = ".movmaker-state.json" STATE_NAME = ".movmaker-state.json"
@ -30,6 +31,7 @@ ENABLED_NAME = ".movmaker-enabled"
PREVIEW_NAME = ".movmaker-preview" PREVIEW_NAME = ".movmaker-preview"
HIDDEN_NAME = ".mvlog-hidden" HIDDEN_NAME = ".mvlog-hidden"
NOTIFIED_CACHE = "cache/push_notifications.json" NOTIFIED_CACHE = "cache/push_notifications.json"
INTRO_LOGO_ALPHA = "0.5"
def now() -> str: def now() -> str:
@ -204,7 +206,14 @@ def publish_output(host: str, remote_root: str, local_mp4: Path, old_output: str
def render_job(local_input: Path, local_out: Path, extra_args: list[str]) -> Path: def render_job(local_input: Path, local_out: Path, extra_args: list[str]) -> Path:
local_out.mkdir(parents=True, exist_ok=True) local_out.mkdir(parents=True, exist_ok=True)
cmd = [sys.executable, str(MOVMAKER), str(local_input), "--out-dir", str(local_out), *extra_args] intro_logo = INTRO_LOGO if INTRO_LOGO.exists() else None
intro_args = []
if intro_logo is not None:
intro_args = [
"--intro-logo", str(intro_logo),
"--intro-logo-alpha", INTRO_LOGO_ALPHA,
]
cmd = [sys.executable, str(MOVMAKER), str(local_input), "--out-dir", str(local_out), *intro_args, *extra_args]
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)
outputs = sorted(local_out.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True) outputs = sorted(local_out.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True)
if not outputs: if not outputs: