From cdb57525ca1a786990cff781cf7c7d17e0de6ee0 Mon Sep 17 00:00:00 2001 From: hbrain Date: Thu, 28 May 2026 14:08:45 +0000 Subject: [PATCH] Improve intro logo timing --- README.md | 15 +++++++ movmaker.py | 102 +++++++++++++++++++++++++++++++++++++++++++----- mvlog_worker.py | 11 +++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 71dfc91..c4e78ca 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,21 @@ Fast preview render: 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 - scans one flat input directory diff --git a/movmaker.py b/movmaker.py index ead7135..ae07da6 100755 --- a/movmaker.py +++ b/movmaker.py @@ -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 diff --git a/mvlog_worker.py b/mvlog_worker.py index b79fe03..a57c3b8 100755 --- a/mvlog_worker.py +++ b/mvlog_worker.py @@ -22,6 +22,7 @@ from shlex import quote ROOT = Path(__file__).resolve().parent MOVMAKER = ROOT / "movmaker.py" +INTRO_LOGO = ROOT / "img" / "moto_travel.png" PUSH_SENDER = ROOT / "send_push.js" PUSH_CONFIG = Path.home() / ".config" / "mvlog" / "push.json" STATE_NAME = ".movmaker-state.json" @@ -30,6 +31,7 @@ ENABLED_NAME = ".movmaker-enabled" PREVIEW_NAME = ".movmaker-preview" HIDDEN_NAME = ".mvlog-hidden" NOTIFIED_CACHE = "cache/push_notifications.json" +INTRO_LOGO_ALPHA = "0.5" 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: 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) outputs = sorted(local_out.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True) if not outputs: