diff --git a/README.md b/README.md index c4e78ca..fa1241d 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Pass extra movmaker options: ./mvlog_worker.py --movmaker-arg=--preview ``` -Recommended scheduling is a systemd timer or cron with a separate lock, e.g. every 5 minutes. +Recommended scheduling is a systemd timer or cron with a separate lock, e.g. every `MVLOG_WORKER_RECOMMENDED_INTERVAL_MINUTES` minutes (5 minutes; see `mvlog_worker.py`). ## Usage @@ -225,12 +225,19 @@ The most common visual timing/styling tweaks are exposed as constants near the t | 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_TARGET_HEIGHT_RATIO` | Portion of the video height used for the intro logo before padding (e.g. `0.75` = 75 % of frame height). | `0.75` | | `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_LOGO_FADE_OFFSET` | Minimum time (s) the logo stays fully visible before the first xfade is allowed to start. | `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` | +| `DEFAULT_IMAGE_DURATION` | Default fully visible time per still image before fades are added. | `6.0` | +| `DEFAULT_CROSSFADE_DURATION` | Default crossfade duration between media items. | `3.0` | +| `FIRST_IMAGE_DURATION_MULTIPLIER` | Multiplier applied to the first still’s `--image-duration` to delay the first transition. | `2.0` | +| `DEFAULT_AUDIO_FADE_IN` | Default audio fade-in duration. | `2.0` | +| `DEFAULT_AUDIO_FADE_OUT` | Default audio fade-out duration. | `10.0` | +| `DEFAULT_VIDEO_FADE` | Default video fade-in/out duration for the first/last real clips. | `1.0` | +| `INTRO_VIDEO_FADE_ENABLED` | Whether the synthesized intro/logo pad gets the global fade-in effect (`False` keeps it fully visible from frame 0). | `False` | Adjust these constants and rerun `movmaker.py` to change the intro behavior without touching the rest of the pipeline. @@ -239,19 +246,18 @@ Adjust these constants and rerun `movmaker.py` to change the intro behavior with - scans one flat input directory - supports an optional intro logo overlay via `--intro-logo` - supports AJAX save for input-dir create, edit, delete with toast notifications and redirect cleanup -- supports an optional intro logo overlay via `--intro-logo` - auto-detects media/audio/data files by extension -- shows each image fully visible for 6 seconds by default, not counting crossfades -- keeps the first image longer so the first transition starts after 2x image duration, 12 seconds by default +- shows each image fully visible for `DEFAULT_IMAGE_DURATION` seconds by default (6 s), not counting crossfades +- keeps the first image longer so the first transition starts after `FIRST_IMAGE_DURATION_MULTIPLIER * DEFAULT_IMAGE_DURATION` seconds (12 s with defaults) - includes video clips inline -- crossfades between media items over 3 seconds by default +- crossfades between media items over `DEFAULT_CROSSFADE_DURATION` seconds by default (3 s) - loops/trims music to match the video - 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` -- fades music in over 2 seconds and out over 10 seconds by default -- shows opening title/stamps immediately on a black background while audio fades in, then fades the first image/video in over 1.5 seconds by default -- fades video out over 1.5 seconds by default -- overlays the opening title for the first 6 seconds: large title, then date/location on one line +- fades music in over `DEFAULT_AUDIO_FADE_IN` seconds (2 s) and out over `DEFAULT_AUDIO_FADE_OUT` seconds (10 s) by default +- builds a black intro still (matching the first media item’s 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 full opacity 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. 8 s) before allowing the first crossfade +- opening title text fades in after `INTRO_TITLE_FADE_DELAY` seconds (2 s), stays visible for `INTRO_TITLE_VISIBLE` seconds (4 s), then fades out over `INTRO_TITLE_FADE_LENGTH` seconds (1 s) +- fades the first real clip in and the final clip out with the `--video-fade` duration (default `DEFAULT_VIDEO_FADE`, currently 1 s); the black intro/logo pad respects this fade-in only when `INTRO_VIDEO_FADE_ENABLED` is `True` - 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 - preserves special characters in rendered overlays and metadata diff --git a/movmaker.py b/movmaker.py index ae07da6..ac43009 100755 --- a/movmaker.py +++ b/movmaker.py @@ -36,12 +36,19 @@ 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_TARGET_HEIGHT_RATIO = 0.75 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 +INTRO_TITLE_VISIBLE = 6.0 +DEFAULT_IMAGE_DURATION = 6.0 +DEFAULT_CROSSFADE_DURATION = 3.0 +FIRST_IMAGE_DURATION_MULTIPLIER = 2.0 +DEFAULT_AUDIO_FADE_IN = 2.0 +DEFAULT_AUDIO_FADE_OUT = 10.0 +DEFAULT_VIDEO_FADE = 1.0 +INTRO_VIDEO_FADE_ENABLED = False JAMENDO_ENV = Path.home() / ".config" / "movmaker" / "jamendo.env" LOG_PATH: Path | None = None @@ -662,10 +669,9 @@ def render_intro_logo_frame(logo: Path, out: Path, width: int, height: int, alph 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})" + target_logo_height = max(1, int(round(height * INTRO_LOGO_TARGET_HEIGHT_RATIO))) filters = ( - f"[1:v]scale=w='{scale_expr_w}':h='{scale_expr_h}':force_original_aspect_ratio=decrease," + f"[1:v]scale=w={width}:h={target_logo_height}: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]" ) @@ -918,13 +924,18 @@ def build_video( fade_label = current - if video_fade > 0: + apply_fade_in = video_fade > 0 and INTRO_VIDEO_FADE_ENABLED + apply_fade_out = video_fade > 0 + if apply_fade_in or apply_fade_out: fade_in_start = intro_duration fade_out_start = max(0.0, total_duration - video_fade) fade_label = "video_faded" - filters.append( - f"[{current}]fade=t=in:st={fade_in_start:.3f}:d={video_fade:.3f},fade=t=out:st={fade_out_start:.3f}:d={video_fade:.3f}[{fade_label}]" - ) + fade_parts = [] + if apply_fade_in: + fade_parts.append(f"fade=t=in:st={fade_in_start:.3f}:d={video_fade:.3f}") + if apply_fade_out: + fade_parts.append(f"fade=t=out:st={fade_out_start:.3f}:d={video_fade:.3f}") + filters.append(f"[{current}]" + ",".join(fade_parts) + f"[{fade_label}]") current = fade_label title_lines = [x for x in [data.title, data.date, data.place] if x] @@ -1113,11 +1124,11 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Make a video from a flat folder of images/videos/audio.") parser.add_argument("input", type=Path, help="flat input directory") parser.add_argument("--out-dir", type=Path, default=Path("."), help="directory for auto-named output videos") - parser.add_argument("--image-duration", type=float, default=6.0, help="seconds each image is visible") - parser.add_argument("--fade", type=float, default=3.0, help="crossfade 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("--video-fade", type=float, default=1.0, help="video fade-in/fade-out duration in seconds") + parser.add_argument("--image-duration", type=float, default=DEFAULT_IMAGE_DURATION, help="seconds each image is visible") + parser.add_argument("--fade", type=float, default=DEFAULT_CROSSFADE_DURATION, help="crossfade duration in seconds") + parser.add_argument("--audio-fade", type=float, default=DEFAULT_AUDIO_FADE_OUT, help="music fade-out duration in seconds") + parser.add_argument("--audio-fade-in", type=float, default=DEFAULT_AUDIO_FADE_IN, help="music fade-in duration in seconds") + parser.add_argument("--video-fade", type=float, default=DEFAULT_VIDEO_FADE, 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") @@ -1176,7 +1187,7 @@ def main(argv: list[str] | None = None) -> int: if item.kind != "image": continue if idx == 0: - item.duration = max(item.duration, (args.image_duration * 2) + fade) + item.duration = max(item.duration, (args.image_duration * FIRST_IMAGE_DURATION_MULTIPLIER) + fade) elif idx == len(media) - 1: item.duration = item.duration + fade else: diff --git a/mvlog_worker.py b/mvlog_worker.py index a57c3b8..07d5196 100755 --- a/mvlog_worker.py +++ b/mvlog_worker.py @@ -32,6 +32,7 @@ PREVIEW_NAME = ".movmaker-preview" HIDDEN_NAME = ".mvlog-hidden" NOTIFIED_CACHE = "cache/push_notifications.json" INTRO_LOGO_ALPHA = "0.5" +MVLOG_WORKER_RECOMMENDED_INTERVAL_MINUTES = 5 def now() -> str: