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
|
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
|
||||||
|
|
|
||||||
102
movmaker.py
102
movmaker.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue