Improve intro logo timing
This commit is contained in:
parent
2f829382c4
commit
cdb57525ca
3 changed files with 118 additions and 10 deletions
15
README.md
15
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
|
||||
|
|
|
|||
102
movmaker.py
102
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue