Improve text safety and add video fades
This commit is contained in:
parent
cfc7e4d32c
commit
3be6199990
1 changed files with 67 additions and 21 deletions
88
movmaker.py
88
movmaker.py
|
|
@ -208,15 +208,19 @@ def media_date(path: Path) -> datetime:
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
def slugify(value: str) -> str:
|
def ascii_safe(value: str) -> str:
|
||||||
replacements = {
|
replacements = {
|
||||||
"æ": "ae", "Æ": "ae",
|
"æ": "ae", "Æ": "Ae",
|
||||||
"ø": "o", "Ø": "o",
|
"ø": "o", "Ø": "O",
|
||||||
"å": "a", "Å": "a",
|
"å": "a", "Å": "A",
|
||||||
"đ": "d", "Đ": "d",
|
"đ": "d", "Đ": "D",
|
||||||
}
|
}
|
||||||
value = "".join(replacements.get(ch, ch) for ch in value)
|
value = "".join(replacements.get(ch, ch) for ch in value)
|
||||||
value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
return unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str) -> str:
|
||||||
|
value = ascii_safe(value)
|
||||||
value = value.strip().lower()
|
value = value.strip().lower()
|
||||||
value = re.sub(r"[^a-z0-9]+", "_", value)
|
value = re.sub(r"[^a-z0-9]+", "_", value)
|
||||||
value = value.strip("_")
|
value = value.strip("_")
|
||||||
|
|
@ -316,8 +320,13 @@ def parse_data_file(path: Path | None) -> MovieData:
|
||||||
|
|
||||||
|
|
||||||
def drawtext_escape(text: str) -> str:
|
def drawtext_escape(text: str) -> str:
|
||||||
# Escape for ffmpeg drawtext text='...'
|
# Escape for ffmpeg drawtext text=... without surrounding quotes.
|
||||||
return text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'").replace("%", "\\%")
|
return (
|
||||||
|
text.replace("\\", "\\\\")
|
||||||
|
.replace(":", "\\:")
|
||||||
|
.replace(",", "\\,")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
@ -369,17 +378,29 @@ def build_video(
|
||||||
audio: Path | None,
|
audio: Path | None,
|
||||||
output: Path,
|
output: Path,
|
||||||
data: MovieData,
|
data: MovieData,
|
||||||
|
creation_date: datetime,
|
||||||
captions: list[str],
|
captions: list[str],
|
||||||
fade: float,
|
fade: float,
|
||||||
audio_fade: float,
|
audio_fade: float,
|
||||||
|
audio_fade_in: float,
|
||||||
|
video_fade: float,
|
||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
fps: int,
|
fps: int,
|
||||||
preset: str,
|
preset: str,
|
||||||
crf: int,
|
crf: int,
|
||||||
|
text_dir: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
text_file_count = 0
|
||||||
|
def textfile_arg(text: str) -> str:
|
||||||
|
nonlocal text_file_count
|
||||||
|
path = text_dir / f"drawtext_{text_file_count:04d}.txt"
|
||||||
|
text_file_count += 1
|
||||||
|
path.write_text(ascii_safe(text), encoding="utf-8")
|
||||||
|
return f"textfile='{path}':expansion=none"
|
||||||
|
|
||||||
cmd = [tool_path(FFMPEG, "ffmpeg"), "-y"]
|
cmd = [tool_path(FFMPEG, "ffmpeg"), "-y"]
|
||||||
for clip in clips:
|
for clip in clips:
|
||||||
cmd += ["-i", str(clip)]
|
cmd += ["-i", str(clip)]
|
||||||
|
|
@ -407,6 +428,15 @@ def build_video(
|
||||||
current = next_label
|
current = next_label
|
||||||
total_duration += durations[i] - fade
|
total_duration += durations[i] - fade
|
||||||
|
|
||||||
|
fade_label = current
|
||||||
|
if video_fade > 0:
|
||||||
|
fade_out_start = max(0.0, total_duration - video_fade)
|
||||||
|
fade_label = "video_faded"
|
||||||
|
filters.append(
|
||||||
|
f"[{current}]fade=t=in:st=0:d={video_fade:.3f},fade=t=out:st={fade_out_start:.3f}:d={video_fade:.3f}[{fade_label}]"
|
||||||
|
)
|
||||||
|
current = fade_label
|
||||||
|
|
||||||
title_lines = [x for x in [data.title, data.date, data.place] if x]
|
title_lines = [x for x in [data.title, data.date, data.place] if x]
|
||||||
final_label = "vout"
|
final_label = "vout"
|
||||||
title_label = "titled"
|
title_label = "titled"
|
||||||
|
|
@ -422,28 +452,28 @@ def build_video(
|
||||||
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}':text='{drawtext_escape(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,6)'[title0]"
|
||||||
)
|
)
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[title0]drawtext=fontfile='{title_font}':text='{drawtext_escape(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,6)'[title0b]"
|
||||||
)
|
)
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[title0b]drawtext=fontfile='{title_font}':text='{drawtext_escape(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,6)'[{title_label}]"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{current}]drawtext=fontfile='{title_font}':text='{drawtext_escape(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,6)'[title0]"
|
||||||
)
|
)
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[title0]drawtext=fontfile='{title_font}':text='{drawtext_escape(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,6)'[{title_label}]"
|
||||||
)
|
)
|
||||||
|
|
@ -481,13 +511,13 @@ def build_video(
|
||||||
y_expr = f"h-text_h-62-{block_height - line_idx * line_gap}"
|
y_expr = f"h-text_h-62-{block_height - line_idx * line_gap}"
|
||||||
next_label = f"caption{idx}_{line_idx}"
|
next_label = f"caption{idx}_{line_idx}"
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{caption_label}]drawtext=fontfile='{stamp_bold_font}':text='{drawtext_escape(caption_line)}':x=(w-text_w)/2:y={y_expr}:"
|
f"[{caption_label}]drawtext=fontfile='{stamp_bold_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:"
|
||||||
f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:"
|
f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:"
|
||||||
f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{next_label}]"
|
f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{next_label}]"
|
||||||
)
|
)
|
||||||
bold_label = f"caption{idx}_{line_idx}b"
|
bold_label = f"caption{idx}_{line_idx}b"
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{next_label}]drawtext=fontfile='{stamp_bold_font}':text='{drawtext_escape(caption_line)}':x=(w-text_w)/2+1:y={y_expr}:"
|
f"[{next_label}]drawtext=fontfile='{stamp_bold_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2+1:y={y_expr}:"
|
||||||
f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:"
|
f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:"
|
||||||
f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{bold_label}]"
|
f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{bold_label}]"
|
||||||
)
|
)
|
||||||
|
|
@ -499,22 +529,33 @@ def build_video(
|
||||||
stamp_left_label = "stamp_left"
|
stamp_left_label = "stamp_left"
|
||||||
if stamp_text:
|
if stamp_text:
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{caption_label}]drawtext=fontfile='{stamp_font}':text='{stamp_text}':x=24:y=h-text_h-18:"
|
f"[{caption_label}]drawtext=fontfile='{stamp_font}':{textfile_arg(' | '.join(stamp_lines))}:x=24:y=h-text_h-18:"
|
||||||
f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{stamp_left_label}]"
|
f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{stamp_left_label}]"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
filters.append(f"[{caption_label}]copy[{stamp_left_label}]")
|
filters.append(f"[{caption_label}]copy[{stamp_left_label}]")
|
||||||
filters.append(
|
filters.append(
|
||||||
f"[{stamp_left_label}]drawtext=fontfile='{stamp_font}':text='@bubulescu':x=w-text_w-24:y=h-text_h-18:"
|
f"[{stamp_left_label}]drawtext=fontfile='{stamp_font}':{textfile_arg('@bubulescu')}:x=w-text_w-24:y=h-text_h-18:"
|
||||||
f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{final_label}]"
|
f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{final_label}]"
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"]
|
cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"]
|
||||||
|
metadata = {
|
||||||
|
"title": ascii_safe(data.title),
|
||||||
|
"date": ascii_safe(data.date),
|
||||||
|
"location": ascii_safe(data.place),
|
||||||
|
"artist": "@bubulescu",
|
||||||
|
"comment": "Created with movmaker",
|
||||||
|
"creation_time": creation_date.isoformat(),
|
||||||
|
}
|
||||||
|
for key, value in metadata.items():
|
||||||
|
if value:
|
||||||
|
cmd += ["-metadata", f"{key}={value}"]
|
||||||
|
|
||||||
if audio:
|
if audio:
|
||||||
audio_index = len(clips)
|
audio_index = len(clips)
|
||||||
audio_fade_out_start = max(0.0, total_duration - audio_fade)
|
audio_fade_out_start = max(0.0, total_duration - audio_fade)
|
||||||
audio_filter = f"afade=t=in:st=0:d={audio_fade:.3f},afade=t=out:st={audio_fade_out_start:.3f}:d={audio_fade:.3f}"
|
audio_filter = f"afade=t=in:st=0:d={audio_fade_in:.3f},afade=t=out:st={audio_fade_out_start:.3f}:d={audio_fade:.3f}"
|
||||||
cmd += ["-map", f"{audio_index}:a:0", "-af", audio_filter, "-c:a", "aac", "-b:a", "192k", "-shortest"]
|
cmd += ["-map", f"{audio_index}:a:0", "-af", audio_filter, "-c:a", "aac", "-b:a", "192k", "-shortest"]
|
||||||
else:
|
else:
|
||||||
cmd += ["-an"]
|
cmd += ["-an"]
|
||||||
|
|
@ -530,7 +571,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
parser.add_argument("--out-dir", type=Path, default=Path("out"), help="directory for auto-named output videos")
|
parser.add_argument("--out-dir", type=Path, default=Path("out"), 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("--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("--fade", type=float, default=3.0, help="crossfade duration in seconds")
|
||||||
parser.add_argument("--audio-fade", type=float, default=10.0, help="music fade-in/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("--video-fade", type=float, default=1.5, help="video fade-in/fade-out duration in seconds")
|
||||||
parser.add_argument("--first-transition-at", type=float, default=12.0, help="seconds before first transition starts")
|
parser.add_argument("--first-transition-at", type=float, default=12.0, help="seconds before first transition starts")
|
||||||
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")
|
||||||
|
|
@ -554,10 +597,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
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)
|
||||||
if not data.date:
|
if not data.date:
|
||||||
data.date = display_date(media_date(media[0].path))
|
data.date = display_date(first_media_date)
|
||||||
fade = min(args.fade, max(0.0, args.image_duration - 0.1))
|
fade = min(args.fade, max(0.0, args.image_duration - 0.1))
|
||||||
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)
|
||||||
|
video_fade = max(0.0, args.video_fade)
|
||||||
|
|
||||||
output = args.output or default_output_path(media[0].path, data, args.out_dir)
|
output = args.output or default_output_path(media[0].path, data, args.out_dir)
|
||||||
|
|
||||||
|
|
@ -602,7 +648,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media]
|
captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media]
|
||||||
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
|
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
|
||||||
build_video(clips, durations, audio, output, data, captions, fade, audio_fade, width, height, fps, preset, crf)
|
build_video(clips, durations, audio, output, data, first_media_date, captions, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp)
|
||||||
|
|
||||||
print(f"Done: {output}")
|
print(f"Done: {output}")
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue